mirror of
https://github.com/MadsLorentzen/ai-job-search.git
synced 2026-11-02 13:12:50 +00:00
* docs(rank): clarify posting search during retrieval * docs(rank): match posting search exception to escalation * changelog: place the #638 entry under [Unreleased] Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Mads Lorentzen <madslorentzen_17@hotmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2424 lines
182 KiB
Markdown
2424 lines
182 KiB
Markdown
# Changelog
|
||
|
||
All notable changes to this project are documented here. The format is based on
|
||
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres
|
||
to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||
|
||
Releases are vetted checkpoints of `master`. If you maintain a personalized fork,
|
||
prefer updating to a tagged release over pulling raw `master` (see
|
||
[SETUP.md, section 8](SETUP.md#8-pulling-upstream-updates-into-your-fork)). The
|
||
`framework_version` markers on methodology files tell you which of your customized
|
||
files a release touched; `python3 tools/check_upstream_updates.py` lists them with
|
||
per-file diff commands.
|
||
|
||
## [Unreleased]
|
||
|
||
### Changed
|
||
|
||
- **`freehire-search` documents the `OR` and `title:` query operators** (#674,
|
||
`.agents/skills/freehire-search/SKILL.md`, `url-reference.md`, `cli/README.md`, `--help`) -
|
||
freehire's search parses an uppercase `OR` between up to five alternatives and a leading `title:`
|
||
per alternative, but nothing told the agent, so "Go or Rust" became two searches merged by hand
|
||
or `-q "golang rust"`, which returns fewer jobs than `golang` alone. Measured live: `golang`
|
||
3,550, `golang OR rust` 6,805. The CLI already passes `-q` through unchanged.
|
||
|
||
### Fixed
|
||
|
||
- **`freehire-search detail` no longer asks the API for a slug cut out of a URL on another
|
||
host** (#623, fixes #622, `.agents/skills/freehire-search/cli/src/helpers.ts`, `tests/parsing.test.ts`,
|
||
`tests/commands.test.ts`, `SKILL.md`) - `normalizeSlug` took the segment after `/jobs/` from
|
||
*any* string, so a Greenhouse apply link - which is what a freehire search result's own `url`
|
||
field carries - was reduced to its last path segment and `detail` requested
|
||
`/api/v1/jobs/1`, then reported `NOT_FOUND` for a URL that was never a freehire posting.
|
||
Every other portal CLI rejects an off-host detail URL with `BAD_ID` (#447, #430, #499);
|
||
freehire was the last one trusting the path. URLs are now parsed for real: `freehire.me`
|
||
(apex or any subdomain, scheme optional) or the host of `FREEHIRE_API_URL` for a self-hosted
|
||
instance, plus a `/jobs/<slug>` path and nothing more, yields the slug; other hosts,
|
||
look-alike and userinfo hosts, and non-job freehire pages exit 1 with `BAD_ID` before any
|
||
request. Bare slugs are unchanged. Pinned by six new `normalizeSlug` cases and a
|
||
handler-level test that pastes the search fixture's own apply URL into `detail` with fetch
|
||
stubbed; the off-host cases fail on master.
|
||
|
||
- **`freehire-search detail` rejects `--description-format` instead of ignoring it**
|
||
(#648, `.agents/skills/freehire-search/cli/src/cli.ts`, `tests/detail-flags.test.ts`) - the flag
|
||
was on `detail`'s known-flag list, but `runDetail` never reads it and always prints the detail
|
||
endpoint's HTML body stripped to plain text. So `detail <slug> --description-format markdown`
|
||
passed the unknown-flag guard, exited 0 and printed text, which is the silently ignored flag
|
||
that guard exists to prevent. `detail` now answers it with `UNKNOWN_FLAG` before any request;
|
||
`search`, where the flag is documented and used, is unchanged.
|
||
|
||
- **`freehire-search` honours `Retry-After` on 429 and 503 within a 30-second budget** (#632,
|
||
`.agents/skills/freehire-search/cli/src/helpers.ts`) - the retry loop backed off on its own
|
||
schedule whatever the server asked for. It now waits for the longer of its backoff and
|
||
`Retry-After`, under a 30-second total sleep budget per request, and a wait that would exceed the
|
||
budget fails the request at once with a message naming the requested wait. Giving up on a
|
||
persistent 5xx still takes about 25 s over 7 calls, as before.
|
||
|
||
- **`freehire-search` docs list the real `--region` buckets and send a country to `--country`**
|
||
(#630, `.agents/skills/freehire-search/SKILL.md`, `url-reference.md`, `cli/README.md`, `--help`) -
|
||
the docs and `--help` offered `us` as a region, but in freehire's vocabulary `us` is a country code
|
||
and the macro-region that holds US postings is `north_america`. `--region us` returned 168
|
||
software-engineer postings where `--country us` returned about 100,000, with nothing in the output
|
||
to say why (reported in #616). The docs now list the nine buckets (`global`, `eu`, `uk`,
|
||
`north_america`, `latam`, `apac`, `mena`, `africa`, `cis`) plus `none`, note that `eu` excludes
|
||
the UK, and point single-country searches at `--country`.
|
||
|
||
- **`freehire-search search` warns when `--region` gets a value outside freehire's vocabulary**
|
||
(#651, fixes #616, `.agents/skills/freehire-search/cli/src/commands/search.ts`, `SKILL.md`,
|
||
`cli/README.md`) - an unknown region such as `us` or `europe` matches nothing, so the search read
|
||
as "no postings" rather than "wrong parameter". `search` now checks `--region` and
|
||
`--facet regions=` against the nine buckets plus `none`, case-insensitively, and prints one
|
||
`REGION_VALUE_UNKNOWN` warning on stderr that lists the buckets and points a country at
|
||
`--country`. Results, stdout and the exit code are unchanged, so a bucket freehire adds later
|
||
still works.
|
||
|
||
- **`job_key.py --audit` reports an entry whose `url` is not a string** (#634, `tools/job_key.py`,
|
||
`tests/test_job_key.py`) - a numeric, list or object `url` crashed the duplicate-URL pass with an
|
||
`AttributeError` traceback on `.rstrip`, although the audit already reports other malformed state
|
||
with a one-line error and exit 1. It now names the offending key the same way.
|
||
|
||
- **`rank_state.py apply` reports a malformed scoring result instead of crashing** (#635, `tools/rank_state.py`,
|
||
`tests/test_rank_state.py`) - a result that is not an object, a `key` that is not a string, or a
|
||
`scores` value that is not an object raised an `AttributeError`/`TypeError` traceback and discarded the
|
||
whole batch, although `apply` already collects per-result problems in `errors`. Each now becomes an
|
||
`errors` row (exit 1) and the valid results in the same file are still written.
|
||
|
||
- **`rank_state.py candidates` reports a tracker that is not UTF-8 instead of crashing** (#639, `tools/rank_state.py`,
|
||
`tests/test_rank_state.py`) - a `job_search_tracker.csv` saved with a spreadsheet's plain "CSV" type is
|
||
written in the ANSI code page (cp1252 on most Western installs), so one accented company name such as
|
||
"Ørsted" raised a `UnicodeDecodeError` traceback and stopped `/rank` at Step 1. It now exits 1 with a
|
||
one-line message naming the file and asking for a CSV UTF-8 save, as `load_state` does for invalid JSON.
|
||
|
||
- **`rank_state.py sweep --exclude` ignores spaces after the commas** (#641, `tools/rank_state.py`,
|
||
`tests/test_rank_state.py`) - rank.md asks for the keys scored this run as a comma-separated list, and
|
||
`"a_b, c_d"` kept every key after the first in the sweep, because `" c_d"` matched no entry. On an
|
||
`--all` run, a re-scored entry whose stored deadline had passed was written `expired` and reported under
|
||
"newly expired" by the sweep, then re-ranked by `apply` from its fresh deadline, so the same job appeared
|
||
as both expired and shortlisted. Keys are now stripped before matching.
|
||
|
||
- **Salary workbook conversion handles wrapped header cells** (#643, `tools/convert_salary_excel.py`,
|
||
`tests/test_salary_wrapped_header_cli.py`) - a header cell with a line break inside it
|
||
("Median⏎salary", "Løn-⏎indeks") kept the break in the derived category name, so the column no
|
||
longer paired with its count column and `salary_lookup.py` printed the label across two lines.
|
||
Header whitespace runs now collapse to one space, so a wrapped header converts like its one-line spelling.
|
||
|
||
- **Salary workbook conversion skips prose notes tabs** (#644, `tools/convert_salary_excel.py`,
|
||
`tests/test_salary_notes_sheet_cli.py`) - a "Notes" tab mentioning "company" in a sentence was
|
||
taken as a header row by the fallback pass, and every following sentence was emitted as a company
|
||
in `salary_data.json` and `--list-all`. A fallback-detected header with no salary column beside it
|
||
now skips the sheet with a warning; strictly detected headers are unchanged.
|
||
|
||
- **`convert_salary_excel.py` rejects a non-finite `--baseline`** (#665,
|
||
`tools/convert_salary_excel.py`, `tests/test_salary_baseline_cli.py`) - `nan`, `inf`, `-inf` and
|
||
an overflow such as `1e309` were accepted with exit 0 and written as `index_baseline`,
|
||
overwriting a good `salary_data.json` with one that `salary_lookup.py --validate` rejects; every
|
||
lookup then failed and `/apply` quietly dropped the benchmark. The converter now exits 2 before
|
||
reading the workbook or opening the output, and `0` or a finite custom baseline still converts.
|
||
|
||
- **`linkedin-search` rejects a `--remote` mode it does not recognise instead of searching every
|
||
workplace type** (#645, `.agents/skills/linkedin-search/cli/src/cli.ts`, `tests/remote-mode.test.ts`) -
|
||
`workTypeFlag()` maps an unknown mode to `null` and the request then carries no `f_WT`, so
|
||
`--remote remote,hybrid`, a misspelt mode or a bare `--remote` returned onsite postings too,
|
||
with exit 0 and an empty stderr. Anything other than `remote`, `hybrid` or `onsite` (and the
|
||
accepted `on-site` spelling) now exits 1 with `BAD_ARG` before any request is sent.
|
||
|
||
- **`linkedin-search` requires a value for `--query` instead of dropping the keywords**
|
||
(#646, `.agents/skills/linkedin-search/cli/src/cli.ts`, `tests/required-query-value.test.ts`) - the
|
||
flag parser stores a bare `-q` as `true` and `-q ""` as an empty string, and the search took
|
||
`--query` only when it was a non-empty string, so a bare flag, an empty argument (an unset
|
||
shell variable) or a blank query returned every posting in the location with exit 0. A present
|
||
`--query` now needs a non-empty value or exits 1 with `BAD_ARG` before any request; omitting
|
||
the flag is still a location-only search. The Danish portals (#573) and `freehire-search`
|
||
(#568) already reject a value-less option.
|
||
|
||
- **`jobindex-search` rejects a `--sort` other than `score` or `date` instead of sending it to the
|
||
portal** (#647, `.agents/skills/jobindex-search/cli/src/commands/search.ts`,
|
||
`tests/sort-values.test.ts`) - the option was a free string, and jobindex does not reject an
|
||
unknown sort: `--sort newest` returned exit 0 in an order that was neither relevance nor date,
|
||
so the requested newest-first sort was silently not applied. The option is now an enum like
|
||
`--format`, and any other value exits 1 with the CLI's validation error before any request.
|
||
|
||
- **`jobnet-search search --occupation` filters by the ESCO occupation that `occupations` prints**
|
||
(#649, `.agents/skills/jobnet-search/cli/src/commands/search.ts`, `tests/occupation-filter-cli.test.ts`,
|
||
`SKILL.md`, `cli/README.md`) - the skill and README said to pass the `conceptUriDa` UUID from
|
||
`occupations` to `--occupation-area` / `--occupation-group`, but those take the numeric area and
|
||
group codes (`10000`, `10060`); a UUID there returned a successful search with zero ads, while the
|
||
API's `occupations` parameter returns the 830 nurse ads for the same UUID. The new flag sends that
|
||
parameter and accepts the full URI or the bare UUID; the docs stop pointing at the numeric flags.
|
||
|
||
- **`jobdanmark-search search` can filter by the administrative regions that `locations` returns**
|
||
(#650, `.agents/skills/jobdanmark-search/cli/src/commands/search.ts`, `tests/region-filter-cli.test.ts`,
|
||
`SKILL.md`, `cli/README.md`) - `locations` lists two kinds of region: landsdele such as `Østjylland`
|
||
(category `region`) and the five administrative regions such as `Region Midtjylland` (category
|
||
`administrativeRegion`), and the search API filters each by its own type. `--region` always sent
|
||
type `region`, so an administrative region - and SKILL.md's own example `--region Midtjylland` - came
|
||
back as a successful search with zero jobs. A new `--administrative-region` flag sends the
|
||
`administrativeRegion` filter (2,486 jobs for `Region Midtjylland`), and the docs map each `locations`
|
||
category to its flag and give `--region` examples that exist.
|
||
|
||
- **`upstream_triage.py` skips a new upstream file inside a directory the fork removed**
|
||
(#656, follow-up to #585, `tools/upstream_triage.py`, `tests/test_upstream_triage.py`) - #585 counted
|
||
every path absent from both HEAD and the merge base as new upstream work, so a file added
|
||
inside a removed tree landed under "Worth reviewing": on a fork that dropped
|
||
`.agents/skills/jobnet-search/`, #586 (which adds a test file there and edits a source file
|
||
that is also gone) was listed with a ready-to-run cherry-pick that can only conflict. A new
|
||
path now counts as removed when its nearest ancestor directory exists at the base but not
|
||
in HEAD; a new file or subdirectory under a directory the fork kept is still worth reviewing.
|
||
|
||
- **The `/reset` skeleton test lets a fork's profile carry its own `##` sections** (#676,
|
||
`tests/test_reset_command.py`) - it compared `/reset`'s `##` headings with
|
||
`01-candidate-profile.md` and `02-behavioral-profile.md` exactly, so a fork that added a section
|
||
to its own profile failed its CI and local runs although `/reset` worked. Off upstream it now
|
||
requires every shipped heading to appear in order, so an added section passes and a renamed,
|
||
dropped or reordered one still fails; upstream keeps the exact comparison.
|
||
|
||
- **`verify_layout.py` uses a `pdftotext` that supports `-bbox` wherever it sits on PATH** (#668,
|
||
fixes #655, `tools/verify_layout.py`, `tests/test_verify_layout.py`, `.claude/commands/apply.md`) -
|
||
under Git Bash, which Claude Code uses on Windows, Git's xpdf `pdftotext` 4.00 (no `-bbox`) sits
|
||
ahead of MiKTeX's Poppler, so the layout check skipped on the standard Windows setup even with
|
||
Poppler installed. It now probes each distinct `pdftotext` on PATH with `-h`, runs the first that
|
||
lists `-bbox` by its full path, and keeps the `skipped:` exit 2 for when none does.
|
||
|
||
- **`/notion-sync` can set a page's Status to `expired`** (#666, fixes #597) (`.claude/commands/notion-sync.md` Steps 2-5,
|
||
`tests/test_notion_sync_command.py`) - Step 2 built the sync set from `ranked` entries and tracker rows only, so a job
|
||
synced while ranked and later retired by `/rank`'s sweep never re-entered the set and its page stayed `ranked`, although
|
||
Step 4 says to set Status to `expired`. `expired` entries now join the sync set as update-only candidates: a matching page
|
||
gets Status `expired` and nothing else, a Key with no page is skipped rather than created, no body is written for them even
|
||
under `--rebuild`, and a first run with only such entries stops before creating the database. An expired posting with a
|
||
tracker row still syncs as an ordinary row.
|
||
|
||
- **`/rank` Step 2 no longer forbids the web search its own escalation requires** (#638) (`.claude/commands/rank.md`) - the
|
||
escalation bullet ends with a search for the employer's own careers site (step 4 of `09-web-research.md` is `WebSearch`),
|
||
and the next bullet said "no web searches", in the same prompt every scoring agent receives. The scope bullet now allows a
|
||
web search only for that last escalation step, finding the employer's own copy of the posting; company research and salary
|
||
lookups stay out of `/rank`.
|
||
|
||
## [1.7.3] - 2026-10-19
|
||
|
||
### Added
|
||
|
||
- **One cross-platform command installs every portal CLI: `python3 tools/install_portal_clis.py`**
|
||
(#540, `README.md` quick start step 2, `SETUP.md` section 3,
|
||
`tests/test_install_portal_clis.py`) - the README and SETUP.md each carried a PowerShell and a
|
||
Bash loop over a hand-copied list of the six shipped portals, four copies in all, while CI's
|
||
`discover-clis` job globs `.agents/skills/*/cli/package.json` precisely so a portal added in a
|
||
fork needs no list edit. A portal generated by `/add-portal` or borrowed from another fork was
|
||
therefore skipped by the documented install on every fresh clone of that fork. The script uses
|
||
CI's glob, installs each CLI independently so one failure does not stop the rest, and skips -
|
||
with a message naming the reason - any manifest that declares a lifecycle script or
|
||
`trustedDependencies`, the same set `security_guards.py` forbids in-tree, so a borrowed portal
|
||
carrying one is read before it runs. `--dry-run` lists what it would install.
|
||
|
||
### Changed
|
||
|
||
- **`/apply` and `/rank` route matching portal URLs to installed CLI `detail` before `WebFetch`**
|
||
(#535, `.claude/commands/apply.md` Step 0, `.claude/commands/rank.md` Step 2,
|
||
`.claude/skills/job-application-assistant/09-web-research.md`) - job portal URLs pasted
|
||
into `/apply` or evaluated in batch by `/rank` now invoke the matching installed portal CLI's
|
||
`detail <url> --format plain` command first, avoiding bot-detection 403s and login walls on
|
||
aggregator boards before falling back to `WebFetch`. Escalation order in `09-web-research.md`
|
||
places portal CLI detail as the first rung (`framework_version: 1.1.1 -> 1.1.2`).
|
||
|
||
- **`/apply` Step 5e names explicit cleanup shell commands for build intermediates**
|
||
(#531, `.claude/commands/apply.md`) - the step previously described deleting `.aux`/`.log`/`.out`
|
||
in prose, leaving artifact cleanup vulnerable to being skipped by agents. It now specifies explicit
|
||
`rm` commands for stock/custom LaTeX toolchains and redirected `build/` directories, timed after
|
||
log-inspecting steps 5b-5d complete, and explicitly preserves template assets (`cover.cls`,
|
||
`OpenFonts/`, custom template files).
|
||
|
||
- **`/rank` retries a posting it could not fetch instead of expiring it** (#534,
|
||
`tools/rank_state.py`, `.claude/commands/rank.md`) - a data-model change: scoring agents
|
||
now return a third status, `unreachable` (with a short `fetch_error`), when retrieval still
|
||
fails after the `09-web-research.md` escalation order (401/403, login wall, 404, timeout,
|
||
5xx, empty page), and keep `expired` for positive evidence the posting is closed (closed
|
||
page, 410, redirect to a listing page, passed deadline). Before, both became `expired`,
|
||
which `candidates` never selects again, so a transient failure on ranking day removed a
|
||
live job from the pipeline and the report presented it as closed. `candidates` now selects
|
||
`unreachable` alongside `new`; `apply` increments `fetch_attempts` and stores
|
||
`last_fetch_error`, and after 3 attempts with at least 48 hours between the first and the
|
||
last it writes `expired` with `expired_reason: "unreachable"` and reports the job once under
|
||
"Gave up, check manually" with its URL, so a login-walled live posting is still seen by a
|
||
human. The 48-hour floor keeps a scheduled run plus a manual one on the same day from
|
||
burning every attempt on one outage. A failed fetch on a posting whose stored deadline has
|
||
already passed still expires at once, as on the scored path, rather than being retried and
|
||
listed as possibly live. `rank_state.py` owns the status set (`AGENT_STATUSES`) and a test
|
||
pins `rank.md`'s JSON contract to it. `09-web-research.md`'s give-up step now marks a
|
||
`/rank` entry `unreachable` rather than `expired` (`framework_version: 1.1.2 -> 1.1.3`), so
|
||
an agent following it cannot write a 403 off as closed. No migration: no existing entry
|
||
carries the new status. Reported by @ai-tvang, who also proposed the time floor and the
|
||
give-up listing.
|
||
|
||
- **`freehire-search --no-description` calls the lighter web search, and `search` stops sending two
|
||
parameters the API ignores** (#631, `.agents/skills/freehire-search/cli/src/commands/search.ts`,
|
||
`SKILL.md`, `cli/README.md`, `url-reference.md`) - `semantic_ratio` and `include_description`
|
||
came back in `meta.ignored_params`, so `--no-description` still made the API read every hit's
|
||
full description and then dropped it client-side. It now calls `/api/v1/jobs/search` (same
|
||
query, facets, ranking and pagination, preview-only hits), measured at roughly half the bytes
|
||
per page and inside freehire's wider public rate budget. Default search is unchanged.
|
||
|
||
- **`freehire-search` names this project in its User-Agent** (#633,
|
||
`.agents/skills/freehire-search/cli/src/helpers.ts`, `url-reference.md`) - the old
|
||
`freehire-search-skill/1.0 (+https://freehire.me)` pointed at the API itself, so freehire's
|
||
operators could not tell whose client it was. It now sends
|
||
`MadsLorentzen/ai-job-search/1.0 (+https://github.com/MadsLorentzen/ai-job-search; freehire-search)`,
|
||
the `owner/project` form freehire's API docs ask for. Nothing behaves differently; the API does
|
||
not gate on it.
|
||
|
||
### Fixed
|
||
|
||
- **`.gitignore` ignores `node_modules` without a trailing slash, and `security_guards.py` fails on any tracked `node_modules` path** (`.gitignore`, `tools/security_guards.py`, `tests/test_security_guards.py`) - the old `node_modules/` rule matched directories only, so a symlink named `node_modules` pointing at a contributor's absolute path was committed in a PR, broke `bun install` in CI with a dangling link and checked out as a text file on Windows. The rule loses its slash, and the guard now lists every tracked path with a `node_modules` component (skipped quietly outside a git checkout). Also qualifies the one bare `seen_jobs.json` mention left in `README.md` after #617.
|
||
|
||
- **`job_key.py --audit` reports a state file that is not an object of entries** (#626) (`tools/job_key.py`,
|
||
`tests/test_job_key.py`) - a top-level JSON array, or an entry that is a string or number, crashed the
|
||
audit with an `AttributeError` traceback on `.get`, although the function already had a one-line error
|
||
for an unusable `seen` value. Both now print a one-line message naming the problem and exit 1.
|
||
|
||
- **`freehire-search` emits the contract's `YYYY-MM-DD` date instead of the API's ISO timestamp** (#621)
|
||
(`.agents/skills/freehire-search/cli/src/helpers.ts`, `tests/parsing.test.ts`,
|
||
`tests/commands.test.ts`, `SKILL.md`) - `toResult` passed `posted_at` through verbatim, so
|
||
search and detail JSON carried `"date": "2026-07-06T00:00:00Z"` while every other portal CLI
|
||
emits the date part only and `/scrape` stores it as `posted_date: "YYYY-MM-DD"`. The table
|
||
and plain renderers already cut the value to ten characters, so only JSON consumers - the
|
||
`/scrape` path - saw the raw timestamp. A `contractDate()` helper now cuts the timestamp to
|
||
its date part and yields `null` for an absent or non-date value; the two tests that pinned
|
||
the raw timestamp now expect the date and fail on master, plus three cases on the helper.
|
||
|
||
- **`/rank`, `/notion-sync` and `/upskill` name `job_scraper/seen_jobs.json` by its full path** (#617)
|
||
(follow-up to #608, `.claude/commands/rank.md`, `.claude/commands/notion-sync.md`,
|
||
`.claude/skills/upskill/SKILL.md`, `tests/test_scrape_seen_jobs_path.py`) - #608 qualified the
|
||
path in `/scrape`, where the file is written; the three readers still said the bare
|
||
`seen_jobs.json`, which an agent can resolve to the repo root, outside the ignore rule. The
|
||
guard test now covers all four specs, and `test_notion_sync_command.py` pins the qualified
|
||
phrase.
|
||
|
||
- **Framework version checks compare marker values using Git-compatible paths on every platform** (#578, `tools/check_framework_version.py`) - quoting or spacing an unchanged version no longer exempts an accompanying guidance edit, hardening a latent bypass without changing legitimate version bumps.
|
||
|
||
- **`/reset documents` clears nested and hidden user entries while preserving scaffolds** - cleanup retains tracked `.gitkeep` files and the README, with the complete inventory shown before confirmation (#580).
|
||
|
||
- **`jobindex-search` examples follow the search CLI contract** - the posted-today example includes a required query, unsupported flags are removed, and regression checks accept LF and CRLF checkouts (#579).
|
||
|
||
- **`convert_salary_excel.py` converts the IDA workbook again** (#615, `tools/convert_salary_excel.py`,
|
||
`tests/test_convert_salary_excel.py`) - the workbook heads its company column "Firmanavn og by",
|
||
and since #75 made header matching whole-token only, "firma" no longer matched inside "Firmanavn",
|
||
so both sheets were skipped with "Could not find header row" and the converter exited 1 on the
|
||
repository's own demonstration data. "firma" is now a compound token, like the other glued Danish
|
||
headers; every other workbook converts byte-identically.
|
||
|
||
- **`/scrape`'s entry schema lists the `unreachable` status** (#615, `.claude/skills/job-scraper/SKILL.md`) -
|
||
follow-up to #564: the `status` enum in the `seen_jobs.json` schema block still read
|
||
`new/skipped/ranked/expired` while the dedup rule two sections down already named `unreachable`.
|
||
|
||
- **`/scrape` names `job_scraper/seen_jobs.json` by its full path in every step** (#608,
|
||
`.claude/skills/job-scraper/SKILL.md`, `tests/test_scrape_seen_jobs_path.py`) - seven references
|
||
after Step 0, including the Step 4 write instruction, used the bare filename, and an assistant
|
||
following them wrote the state file at the repository root, where the
|
||
`**/job_scraper/seen_jobs.json` ignore rule does not reach it; one fork committed its scrape
|
||
history that way. Every reference now carries the directory, and a test fails on any bare mention.
|
||
|
||
- **`salary_lookup.py` accepts a BOM-prefixed `salary_data.json`** (#594, #602, `salary_lookup.py`,
|
||
`tests/test_salary_lookup.py`) - the data file is opened with `utf-8-sig`, so a file saved by
|
||
Windows PowerShell `Out-File`/`Set-Content` or by an editor that prepends a byte-order mark now
|
||
validates and loads instead of exiting 1 with "Unexpected UTF-8 BOM". Plain UTF-8 files read
|
||
identically, and malformed JSON after a BOM still reports its line and column. Reported by
|
||
rahul05ranjan; same class as #485 and #509.
|
||
|
||
- **Upstream triage emits cherry-pick suggestions in dependency order**
|
||
(`tools/upstream_triage.py`) - ready-to-run commands now list ancestors before
|
||
descendants, so a follow-up patch is not suggested before the change it edits.
|
||
The report tables remain newest first; the tool still executes no Git mutations.
|
||
|
||
- **`/upskill` and `/notion-sync` now name the field ranked entries actually carry when joining
|
||
them to tracker rows** (`.claude/skills/upskill/SKILL.md` Step 3, `.claude/commands/notion-sync.md`
|
||
Step 2, `tests/test_upskill_skill.py`, `tests/test_notion_sync_command.py`) - both said to match
|
||
"on company + role" with "casefold + strip on both fields", but a `seen_jobs.json` entry has
|
||
`title` and no `role` key (the job-scraper schema), so read literally the lookup never matched:
|
||
`/upskill` then weighted the same job twice, once as a tracker row and once as a ranked entry,
|
||
the double-count its dedupe step exists to prevent, and `/notion-sync` split one job into two
|
||
rows with different Keys. `tools/rank_state.py` already does the cross-name join (tracker
|
||
`role` against entry `title`); both specs now say so. Two derived tests read the entry schema
|
||
and require the join wording to name `title` for the entry and `role` for the tracker; they
|
||
fail on master.
|
||
|
||
- **`/reset profile` keeps the `### Languages` table header under Identity**
|
||
(`.claude/commands/reset.md` Step 3, `tests/test_reset_command.py`) - `04-job-evaluation.md`'s
|
||
Language Gate and `job-scraper/search-queries.md`'s query-language generation both read "your
|
||
Languages table" from `01-candidate-profile.md`, but the skeleton `/reset` wrote left
|
||
`## Identity` empty, so after a reset `/rank` and `/scrape` looked for a table the file no
|
||
longer had. The skeleton now carries `### Languages` and the table's header and separator
|
||
rows, derived from the shipped file in the test; the placeholder data row is deliberately
|
||
not reintroduced. Follow-up noted on #532.
|
||
|
||
- **`05-cv-templates.md` snippets write the `\cventry` date argument with an ASCII hyphen, as the same
|
||
file's own rule requires** (`.claude/skills/job-application-assistant/05-cv-templates.md`,
|
||
`framework_version: 1.4.6 -> 1.4.7`) - the in-progress-qualification snippet under "In-progress
|
||
qualifications must say so explicitly" wrote `\cventry{2025--2026}`, the `\needspace` snippet
|
||
under the orphaned-entry fix wrote `\cventry{YEAR--YEAR}`, and the in-progress section offered a date field
|
||
of `2025–present` (U+2013) as an accepted alternative. All three are values copied straight into a
|
||
CV, so an assistant following the file produced exactly the en-dash that **Date fields must be ASCII
|
||
ranges** forbids 130 lines further down, and that `/apply` Step 5d then rejects: inserting the
|
||
in-progress snippet as shipped into `cv/main_example.tex` and compiling with `lualatex` makes
|
||
`python tools/verify_pdf.py cv/main_example.pdf --pages 2 --ascii-dates` exit 1 with
|
||
`U+2013 in '... 2025–2026'`, where the unmodified template passes. #276 fixed the template's own
|
||
date fields and added the rule section, but these snippets predate it (#210) and were missed. The
|
||
deliberate negative example (`% en-dash, may not`) and the illustrations of the forms `--ascii-dates`
|
||
catches are left as they are.
|
||
|
||
- **`verify_pdf.py --ascii-dates` no longer reads a numeric range with year-shaped ends as a
|
||
date** - #512 bounded the year to four standalone digits, but `EUR 1500–2000 per month` and
|
||
`2000–5000 requests per second` still failed `/apply` Step 5d: each has a four-digit number
|
||
next to the dash that happens to fall in 1900-2099. A range whose two ends are both bare
|
||
numbers (the whole whitespace-delimited token, outer punctuation stripped) now counts as a
|
||
date only when both are years, so the salary band and the throughput range pass, while
|
||
`2016–2024`, `1990–2010` and `2016 – Present` still fail as before and a numerically
|
||
formatted range (`03/2016–07/2024`, `01.2016 – 12.2024`, `2016-03–2024-07`, `2016/17–2018/19`)
|
||
is not a bare number on either side and is still caught. Three new `test_verify_pdf.py`
|
||
cases; the quantity one fails on master.
|
||
|
||
- **`upstream_triage` forces UTF-8 stdout and decodes git output as UTF-8** (#544,
|
||
`tools/upstream_triage.py`, `tests/test_tools_utf8_output.py`) - like the other Python tools,
|
||
the triage report now calls `_force_utf8_output()` on startup and decodes git subprocess
|
||
streams with `encoding="utf-8", errors="replace"`. Under Windows default ANSI code pages (such as
|
||
`cp1252`), upstream commits with Cyrillic, CJK, or emoji characters in their subjects raised
|
||
`UnicodeEncodeError` when writing stdout or `UnicodeDecodeError` in subprocess pipes, terminating
|
||
triage runs before the report could be emitted.
|
||
|
||
- **`jobnet-search occupations` preserves full identifiers in table output** - ESCO URIs and UUIDs are no longer truncated, with documentation-based regression checks covering LF and CRLF checkouts (#586).
|
||
|
||
- **`jobnet-search` routes external ads to employer `jobAdUrl` rather than broken `find-job` links**
|
||
(#538, `.agents/skills/jobnet-search/cli/src/commands/search.ts`, `.agents/skills/jobnet-search/cli/README.md`,
|
||
`.agents/skills/jobnet-search/cli/tests/search-normalization.test.ts`) - external job postings on
|
||
Jobnet (`isExternal: true`) point to `/find-job/{jobAdId}`, which returns 404, while the Jobnet API
|
||
carries the destination in `jobAdUrl`. The search command now routes external ads to `jobAdUrl` when
|
||
a valid http(s) destination is present, falling back to `/find-job/{jobAdId}` when missing or
|
||
non-http(s) to preserve distinct URLs for `/scrape` deduplication. Both `url` and `jobAdUrl`
|
||
validate the scheme the same way. Reported with live testing by NishilRathod.
|
||
|
||
- **Fork-skip guards no longer run on a fork during a local test run** (`tests/_upstream.py`,
|
||
`tests/test_placeholder_integrity.py`, `tests/test_setup_command.py`,
|
||
`tests/test_upstream_checkout.py`) - the fork skip read
|
||
`os.environ.get("GITHUB_REPOSITORY", UPSTREAM)`, so with the variable unset a local
|
||
run fell back to upstream and the placeholder guards fired on every personalized fork,
|
||
leaving its suite permanently red after `/setup`. The three skips now share one helper:
|
||
`GITHUB_REPOSITORY` decides when set, otherwise the checkout's `origin` remote is matched
|
||
against the upstream repo (https and ssh forms, optional `.git` suffix), and an
|
||
undeterminable checkout assumes upstream. Tests only, no `framework_version` bump.
|
||
|
||
- **Upstream companion commands retain the selected refs** (#591) - generated follow-up commands now preserve custom base and head references instead of silently comparing the defaults.
|
||
|
||
- **`linkedin-search` and `freehire-search` reject invalid `--format` flags with `BAD_ARG`** (#555,
|
||
`.agents/skills/linkedin-search/cli/src/cli.ts`, `.agents/skills/freehire-search/cli/src/cli.ts`) -
|
||
unrecognized `--format` flags previously fell back silently to `"json"` with exit code 0, masking
|
||
formatting typos and violating the CLI contract. Both CLIs now validate format arguments strictly
|
||
(`json|table|plain` for search, `json|plain` for detail) and exit 1 with `{ error, code: "BAD_ARG" }`
|
||
on invalid options.
|
||
|
||
- **Upstream checks report incomplete comparisons** (#583) - missing base or head framework files now remain visible as incomplete comparisons instead of being reported as unchanged.
|
||
|
||
- **`jobdanmark-search detail` no longer keeps a malformed closing tag as text on the
|
||
rendered-HTML branch** (#525, `detail.ts`, `tests/detail-parsing.test.ts`) - a live page wrote
|
||
its Jobtype item as `Fuldtid </ li >`, and because `node-html-parser` does not recognise a
|
||
closing tag with a space after the slash, the fragment reached the output verbatim:
|
||
`employmentType: ["Fuldtid </ li >"]`, a value that matches none of the documented job-type
|
||
vocabulary. The same `cleanText` feeds every text field on both branches, so any of them
|
||
could carry it. `cleanText` now drops the closing-tag shape (`</ li >`, `</ li>`, `< /li>`)
|
||
before collapsing whitespace - and only that shape, because the parser decodes entities
|
||
before `cleanText` runs, so author text the page encoded as `<div>` or `<T>`
|
||
arrives as a literal `<div>` / `<T>` and must survive. Two fixtures: the stray tag in both
|
||
the Jobtype item and a description paragraph (fails on master), and an entity-encoded
|
||
`<div>` in a description that stays in the text.
|
||
|
||
- **Upstream checks distinguish new files from fork deletions** (#585) - a path absent from the base is reported as newly added upstream instead of incorrectly treated as a deletion in the fork.
|
||
|
||
- **`/add-portal` includes the `.agents` prefix in generated `allowed-tools`** (#545,
|
||
`.claude/commands/add-portal.md`, `tests/test_add_portal_command.py`) - the specification template
|
||
for the new portal's `SKILL.md` frontmatter wrote `allowed-tools: Bash(bun run skills/<name>/cli/src/cli.ts *)`
|
||
without `.agents/`, the one path in the file without it. A skill's `allowed-tools` grants the
|
||
listed command when that skill is invoked, so a generated portal invoked on its own granted a
|
||
command that does not exist and prompted instead; the string now names the real path, matching
|
||
the six shipped `SKILL.md` files.
|
||
|
||
- **Salary workbook conversion accepts Unicode whitespace grouping** (#592) - numeric cells written as text with non-breaking or narrow non-breaking grouping spaces now parse without losing their values.
|
||
|
||
- **`robots_check` unquotes incoming query strings and restricts `$` anchors to pattern end**
|
||
(#543, `tools/robots_check.py`, `tests/test_robots_check.py`) - two path-matching edge cases in
|
||
the robots.txt gate failed open. First, `gate(url)` called `unquote(parts.path)` but concatenated
|
||
`parts.query` raw, while `_match()` decodes the pattern: rules targeting encoded query parameters
|
||
(such as `Disallow: /search?q=machine%20learning`) never matched the incoming URL, evaluating
|
||
disallowed scraping targets as allowed. Query parameters are now decoded when assembling `path`.
|
||
Second, `_match()` converted every `$` character unconditionally into a regular-expression
|
||
end-of-string anchor, causing rules with an internal `$` (e.g. `/tag/$var`) to never match; under
|
||
RFC 9309 section 2.2.3, `$` is an end anchor only at the end of the pattern, and internal `$`
|
||
characters are now matched literally.
|
||
|
||
- **Salary workbook conversion prefers exact identity headers** (#571) - exact company column names take precedence over incidental substring matches in other headers.
|
||
|
||
- **CV template guidance aligns geometry scale with master template** (#553,
|
||
`.claude/skills/job-application-assistant/05-cv-templates.md`, `tests/test_latex_guidance.py`) -
|
||
the preamble snippet in `05-cv-templates.md` specified `\usepackage[scale=0.77]{geometry}`,
|
||
whereas the master reference template `cv/main_example.tex` configures `scale=0.80`. The guide
|
||
has been aligned to `scale=0.80`, preventing tighter margins and unwanted page overflow during
|
||
CV generation, and a test now guards that the two files stay in sync.
|
||
|
||
- **Salary workbook conversion skips chart-only tabs** (#590) - chartsheets no longer cause iteration failures while worksheet data continues to be converted.
|
||
|
||
- **`.claude/settings.local.json` is ignored in `.gitignore` and pinned in security guards** (#551,
|
||
`.gitignore`, `tools/security_guards.py`) - Claude Code stores machine-local settings and
|
||
ad-hoc tool approvals in `.claude/settings.local.json`. The file is now excluded from version
|
||
control in `.gitignore` and pinned in `REQUIRED_IGNORE_RULES` to prevent fork users from
|
||
accidentally committing local session configurations.
|
||
|
||
- **Salary lookups distinguish missing indexes from suppression** (#584) - count-only data displays an unavailable index without implying that a privacy-suppression threshold was applied.
|
||
|
||
- **`/rank` retires scored jobs whose preserved deadline has passed**
|
||
(`tools/rank_state.py`, `tests/test_rank_state.py`) - a fresh `null` deadline
|
||
preserves the stored date, but `apply` previously wrote the job as `ranked`
|
||
even after that date had passed. The later sweep excludes re-scored keys,
|
||
so it could not correct the state or shortlist. Apply now expires the job
|
||
before changing rank metadata; current-day deadlines, explicit future
|
||
extensions, invalid dates, dry runs, and scoring errors keep their behavior.
|
||
|
||
- **Salary workbook conversion skips whitespace-only companies** (#576) - blank-looking company cells no longer produce empty company entries.
|
||
|
||
- **`/reset profile` keeps the `framework_version` frontmatter on the two files it rewrites**
|
||
(`.claude/commands/reset.md` Step 3, `tests/test_reset_command.py`) - the full-file skeletons
|
||
for `01-candidate-profile.md` and `02-behavioral-profile.md` started at the `#` title, while
|
||
the shipped files open with a `---` block carrying `framework_version`. Applied as written,
|
||
one `/reset profile` left both files failing `tools/check_framework_version.py` ("missing
|
||
'framework_version' in frontmatter") and invisible to `tools/check_upstream_updates.py`,
|
||
which records a configuration error for a file with no local version to compare - the fork
|
||
update path the README sends every fork through. The step now says to keep the frontmatter
|
||
block and replace only what follows, with a note under "Profile reset" that the marker is
|
||
framework data, never candidate data; the version is not hardcoded into the skeleton, where
|
||
it would go stale on the next bump. A derived test applies each skeleton the way the step
|
||
says and asserts the shipped file's version still parses out unchanged; it fails on master.
|
||
Found while re-checking #519's class of drift.
|
||
|
||
|
||
- **`/reset profile` now targets the headings the shipped skill files actually have**
|
||
(`.claude/commands/reset.md` Step 3, `tests/test_reset_command.py`) - two of its Step 3
|
||
targets had drifted. The `05-cv-templates.md` step anchored on a section beginning
|
||
`**Profile statement templates`, a line the guide has never contained (its heading is
|
||
`**Create 2-3 profile statement templates for your main role types:**`), so the wipe found
|
||
nothing: the user's role-tailored profile statements, and any `*[Used for: ...]*`
|
||
statements `/setup` Path A extracted from archived applications, survived a reset that
|
||
reported success. The `02-behavioral-profile.md` skeleton used headings the shipped file
|
||
does not have (`Strongest Behavioral Traits`, `How I Work Best`, a truncated `Growth Areas`)
|
||
and dropped `## Core Behavioral Drives` - the assessment table the shipped file carries - so
|
||
one reset left the file's headings permanently different
|
||
from every other fork's, with nothing to recreate the table. The anchor now names the real
|
||
heading and the replacement re-emits it; the skeleton carries the shipped headings in order.
|
||
`01-candidate-profile.md` and `07-interview-prep.md` were already correct. Two new tests
|
||
derive both checks from the shipped files, so a renamed heading fails until `/reset`
|
||
follows it; both fail on master.
|
||
|
||
- **`freehire-search` rejects empty `--facet` filters before requesting jobs** (#561) -
|
||
a bare flag (including one after a valid repeated facet), `salary_min=`, or a
|
||
comma-separated value list containing only whitespace now returns `BAD_ARG` and
|
||
exits 1 instead of silently dropping the filter and running a broader search.
|
||
|
||
- **Salary lookups validate metadata before rendering** (#575) - malformed metadata produces an actionable error instead of a rendering traceback.
|
||
|
||
- **The `/apply` host-check test derives its installed-portal hosts from the skills tree
|
||
instead of a hand-copied set** (#497, `tests/test_apply_host_check.py`) - `SHIPPED_PORTAL_HOSTS`
|
||
was six names typed into the test and nothing tied it to anything: a portal added by
|
||
`/add-portal` fetches from a host the copy never heard of, so its postings reached `/apply`
|
||
classified "unverified" while the suite stayed green, and a dropped portal left a stale
|
||
name behind. Rule 1 in `apply.md` defines an installed portal as "any configured job portal
|
||
in `.agents/skills/`", so that tree is now the source: the host literals in each
|
||
`*-search/cli/src/**/*.ts` (one `BASE_URL` per shipped CLI, `www.` dropped so apex and
|
||
subdomain both match), discovery by installation rather than by the `enabled:` flag
|
||
because an installed-but-disabled board is still a legitimate source for a pasted posting.
|
||
`classify_posting_host`'s default is that discovered set, and a new test class asserts that
|
||
every installed portal has a discoverable host and that each host classifies as
|
||
`installed_portal` at the apex, under `www.` and under a subdomain. The ATS apex list stays
|
||
the fixed set it is; parsing `apply.md`'s example list was deliberately not done, since a
|
||
parser keyed on prose wording is the next thing to go stale. Reported by Holo-Eter.
|
||
|
||
- **Danish portal CLIs reject missing option values** (#573) - value-taking flags followed by another option now fail before search dispatch instead of treating the next flag as the value.
|
||
|
||
- **`/expand` and `/setup` now append behavioral findings to sections `02-behavioral-profile.md`
|
||
actually has** (`.claude/commands/expand.md` Step 5, `.claude/commands/setup.md` Step A7,
|
||
`tests/test_expand_command.py`, `tests/test_setup_command.py`) - both told the model to add
|
||
to "Strongest Behavioral Traits" and "How I Work Best" / "How [Candidate] Works Best" while
|
||
also saying "match existing structure", but the shipped file's headings are "Strongest
|
||
Behaviors" and "How You Work Best", so an inferred trait either opened a duplicate
|
||
near-identical section or was dropped. The companion to #519, which fixed the same drift in
|
||
`/reset`'s skeleton; the maintainer asked for this half as its own PR. Both bullets now name
|
||
the shipped headings, and a derived test in each command's suite reads the quoted section
|
||
names and requires each to be a `##` heading of the shipped file; both fail on master.
|
||
|
||
- **`freehire-search` rejects missing named-option values** (#568) - value-taking search options now reject a missing value or another flag before dispatch.
|
||
|
||
- **`jobnet-search` and `jobdanmark-search` search results now carry the contract's `id`
|
||
field** (`search.ts` and `search-normalization.test.ts` in both CLIs, both `SKILL.md`s) -
|
||
`/add-portal`'s contract says every search result has at least `id`, `title`, `company`,
|
||
`location`, `date`, `url`, and the four other shipped CLIs emit `id`. When #340 added the
|
||
`company`/`location`/`date`/`url` aliases to these two, `id` was left out: jobnet exposed the
|
||
value only as `jobAdId`, jobdanmark only as `slug`, so a consumer reading every portal's
|
||
JSON through one shape had to special-case both to call `detail`. Purely additive: `id`
|
||
equals `jobAdId` on jobnet and `slug` on jobdanmark, the native keys stay, nothing is
|
||
renamed. The additive-contract test in each CLI now asserts `id` and its equality with the
|
||
native key; both fail on master.
|
||
|
||
- **Danish portal installation examples use the actual CLI directories** (#589) - documented install commands now include the `.agents/skills` prefix.
|
||
|
||
- **`jobdanmark-search detail` renders a JSON-LD description as text instead of passing
|
||
the markup through** (`.agents/skills/jobdanmark-search/cli/src/commands/detail.ts`,
|
||
`tests/detail-jsonld.test.ts`) - the JSON-LD branch emitted `jobPosting.description`
|
||
verbatim, so `detail --format plain` printed
|
||
`description: <p>Vi søger en udvikler til R&D.</p><ul><li>Python & Go</li>...` while the
|
||
rendered-HTML branch of the same command printed one clean line per paragraph and bullet:
|
||
two description shapes from one command depending on which page layout it hit, and the
|
||
markup landed in `/scrape`'s stored snippet and `/rank`'s agent context as-is. The
|
||
portal contract (`/add-portal` Step 4) asks for "readable text (entities decoded, tags
|
||
stripped, paragraph breaks preserved)". The JSON-LD branch now renders the HTML through
|
||
`node-html-parser`'s `structuredText` - one line per block element, entities decoded, tags
|
||
gone - which is the shape the fallback already produces. A plain-text description is
|
||
unchanged, an absent one stays `""`. The JSON-LD test that pinned the raw `<p>...</p>` now
|
||
expects the text, plus two new cases; the HTML case fails on master.
|
||
|
||
- **Valid portal flag tests stay offline** (#581) - success controls use local fixtures instead of depending on live job portals or their credentials.
|
||
|
||
- **`job_key.py` no longer gives two postings one key when a title or company is partly
|
||
non-Latin** (`tools/job_key.py`, `tests/test_job_key.py`) - the hash fallbacks from #487
|
||
and #502 fired only when the slug was completely empty, so a mixed-script name kept its
|
||
Latin or digit fragment and skipped them: `Программист 1С` and `Аналитик 1С` at one company
|
||
both keyed as `company-6d769a_1`, `Python-разработчик` and `Python-аналитик` as `..._python`,
|
||
`Сбер AI` and `Яндекс AI` as `ai_...`. `/scrape` Step 4 then dropped the second posting as
|
||
already seen, and because the dict key is overwritten, `--audit` could not report the
|
||
collision as a duplicate URL either. "1С" titles and "N категории" grade suffixes are
|
||
everyday Russian listings on freehire, the shipped multi-market portal. The fold is now
|
||
treated as lossy whenever it drops a letter outside the Latin script, and the existing
|
||
fallbacks take over: the title half uses the portal's numeric id or the URL hash with the
|
||
fragment kept as a readable prefix (`1-4461771225`, `python-fb29d7`), the company half uses
|
||
the NFC-casefold name hash with the fragment as prefix (`ai-d35210`). Latin letters that
|
||
also lack a decomposition (`ø`, `æ`, `ß`, `ł`) are deliberately not counted, so `Ørsted`
|
||
still keys as `rsted` and a live Danish `seen_jobs.json` does not re-key; an existing
|
||
mixed-script entry re-keys once on the next scrape and `--audit` lists it under
|
||
`keys_not_matching_current_rule`, the same one-time drift #502 accepted. Seven new cases;
|
||
the four collision cases fail on master. A follow-up keeps the rule inside its own case:
|
||
an empty title whose URL carries no six-digit run keys on the URL-derived basis alone
|
||
(`acme_https-example-com-jobs-abc-xyz`) exactly as before, where the first cut had started
|
||
appending the digest - unreachable from `/scrape`, which always passes a title, but a key
|
||
is a pure function of the posting and should not move for a case the rule was never about.
|
||
|
||
- **`verify_pdf.py --ascii-dates` no longer reads a year-like run inside a longer number as a
|
||
date** - the year pattern had no digit boundaries, so `2000` inside `120000` or `12000` made
|
||
`Grew budget DKK 120000–200000` and `12000–15000 events/s` fail `/apply` Step 5d as
|
||
en-dashed date ranges, and the agent was sent to fix a "date argument" that does not exist.
|
||
The guide promises a numeric range with no year is left alone; now it is. `2016–2024` next to
|
||
a longer number is still caught. Two new `test_verify_pdf.py` cases; the numeric one fails
|
||
on the unbounded pattern.
|
||
|
||
- **`jobbank-search` preserves repeated filter values** (#577) - repeated documented filter options now retain all supplied values instead of only the last value.
|
||
|
||
- **`check_framework_version.py`'s git diff read no longer crashes on non-ASCII framework-file
|
||
content** (`tools/check_framework_version.py`) - `run_git()` called
|
||
`subprocess.run(text=True)` without an explicit `encoding`, so output decoded via the host
|
||
locale's default codec instead of UTF-8. On a real Windows checkout (cp1252 default) this
|
||
raised `UnicodeDecodeError` on any byte cp1252 leaves undefined - Cyrillic Ё/ё, much CJK,
|
||
Á-class Latin - appearing in a framework file's diff, crashing the version gate before it
|
||
ever evaluated the change. `run_git()` now passes `encoding="utf-8"` and `errors="replace"`
|
||
(matching the convention already used elsewhere in this repo, e.g. `robots_check.py`),
|
||
decoding deterministically regardless of host locale. Pinned by `RunGitEncodingTests` in
|
||
`tests/test_check_framework_version.py`, which fails against the original un-pinned call.
|
||
|
||
- **`/rank` still sweeps deadlines when there is nothing new to score** (`.claude/commands/rank.md`
|
||
Step 1, `tests/test_rank_command.py`) - when `rank_state.py candidates` reported no eligible
|
||
jobs, Step 1 said "Nothing new to rank" and stopped before Step 3's rule 6 expiry sweep ever
|
||
ran. Once a backlog has been ranked, that is the path every later `/rank` takes, so
|
||
past-deadline jobs stayed `ranked` and approaching-deadline reminders were never shown.
|
||
Reproduced on the real CLI with four `ranked` entries (deadlines 2026-09-22, 2026-09-28,
|
||
null, `ASAP`): `candidates --today 2026-09-25` returns `eligible: 0` and master stops there,
|
||
while `sweep --write` on the same state expires the 09-22 entry, lists 09-28 under
|
||
`closing_soon` and `ASAP` under `unparseable_deadlines`, and leaves the null one alone. The
|
||
empty-candidate branch of Step 1 now runs `python3 tools/rank_state.py sweep --write`, skips
|
||
profile loading and Steps 2-4 (nothing to fetch or score; the tracker is untouched), and
|
||
presents a sweep-only Step 5 summary - `swept`, `newly_expired`, `closing_soon` with deadlines
|
||
and URLs, `unparseable_deadlines` with portals - before suggesting `/scrape`. An empty batch
|
||
caused by a focus filter or tracker exclusion takes the same path. Pinned by
|
||
`test_step1_empty_candidates_still_sweeps_and_reports`, which fails against master's `rank.md`.
|
||
|
||
- **`linkedin-search` preserves description structure** (#567) - job descriptions retain paragraph and list breaks instead of flattening them into one text block.
|
||
|
||
- **`robots_check` no longer reads a leading BOM or an undecodable rule as permission**
|
||
(`tools/robots_check.py`, `tests/test_robots_check.py`) - two decoding edge cases failed
|
||
open, both flagged as follow-ups in #506. A robots.txt saved with a UTF-8 byte-order mark
|
||
decodes to a body that starts with U+FEFF, so its first field was not `user-agent`: a
|
||
leading `User-agent: *` went unseen, every rule in that group was dropped for want of an
|
||
agent, and the gate read the file as allow-all. Reproduced through the CLI on real hosts:
|
||
cnnturk.com and sakarya.edu.tr serve `EF BB BF` + `User-agent: *`, and master printed
|
||
`ALLOWED` for their disallowed `/hesap/` and `/bin/`. `_groups()` and `is_robots_body()`
|
||
now skip one leading U+FEFF, as Google's reference parser does, so every caller of
|
||
`allowed()` is covered (a BOM-only body is now the empty file it is, allow-all, rather than
|
||
a soft 200). Separately, #506's `errors='replace'` turns raw non-ASCII bytes in a
|
||
non-conformant robots.txt saved in a legacy code page (cp1254, ISO-8859-9) into U+FFFD, so
|
||
a rule such as `Disallow: /şirket/` could never match and was silently skipped. A
|
||
User-agent, Allow or Disallow line holding U+FFFD now makes the body unreadable, and the
|
||
gate prints `UNCONFIRMED`. The field is named with U+FFFD removed, so a stray byte before
|
||
`Disallow` cannot hide the line either; U+FFFD in a comment or another field stays
|
||
harmless, and valid UTF-8 rules decide as before. Nine new tests; the seven that pin the
|
||
fail-opens fail on master.
|
||
|
||
- **Salary workbook conversion rejects non-finite text cells** (#572) - hand-typed `Infinity` or `NaN` text cells now produce validation errors instead of numeric-conversion tracebacks.
|
||
|
||
- **`verify_layout.py` finds an orphaned entry header by where the text starts**
|
||
(`tools/verify_layout.py`, `tests/test_verify_layout.py`) - the orphan rule compared line
|
||
left edges against the document margin, and a list marker moves a line's left edge without
|
||
moving its text. A template that merges each bullet's marker into its first line (#481) got
|
||
a bullet wrapping across the page break reported as an orphaned header, and a real orphaned
|
||
header followed by such bullets not reported at all. On the stock CV, when the outer marker
|
||
extracts as a line of its own, a `[Job Title]` header left at the foot of a page with its
|
||
bullets overleaf was not reported (its text at x70.4 counted as indented), and a list item
|
||
whose outer marker is set 1.3pt below its line was reported as a split bullet. The rule now
|
||
compares where the text of the page's last printed line starts - after any leading bullet
|
||
glyph from `• ‣ ▪ ● ·` (ASCII, dashes and other glyphs are text) - with where the text of
|
||
the next page's first printed line starts, and reports an orphaned header when the latter
|
||
is set further in. Bbox lines whose tops are within 3pt count as one printed line, and a
|
||
line whose text starts more than 60pt past the margin (a running header, a right-set date)
|
||
is not where the next page resumes. A lone marker with no text beside it keeps the
|
||
split-bullet report. On 304 builds of the stock CV (`\vspace*` 0-600pt before four
|
||
entries) this adds 15 reports, each a `[Job Title]` header at the foot of a page with its
|
||
bullets overleaf, and the other 289 builds give the same output as before.
|
||
|
||
- **One posting on a portal without a numeric id no longer gets a second key when its
|
||
title is re-listed with different casing** (#501, `tools/job_key.py`,
|
||
`tests/test_job_key.py`) - the key hashed a slugified title alongside the URL whenever
|
||
`normalizeId` found no six-digit run in the path, which is every freehire posting. The
|
||
live API returns both `Инженер` and `инженер` across rows, and the slugifier maps those
|
||
to different slugs, so one URL could be stored under several keys. `/rank` builds its
|
||
exclusion set from `seen_jobs.json`, so a posting already seen could be presented again.
|
||
The key is now a hash of the URL alone: the URL is the identity `/scrape` stores, and
|
||
the title was never a stable half of it. **Entries written before this fix for a
|
||
non-Latin title on a portal without a numeric id will re-key once on the next scrape.**
|
||
`python3 tools/job_key.py --audit` reports those entries under
|
||
`keys_not_matching_current_rule`; it never rewrites them.
|
||
|
||
- **Salary lookups preserve Unicode company identities and match ASCII-typed accented names such as `Nestle` to `Nestlé`, while retaining Nordic spelling variants** (#570).
|
||
|
||
### Security
|
||
|
||
- **Skill frontmatter no longer grants bare `Bash` or a portal-CLI glob**
|
||
(#642, `.claude/skills/job-application-assistant/SKILL.md` `framework_version: 1.3.4 -> 1.3.5`,
|
||
`.claude/skills/job-scraper/SKILL.md`, `.claude/commands/add-portal.md` Step 5) - a skill's
|
||
`allowed-tools` pre-approves the listed tools for the turn that invokes it, with no prompt and
|
||
regardless of workspace trust. The job-application skill granted `Bash`, `Edit`, `Write`
|
||
outright, and the scraper granted `bun run` on any `.agents/skills/*/cli/src/cli.ts`, while
|
||
`.claude/settings.json` carefully enumerated six portals - a public review by an AI agent
|
||
(shared on Reddit) read exactly that and concluded local use was unsafe as configured. The
|
||
tools scripts, salary lookup, `pdftotext` and the portal CLIs were already allowlisted per
|
||
command in `settings.json`, so no new standing grant is needed. What the bare grant did cover,
|
||
in a turn where the skill fired, was compile (`lualatex`/`xelatex`) and the `rm -f` of build
|
||
intermediates; those now prompt in default mode, as the SECURITY.md table says - a
|
||
`lualatex:*` grant would also approve `-shell-escape`, so they are deliberately not
|
||
pre-approved. The per-portal grants stay in `settings.json`; `/add-portal` now says a new
|
||
portal is pre-approved only once its own entry is there and in `ALLOWED_PERMISSIONS`.
|
||
|
||
- **`.claude/settings.json` ships `ask` and `deny` rules, and the guard requires them**
|
||
(#642, `tools/security_guards.py` `REQUIRED_ASK_RULES` / `REQUIRED_DENY_RULES`,
|
||
`tests/test_security_guards.py`) - deny rules block in every permission mode including
|
||
`bypassPermissions` and override any allow rule or skill grant; ask rules force a prompt even
|
||
when a skill's `allowed-tools` would pre-approve the call. Denied: `curl`/`wget` with data,
|
||
form or upload flags, `scp`, `sftp`, `ssh`, `nc`, and reads of `.env`, `.env.*`, `~/.ssh/**`,
|
||
`~/.aws/**`, `~/.gnupg/**`. Always asked: edits to `settings.json`, `settings.local.json`,
|
||
`.claude/skills/*/SKILL.md`, `.claude/agents/**`, `.github/**`, `tools/security_guards.py`, and
|
||
`git push` - the files whose content is the permission surface cannot be widened silently from
|
||
inside a session. Prefix rules are tripwires, not walls; `SECURITY.md` says so.
|
||
|
||
- **The security guard now reads skill and command frontmatter** (#642, `tools/security_guards.py`
|
||
check 4, `ALLOWED_SKILL_TOOLS`) - it allowlisted `settings.json` permissions and hooks but never
|
||
looked at `allowed-tools`, so the narrow settings allowlist sat next to a bare `Bash` grant it
|
||
could not see. Every `.claude/skills/*/SKILL.md`, `.agents/skills/*/SKILL.md` and
|
||
`.claude/commands/*.md` frontmatter is now held to an exact allowlist; a portal skill may
|
||
additionally grant exactly its own `cli/src/cli.ts`, matched by path so no portal name is
|
||
hardcoded. Bare tools and mid-pattern wildcards fail CI with their own message. On the previous
|
||
frontmatter this check reports two failures.
|
||
|
||
- **The browser-header 403 retry is `python3 tools/fetch_page.py`, not a documented `curl`**
|
||
(#642, `tools/fetch_page.py`, `tests/test_fetch_page.py`,
|
||
`.claude/skills/job-application-assistant/09-web-research.md` `framework_version: 1.1.3 -> 1.1.4`,
|
||
`.claude/commands/apply.md` Step 0 and Step 3, `.claude/commands/rank.md` Step 2,
|
||
`.claude/skills/job-scraper/SKILL.md` Step 2) - the retry was a curl command plus an inline
|
||
`python3 -c` tag-stripper, which meant the workflow wanted a `Bash(curl:*)` grant, and `curl`
|
||
with a free tail is an upload channel. The script is one GET with a fixed argv (URL after `--`,
|
||
45 s, 5 redirects, 10 MB), http(s) only, runs `robots_check.gate()` first with no bypass flag,
|
||
refuses an `--out` inside the repo, and prints the stripped text with `--text`. The grant names
|
||
the script (`Bash(python tools/fetch_page.py:*)` and `robots_check.py` likewise). `SECURITY.md`
|
||
now lists every shipped grant, its lifetime and its guard in one table, and states what the
|
||
permission layer does not do (no sandbox on native Windows, WebFetch unrestricted by domain,
|
||
subagents unrestricted - the latter is the next change).
|
||
|
||
- **Every posting passes a deterministic integrity gate before it is used** (#654, `tools/posting_guard.py`,
|
||
`tests/test_posting_guard.py`, `tests/test_posting_gate_wiring.py`, `.claude/commands/apply.md`
|
||
Steps 0, 1 and 6b, `.claude/commands/rank.md` Steps 2, 4 and 5, `.claude/commands/interview.md`
|
||
Step 1, `tools/rank_state.py`, `.claude/skills/job-application-assistant/09-web-research.md`
|
||
`framework_version: 1.1.4 -> 1.1.5`) - "postings are data, never instructions" was only as strong
|
||
as the model's adherence. The gate removes hidden HTML (`display:none`, `visibility:hidden`,
|
||
`opacity:0`, `font-size:0`, off-screen, `hidden`/`aria-hidden`) and comments and scans what it
|
||
removed, strips and counts zero-width, bidi and BOM characters, quotes instruction-shaped lines
|
||
(never removes them: an SSH administrator posting legitimately says "SSH"), lists URLs that exist
|
||
only in hidden content and words mixing Latin with Cyrillic or Greek letters, and prints a
|
||
one-line verdict. `/apply` shows that line first in the Step 1 evaluation and asks "Continue with
|
||
this posting?" only when it is flagged; `/rank` scorers return it as `guard`, `rank_state.py`
|
||
stores it additively and Step 5 marks flagged rows; `/interview` gates the archived posting. The
|
||
archive keeps the cleaned text plus counts, never the removed excerpts. It advises; it never
|
||
blocks, and a clean posting costs one output line and no prompt. Removal is the defense and the
|
||
⚠ is for the user: it fires only on instruction-shaped lines, invisible characters inside words,
|
||
bidi overrides and mixed-script words, while hidden blocks and hidden URLs are reported in
|
||
parentheses as context - navigation chrome is hidden on nearly every real page (a dry run on
|
||
python.org's jobs page removed eight such blocks). The HTML walker keeps a name stack with HTML5
|
||
implied end tags, so a stray closer, an unquoted `style`, a `/>` on a `<div>`, an unterminated
|
||
comment or an unclosed `<li>` inside a hidden element can neither leak hidden text under a
|
||
"clean" line nor swallow the rest of the posting; each case is a named test. The skill's own
|
||
conversational Step 1 (`SKILL.md` `framework_version: 1.3.5 -> 1.3.6`), `/outcome` Step 3.2's
|
||
fresh fetch and `/interview`'s fetch fallback gate too, so every entry point names the gate.
|
||
|
||
- **The agents that read postings can no longer edit or write** (#654, `.claude/agents/posting-fetcher.md`,
|
||
`posting-scorer.md`, `application-reviewer.md`, `tools/security_guards.py` check 5,
|
||
`tests/test_security_guards.py`) - the `/apply` reviewer and the `/rank` scorers ran as
|
||
`general-purpose`, with every tool, while holding untrusted posting text. Three shipped definitions
|
||
replace that with Read, Glob/Grep where needed, WebFetch, WebSearch and Bash only; an agent's
|
||
`tools:` list is tool-name granularity (verified: a `Bash(echo *)` entry gives plain Bash), so
|
||
Bash stays scoped by `settings.json`'s allow list. The reviewer returns its edits and the company
|
||
research cache as text and the orchestrator writes them. Guard check 5 requires every
|
||
`.claude/agents/*.md` to declare `tools:` from `ALLOWED_AGENT_TOOLS` and rejects a missing key
|
||
(which means every tool) or a patterned entry. `/scrape`'s portal-search agents stay
|
||
`general-purpose` for now.
|
||
|
||
### Removed
|
||
|
||
- **`.claude/agents/gemini-research-expert.md`** (#654; found independently by @MaverickHQ in #659) -
|
||
a personal helper that shipped in the initial release with no `tools:` list (every tool) and whose
|
||
only function is piping arbitrary text to a third-party model via `gemini -p`. Nothing in the tree
|
||
referenced it, yet Claude Code registered it in every session opened in a clone, where its
|
||
open-ended description made it a candidate for automatic delegation during company research.
|
||
Guard check 5 would reject it. Restore it in your own fork from git history if you used it.
|
||
|
||
## [1.7.2] - 2026-09-29
|
||
|
||
### Added
|
||
|
||
- **`verify_pdf.py --ascii-dates` makes the documented date-range rule executable**
|
||
(`tools/verify_pdf.py`, `/apply` Step 5d, `05-cv-templates.md`, CI) - "Date fields must be
|
||
ASCII ranges" records a real Workday import that dropped a role's end date because
|
||
`2016--2024` reaches the text layer as `2016<U+2013>2024`, and told Step 5d to confirm every
|
||
entry's years are joined by an ASCII hyphen. Nothing executed that: the natural probe,
|
||
`--contains "2016-2024"`, failed identically for "en-dashed" and "absent" before #458 and
|
||
passes on both since, because the fold maps the en-dash back to `-` on purpose. The new flag
|
||
reads the raw layer (never the fold), fails on a year joined to any Unicode dash or the
|
||
minus sign, and names each hit with its line and code point. A year on either side is
|
||
enough (`Mar 2016 – Jul 2016`, `2016 – Present`); a numeric range with no year is not a
|
||
date. Step 5d's extraction command now carries the flag (the dump is still written before
|
||
the check fails), the "Dates recognizable" checklist item points at it and keeps the bare
|
||
single-year half as a read-through, the guide's "add this to the step 5d checks" paragraph
|
||
names the command, and the upstream-only CI assertion runs it on the stock CV. Demonstrated
|
||
on the stock template: a `\cventry{2016--2024}` build fails with `U+2013`, the
|
||
`\cventry{2016-2024}` build passes, and `--contains "2016-2024"` passes on both. Only
|
||
horizontal whitespace may sit between the year and the dash, so a year ending one line is
|
||
not joined to a dash that opens the next (a bullet, a wrapped prose line) - yang2632's
|
||
catch in review, with the regex and fixture. Thirteen new `test_verify_pdf.py` cases.
|
||
Proposed by 9scorp4 in Discussion #385.
|
||
|
||
- **Real Excel workbook integration tests for the salary converter**
|
||
(`tests/test_convert_salary_excel_integration.py`, `.github/workflows/ci.yml`) -
|
||
generate temporary `.xlsx` files and invoke the documented converter CLI,
|
||
checking multiple worksheets, metadata options, Unicode text, localized
|
||
numbers, and compatibility with salary lookup. A workbook without salary
|
||
headers must fail without creating an output file. CI installs `openpyxl`
|
||
across the Python matrix; local runs without this optional dependency skip
|
||
the two integration cases while retaining the existing dependency-free tests.
|
||
|
||
- **`documents/projects/` portfolio ingestion in `/setup` (Path A)** (`documents/README.md`,
|
||
`.claude/commands/setup.md`, `.claude/commands/reset.md`, `tests/test_setup_command.py`) -
|
||
onboards project writeups, case studies, and documentation (`.md`, `.txt`, `.pdf`)
|
||
from `documents/projects/`, extracting structured summaries (problem domain, tech stack,
|
||
technical challenges, and measurable outcomes) to populate `## Independent Projects`
|
||
in `01-candidate-profile.md`.
|
||
|
||
- **Source host verification in `/apply` Step 1** (#431, `.claude/commands/apply.md`,
|
||
`tests/test_apply_host_check.py`) - before proceeding to draft CV and cover letters,
|
||
Step 1 verifies the posting URL's provenance against installed portal boards and the
|
||
six standard ATS apex domains (`greenhouse.io`, `lever.co`, `myworkdayjobs.com`/`workday.com`,
|
||
`ashbyhq.com`, `smartrecruiters.com`, `workable.com`). Look-alike prefix/suffix spoofing
|
||
fails closed, and unrecognized hosts are plainly flagged as unverified in the evaluation
|
||
output (`⚠ Unverified source host: <hostname>`) before drafting tokens are spent.
|
||
|
||
- **`/expand` project and portfolio expansion** (`.claude/commands/expand.md`,
|
||
`tests/test_expand_command.py`) - expands candidate discovery
|
||
to technical projects from public GitHub repositories, extracting structured summaries
|
||
(problem domain, tech stack, key technical challenges, and verifiable outcomes) to
|
||
populate the `## Independent Projects` section of `01-candidate-profile.md`.
|
||
|
||
- **Stale sweep branch in `/outcome`** (`.claude/commands/outcome.md`,
|
||
`tests/test_outcome_stale.py`) - introduces `/outcome stale [N]` (and `/outcome sweep [N]`)
|
||
to batch-resolve open applications quiet for 60+ (or N) days. Displays a numbered summary
|
||
of qualifying applications, requires explicit user confirmation (`all`, `select`, or `skip`),
|
||
resolves confirmed rows to `no_response`, logs dated entries to `notes`, updates archive
|
||
`outcome.md` files, and hands off to calibration when 3+ applications are resolved.
|
||
|
||
- **Mechanical layout verification for compiled PDFs** - `tools/verify_layout.py` measures
|
||
what `/apply` Step 5b previously only eyeballed: per-page text extent, bottom whitespace,
|
||
the largest internal vertical gap, footer collisions, and entry headers or section
|
||
headings stranded at a page break. It exists for a failure that survives every existing
|
||
check - a moderncv `\cventry` is an unbreakable `tabular`, so an entry that does not fit
|
||
jumps to the next page and leaves a hole behind (observed at 273pt, roughly 19 blank
|
||
lines) while the document still compiles, still reports the correct page count, and still
|
||
passes `tools/verify_pdf.py`. Geometry comes from Poppler `pdftotext -bbox`; Poppler is
|
||
optional repo-wide (since #369 `verify_pdf.py` prefers pypdf), and word bounding boxes
|
||
have no pypdf equivalent, so this is the one step that still wants it. A missing Poppler
|
||
- or the xpdf-based `pdftotext` Git for Windows puts ahead of it in PATH, which rejects
|
||
`-bbox` - degrades to a `skipped:` exit 2 rather than reporting a phantom layout failure.
|
||
Page count is deliberately left to `verify_pdf.py --pages` so that one rule keeps one
|
||
implementation. Thresholds are calibrated for the stock moderncv and `cover.cls`
|
||
geometry. Tests use synthetic page geometry, so they need neither Poppler nor a
|
||
LaTeX toolchain.
|
||
|
||
### Changed
|
||
|
||
- **`/add-template` keeps a registered template's intermediates in `build/`**
|
||
(#473, `.claude/commands/add-template.md`, `.gitignore`,
|
||
`tests/test_add_template_build_dir.py`) - the elicited compile command
|
||
now redirects intermediates (`.aux`, `.log`, ...) to a `build/` folder beside the source
|
||
and moves the PDF back, so Step 4's test-compile cleanup deletes one folder instead of
|
||
enumerating LaTeX extensions. The LaTeX command deletes the previous PDF first, so a
|
||
failed compile leaves no stale PDF for `/apply` to inspect. Toolchains with nothing to
|
||
redirect (`typst compile`) keep their command unchanged. The `ACTIVE-TEMPLATE` block now
|
||
tells `/apply` to run the command from the output directory and to delete `build/` in
|
||
its Step 5e cleanup. Stock templates are unchanged.
|
||
|
||
### Fixed
|
||
|
||
- **`freehire-search` titles no longer carry HTML entities or stray whitespace**
|
||
(`.agents/skills/freehire-search/cli/src/helpers.ts`) - titles are indexed as the source ATS
|
||
wrote them and `toResult` passed them through untouched, so `detail
|
||
intern-fullstack-amp-ai-innovation-noise-edaawqq2` printed `Intern - Fullstack & AI
|
||
Innovation ` (trailing space included), and the same title reached `search` output. In a
|
||
93-result `/scrape` batch (`--country IN --seniority intern`), 1 title carried `&` and 3
|
||
ended in whitespace. `/scrape` Step 4.75 reads undecoded entities in titles as a
|
||
half-working parser, so by that rule a single such posting marks a healthy portal
|
||
degraded. The title now goes through the CLI's existing `decodeHtmlEntities` (already used
|
||
for `detail` descriptions) and is trimmed; an empty or whitespace-only title still falls
|
||
back to `(untitled)`. Pinned by three new `parsing.test.ts` cases, which fail on the old
|
||
pass-through.
|
||
|
||
- **`freehire-search detail` keeps the salary period, so a yearly figure no longer reads as
|
||
monthly** (`.agents/skills/freehire-search/cli/src/helpers.ts`, `SKILL.md`) - `formatSalary`
|
||
printed only the currency and amounts, although freehire's enrichment records the period:
|
||
`detail software-developer-trainee-codifi-fra635ux` printed `INR 300000–300000` while the
|
||
API returns `salary_period: "year"` for that posting, so anyone screening pay against a
|
||
monthly figure read a yearly salary as a monthly one, twelve times its real monthly value.
|
||
The period is now appended when freehire records one (`INR 300000–300000/year`,
|
||
`INR 12000/month`); a record without a period prints exactly as before. Pinned by two new
|
||
`parsing.test.ts` cases, both failing on the old formatter.
|
||
|
||
- **`robots_check` decodes curl output as UTF-8, so a non-ASCII response no longer reads as
|
||
UNCONFIRMED on Windows** (`tools/robots_check.py`, `tests/test_robots_check.py`) -
|
||
`_fetch()` ran curl with `text=True` and no encoding, so Python decoded the response with
|
||
the ANSI code page. On a byte that code page leaves undefined (0x81 in cp1252 and cp1254,
|
||
0x9e in cp1254) subprocess's pipe reader thread died, `stdout` came back `None`, and the
|
||
gate printed `UNCONFIRMED (AttributeError)` - failing closed, but blocking the
|
||
browser-header retry `09-web-research.md` permits. Reproduced through the CLI on real
|
||
hosts: tr.indeed.com's robots.txt carries `Disallow: /職涯貼士/` (職 is `e8 81 b7`), and
|
||
kap.org.tr answers `/robots.txt` with a UTF-8 HTML 404 whose "A.Ş." carries `c5 9e`. The
|
||
call now pins `encoding='utf-8', errors='replace'` (RFC 9309: robots.txt is UTF-8), as
|
||
`verify_pdf.py` and `verify_layout.py` already do. That also closes a silent fail-open: a
|
||
UTF-8 rule whose bytes the code page does define (`Disallow: /şirket/` under cp1254)
|
||
decoded as `/ÅŸirket/`, never matched, and read as allowed. Decision rules are unchanged.
|
||
Two new tests swap curl for a child process that prints those bytes and pin cp1254 when no
|
||
encoding is passed, so the failure does not depend on the host's locale; both fail without
|
||
the fix.
|
||
|
||
- **The compile-warning check covers `tests/*.py` too** (`tests/test_verify_pdf.py`,
|
||
`tests/test_verify_layout.py`) - `FindNonAsciiDateRangesTests`' docstring quotes the regex
|
||
`\s*` unescaped, so Python 3.12+ prints a `SyntaxWarning` for the invalid escape `\s` when
|
||
the test module is compiled. It shows up in this repo's own CI log on 3.12, 3.13 and 3.14 while
|
||
the run stays green, and under `-W error::SyntaxWarning` the module fails to import. Escaped
|
||
in the docstring; `ToolsCompileWithoutWarnings` now compiles `tests/*.py` as well as
|
||
`tools/*.py` and fails on the unescaped version.
|
||
|
||
- **`linkedin-search detail` no longer fetches an unrelated posting for a URL on another
|
||
host** - `normalizeId` took the first 6+-digit path segment from *any* URL, so a
|
||
Greenhouse or Lever apply link (the kind a posting's own page hands out, and what a user
|
||
pastes back into `detail`) was reduced to that number and the handler fetched
|
||
`jobPosting/<number>` from LinkedIn: whatever job carried that id came back, printed with
|
||
exit 0, or `NOT_FOUND` if none did - never an error about the input. Demonstrated by driving
|
||
the real handler with a stubbed fetch: `https://boards.greenhouse.io/acme/jobs/4567890`
|
||
requested `.../jobPosting/4567890`. Every other portal CLI rejects an off-host detail URL
|
||
with `BAD_ID` (the #447 shape); linkedin was the one still trusting the digits. URLs are
|
||
now parsed for real: a `linkedin.com` host (apex or any subdomain, scheme optional) plus a
|
||
`/jobs/view/<slug-><id>` path yields the id, and anything else - other hosts, look-alike and
|
||
userinfo hosts, a linkedin.com profile or search URL - exits 1 with the stderr-JSON
|
||
`BAD_ID` contract before any request. Bare ids, URNs, and slash-free title slugs are
|
||
unchanged. Pinned by four new `normalizeId` cases and a new `detail-input.test.ts` that
|
||
drives `runDetail` and the CLI with fetch stubbed; the off-host cases fail on the old
|
||
pattern.
|
||
|
||
- **`/rank` tracker matching preserves Unicode company and role names**
|
||
(`tools/rank_state.py`, `tests/test_rank_state.py`) - ASCII-only normalization
|
||
collapsed distinct non-Latin roles to the same empty value and dropped
|
||
entirely non-Latin companies from tracker exclusions. Match using Unicode
|
||
case folding and NFC normalization, retaining letters, numbers, and combining
|
||
marks while continuing to ignore punctuation and spacing. CLI regressions
|
||
use the standard tracker header and cover distinct names, tracked matches,
|
||
equivalent accent encodings, and the existing ASCII matching behavior.
|
||
|
||
- **The Python tools no longer crash on Windows when a posting, company, CV line or file
|
||
name falls outside the ANSI code page** (`tools/rank_state.py`, `tools/job_key.py`,
|
||
`tools/verify_pdf.py`, `tools/verify_layout.py`, `tools/convert_salary_excel.py`,
|
||
`salary_lookup.py`, `tests/test_tools_utf8_output.py`) - a piped stdout on Windows
|
||
defaults to the ANSI code page (cp1252 on most Western installs), and that is how Claude
|
||
Code runs every tool. `/rank`'s candidate listing printed titles and companies with
|
||
`ensure_ascii=False`, so a single Cyrillic, CJK, Devanagari, Polish or Turkish posting
|
||
ended the run with `UnicodeEncodeError` before any output reached the workflow; the key
|
||
audit, the salary lookup, the salary converter and the layout report failed the same way.
|
||
Each tool now switches stdout and stderr to UTF-8 at entry, which also stops Danish and
|
||
other Western accents from arriving as cp1252 bytes. The regression tests run every
|
||
tool in a child process with a cp1252 stdout forced through `PYTHONIOENCODING`, so the
|
||
Linux CI job reproduces the Windows failure; all seven fail without the fix. The
|
||
subprocess helpers in `tests/test_rank_state.py` and `tests/test_job_key.py` now decode
|
||
child output as UTF-8 to match.
|
||
|
||
- **`/rank` rejects invalid score dimensions before updating an entry**
|
||
(`tools/rank_state.py`, `tests/test_rank_state.py`) - enforce the rubric's
|
||
inclusive 0-100 range and reject booleans, NaN, and infinities. Invalid results
|
||
now use the existing per-job error report, leaving the rejected entry intact
|
||
while valid results in the same batch are saved. CLI tests cover every score
|
||
dimension, oversized integers, boundary values, and fractional-score rounding.
|
||
|
||
- **Distinct non-Latin company names no longer share the unknown-company job key**
|
||
(`tools/job_key.py`, `tests/test_job_key.py`) - when a non-empty company name
|
||
has no ASCII slug, derive its fallback from a hash of the normalized name.
|
||
Missing names retain `unknown-company`, and existing ASCII keys are unchanged.
|
||
CLI tests cover distinct companies, case and canonical Unicode equivalence,
|
||
and stable keys across posting URLs. Existing state is not rewritten; `/scrape`
|
||
already recognizes stored postings by URL regardless of their previous key.
|
||
|
||
- **`/rank` tracker exclusion handles UTF-8 BOMs on reordered CSV headers**
|
||
(`tools/rank_state.py`, `tests/test_rank_state.py`) - when `company` or `role`
|
||
is the first column, a leading BOM becomes part of the header name and an
|
||
already-tracked application is selected for ranking again. Read with
|
||
`utf-8-sig` so both BOM-prefixed and plain UTF-8 trackers match correctly.
|
||
The standard `date`-first header already worked; regression coverage checks
|
||
all three column orders with and without a BOM.
|
||
|
||
- **`verify_layout.py` no longer emits a `SyntaxWarning` on every run** (`tools/verify_layout.py`,
|
||
`tests/test_verify_layout.py`) - the module docstring names the macro whose absence triggers the
|
||
Poppler `-bbox` crash, `\hypersetup{pdftitle=...}`, and a bare `\h` in a non-raw docstring is an
|
||
invalid escape sequence. Python 3.12+ prints `SyntaxWarning: "\h" is an invalid escape sequence`
|
||
the first time the module is compiled (CPython gh-98401) - it shows up in this repo's own CI log,
|
||
and in the middle of `/apply` Step 5b's layout report. The 3.15 language reference still
|
||
documents the sequence as a `SyntaxWarning`, with a `SyntaxError` only in a future Python
|
||
version. Escaped in the docstring; the new `ToolsCompileWithoutWarnings` case compiles the
|
||
source of every `tools/*.py` with warnings captured and fails on the unescaped version, so the
|
||
next docstring that quotes a LaTeX macro is caught too.
|
||
|
||
- **`/apply` Step 5b now actually runs the page-count check it claimed Step 5d ran**
|
||
(`.claude/commands/apply.md`, `tests/test_apply_page_count.py`) - the 5b prose said
|
||
"Page count is not checked here - that is `verify_pdf.py --pages`'s job, and Step 5d already
|
||
runs it", and `verify_layout.py`'s docstring declines to measure page count for the same
|
||
reason. Step 5d's only `verify_pdf.py` call is `--dump-text`, and no step in the workflow
|
||
passed `--pages` at all (only the upstream-only CI assertion on the stock examples does),
|
||
so the hard 2-page CV and 1-page cover letter limits were enforced by nothing but the
|
||
visual PDF read - the "measure first, then look" failure 5b was written to stop. 5b now
|
||
runs `verify_pdf.py --pages 2` on the CV and `--pages 1` on the cover letter ahead of
|
||
`verify_layout.py`, names the `ACTIVE-TEMPLATE` page limit as the substitute for a custom
|
||
template, and the deferral sentence points at those lines. Four spec tests pin the
|
||
invocations, their counts, their order relative to the layout measurement, and that no
|
||
prose defers the check to a step that does not run it; all four fail on master.
|
||
|
||
- **`salary_lookup.py` prints the privacy footnote only when a row actually carries `N/A*`**
|
||
- the `* N/A = Too few employees to publish (privacy)` line was appended under every
|
||
category table, including one where every row has an index, so the output asserted a
|
||
suppression that never happened (the residual noted on #470). The footnote now follows a
|
||
flag set by the `N/A*` branch; a table with a suppressed row renders exactly as before.
|
||
Two `FormatEntryTests` cases pin both directions; the "omitted" one fails on master.
|
||
|
||
- **`convert_salary_excel.py` pairs a bare `Count`/`Index` column pair instead of
|
||
splitting it, so `salary_lookup.py` no longer labels a published headcount as
|
||
privacy-suppressed** - the pairing loop required a non-empty derived category name on
|
||
both sides, but a header with no category word (`Count` + `Index`, Danish `Antal` +
|
||
`Lønindeks`) strips to an empty name, so the simplest layout the README advertises
|
||
("auto-pairs count/index columns") came out as two unrelated standalone categories:
|
||
`{"count": {"count": 500}, "index": {"index": 108.5}}`. `salary_lookup` then rendered
|
||
a `Count 500 N/A*` row above an `Index - 108.5` row, and the footnote read the
|
||
`N/A*` as "too few employees to publish (privacy)" - a false statement about a company
|
||
whose headcount is in the file, shown during `/apply`'s salary step. Demonstrated
|
||
through the documented Excel -> JSON -> lookup path with `openpyxl`; adding any suffix
|
||
(`Antal alle`) made pairing work, which is why the shipped tests, all suffixed, never
|
||
saw it. Bare pairs now pair under the README's top-level category name
|
||
(`all_employees`); a bare `Antal` with no bare index column still stays a standalone
|
||
count, and named pairs alongside are untouched. Four new cases in
|
||
`test_convert_salary_excel.py`, including one that renders the converter's output
|
||
through `salary_lookup.format_entry`; all four fail on the old pairing rule.
|
||
|
||
- **`tools/verify_layout.py`'s `skipped:` message named only one cause of a broken
|
||
extractor when there are two** (#451) - it blamed the xpdf-based `pdftotext` Git for
|
||
Windows puts ahead of Poppler in PATH (no `-bbox` flag, exits 99), but a real Poppler
|
||
can abort `-bbox`/`-bbox-layout`/`-htmlmeta` too: Poppler 26.0x before 26.05 crashes on
|
||
any PDF whose Info dictionary carries an empty string in any field, and `hyperref`
|
||
writes exactly that for every field it does not set. A `lualatex`/`pdflatex` document
|
||
built with `hyperref` and no `\hypersetup{pdftitle=...}` - an ordinary `/add-template`
|
||
CV template, not a malformed one - hits this with a working Poppler installed, and the
|
||
old message sent the reader to check their PATH when nothing was wrong with it. The
|
||
message now names both causes; behavior is unchanged, degrading to `skipped:` exit 2
|
||
either way, since a broken extractor is still not a broken document.
|
||
|
||
- **The template-placeholder guard in `test_setup_command.py` now skips on forks** (#463) -
|
||
`TemplatesStillCarryThePlaceholders` asserts that `05-cv-templates.md` and
|
||
`06-cover-letter-templates.md` still contain `[FIRST_NAME]`, `[LAST_NAME]`, `[YOUR_EMAIL]`,
|
||
`[YOUR_PHONE]`, `[YOUR_NAME]`, and `[YOUR_LINKEDIN_URL]`. Running `/setup` - the documented
|
||
path, and what Step 3.5/3.6 of that command exist to do - replaces exactly those tokens, so on
|
||
a personalized fork `python3 -m unittest discover -s tests` fails both checks permanently and
|
||
marks every push red. The class now uses the same `@unittest.skipIf` on `GITHUB_REPOSITORY`
|
||
(defaulting to upstream when unset, so local pristine-template runs still execute the guards)
|
||
that `test_placeholder_integrity.py` received in #407. The guard landed three days after that
|
||
fix and did not pick up the pattern; the `placeholder-integrity` CI job is upstream-gated and
|
||
does not cover the `05`/`06` tokens, so `python-tests` was their only check.
|
||
- **`verify_pdf.py --contains` now sees through LaTeX's typographic substitutions and
|
||
the pdflatex text layer keeps accents precomposed** (Discussions #385, #384) - the
|
||
comparison folded whitespace only, but LaTeX ligatures `'` into U+2019 and `--` into
|
||
U+2013, so on the stock CV compiled with the documented `lualatex` command
|
||
`--contains "Master's degree"` and `--contains "2016-2024"` both reported the keyword
|
||
missing from a document that plainly contains it (measured through both extractors;
|
||
`Six Sigma` and `Statistics` on the same page passed). The documented remedy for a
|
||
missing keyword is to add it, so the false negative nudged toward the one thing the ATS
|
||
section forbids. `normalize_text()` now folds both sides - NFC, then curly
|
||
apostrophes/quotes to ASCII, en/em dashes to `-`, no-break space to space - at
|
||
comparison time only; `--dump-text` still writes the raw layer, because that is what an
|
||
ATS parses and the date-range rule in `05-cv-templates.md` needs the raw en-dash visible
|
||
there. Separately, pdflatex without T1 font encoding stores accents decomposed
|
||
(`e` + U+0300; pypdf reads it as a stray spacing accent), which NFC cannot fully
|
||
repair - moderncv 2.5 loads T1 itself under pdflatex but the apt-packaged 2.3.1 does
|
||
not, so `cv/main_example.tex` and the guide's preamble gain
|
||
`\ifpdftex\usepackage[T1]{fontenc}\fi`, a no-op on the lualatex path. Pinned by
|
||
ten new `test_verify_pdf.py` cases (the fold-through ones fail on the whitespace-only
|
||
code) and a `test_latex_guidance.py` guard that the line exists and stays
|
||
pdflatex-only. Reported and diagnosed by 9scorp4. Fork users: your
|
||
personalized `cv/main_example.tex` gains the one guarded preamble line on rebase (a clean
|
||
3-way merge unless you edited the preamble); tailored CVs compiled with lualatex need nothing.
|
||
- **`jobdanmark-search detail` now backs off on 429/5xx like every other portal's detail
|
||
command** - the handler called `fetch()` directly instead of going through the CLI's own
|
||
request wrappers, so it carried none of the three things `apiFetch`/`apiPost` guarantee:
|
||
no 429/5xx retry loop (a rate-limited detail page wrote `API_ERROR` and exited after one
|
||
attempt, where jobnet, jobbank, jobindex, linkedin, and freehire all retry up to six
|
||
times), a hand-inlined User-Agent string that would drift from the exported `USER_AGENT`,
|
||
and a timeout the wrappers' tests never saw. `/scrape` calls `detail` once per
|
||
shortlisted posting, so a burst that tripped jobdanmark's rate limiter dropped those
|
||
postings outright - no description, no deadline - while the same burst on any other
|
||
portal rode it out. Demonstrated by driving the real command handler with a stubbed 429:
|
||
1 fetch attempt and exit 1 before, 7 attempts after (the contract's initial try plus six
|
||
retries). Fixed by adding `htmlFetch` to `helpers.ts` with the same backoff schedule,
|
||
timeout, and shared User-Agent as the JSON wrappers (404 returns `null` so `detail` keeps
|
||
its `NOT_FOUND` contract) and routing `detail` through it. Pinned in the existing
|
||
`retry-backoff`, `user-agent`, and `request-timeout` suites, which now cover all three
|
||
wrappers, plus a new `detail-backoff.test.ts` that exercises the handler path itself -
|
||
its retry cases fail against the bare `fetch()`.
|
||
|
||
- **Free-form tracker notes no longer break the CSV row** (#454) (`.claude/commands/gmail-sync.md`,
|
||
`.claude/commands/outcome.md`, `tests/test_tracker_notes_csv_safe.py`) - two writers put
|
||
free-form text into the `notes` column of `job_search_tracker.csv`: `/gmail-sync` Step 7a
|
||
copied the raw subject of a received email, and `/outcome` Step 4 appended "a short dated
|
||
note" with no constraint on its content. No writer in the framework emits a quoted tracker
|
||
field, so an unescaped comma splits the row for a naive split and for `csv.DictReader` alike -
|
||
the latter being what the repo's only machine reader of the tracker uses
|
||
(`tools/rank_state.py`). `notes` is column 10 of 14, so a subject as ordinary as
|
||
`Re: Your application, Data Scientist`, or a note as natural as `rejected, no feedback given`,
|
||
shifted `cv_file`, `cover_letter_file` and `source` a column left. A line break is worse: it
|
||
ends the row and starts a second one. Nothing validated the row afterwards, and the
|
||
`/gmail-sync` half was written unattended, so the corruption was silent. Both append
|
||
instructions now carry the rule themselves - `/gmail-sync` deletes commas, double quotes and
|
||
line breaks from the subject, `/outcome` writes its note without them - rather than a general
|
||
note a writer can miss. Nothing is lost on the `/gmail-sync` side: Step 7a item 2 still
|
||
records the subject verbatim in the archive's `outcome.md`, which is Markdown and carries no
|
||
such constraint. The fixed-format writers (`followed up YYYY-MM-DD`,
|
||
`stale resolved no_response (YYYY-MM-DD)`, `redrafted`) could never contain these characters
|
||
and are unchanged.
|
||
|
||
- **`jobindex-search detail` no longer fetches arbitrary URLs or invents posting-shaped
|
||
output** (#447) - the command fetched any `http(s)` input verbatim (no host check) and,
|
||
when the path didn't match its one pattern, silently used the whole input URL as the job
|
||
id; the only net was "the fetched page has a title", so a non-posting page came back as
|
||
a well-formed fake posting with exit 0 (demonstrated with jobindex's own homepage:
|
||
`id` = the URL, `title` = the site's tagline, `description` = navigation chrome). Every
|
||
other portal CLI rejects unparseable detail input with `BAD_ID` and constructs its fetch
|
||
URL from the extracted id; jobindex was the one CLI trusting the raw string - and
|
||
`/scrape`/`/rank` agents feed it stored URLs, so a ghost or redirected URL (the #331
|
||
class) yielded plausible garbage instead of an error. `buildUrl` now requires a
|
||
jobindex.dk host (apex or subdomain - look-alike and userinfo tricks rejected via real
|
||
URL parsing) plus a `/jobannonce/<id>` path, rebuilds the fetch URL from the extracted
|
||
id (the canonical short form the bare-id path always used), and exits 1 with the
|
||
stderr-JSON `BAD_ID` contract otherwise; bare ids stay permissive scheme- and
|
||
slash-free tokens (the jobnet precedent - the server 404s unknowns loudly). Pinned by
|
||
eight cases in the new `detail-input.test.ts`; the five rejection/canonicalization
|
||
cases fail against the verbatim unguarded extraction. Complementary to the `/apply`
|
||
host-check rule proposed in #431, which stays with its proposer.
|
||
|
||
- **`/outcome` and `/interview` no longer confuse two roles at the same company** (#443)
|
||
(`.claude/commands/outcome.md`, `.claude/commands/interview.md`,
|
||
`tests/test_apply_records_application.py`) - when a tracker row's `cv_file` /
|
||
`cover_letter_file` columns are empty, both commands fell back to a company-prefix glob
|
||
(`cv/main_<company>*.tex`). Two roles at one company both match it, so `/outcome` copied
|
||
whichever the filesystem returned first into the archive as `cv_draft.tex` - the file whose
|
||
purpose is to record what was actually submitted - and its own "leave an existing archived
|
||
file" rule then made the wrong copy permanent. Both fallbacks now glob the full
|
||
`<company>_<role>` stem, derived by the **Subfolder naming** rule in `documents/README.md`
|
||
rather than restated, and skip with a note instead of widening the search. Dropping the
|
||
hardcoded `.tex` also makes a template registered by `/add-template` findable.
|
||
|
||
- **`jobnet-search detail` no longer reports an externally hosted ad as not found** (#432) -
|
||
Jobnet's `/FindJob/JobAdDetails/<id>` returns 404 for ads with `isExternal: true`, so `detail`
|
||
on an ad `search` had just listed exited 1 with `NOT_FOUND`, and `/scrape` read the posting as
|
||
gone rather than hosted elsewhere (2 of 3 ads in a fresh sample). On that 404 the command now
|
||
falls back to the search endpoint, which does carry the ad's description and the external
|
||
application URL, and returns the record marked `isExternal: true` with a stderr note; fields the
|
||
search payload does not carry (`views`, `approvalStatus`, the boolean flags) are `null`, never
|
||
guessed. Verified live on two external ads.
|
||
|
||
- **`09-web-research.md`'s curl snippets no longer write into the repo when `$SCRATCHPAD`
|
||
is unset** - both runnable blocks in the 403-escalation path start with `cd "$SCRATCHPAD"`,
|
||
and nothing in the repository ever sets that variable (`git grep 'SCRATCHPAD='` returns
|
||
nothing). Unset, it expands to `cd ""`, which succeeds and leaves the shell where it
|
||
started, so the `&&` chain proceeds and `curl -o page.html` writes to the working
|
||
directory - in practice the checkout, which is exactly what the paragraph directly beneath
|
||
the curl block forbids ("Write to the session scratchpad directory, never into the repo").
|
||
The file's instruction and its own snippet disagreed, and the snippet won silently.
|
||
Both expansions are now guarded with `${SCRATCHPAD:?...}`, turning a silent repo write into
|
||
an immediate failure whose message names where the value comes from. Behaviour is unchanged
|
||
wherever the variable is set. The same undefined reference in `.claude/commands/rank.md`
|
||
was removed by #425 as a side effect of rewriting Step 2/4; this is the remaining instance.
|
||
|
||
- **`seen_jobs.json` keys are now a pure function of the posting** - `/scrape` Step 4 described
|
||
the key as prose (`"<url_or_company_title_key>"`) and nothing said how to derive it, so each
|
||
run slugified in its own way. Two failures followed, both observed in a live state file. Keys
|
||
carried characters that break the path they later become: `/apply` and `/outcome` derive an
|
||
archive folder from the same company+role pair, which is why `documents/README.md` has a
|
||
subfolder rule, and keys like `deloitte_junior-cybersecurity-analyst-(ot/iot)` and
|
||
`neverhack-estonia_penetration-tester-/-red-teamer` violate it. And the same posting was
|
||
stored twice when two runs truncated one title at different points
|
||
(`deloitte_cyber-intelligence-center-security-analy` and
|
||
`...-security-analyst-at` are one job, one URL, two entries) - which defeats the dedup the
|
||
file exists for. `tools/job_key.py` now owns the derivation: the slug is normalised, and
|
||
truncation is length-capped *and* disambiguated by a hash of the full slug, so a long title
|
||
always produces the same key and two long titles sharing a prefix cannot collide. Step 4
|
||
calls the helper instead of describing it. `--audit` reports non-conforming entries in an
|
||
existing state file and deliberately never rewrites them: stored keys are matched against
|
||
`job_search_tracker.csv` by company+role elsewhere, so a silent rewrite would break the link
|
||
between a stored job and its application record.
|
||
Existing state files need no migration: Step 2's candidate filter matches a posting to a stored
|
||
entry by URL regardless of that entry's key, so a workspace whose entries predate the helper does
|
||
not see its still-live postings re-presented as new.
|
||
|
||
## [1.7.1] - 2026-09-06
|
||
|
||
### Added
|
||
|
||
- **CHANGELOG structure guard** (`tests/test_changelog_structure.py`) - every PR edits this one
|
||
shared file by hand near the same line, and nothing checked the result: a second `### Fixed`
|
||
heading landed directly under `[Unreleased]`, above `### Added`, on #425 and was fixed by hand
|
||
at merge time. The `[Unreleased]` section is now checked on every PR for duplicate headings,
|
||
headings outside the Keep a Changelog set, entries above any heading, and leftover conflict
|
||
markers. Released sections are history and are not inspected.
|
||
|
||
- **`/rank` now consumes the `posted_date` #391 persists** (#390, the deferred second
|
||
half) - Step 3 gains a staleness flag: a posting whose stored `posted_date` is more
|
||
than 30 days old at rank time carries a visible ⚠ marker with its age spelled out
|
||
alongside the score ("⚠ posted 2024-05-13, 27 months ago"), the same FLAG treatment as
|
||
location and language - in the ranking, for the user to judge, never an exclusion (the
|
||
#390 posting was 27 months old *and still live*; age is a signal, not a veto, and a
|
||
future stored `deadline` outranks it). Costs no fetch: age is re-derived each run from
|
||
the stored value and never persisted. Boundary rules carried over verbatim from the
|
||
schema and rule 6: no `posted_date` or `null` means no flag and no guess (never
|
||
inferred from `first_seen`), and unparseable values are treated as absent and reported
|
||
once with their portal. Pinned by four new cases in `test_rank_command.py`, each
|
||
verified to fail against the rule-less spec.
|
||
|
||
### Security
|
||
|
||
- **`settings.json` no longer pre-approves `bun run` on arbitrary files** (#396) - the
|
||
template's permission allowlist granted `Bash(bun run:*)`, which auto-approved
|
||
`bun run <any file on disk>` in every fork. It is now one path-scoped entry per shipped
|
||
portal CLI, matching what each portal SKILL.md already declares. `/scrape` is unaffected
|
||
for all portals, including ones added by `/add-portal` - the job-scraper skill's own
|
||
`allowed-tools` carries the path-scoped wildcard that covers them during the workflow.
|
||
Running a portal CLI ad hoc outside a skill now prompts once, which is the intended
|
||
behavior for anything not on the reviewed list. Thanks @vkotaru.
|
||
|
||
### Fixed
|
||
|
||
- **`/setup` now fills the contact blocks inside `05-cv-templates.md` and
|
||
`06-cover-letter-templates.md`, and `/reset` restores them** - Step 3 personalised
|
||
`cv/main_example.tex` but never the LaTeX contact blocks embedded in the two template files
|
||
`/apply` actually compiles from, so a full Path B or C run left `[YOUR_NAME]`, `[YOUR_EMAIL]`
|
||
and `[YOUR_PHONE]` in both, and whether they reached a document depended on the drafter
|
||
noticing (a real user ran `/setup` and then hand-edited both files, #420).
|
||
`06-cover-letter-templates.md` was not a Step 3 target at all. Step 3.5 now names the `05`
|
||
contact tokens, a new Step 3.6 covers the `06` contact line and signature (Path A never fills
|
||
it, so it runs for every path), the completion summary lists `06`, and `/reset` clears both
|
||
blocks instead of listing `06` as framework-only. Pinned by `tests/test_setup_command.py`; the
|
||
existing `/reset` coverage test is what forced the `reset.md` half.
|
||
|
||
- **`/rank` no longer reads or rewrites the whole of `seen_jobs.json` on every run** (#395) -
|
||
Step 1 used to read the entire state file into the conversation to select candidates by
|
||
eye, and Step 4 emitted it back to record scores: a cost paid on every run regardless of
|
||
batch size, growing for the life of the workspace. `tools/rank_state.py` now owns that
|
||
traffic - `candidates` selects and projects only the fields a scoring agent needs, `sweep`
|
||
runs rule 6's expiry pass on disk, and `apply` writes results back atomically and prints
|
||
the rows Step 5's report is built from. Preserves Step 4's existing write-back rules
|
||
exactly: the `location` → `location_verdict` legacy migration, the deadline
|
||
null-is-not-a-correction rule, and verbatim strengths/gaps persistence. No scoring policy
|
||
changes - no new status value, no new persisted field.
|
||
|
||
- **`jobbank-search`, `jobdanmark-search`, and `jobnet-search` detail commands now accept full URLs** -
|
||
the portal contract specifies `detail <id|url>`. Passing a full posting URL (with or without
|
||
trailing slashes, slug segments, or query parameters) previously caused `jobbank-search` and
|
||
`jobdanmark-search` to construct invalid double-URL strings, and `jobnet-search` to interpolate the
|
||
full URL into the API endpoint path. All three detail handlers now extract and normalize the
|
||
underlying ID or slug via dedicated helper functions, and exit 1 with code `BAD_ID` on unparseable
|
||
inputs, matching `linkedin-search` and `freehire-search`. Pinned by 24 unit tests across the three
|
||
CLIs' `detail-url-normalization.test.ts`.
|
||
|
||
- **`/rank` now bounds each scoring batch** (#395) - a bare run scores at most 10
|
||
eligible jobs instead of attempting the entire backlog. `--limit <N>` controls
|
||
scoring independently of `--top`, and the report makes deferred work visible so
|
||
re-running `/rank` can continue it.
|
||
|
||
- **The portal CLIs' unknown-flag guard no longer lets a single-dash flag through** (#426) -
|
||
the guard in the four bunli-based CLIs (`jobnet`, `jobbank`, `jobindex`, `jobdanmark`) inspected
|
||
only tokens starting with `--`, so an undefined *short* flag bypassed it entirely: bunli
|
||
discarded it, the search ran unfiltered, and the CLI exited 0 with no error. Live against
|
||
jobnet, `search -q "sygeplejerske"` returned all 18,179 ads as a successful search against 667
|
||
for the real `--search-string` query - the same shape as review finding F13 (jobdanmark, 13,862
|
||
results) that motivated the guard in the first place, reached by the likelier route: `-q` is the
|
||
documented short for the keyword search in `linkedin-search`, `freehire-search` and
|
||
`jobindex-search`, so a cross-portal habit produces it. Both dash forms are now checked, with
|
||
declared shorts (`jobindex`'s `-q`) and bunli's built-in `-h`/`-v` still valid. A negative number
|
||
is rejected too rather than skipped: bunli does not consume a `-`-prefixed token as the previous
|
||
flag's value, so `--radius -5` silently fell back to the default radius instead of failing its
|
||
own `min(1)` schema - erroring on it is the trade `linkedin-search` already makes, and a value
|
||
that must begin with a dash uses the `--flag=value` form. `linkedin-search` and
|
||
`freehire-search` were unaffected; they normalize `-x` to a long name before checking it. Pinned
|
||
by thirteen new cases across the four CLIs' `cli-flag-validation.test.ts`, network-free because
|
||
the guard runs before dispatch: eight bug-pinning cases (the short flag and the negative number,
|
||
per CLI), each verified to fail on the unfixed guard, plus five regression guards that pass on
|
||
both and exist to keep the fix from over-rejecting - `-h` in each CLI, and `jobindex`'s declared
|
||
`-q`.
|
||
|
||
- **`jobdanmark-search` autocomplete no longer dies over one suggestion without text**
|
||
(#421, closing out the #416/#418 audit - every other deref site in the six CLIs
|
||
checked and confirmed guarded) - the filter derefed `item.text.toLowerCase()` from a
|
||
cast API response on the same line that already guards `g.items ?? []`, so one item
|
||
with a null or missing `text` threw `TypeError` and the whole command exited 1 as
|
||
`API_ERROR`. The filter now lives in an exported `filterAutocompleteGroups` (the
|
||
jobnet testability pattern), `text` is typed nullable so the compiler enforces the
|
||
guard, and an item without usable text is skipped - it can never match the required
|
||
non-empty query, so downstream output never sees one. Pinned by three cases in the
|
||
new `autocomplete-filtering.test.ts`; the null-text case fails against the verbatim
|
||
unguarded extraction with the exact production TypeError.
|
||
|
||
- **`jobnet-search` no longer dies over one ad with a null publication date** (#418, the
|
||
sibling of #416 from the same audit) - `date: job.publicationDate.slice(0, 10)` trusted
|
||
a TypeScript interface claim (`publicationDate: string`) that nothing validates at
|
||
runtime: `apiFetch` casts the JSON body, so one `null` threw `TypeError` inside the
|
||
`jobAds` map and the whole search of a default-ON portal exited 1 as `API_ERROR` - while
|
||
the neighboring `applicationDeadline` field was already null-guarded with a `1900-01-01`
|
||
sentinel check. The field is now typed nullable (so the compiler enforces the guard) and
|
||
degrades per-item to `date: null`, the shape the `seen_jobs.json` contract documents.
|
||
Pinned by a new case in `search-normalization.test.ts`, verified to fail on the unfixed
|
||
code with the exact production TypeError.
|
||
|
||
- **Placeholder-integrity tests in `python-tests` now skip on forks** (#405) - the dedicated
|
||
`placeholder-integrity` job already gates on the upstream repo name, but `python-tests` ran
|
||
`unittest discover` with no such guard, so forks that personalized files via `/setup` failed
|
||
three sentinel checks permanently. Both test classes now use `@unittest.skipIf` on
|
||
`GITHUB_REPOSITORY` (defaulting to upstream when unset so local pristine-template runs still
|
||
execute).
|
||
- **`convert_salary_excel.py` no longer mistakes a title/citation row for the header row**
|
||
(#414) - header-row detection accepted the first row in the first 10 where *any* cell merely
|
||
contained a company-pattern word, with no check that the row actually looked like a header. A
|
||
source-citation line above the real header table - standard in real Danish union/statistics
|
||
exports, e.g. "Kilde: ... opdelt efter arbejdsgiver ..." - tripped it purely because
|
||
"arbejdsgiver" (employer) appeared in prose. The real header row then got parsed as a data row
|
||
(its "Firma" cell became a bogus company entry), and every genuine company lost all its salary
|
||
data, silently: exit 0, "Done! Wrote N company entries," with `categories: {}` on every one. A
|
||
candidate row is now accepted only when a *different* cell in the same row also matches a
|
||
city/count/index pattern - same-cell corroboration doesn't count, since a citation sentence can
|
||
pack a count-pattern word into the same sentence as the company-pattern one (e.g. "...opdelt
|
||
efter arbejdsgiver, antal svar 1234"). Sheets whose only real header has purely untyped salary
|
||
columns (e.g. "Base pay 2025" / "Bonus 2025", neither of which matches a known city/count/index
|
||
pattern) have nothing to corroborate against in any row, so detection falls back to the original
|
||
any-cell-mentions-company rule when the strict pass finds nothing in the first 10 rows. As a
|
||
backstop independent of either pass, a sheet that ends up with zero detected salary columns now
|
||
prints a warning instead of reporting success silently. Pinned by four cases in
|
||
`tests/test_convert_salary_excel.py`: the original citation-row and zero-columns cases fail
|
||
against the pre-fix script; the same-cell-corroboration and untyped-column-fallback cases each
|
||
fail against the single-pass version of this fix that came before the fallback was added.
|
||
|
||
- **`jobbank-search` no longer dies over one malformed feed date** (#416) - `new Date()`
|
||
on a present-but-unparseable `pubDate` yields an Invalid Date whose `toISOString()`
|
||
throws `RangeError`, and `normalizeSearchItem` runs inside an unguarded `items.map()`,
|
||
so a single bad RSS item killed the entire search with `{"error": "Invalid Date",
|
||
"code": "API_ERROR"}` and exit 1 - a whole default-ON portal lost to one item, with
|
||
the error pointing at the API. The un-CDATA'd fallback capture in `parseRssItems` can
|
||
deliver exactly such a value. An unparseable `pubDate` now degrades to the same shape
|
||
as an absent one (`posted` empty, `date: null`, per the `seen_jobs.json` contract that
|
||
#391 put this field on), and every other item survives. Pinned by three new cases in
|
||
`search-normalization.test.ts`, each verified to fail on the unfixed code.
|
||
|
||
- **`linkedin-search` rejects fractional numeric flags instead of silently changing
|
||
the query** (#371) - bare `parseInt` truncated values before validation, so
|
||
`--jobage 0.5` became `0` and silently omitted LinkedIn's `f_TPR` freshness filter
|
||
while the CLI reported no argument error. `--jobage`, `--jobage-minutes`, `--page`,
|
||
and `--limit` now accept whole numbers >= 1 only and reject fractions and zero with
|
||
the stderr-JSON `BAD_ARG` contract, matching the other portal CLIs. Pinned by eight
|
||
cases verified to fail on the unfixed CLI. Reported by @Meet6338-X.
|
||
|
||
- **`linkedin-search detail` accepts LinkedIn job URLs with trailing slashes** (#411) -
|
||
passing a job URL with a trailing slash (e.g., `https://www.linkedin.com/jobs/view/<id>/`
|
||
or a slugged variant with or without query strings) failed validation and exited 1 with
|
||
`BAD_ID` before any network request because the regex delimiter strictly expected `?`
|
||
or end-of-string immediately after the numeric ID. The boundary check now matches
|
||
`[\/?]`, correctly extracting IDs from browser-copied URLs, regional subdomains, and
|
||
links with tracking parameters. Pinned by eleven new cases in `parsing.test.ts`.
|
||
|
||
- **The `documents/interview/**` ignore rule no longer claims interview prep is written there**
|
||
(#336). `/interview` saves its pack to
|
||
`documents/applications/<company>_<role>/interview_prep_<stage>.md`, already ignored by
|
||
`documents/applications/**`; nothing has ever written to `documents/interview/`. Nothing leaked -
|
||
but it was the personal-data block's one dedicated line about interview material, so an auditor
|
||
checking the framework's most sensitive artifact had every reason to read it and stop, at the
|
||
only path in the block with no writer. The protection rationale now sits above
|
||
`documents/applications/**`, the rule that actually provides it, so the next reader finds it
|
||
where it lives; `documents/interview/**` stays, relabelled belt-and-braces rather than primary
|
||
guard (`REQUIRED_IGNORE_RULES` pins it, so removing it from `.gitignore` alone fails the guard).
|
||
Pinned by `tests/test_security_guards.py`, which derives the prep-pack path from
|
||
`/interview`'s own spec instead of hardcoding it - so moving that path fails CI rather than
|
||
quietly re-staling the comment.
|
||
|
||
- **`/scrape` now persists each posting's publication date** (#390) - Step 2's contract guarantees a
|
||
`date` on every portal CLI's search output (CI enforces it in `test_scrape_contract.py`) and
|
||
Step 1b uses that date to scope a run to the last 14 days, but Step 4's `seen_jobs.json` schema
|
||
stored no posting date at all: `first_seen` is when the scraper saw an entry, not when the
|
||
employer posted it. The freshness window was therefore unauditable the moment a run ended, and
|
||
`/rank` - which reads the stored entry, not the run - had no age signal to weigh. A
|
||
`freehire-search` posting dated 2024-05-13 was scraped 27 months later and ranked Strong Fit at
|
||
position 1 of 133; the scoring note recorded that the listing "may be long stale" in prose
|
||
nothing reads, and an `/apply` run drafted a tailored CV and cover letter against it. The schema
|
||
gains `posted_date` (`null` when the portal returned no date, never inferred or backfilled).
|
||
Pinned by three new cases in `test_scrape_contract.py`, each verified to fail on the unfixed
|
||
spec. Reported and diagnosed from a real run by @sandunwijerathne.
|
||
|
||
- **`salary_lookup.py` no longer crashes on a `null` `metadata` or `categories`** - `--validate`
|
||
treats an explicit `"metadata": null` / `"categories": null` the same as an omitted key (the
|
||
shape checks are "...must be an object *when provided*" and skip `None`), but the renderer read
|
||
both through `dict.get(key, {})`, which only substitutes the default for an *absent* key - a
|
||
present-but-null value passed straight through. `format_entry` then hit `None.get("index_label",
|
||
...)` (`AttributeError`) or, via the numeric-field fallback, `None[key] = value` (`TypeError`),
|
||
so a hand-maintained `salary_data.json` using `null` for "no value here" died with an uncaught
|
||
traceback right after printing `Found 1 match(es)`. `format_entry` now coerces both to `{}` up
|
||
front, so `null`, absent, and `{}` behave identically. Pinned by four cases in
|
||
`test_salary_lookup.py` - two unit calls into `format_entry` and two end-to-end (`main()
|
||
--validate` blesses the file, then the lookup path renders it), one per null shape, all verified
|
||
to fail on the unfixed renderer.
|
||
|
||
## [1.7.0] - 2026-08-29
|
||
|
||
### Fixed
|
||
|
||
- **Fork clones no longer point `gh issue create` at the upstream public tracker
|
||
undetected** (#389) - `gh repo fork --clone`, the exact command SETUP.md's fork step
|
||
recommends, sets the *upstream* repo as gh's default repository, and gh uses the
|
||
default for creating issues and PRs - so a user's own automation ("file a tracking
|
||
issue per application") silently published personal job-search data on the upstream
|
||
repo, under the user's identity, where they cannot delete it (four live instances from
|
||
two users in one week). SETUP.md section 2 now adds `gh repo set-default
|
||
<your-username>/ai-job-search` directly to the fork commands with a warning at the
|
||
point of decision (the #348 pattern), and a new `.github/ISSUE_TEMPLATE/` carries the
|
||
same heads-up the PR template already had, for the web-UI path. Blank issues stay
|
||
enabled - the template warns, it does not gatekeep.
|
||
- **`freehire-search` fractional numeric flags no longer silently change the query** (#373) -
|
||
`parseIntFlag` used bare `parseInt`, so a fractional value was truncated instead of
|
||
rejected: `--jobage 0.5` became `0`, failed the `jobage > 0` guard, and the
|
||
`posted_within_days` freshness filter was silently omitted from the outbound request
|
||
while the CLI exited 0 - on a default-ON `/scrape` portal, exactly the
|
||
discarded-filter failure the CLI's own `UNKNOWN_FLAG` guard documents. Numeric flags
|
||
(`--jobage`/`--page`/`--limit`) now accept whole numbers >= 1 only, mirroring the
|
||
Danish CLIs' `z.coerce.number().int().min(1)` contract, and reject everything else
|
||
with the stderr-JSON `BAD_ARG` error. The sibling of #371 (`linkedin-search`), which
|
||
remains with its reporter. Pinned by five new cases in `cli-flag-validation.test.ts`,
|
||
each verified to fail on the unfixed code.
|
||
|
||
### Added
|
||
|
||
- **`linkedin-search detail` reports closed postings** (#280, adopted with the original
|
||
author's commit preserved) - a new `isActive` field: `false` when the posting page
|
||
renders LinkedIn's own "No longer accepting applications" top-card banner. Detection
|
||
is scoped to the top card and pinned by fixture tests in both directions, including
|
||
the false-positive case the review required (recruiter boilerplate quoting the closed
|
||
phrase in a *description* must not flag a live job - on the unscoped first version it
|
||
did, and the new tests fail there). Only the two markers real closed pages carry are
|
||
matched (`closed-job__flavor` and the banner text, verified against live guest
|
||
pages); three speculative phrases from the first version were dropped as
|
||
false-positive-only risk. `/scrape` Step 2 now consumes the signal: a closed-at-source
|
||
job is recorded in `seen_jobs.json` as `"status": "expired"` - marked, never silently
|
||
dropped, per the `/rank` pattern - which is the fix for the ghost-LinkedIn-jobs class
|
||
in #331 (an expired LinkedIn URL redirects to a *similar live job*, so a stored hit
|
||
can die unnoticed between scrape and click). `isActive: true` is documented as
|
||
absence of the banner, not proof the posting is open.
|
||
- **pypdf ATS text-layer fallback** - `/apply` Step 5d and `tools/verify_pdf.py` extract the CV PDF text layer with **pypdf** first (BSD, `pip install pypdf`) so Windows machines without Poppler still get a mechanical parseability check. Poppler `pdftotext -layout -enc UTF-8` remains the fallback; if both are missing the check still degrades to a visual keyword review. No extra cache or installer. `05-cv-templates.md` `framework_version` 1.4.2 → 1.4.3.
|
||
- **CI now tests the full documented Python range** (#370) - the Python tool tests job
|
||
runs a 3.10-3.14 version matrix instead of pinning 3.12, so both the documented 3.10
|
||
minimum and the newest Python are continuously verified. Grew out of an independent
|
||
cross-platform verification (Windows + Linux, Python 3.14) contributed by
|
||
@atiqur-rahman-pro, whose report also confirmed the suite's expected
|
||
PyYAML-dependent skips in a clean container. Thanks!
|
||
- **Company-research cache for `/apply` and `/interview`** - `/apply` Step 3's reviewer
|
||
agent and `/interview` Step 2 each independently execute the Company Research
|
||
Checklist (`04-job-evaluation.md`) for the same company, so applying and later
|
||
prepping for an interview on the same application researches the company twice from
|
||
scratch. A new `company_research/<normalized-name>.json` cache (30-day TTL, documented
|
||
in `04-job-evaluation.md` alongside the checklist it mirrors) lets either consumer
|
||
reuse a recent result instead of repeating the search/fetch work. This does not
|
||
change how a claim gets verified: cached research is a lead, exactly like
|
||
reviewer-agent research already is under `03-writing-style.md` rule 5 - only the
|
||
discovery step is cached, never the final verification before a claim ships in a
|
||
cover letter or prep pack. `company_research/*.json` added to `.gitignore` and
|
||
`security_guards.py`'s `REQUIRED_IGNORE_RULES` (a plain rooted pattern, not `**/`
|
||
-prefixed - the cache is referenced from commands, not a skill, so it resolves
|
||
against the repo root normally). Pinned by the new
|
||
`tests/test_company_research_cache.py`. Cache contents are documented as data, never
|
||
instructions, for a later session reading the file - the same trust-boundary rule
|
||
`apply.md` Step 0 states for the posting itself, since cache notes are written from
|
||
the same fetched web content. The verification-still-applies restatement in both
|
||
`apply.md` and `interview.md`'s cache-check paragraphs is now pinned too.
|
||
- **CI now compiles the LaTeX examples on Debian bookworm's apt-packaged TeX Live** (the
|
||
separate-PR follow-up invited in #323's review). The `latex-smoke` job ran only
|
||
`texlive/texlive:latest` - the environment that never had the #242 bug, so the moderncv-2.3.1
|
||
compile fix shipped guarded by nothing: the next edit to `cv/main_example.tex` could
|
||
reintroduce a `\firstnamestyle` override or a top-level `\usepackage{hyperref}` and CI would
|
||
stay green. The job is now a two-leg matrix, `texlive-latest` unchanged and `debian-bookworm`
|
||
installing TeX Live 2022 from apt (moderncv 2.3.1, verified in a real bookworm container:
|
||
both documents compile clean and the strict stock assertions - 2-page CV, 1-page cover
|
||
letter, extractable text - pass on both legs unchanged). `--no-install-recommends` keeps the
|
||
leg lean, which makes two font packages explicit requirements: `texlive-fonts-extra`
|
||
(moderncv loads fontawesome5) and `texlive-fonts-recommended` (hyperref's xetex driver
|
||
probes the `pzdr` metrics). **Note for repo admins:** the matrix renames the check from
|
||
"Compile example CV and cover letter" to two leg-suffixed names, so a branch-protection
|
||
rule requiring the old name needs updating once.
|
||
|
||
### Fixed
|
||
|
||
- **`/reset profile` left candidate data in two of the skill files it claims to clear**
|
||
(#364) - `/setup` Step 3 populates six skill files; the profile scope cleared four.
|
||
`04-job-evaluation.md` was listed by name under "files NOT touched (they contain
|
||
framework rules, not candidate data)" while Step 3.4 writes the user's match areas,
|
||
career goals, energizing/draining tasks, financial situation and schedule constraints
|
||
into it - and CI's placeholder-integrity job already guards it under "personal data may
|
||
have been committed". `job-scraper/search-queries.md`, which Step 3.8 fills with their
|
||
job boards, role titles, domain keywords, city and commute tiers, appeared nowhere in
|
||
`reset.md` at all. Both are tracked and unignored, so the Step 1 preview asked the user
|
||
to confirm a wipe list that omitted them and Step 4 then reported a blank profile while
|
||
`/rank` kept scoring against the old skills and career goals and `/scrape` kept running
|
||
the old city and queries. Both files are now previewed and cleared, restoring their
|
||
`/setup` placeholders while preserving the scoring framework and the query structure;
|
||
`04-job-evaluation.md` is out of the preserved list, which keeps `03-writing-style.md`
|
||
and `06-cover-letter-templates.md` (correctly - the latter's `[YOUR_NAME]` tokens are
|
||
LaTeX scaffolding Step 3 never writes to). `CLAUDE.md` and `cv/main_example.tex` stay
|
||
outside the `profile` scope, which covers skill files only, and the preview and Step 4
|
||
now say so instead of implying a full wipe. `tests/test_reset_command.py` gains a
|
||
profile-scope guard alongside its documents-scope one, deriving the file list from
|
||
`/setup` Step 3's own headings so a future `/setup` target that `/reset` forgets fails
|
||
in CI; the third case pins that a personalized file is never labelled framework-only,
|
||
which a filename search alone would have missed.
|
||
- **`salary_lookup.py` never stripped the dotted "A.M.B.A." legal suffix** (#356) - the
|
||
`STRIP_PATTERNS` regex ended in `\.\b`, and a word boundary can't sit between a literal
|
||
dot and the space or end-of-string that follows it in real company names, so the
|
||
pattern was dead code: `"Arla Foods A.M.B.A."` normalized differently from
|
||
`"Arla Foods amba"` and fuzzy-matched at 86 instead of 100. The trailing dot is now
|
||
optional (`\.?\b`), both forms normalize identically, and two regression tests pin it.
|
||
Thanks @Ritik650.
|
||
|
||
## [1.6.0] - 2026-08-19
|
||
|
||
### Added
|
||
|
||
- **Cross-portal `/scrape` contract pin** (#344) - a repo-level test deriving the Step 2
|
||
search-output field list (`title`, `company`, `location`, `date`, `url`) from
|
||
`job-scraper/SKILL.md`'s own contract sentence and checking every installed portal
|
||
CLI's search source for it, so a portal that quietly stops emitting a contract field
|
||
(the failure class jobnet and jobdanmark actually shipped before #339/#340) fails CI
|
||
with a clean diff instead of degrading every `/scrape` run silently. The pin survived
|
||
the #347 output-shape changes unmodified - evidence the derived-from-spec design holds.
|
||
Contributed by @oscarbol09, the invited follow-up from #342's review.
|
||
- **`freehire-search` gains `--no-description` for cheap discovery passes** - a default
|
||
search hydrates full description bodies (~73% of the payload, ~20k tokens per query)
|
||
while `/scrape` is told to pre-filter by title before reading bodies. The new flag
|
||
drops the bodies (a live 10-result search shrinks from ~58k to ~10k chars) while
|
||
keeping every other field; hydration stays the default. The API currently returns
|
||
bodies regardless of `include_description=false`, so the lean guarantee is enforced
|
||
client-side. Pinned in `tests/commands.test.ts`.
|
||
- **Fixture coverage for linkedin's date/location and jobindex's `parseSearchPage`** -
|
||
linkedin's search-card fixture carried no `<time>` or location element, so deleting
|
||
the `date` extraction (a `/scrape` contract field on a default-ON portal) left every
|
||
test green; jobindex's Stash parser had no tests at all, so `meta.total` could stop
|
||
using `hitcount` unnoticed. Four new linkedin cases (both listdate class variants,
|
||
location, absent-element nulls) and a new jobindex `search-page.test.ts` (hitcount
|
||
vs page count, contract-field mapping, deadline fallbacks). Both mutation-verified.
|
||
- **Tests for `check_framework_version.py`** - the CI gate that stops a framework file
|
||
from being edited without a `framework_version` bump had zero tests, so the one-line
|
||
mutation `return meaningful_changes > 0` -> `return False` disabled it while the suite
|
||
stayed green. Four cases in the new `tests/test_check_framework_version.py` (clean
|
||
tree, unbumped edit, bumped edit, missing marker), each running the real script inside
|
||
an isolated git repo. Mutation-verified against that exact disable.
|
||
- **Tests for `lint_skills.py`'s skill and command checks** - only `check_settings()`
|
||
had coverage; the linter's main job (frontmatter keys, `allowed-tools` targets
|
||
existing, the `# /<name>` command title rule) was unasserted, so deleting the
|
||
missing-allowed-tools error survived the whole suite. Four new cases in
|
||
`tests/test_lint_skills.py`, with the fixture's yaml stub upgraded to parse the real
|
||
frontmatter. Mutation-verified.
|
||
- **Discriminating tests for `robots_check`'s tie-break and browser-UA fallback** - the
|
||
existing tie test put Disallow first, the one ordering that cannot detect deletion of
|
||
the tie-break clause; and the browser-readback recovery that `09-web-research.md`
|
||
claims is covered had no test at all. Three new tests in `tests/test_robots_check.py`
|
||
pin the Allow-first tie, the 403-to-honest/200-to-browser recovery, and that a
|
||
browser-fetched policy is still obeyed strictly. Each was mutation-verified: deleting
|
||
the tie-break clause or the UA fallback now fails the suite.
|
||
- **LaTeX special-character guidance for CVs** (`framework_version` 1.4.1 -> 1.4.2 in
|
||
`05-cv-templates.md`, 1.0.1 -> 1.0.2 in `06-cover-letter-templates.md`) - `05` gains a
|
||
"LaTeX Special Characters" section and `06`'s existing one is completed beyond `\_`/`\&`.
|
||
The load-bearing case is an unescaped `%` in a quantified achievement bullet: it starts a
|
||
LaTeX comment, so "cut latency by 40% and saved DKK 2M" compiles with zero errors and
|
||
renders as "cut latency by 40" - silent content loss in the deliverable, on exactly the
|
||
content the guidance steers users to write. `&` in employer names (Bang & Olufsen, H&M)
|
||
fails loudly at compile time and is now documented alongside. Pinned by
|
||
`tests/test_latex_guidance.py`.
|
||
|
||
- **`seen_jobs.json` entries record which mechanism produced them** - a new additive `source`
|
||
field (`cli` for Step 1b portal-CLI output, `websearch` for the Step 1c fallback), a Step 1c
|
||
rule tagging fallback results at collection time, and a `fallback (websearch):` line in the
|
||
Step 5 run summary naming the portals that ran on the fallback. Motivated by the
|
||
ghost-LinkedIn-jobs report (#331): when a stored job later turns out not to exist at its URL,
|
||
triage hinges on whether the entry came from live CLI output or a search index that can be
|
||
weeks stale - evidence that previously lived only in the run's scrollback. An entry that is
|
||
missing `source` predates the field and is never backfilled; a presented job with no
|
||
`seen_jobs.json` entry at all points at fabrication, which the scraper's Rule 1 forbids.
|
||
Pinned by `tests/test_scrape_provenance.py`. `job-scraper/SKILL.md` sits outside the
|
||
`framework_version`-marked set, so no version bump applies.
|
||
|
||
### Changed
|
||
|
||
- **BREAKING (scripts passing stray flags): all six portal CLIs reject unknown flags**
|
||
with exit 1 and `{"error", "code": "UNKNOWN_FLAG"}` on stderr, instead of silently
|
||
discarding them. A discarded filter changes what a search returns with no error - a
|
||
wrong flag name on jobdanmark returned the entire database (13,862 results, none
|
||
matching) as if it matched the query, and the six portals use four different names for
|
||
the free-text flag, so cross-portal guessing is likely. `add-portal.md` already
|
||
required contributed portals to exit 1 on a bogus flag; the reference CLIs now meet
|
||
their own bar. Pinned by nine new cases across the six `cli-flag-validation` suites.
|
||
- **`/rank` persists its location verdict as `location_verdict`** - the bare `location`
|
||
key meant two incompatible things in `seen_jobs.json`: a place (scraper search output,
|
||
driving the commute filter) and a PASS/FAIL/FLAG verdict (`/rank` Step 4), so ranking
|
||
could overwrite "Aarhus, Denmark" with "PASS" and no reader could tell which meaning a
|
||
stored value carried. Legacy entries are read compatibly (a PASS/FAIL/FLAG string in
|
||
`location` counts as the verdict when `location_verdict` is absent) and migrated on
|
||
re-write. The `seen_jobs` schema note in `job-scraper/SKILL.md` now also enumerates
|
||
`location_verdict`/`language_gate`/`language_note`, so its "do not drop any of these
|
||
fields" instruction finally covers the fields `/rank` calls as important as the score.
|
||
Pinned by two new tests in `tests/test_rank_command.py`.
|
||
- **`linkedin-search detail` drops the `applyUrl` field** - the extraction regex
|
||
assumed `class=` before `href=` and never matched LinkedIn's real markup (`null` on
|
||
every live posting since the markup ordering differs), and fixing the regex would only
|
||
capture the job-view URL, a duplicate of the record's own `url`. The field and the
|
||
SKILL.md "apply link" claim are removed; a test pins the removal.
|
||
- **`jobdanmark-search` search output drops presentation-only keys** - `coverImage`,
|
||
`companyLogo`, `companyLogoSvgMarkup`, `overlayColor`, and `silhouetteLogo` were ~40%
|
||
of a live payload (a 30-result response shrinks from ~30k to ~20k chars), fed into
|
||
agent context on every `/scrape` query, and unusable by an agent. The #340
|
||
compatibility duplicates (`companyName`, `publishedDate`, `applicationDeadline`) and
|
||
`slug` stay. Pinned in `tests/search-normalization.test.ts`.
|
||
- **BREAKING (jobbank forks): `jobbank-search` search output emits `deadline` as
|
||
`YYYY-MM-DD`** - the feed's `DD.MM.YYYY` parenthetical was passed through raw,
|
||
contradicting the `/scrape` contract, the other portals, and the same CLI's own
|
||
`detail` command (which already emits ISO for the same job). `01.09.2026` is also
|
||
ambiguous to a date parser (1 Sep vs 9 Jan). The known shape is now converted;
|
||
"løbende" still maps to `null`, and an unrecognized shape passes through for `/rank`'s
|
||
defensive handling. Anything parsing the old `DD.MM.YYYY` output must update - though
|
||
the README's own search example already showed the ISO form. Pinned in
|
||
`tests/rss-parsing.test.ts` and `tests/search-normalization.test.ts`.
|
||
- **Job matching reframed around function, not title** (`framework_version` 1.2.2 -> 1.2.3 in
|
||
`04-job-evaluation.md`) - title-lookalike matching throws away career capital that doesn't
|
||
fit one job-title box (e.g. a background spanning research leadership, platform ownership,
|
||
and program management gets collapsed into whichever single title sounds closest). `/setup`,
|
||
`search-queries.md`, and `04-job-evaluation.md` now guide the candidate to define priority
|
||
categories by function - the kind of problem a role solves - and to list several plausible
|
||
job titles as query variants within each category, rather than betting an entire priority
|
||
tier on one exact title string.
|
||
|
||
- **CONTRIBUTING: invited PRs are reserved for the invitee** - when a maintainer comment
|
||
explicitly invites a named contributor to implement an issue they diagnosed or designed,
|
||
the implementation is theirs for a stated window (default seven days, longer on request);
|
||
a duplicate PR filed inside that window closes in the invitee's favor regardless of
|
||
arrival order. Prospective from 2026-08-14. Sits alongside the existing credit norm.
|
||
|
||
### Fixed
|
||
|
||
- **Onboarding warns about public forks at the point of decision** (#345) - the quick
|
||
start walked a new user into `gh repo fork` (forks of public repos are always public)
|
||
and two steps later had `/setup` write personal data into tracked files, with the only
|
||
complete warning sitting in SETUP.md section 8 - a section about pulling updates that a
|
||
first-time user has no reason to open. A real user hit exactly this. The warning now
|
||
sits adjacent to both fork commands (README step 1, SETUP.md section 2), and `/setup`
|
||
checks the origin's visibility **before** writing anything: a public-fork origin gets a
|
||
confirm-first warning instead of a note after every file is already on disk. Reported
|
||
by @basilevs with a complete reproduction and fix analysis. Pinned by the new
|
||
`tests/test_onboarding_privacy.py`.
|
||
- **`jobindex-search detail` rewritten against jobindex's current markup** - every
|
||
selector the old parser used is gone from live pages, so on 4 of 5 live postings it
|
||
returned CSS-comment text as the deadline (`"K \t\t... */"`), an external ATS URL as
|
||
its own `id` and `url`, null company/location/date, and a 160-char teaser as the
|
||
description - exit 0 every time. The new parser handles both live shapes (the
|
||
jobindex-native `jd-*` layout and the external-ATS passthrough), always keeps the
|
||
jobindex id and `jobannonce` URL, requires a real date next to the deadline label and
|
||
scans only visible markup (killing the CSS-comment capture), converts Danish long
|
||
dates to ISO, and reports `company: null` honestly on passthrough pages instead of
|
||
the ATS brand. Verified live on 5/5 postings (full descriptions of 5.5-9k chars, 4/5
|
||
ISO deadlines and locations). Fixture tests for both shapes, including the
|
||
CSS-comment trap, in the new `tests/detail-parsing.test.ts`.
|
||
- **`/scrape` gains a recency fallback for portals with no recency flag** - Step 1b.3
|
||
told every portal to scope to 14 days "using the portal's supported recency flag", but
|
||
jobdanmark has none, leaving the instruction unsatisfiable there: the agent either
|
||
silently skipped the scoping or invented a flag (which the CLIs now reject). Every
|
||
portal emits a `date` field, so the instruction now says to filter client-side after
|
||
the call, and stops presenting `--order` (a sort) as interchangeable with a filter.
|
||
Pinned in `tests/test_scrape_provenance.py`.
|
||
- **`/html-report`'s funnel counts stages from history; the rejection rate stops
|
||
counting non-rejections** - the funnel was computed from current status, which is a
|
||
state, not a history: an application that interviewed and was then rejected never
|
||
counted as reaching Interview, so a finished search rendered as though nobody ever
|
||
interviewed. The funnel (Step 2 and chart 4) now derives stage-reached from current
|
||
status plus the `outcome.md` stage checkboxes Step 1.2 already merges. And the
|
||
rejection rate no longer counts `offer_declined` (a success) or `withdrawn`
|
||
(candidate-initiated) as rejections, nor unresolved Interview/Offer rows in its
|
||
denominator. Pinned by two new tests in `tests/test_html_report_command.py`.
|
||
- **`jobdanmark-search detail`'s HTML fallback emits the same shapes as its JSON-LD
|
||
branch** - a posting without JSON-LD returned `datePosted` as the page's raw
|
||
`DD-MM-YYYY` text, `validThrough` as free text (including the literal `"Løbende"`,
|
||
which would flow into stored data as a deadline), and a hardcoded `null`
|
||
`addressLocality`. The fallback now converts overview dates to `YYYY-MM-DD`, maps
|
||
`Løbende` to `null` (jobbank's precedent for the equivalent), and derives the locality
|
||
from the workplace address with the same postcode extraction search uses. Pinned in
|
||
`tests/detail-parsing.test.ts`.
|
||
- **`jobnet-search detail` no longer leaks the `1900-01-01` undisclosed-deadline
|
||
sentinel** - `search` maps the API's sentinel to `null` (with a test pinning it), but
|
||
`detail` dumped the raw response, so a posting whose deadline is simply not disclosed
|
||
contributed a deadline 126 years in the past to stored data, and `/rank`'s expiry
|
||
sweep would retire the job instantly. All three output formats now flow through a
|
||
`prepareDetail` normalization that maps the sentinel to `null`. Pinned in
|
||
`tests/detail-formatting.test.ts`.
|
||
- **CI's placeholder guard now watches the CV's actual personal-data lines** - the
|
||
sentinel for `cv/main_example.tex` was `[YOUR_NAME]`, whose only occurrences are a
|
||
header comment and the hyperref `pdftitle`; `/setup`'s documented edit replaces the
|
||
`\name{}`/`\address{}`/`\phone{}`/`\email{}` data and touches neither, so a fully
|
||
personalized CV with a real name, address, phone and email passed the check (proven
|
||
empirically in the review). The guard now asserts sentinels inside the `\name{}` and
|
||
`\email{}` lines, and `01-candidate-profile.md`'s sentinel moves from the `<!-- SETUP`
|
||
header comment onto the `[YOUR_EMAIL]` Identity field for the same reason. The new
|
||
`tests/test_placeholder_integrity.py` simulates the `/setup` edit and requires the
|
||
guard to fire on it.
|
||
- **`jobindex-search` maps ASAP postings' deadline to `null`** - the portal's
|
||
`apply_deadline_asap` flag was emitted as the literal string `"ASAP"` on roughly half
|
||
of live results, contradicting the CLI's own README ("date string; null if not
|
||
listed") and the `/scrape` schema, and breaking every consumer that does date
|
||
arithmetic (`/rank`'s urgency and expiry sweep, `/outcome`'s deadline check,
|
||
`/notion-sync`'s typed date column). ASAP means "no stated deadline", which the
|
||
contract already represents as `null`. Pinned in `tests/search-page.test.ts`.
|
||
- **`/gmail-sync` no longer restricts its search to the Inbox** - the query used
|
||
`in:inbox` to "skip sent/drafts", but that operator also excludes every archived
|
||
message, and self-defeatingly the mail matched by the very job-search label Step 3.1
|
||
hunts for (the standard filter that applies such a label also archives). The query now
|
||
uses `-in:sent -in:drafts`, which matches the stated intent exactly. The failure mode
|
||
was silent under-detection: a missed rejection or interview invite read as "no
|
||
updates". Pinned by the new `tests/test_gmail_sync_command.py`.
|
||
- **`/upskill` no longer divides by a blank `fit_rating`** - `/outcome` creates tracker
|
||
rows for applications made outside the workflow with no fit evaluation, so their
|
||
`fit_rating` is blank, and Step 3.3's `(100 - fit_rating) / 100` had no rule for that.
|
||
The naive blank-as-0 reading yields weight 1.0 (the maximum), letting the one job the
|
||
framework knows nothing about dominate the skill-gap heatmap. A blank or non-numeric
|
||
`fit_rating` now falls back to a matched ranked entry's `rank_score`, else the row is
|
||
skipped, counted, and reported once - mirroring the skill's own missing-`gaps`
|
||
handling. Pinned by `tests/test_upskill_skill.py`.
|
||
- **`/rank`'s expiry sweep parses stored deadlines defensively** - the sweep changes
|
||
status automatically from a date comparison against values on disk, but portals have
|
||
shipped non-ISO shapes into `seen_jobs.json` (`"ASAP"`, `DD.MM.YYYY`, free text), and
|
||
`/rank` had no rule for them while the display-only `/outcome` already did. A stored
|
||
deadline that is not `YYYY-MM-DD` is now treated exactly like an absent one wherever a
|
||
stored deadline is compared (urgency and sweep), and reported once with its portal.
|
||
Pinned by `tests/test_rank_command.py`.
|
||
- **Language Gate preamble no longer claims the gate is untracked** (`framework_version`
|
||
1.2.3 -> 1.2.4 in `04-job-evaluation.md`) - the paragraph still said the result "is not
|
||
a field `/scrape` or `/rank` track", written before the gate was wired into both
|
||
consumers. An agent reading the authoritative framework file learned the opposite of
|
||
what `rank.md` itself insists on ("These veto fields are as important to persist as
|
||
the score itself"). The preamble now names `language_gate`/`language_note` and how each
|
||
consumer uses them; a coupling test in `tests/test_rank_command.py` keeps the framework
|
||
text honest about the tracking.
|
||
- **`/reset documents` now clears `documents/postings/`** - the drop folder for
|
||
hand-pasted job posting text was absent from the preview, the delete block, and the
|
||
user-facing scope description, after which the command told the user "The `documents/`
|
||
folder is now empty" - false whenever postings were present, and they are exactly the
|
||
personal residue a reset exists to clear. A new `tests/test_reset_command.py` derives
|
||
the folder list from the git tree, so any future drop folder fails the test until
|
||
`/reset` covers it.
|
||
- **`convert_salary_excel.py` no longer corrupts US/UK-formatted numbers 1000x** - the
|
||
both-separators branch always assumed European locale, so a `"1,234.56"` cell was
|
||
silently converted to `1.23456` and written into `salary_data.json`. The rule is now
|
||
"the separator that appears last is the decimal separator", which also makes
|
||
multi-group values (`"1,234,567.89"`, `"1.234.567,89"`) parse instead of raising. And
|
||
`strip_type_patterns` now strips `COMPOUND_PATTERNS` words as substrings, mirroring
|
||
`header_matches`, so a Danish compound header pair ("Antal alle" / "Lønindeks alle")
|
||
pairs into one category instead of two unpaired standalones - the exact locale the
|
||
compound support was added for. Pinned by six new cases in
|
||
`tests/test_convert_salary_excel.py`.
|
||
- **`jobdanmark-search` extracts the city when a comma follows the postcode** - the
|
||
`location` regex required whitespace after the 4-digit postcode, but live
|
||
`companyAddress` values frequently read `"2670, Greve"`; those results emitted
|
||
`location: null` (7 of 30 in a live sample), so `/scrape`'s geography/commute filter
|
||
(Rule 3) had nothing to act on. The extraction now accepts an optional comma, trims the
|
||
captured city, and still refuses to mistake a 4-digit street number for the postcode.
|
||
Pinned by three new cases in `tests/search-normalization.test.ts`.
|
||
- **Example-CV bullets no longer swallowed as LaTeX optional labels** - every placeholder
|
||
bullet written as `\item [text]` (11 in `cv/main_example.tex`, 3 in
|
||
`06-cover-letter-templates.md`'s taught template) let LaTeX parse the bracketed text as
|
||
`\item`'s optional argument: the shipped example CV rendered all Professional Experience
|
||
bullets clipped off the left page edge, with the word "Achievement" appearing 9 times in
|
||
the source and 0 times in the PDF text layer - a clean compile, green CI. Bullets are now
|
||
braced (`\item {[text]}`), the cover-letter guide teaches the braced form, and CI's stock
|
||
PDF assertions additionally require `Achievement` to survive `pdftotext`. Pinned by
|
||
`tests/test_latex_guidance.py`.
|
||
- **Documented ATS extraction commands pin `-enc UTF-8`** - `pdftotext -layout` without an
|
||
encoding flag emits Latin-1 on Xpdf builds, so every non-ASCII character in a correct CV
|
||
(Rambøll, Ingeniør, København) read back as a replacement character and failed the
|
||
parseability checklist, steering the agent to "fix" a healthy document. The commands in
|
||
`apply.md`, `05-cv-templates.md`, and `CLAUDE.md`'s verification checklist now carry
|
||
`-enc UTF-8`, which is deterministic on both poppler and Xpdf. Pinned by
|
||
`tests/test_latex_guidance.py`.
|
||
|
||
- **`jobbank-search` search output now carries the `/scrape` contract's `date` field** (#342) -
|
||
the CLI emitted `posted` (full ISO 8601) but not the cross-portal `date` key, the one Step 2
|
||
contract field it was missing. Search results now additively emit `date` as `YYYY-MM-DD`
|
||
derived from `posted` (kept unchanged), `null` when the feed item carries no `pubDate`. The
|
||
result mapping is extracted into an exported `normalizeSearchItem` so the derivation is
|
||
pinned by tests. Completes the portal-contract series with #339 (jobnet) and #340
|
||
(jobdanmark).
|
||
|
||
- **`jobdanmark-search` search output now carries the `/scrape` contract fields** - the CLI
|
||
exposed the API-native schema (`companyName`, `publishedDate` in `DD-MM-YYYY`, …) with no
|
||
`company`, `location`, `date` or `deadline`, so every `/scrape` run flagged jobdanmark as
|
||
degraded and the `seen_jobs.json` dedupe lost the company. Search results now additively emit
|
||
`company`, `location` (city after the postal code in `companyAddress`), and `date`/`deadline`
|
||
in the `YYYY-MM-DD` convention, with null-safe handling of a missing address.
|
||
|
||
- **`jobnet-search` search output now carries the `/scrape` contract fields** - the CLI emitted
|
||
the raw Jobnet API schema (`jobAdId`, `hiringOrgName`, `publicationDate`, …) with no
|
||
`company`, `location`, `date` or `url`, so every `/scrape` run flagged jobnet as degraded
|
||
forever (CI stayed green), the `seen_jobs.json` dedupe fell back to company+title, and `/rank`
|
||
lost the posting link. Search results now additively emit `company`, `location`, `date`,
|
||
`deadline` and `url` (`https://jobnet.dk/find-job/{jobAdId}` - the `/job/` route is
|
||
login-walled); the API's `1900-01-01` "deadline not disclosed" sentinel maps to `null`.
|
||
|
||
- **A `/` in a company or role name no longer nests the application archive one level too deep**
|
||
(jakob1379/ai-job-search#22). `Novo Nordisk A/S` derived
|
||
`documents/applications/novo_nordisk_a/s_data_scientist/` - written and found by every command
|
||
that derives the path, silently skipped by the two that enumerate it, so the application never
|
||
appeared in `/html-report`'s dashboard and `/setup`'s calibration never learned from it. The
|
||
**Subfolder naming** rule in `documents/README.md` now drops every character that is not a
|
||
letter, digit or underscore (collapsing underscore runs, trimming the ends), and the derivation
|
||
sites - `/apply`, `/outcome`, the direct application skill, `/gmail-sync`, `/interview`, and
|
||
`/notion-sync` - cite that rule instead of paraphrasing it. An all-punctuation value that derives
|
||
to an empty name now stops for user correction instead of writing into the archive root. The
|
||
application assistant's `framework_version` moves 1.3.3 → 1.3.4. **Already-nested archives are
|
||
not migrated**: an archive written under the old rule stays where it is until the user moves it;
|
||
only newly derived names change. Thanks @jakob1379 for the report.
|
||
|
||
- **The `/html-report` dashboard now reads and renders the tracker's `deadline`** (follow-up to
|
||
#319). The tracker gained a fourteenth `deadline` column and every other consumer (`/outcome`,
|
||
`/upskill`, `/notion-sync`) was updated to know it, but the dashboard's Step 1 field
|
||
enumeration and Step 3 table columns still listed the original thirteen - the one surface
|
||
where the column could not be seen at all, so a `drafted` application's clock stayed invisible
|
||
in the report that reviews the pipeline end to end. The Step 1 enumeration now matches the
|
||
canonical 14-column header and the applications table can show a `Deadline` column, subject to
|
||
the existing empty-column rule. Pinned by `tests/test_html_report_command.py` so a future
|
||
column addition cannot silently vanish from the dashboard again.
|
||
|
||
- **Application deadlines are written down at every moment the framework provably holds them**
|
||
(#319). `/scrape` fetched the deadline and rendered it in a table, `/rank` turned it into the 🔥
|
||
urgency marker and the expiry check, and nothing stored it - so the marker fired exactly once,
|
||
every later run had to re-fetch a posting that might have expired to recover the date, and a
|
||
`drafted` application (whose only applicable clock is its deadline) had no time-based signal at
|
||
all. `seen_jobs.json` entries now carry a `deadline` (base field, written on first sight,
|
||
refreshed by `/rank` Step 4, `null` vs missing distinguished and never guessed); `/rank` Step 3
|
||
re-derives urgency from the stored value with no re-fetch and sweeps already-ranked entries past
|
||
their deadline into `expired`; the tracker gains a fourteenth `deadline` column appended last,
|
||
with a header-line-only migration for existing trackers; `/apply` Step 0 extracts the deadline
|
||
and Step 6b writes it (including the `/scrape` path via the assistant SKILL.md); `/outcome`
|
||
surfaces it on open rows and flags near/passed deadlines on `drafted` rows without changing the
|
||
no-follow-up rule; and the row-rewriting paths (`/outcome` Step 4, `/gmail-sync` Step 7a) now
|
||
preserve every unparsed field so the new column survives the first status update. `/notion-sync`
|
||
names the tracker as the Deadline source (tracker wins), `/upskill`'s column list stays true, and
|
||
`job-application-assistant/SKILL.md` bumps `framework_version` 1.3.2 → 1.3.3. Pinned by
|
||
`tests/test_rank_command.py`, `tests/test_apply_records_application.py`, and
|
||
`tests/test_upskill_skill.py`.
|
||
|
||
The sweep's edges are stated rather than left to the reader: an entry with no stored `deadline`
|
||
is left alone and never inferred from another field (the majority case, since most entries
|
||
predate the column), `--all` re-scores any status including `expired` so a swept job is
|
||
recoverable, and `/rank` Step 4's idempotency rule now names the sweep as its deliberate
|
||
exception instead of contradicting it. Step 5 reports how many entries were swept and how many
|
||
were retired, so an automated status change is never silent. `/outcome` Step 1 states that the
|
||
header append is the one edit it may make outside a matched row, so it does not read as a
|
||
violation of Step 4's own "never restructure the CSV". `/notion-sync` forbids reconciling two
|
||
disagreeing deadlines by taking the earlier or later of them.
|
||
|
||
- **`convert_salary_excel.py` no longer misreads whole-thousands cells from a Danish-locale
|
||
export** - a cell like `60.000` (thousands separator, no decimal comma) was handed to
|
||
`float()` and silently written as `60.0`, a 1000x-wrong salary in `salary_data.json` that
|
||
then rendered with a meaningless `vs baseline` percentage in `/apply`. The comma-side
|
||
mirror (`1,234`) was already guarded as ambiguous and skipped; the dot side had no guard,
|
||
and tests only pinned the both-separators form (`1.234,5`). `\d+\.\d{3}` is now rejected
|
||
the same way, so the shared never-guess policy applies to both separators and the rows in
|
||
between (e.g. `60.000,50`, `108,5`) keep parsing exactly as before. Pinned by
|
||
`tests/test_convert_salary_excel.py`.
|
||
|
||
- **`main_example.tex` compiles on apt-packaged moderncv** (#242) - the banking template
|
||
set its name styling through `\firstnamestyle`/`\lastnamestyle`, which moderncv 2.3.1
|
||
(Debian/Ubuntu apt) does not have, so a fresh fork could not compile its own example CV
|
||
on that toolchain. Name styling now routes through `\namefont`, the hook every name-style
|
||
macro shares: live on every version (on 2.4+, head iii's `\firstnamestyle`/`\lastnamestyle`
|
||
both route through `\namefont`, so the override is what sets the 34pt name there too), and
|
||
the only option on 2.3.1 where those macros do not exist. Two review follow-ups landed in the
|
||
same change: the `\hypersetup` comment now names the real clash mechanism
|
||
(`\RequirePackage[unicode]{hyperref}` on < 2.4; `\PassOptionsToPackage`, introduced in
|
||
2.4.0, is what removes the clash), and the metadata block sets `pdfpagemode=UseNone` - a
|
||
`FullScreen` value there would win over the class's own `\AtEndPreamble` default and make
|
||
every CV open in fullscreen presentation mode. `05-cv-templates.md`'s preamble copy stays
|
||
in lockstep (framework_version 1.4.0 -> 1.4.1). Verified on moderncv 2.5.1: exit 0,
|
||
exactly 2 pages, rendering unchanged.
|
||
|
||
## [1.5.0] - 2026-08-12
|
||
|
||
### Added
|
||
|
||
- **Commit-level upstream triage for forks** (#305). A new `tools/upstream_triage.py` walks the
|
||
commits a fork is behind upstream and sorts them into "worth reviewing" vs "probably skip":
|
||
cherry-picks already applied drop off on their own (matched by `git patch-id`, so ported work
|
||
needs no bookkeeping), commits that only touch files the fork removed are set aside, and SHAs in
|
||
a flat `.github/upstream-wontport.txt` stop resurfacing. It's the commit-history companion to
|
||
`check_upstream_updates.py`'s version stamps - the two cross-reference each other in their output.
|
||
Report-only by design: it prints ready-to-run `git cherry-pick` lines but never merges, pushes, or
|
||
opens a PR, because on a fork "applies cleanly" isn't "correct". A `.github/workflows/upstream-watch.yml`
|
||
runs it weekly into a rolling issue, guarded to no-op on the upstream template (pinned by a test) and
|
||
scoped to the built-in `GITHUB_TOKEN` so it can never write outside its own fork. SETUP.md 8
|
||
introduces both tools side by side. Offline tests cover patch-id matching, relevance filtering, the
|
||
won't-port list, and the workflow guard. Thanks @anjolok1997.
|
||
|
||
- **`security_guards.py` now holds `.claude/settings.json` hooks to an allowlist** - the
|
||
guard read `permissions.allow` and nothing else, so a `hooks` block in the same file
|
||
passed silently. A hook is strictly more dangerous than a pre-approved permission: a
|
||
permission pre-approves something Claude *may* choose to do, while a hook runs
|
||
unconditionally when its event fires, with no prompt and no model decision in between.
|
||
This is not hypothetical - it is the vector the Shai-Hulud worm used in its August 2026
|
||
wave, planting a `SessionStart` hook in `.claude/settings.json` that executed on session
|
||
start ([JFrog research](https://research.jfrog.com/post/shai-hulud-is-back-august/)).
|
||
For a template thousands of people are invited to fork, that is the riskiest key in the
|
||
file the guard already parses. `ALLOWED_HOOKS` ships empty (the template has no hooks),
|
||
the check runs *before* the permissions shape guards so a malformed permissions block
|
||
cannot return early and skip it, and unrecognised hook layouts fail closed rather than
|
||
being skipped. Eight new `HookGuardTests` cases; 14 of the suite's 26 tests fail against
|
||
the unpatched guard.
|
||
|
||
### Changed
|
||
|
||
- **`/add-portal` now specifies how a generated skill handles an API token** (#304) - the command
|
||
could already scaffold a skill for a portal reachable only through a paid fetching
|
||
service, but said nothing about the credential such a skill needs. It now checks for that
|
||
case during reconnaissance and raises the per-call cost with the user *before*
|
||
scaffolding. That check is explicitly subordinate to the `robots.txt`/terms decision
|
||
in Step 2.4 - a paid fetching service never launders a refusal, and the credential
|
||
path exists only for portals whose `robots.txt` permits access but whose bot
|
||
protection blocks ordinary fetches. The portal-skill contract requires the token to come from a
|
||
`<SERVICE>_API_TOKEN` environment variable (never a CLI flag, never a fixture) and to
|
||
fail with `MISSING_CREDENTIALS` when unset; and such a skill's `SKILL.md` must carry a
|
||
Setup section naming the service, the variable, and the billing. Spec only - no shipped
|
||
portal needs a credential, so no existing skill changes. Thanks @Haseeb-1698.
|
||
|
||
- **`/add-portal`'s fetching contract line now states the honest-UA posture** - it read
|
||
"browser User-Agent", predating the repo-wide shift to honest self-identification
|
||
(#283, #277 and the portal-CLI fixes that followed). A generated skill now defaults to
|
||
`Mozilla/5.0 (compatible; <portal>-cli/1.0)` - the convention every shipped portal CLI
|
||
follows - and escalation to browser headers goes through the robots.txt gate in
|
||
`09-web-research.md`, never the CLI's default.
|
||
|
||
- **CI discovers portal CLIs instead of hardcoding them** (#310). The `cli-checks` matrix
|
||
is now emitted by a `discover-clis` job that finds every `.agents/skills/*/cli/package.json`,
|
||
so a portal skill added with `/add-portal` gets its `typecheck` and `test` scripts run by CI
|
||
automatically - on this repo and on any fork - without editing the workflow. Upstream
|
||
coverage is unchanged (the discovered list on `master` is exactly the six shipped portals).
|
||
`/add-portal`'s Register step now says so. Thanks @ayobamiseun.
|
||
|
||
### Fixed
|
||
|
||
- **`/upskill` reports are now gitignored at the path the skill actually writes them to.**
|
||
The ignore rule `upskill/*.md` is rooted (a middle slash anchors a gitignore pattern to the
|
||
repo root), but `/upskill` is a *skill*, and skills resolve bare relative paths against
|
||
their own directory - the same observed behavior the `**/job_scraper/*` rules exist for.
|
||
A report written to `.claude/skills/upskill/upskill/report-*.md` was therefore not ignored
|
||
(`git check-ignore` confirms it on the unpatched tree), and an upskill report is the
|
||
candidate's skill gaps and weaknesses measured against named employers - among the most
|
||
sensitive files the workflow generates. The obvious widening, `**/upskill/*.md`, would have
|
||
ignored the template's own `.claude/skills/upskill/SKILL.md` (the skill directory shares
|
||
the name), so the new rule pins the report-file prefix instead: `**/upskill/report-*.md`.
|
||
Added to `.gitignore` and `security_guards.py`'s `REQUIRED_IGNORE_RULES`, with a
|
||
`check-ignore`-based test pinning both properties - reports ignored at both depths,
|
||
`SKILL.md` still tracked - which presence checks alone cannot see.
|
||
|
||
- **Dropped the phantom `evaluated` value from `seen_jobs.json`'s status vocabulary** (#315).
|
||
The schema block in the job-scraper skill documented `new/skipped/evaluated/ranked/expired`,
|
||
but `evaluated` has had no writer and no reader since the initial release - `new`/`skipped`
|
||
come from `/scrape`, `ranked`/`expired` from `/rank`, and nothing ever set or selected
|
||
`evaluated`. Post-#269 the tracker owns all lifecycle state after drafting, so the value had
|
||
no future role either; it is now removed rather than wired up. `/rank` Step 1's `--all`
|
||
wording ("all non-applied entries") leaned on an `applied` status the schema deliberately
|
||
lacks and now names what it means: entries of any status, minus the tracker exclusion set.
|
||
Forks that wrote their own tooling against the documented vocabulary should note the value
|
||
was never produced by any shipped command.
|
||
|
||
- **`/apply` archives the job posting while it still holds it** (#306). `/apply` drafted two
|
||
documents and a tracker row from the full posting, then let the text die with the session;
|
||
`/outcome` Step 3.2 tried to recover it by re-fetching a `source` URL the spec itself expects
|
||
to be dead, and a posting pasted from an email or a PDF had no `source` to re-fetch at all.
|
||
Step 6b item 7 now writes the posting verbatim to
|
||
`documents/applications/<company>_<role>/job_posting.md`, never a re-fetch or a
|
||
reconstruction from memory; an existing file is left alone (a re-application to the same
|
||
company and role keeps the earlier posting) and named in the report. Step 0 and the `/scrape`
|
||
path (`job-application-assistant` SKILL.md Step 1) retain the full posting text, not a
|
||
summary. Pinned by `tests/test_apply_records_application.py`.
|
||
|
||
- **Tracker status enum defined once; `offer declined`/`no response` now reach the correct
|
||
`/html-report` bucket and `/gmail-sync` correctly marks them final** (#298). The tracker
|
||
CSV `status` column had no single authoritative definition. Six command files restated it
|
||
independently with inconsistent spellings, producing two concrete bugs:
|
||
|
||
- `/outcome` Step 4 wrote `no response` and `offer declined` (with spaces). `/html-report`
|
||
Step 1 normalised only `no_response` / `offer_declined` (underscores), so any row written
|
||
with spaces matched no bucket and was silently dropped from the rejection-rate denominator.
|
||
- `/gmail-sync` Step 2 defined the "final" set with the space forms, so a row written with
|
||
underscores was never recognised as final and the sync kept chasing closed applications.
|
||
- `/html-report` included `interview_only` in the tracker bucket map; that value belongs to
|
||
the archive `outcome.md` `Status:` field, not the CSV `status` column.
|
||
|
||
Fix: a `## Tracker status vocabulary` block in `/outcome` (the only writer of the CSV)
|
||
now defines the canonical set once with underscore spellings and the **Final** set by
|
||
explicit list — everything else, `drafted` included, is **Open**. The legacy space
|
||
spellings are the same values, not separate statuses: equally **Final**, and every rule
|
||
that names one form applies to the other — readers must accept them on read, and never
|
||
write them. Every reader that makes final/open decisions references that block (`/apply`
|
||
Step 6b, `/interview` Step 0, `/gmail-sync` Step 2, `/html-report` Step 1, `/notion-sync`
|
||
Steps 3-4). `/outcome` Step 4 writes `no_response` / `offer_declined`; `/notion-sync`
|
||
normalises both forms to the canonical spellings before setting the Status property;
|
||
`/html-report`'s bucket map loses `interview_only`, keeps both spellings, and gains a
|
||
case-insensitive catch-all that maps unrecognised values to **Rejected/Closed** and names
|
||
them once in the status breakdown. Pinned by `tests/test_tracker_status_vocab.py`.
|
||
|
||
**Fork heads-up:** if your personalized `/outcome` adds `no response` or `offer declined`
|
||
(space forms) to the tracker write path, swap them for the underscore forms. Existing rows
|
||
keep working because every reader now accepts both spellings on read. If your Notion
|
||
database already carries space-form Status options, they simply go unused — Notion never
|
||
auto-removes select options.
|
||
|
||
## [1.4.0] - 2026-08-07
|
||
|
||
### Added
|
||
|
||
- **`--jobage-minutes` on linkedin-search for sub-day freshness windows** (#302) - LinkedIn
|
||
filters its `f_TPR` parameter server-side at second granularity, so the CLI can now ask
|
||
for postings from the last N minutes instead of whole-day windows only. Conflicts with
|
||
`--jobage` are rejected explicitly (`CONFLICTING_AGE_FLAGS`). Useful for early-applicant
|
||
freshness on high-volume searches; URL construction only, no parsing change.
|
||
|
||
- **README: video walkthrough link in Quick start** - The Next New Thing's hands-on
|
||
walkthrough of the workflow (recorded August 2026), for newcomers who want to see the
|
||
setup-to-application flow before reading. Docs only.
|
||
|
||
- **Spec-pinning tests for the Language Gate's `/rank` contract** (#278) - four regression
|
||
guards in `tests/test_rank_command.py` pinning the `language_gate`/`language_note` fields
|
||
through Steps 2-5 of `/rank`, including the Step 4 persistence rule that was live-debugged
|
||
during #275 (vetoes reported in console output but `language_gate: null` on every persisted
|
||
entry). Mirrors the existing `gaps`/`strengths` pinning pattern. No behavior change.
|
||
|
||
- **The jobnet and jobdanmark CLIs identify themselves on every API request** (#283) - their
|
||
`apiFetch`/`apiPost` wrappers now send an explicit `User-Agent` (`jobnet-cli/1.0`,
|
||
`jobdanmark-cli/1.0`) instead of Bun's anonymous default token, matching the honest
|
||
self-identification jobindex already uses on `htmlFetch`. The new `user-agent.test.ts`
|
||
suites assert the header on every request wrapper. No response behavior observed to
|
||
change.
|
||
|
||
### Changed
|
||
|
||
- **The four Danish demo portals now ship disabled** (#288) - `jobindex-search`,
|
||
`jobbank-search`, `jobdanmark-search`, and `jobnet-search` default to `enabled: false`,
|
||
and `/setup`'s job-portals question now acts on the answer: it flips them to
|
||
`enabled: true` when your market is Denmark, and leaves them off otherwise. Previously a
|
||
non-Danish user's `/scrape` ran all four Danish boards by default, spending tokens
|
||
fetching and filtering irrelevant listings. **Fork heads-up:** if you search the Danish
|
||
market, set `enabled: true` in those four `SKILL.md` files after updating (or re-run
|
||
`/setup --section search`); forks that already curated their portal set are unaffected.
|
||
|
||
### Fixed
|
||
|
||
- **The linkedin-search CLI identifies honestly** - its `User-Agent` was a full Chrome
|
||
browser string, the last portal CLI still spoofing after #283 and the jobbank/jobdanmark
|
||
fix. It now sends `Mozilla/5.0 (compatible; linkedin-search-cli/1.0)`, the same token
|
||
format as every other portal. Verified live on both the search and detail endpoints:
|
||
identical 200 responses with full content under the honest token.
|
||
|
||
- **A `.env` was committable** (`.gitignore`, `tools/security_guards.py`). `/add-portal`
|
||
can generate a skill for a portal that only returns usable content through a paid
|
||
fetching service, and such a skill reads an API token from the environment - but
|
||
nothing stopped the `.env` holding that token from being committed. No shipped portal
|
||
needs a credential, so upstream never hit this; a fork whose generated portals do hit
|
||
it immediately. `.env` and `.env.*` are now ignored and pinned in
|
||
`REQUIRED_IGNORE_RULES`, so the guard fails if the rule is ever dropped.
|
||
|
||
- **The robots gate did not fail closed** (`tools/robots_check.py`, #277). Found by an
|
||
adversarial review run over the merged file, not by inspection. Both cases are pinned
|
||
in `tests/test_robots_check.py` as FAIL-OPEN REGRESSIONs:
|
||
|
||
- **A soft `200` granted permission.** A host answering `/robots.txt` with an HTML
|
||
error page at status 200 produces a body that parses to zero rules, and zero rules
|
||
read as "allowed" - so the browser-header retry ran on permission that was never
|
||
given. A non-empty body carrying no recognised directive is now treated as
|
||
unreadable. A genuinely empty file stays allow-all, per RFC 9309.
|
||
- **`Disallow` patterns were never percent-decoded** while the request path was, so
|
||
`Disallow: /foo%20bar` never matched `/foo bar` and the rule was silently skipped -
|
||
a fail-open on any site that encodes its own rules.
|
||
|
||
- **`curl` argument hardening** (`tools/robots_check.py`). The curl argv had no `--`
|
||
terminator before the URL. `gate()` rebuilds the target as `scheme://host/robots.txt`
|
||
before calling `_fetch`, so the gate path was never exposed; this is hardening for
|
||
direct callers, with a test pinning the terminator, that a dash-leading argument fails
|
||
closed end to end, and that `gate()` never passes a caller-supplied URL through to
|
||
curl. `--max-redirs 5` is set explicitly rather than left to curl's default.
|
||
|
||
- **Negative and fractional filter flags are rejected in the Danish portal CLIs** (#281) -
|
||
`--jobage` (jobindex), `--radius` (jobnet), `--category`/`--jobtitle-id` (jobdanmark), and
|
||
`--company` (jobbank) now validate as positive integers, completing the `page`/`limit`/
|
||
`per-page` tightening from #191. Some portals silently ignore invalid filter values and
|
||
return unfiltered results, so a mistyped ID produced wrong results instead of an error.
|
||
- **The upstream checker reports files missing from the upstream ref instead of a silent
|
||
`[OK]`** (#282) - if upstream renames or deletes a tracked framework file, a fork's
|
||
`check_upstream_updates.py` now lists it under a `[WARNING]` summary instead of skipping
|
||
it and printing a false all-clear.
|
||
- **`09-web-research.md` is now tracked by the upstream checker** - the file shipped in
|
||
#277 but was never added to `FRAMEWORK_FILES`, so forks got no signal when it changed.
|
||
- **jobbank and jobdanmark CLIs identify honestly** - jobbank's `User-Agent` was a full
|
||
Chrome browser string and jobdanmark's detail command sent a bare `Mozilla/5.0`; both now
|
||
use the `Mozilla/5.0 (compatible; <portal>-cli/1.0)` token the other portal CLIs use,
|
||
matching the identification posture settled in #277. Verified live: both portals serve
|
||
identical responses to the honest token.
|
||
|
||
- **A `WebFetch` 403 is no longer treated as a dead posting** - `WebFetch` sends a bot user
|
||
agent, and many bank and corporate sites answer it with HTTP 403 while serving the same
|
||
page to a browser normally. Every command read that as "page unavailable" and degraded
|
||
silently instead of failing loudly: `/rank` marked live postings `expired`, `/apply` fell
|
||
back to search-result snippets or to vague cover-letter prose, and `/scrape` stored
|
||
listing-page `#fragment` URLs that fetch fine but return unrelated jobs, breaking every
|
||
later run on that entry. New `09-web-research.md` (`framework_version` 1.0.0) is the
|
||
single reference: the trust boundary, a curl browser-header retry with a tag-stripping
|
||
extractor, a four-step escalation order, the login-wall case, why the employer's own
|
||
careers posting beats an aggregator listing (the requisition ID and the grade survive
|
||
there), and the rule that a search snippet is a lead rather than a source. Wired into
|
||
`/apply`, `/rank`, `/interview`, `/outcome`, `/notion-sync`, the job-scraper skill, and
|
||
writing-style rule 5 (`03-writing-style.md` 1.1.0 to 1.2.0).
|
||
|
||
**The retry is gated on `robots.txt`.** `WebFetch` identifies itself as `Claude-User`
|
||
and honors `robots.txt`, so a 403 means either a WAF default on a site whose published
|
||
policy allows access, or a site that has actually declined. New `tools/robots_check.py`
|
||
tells them apart and the escalation runs it before retrying: a disallow for `*` or
|
||
`Claude-User` skips the retry entirely and goes straight to finding the employer's own
|
||
posting. The rule is stated in the file so later edits do not erode it - *the retry
|
||
exists to get past bot-filtering firewalls on sites whose robots.txt permits access; it
|
||
is never used to override a site that has said no.* Two findings are pinned by
|
||
`tests/test_robots_check.py` (15 offline cases): the WAF usually blocks `robots.txt`
|
||
itself, so the policy is read as a browser when the honest request is refused and then
|
||
obeyed strictly; and `urllib.robotparser` cannot be used, because it ends a record at a
|
||
blank line and matches in file order, which reads a real-world policy as
|
||
"everything allowed".
|
||
|
||
- **`/apply` now records the application in the tracker** - the flagship command wrote a CV
|
||
and a cover letter to disk and then wrote nothing to `job_search_tracker.csv`, so a drafted
|
||
and submitted application was invisible to `/gmail-sync`, `/html-report`, `/notion-sync`,
|
||
`/interview`, `/upskill` aggregate mode, and to `/rank`'s dedup exclusion - and the safety
|
||
net that would have caught it (`/gmail-sync`) refuses to create missing rows, so nothing
|
||
detected the loss. A new Step 6b appends a `drafted` row carrying the two document paths,
|
||
the fit rating and the posting URL, reusing `/outcome`'s exact header so the two commands
|
||
cannot diverge; re-running `/apply` updates that row rather than duplicating it, unless every
|
||
matching row holds a final status, in which case a second application to the same role gets
|
||
its own row. The same
|
||
step is mirrored into `job-application-assistant` because `/scrape` Step 5 routes straight
|
||
into the skill (`framework_version` 1.2.0 -> 1.3.0), and `/scrape` Step 6 now defers to it
|
||
instead of adding a row of its own. `seen_jobs.json` is deliberately left alone. **Forks:**
|
||
the bump means `check_upstream_updates.py` will flag the skill - reconcile the new Step 3b
|
||
(and Step 6b in `apply.md`) into your personalized copies rather than skipping the flag.
|
||
|
||
**`drafted` is introduced into the tracker status vocabulary**, and every reader that
|
||
meant *submitted* now says so. These readers define "open" by exclusion from the final
|
||
statuses, so a new non-final value would otherwise have joined all of them silently:
|
||
`/outcome`'s follow-up branch no longer drafts a chase email for an application that was
|
||
never sent, `/gmail-sync` no longer reports unsent drafts as stale, `/notion-sync` leaves
|
||
"Applied on" empty for them and says "not yet submitted" in the page body rather than
|
||
calling drafts submitted documents, and `/html-report` gains a sixth **Drafted** bucket
|
||
kept out of the funnel, the rejection rate and the headline count. `/outcome` Step 4
|
||
overwrites `date` with the submission date when a row leaves `drafted`, so the column
|
||
keeps meaning "applied on".
|
||
|
||
**`/gmail-sync` deliberately keeps searching for drafted rows.** `/apply` drafts but the
|
||
user submits, and forgetting to run `/outcome` afterwards is the failure this issue is
|
||
about. An employer reply arriving against a row still marked `drafted` is how that gets
|
||
caught, so those rows stay in the search set, the application acknowledgement is promoted
|
||
from noise to a `drafted` -> `applied` signal (it is the one email that proves a hand
|
||
submission, and it arrives within a day of it), and an approved match corrects the `date`
|
||
as well as the status. Only the staleness check skips them, since nothing was sent. (#269)
|
||
|
||
## [1.3.0] - 2026-08-03
|
||
|
||
### Added
|
||
|
||
- **Language Gate** - no dimension or gate anywhere in the framework checked a posting's
|
||
language requirements against what the candidate actually speaks (not a Scoring Dimension,
|
||
not a `/scrape`/`/rank` field, nothing for `/apply`'s existing generic language detection
|
||
to report to). Adds that check, structured like the existing Eligibility Gate, on a new
|
||
structured `Languages` table in CLAUDE.md / `01-candidate-profile.md` (`/setup` asks, or
|
||
infers it from a CV/LinkedIn export): a posting requiring a language you haven't declared
|
||
at all is a hard **FAIL**; one requiring a higher level than you declared in a language you
|
||
*do* work in is **FLAG**, not an auto-reject, so borderline cases (a strict "fluent" bar vs.
|
||
your own B1/B2) get your judgment instead of a silent drop; a requirement at or below your
|
||
declared level is a clean **PASS**. Wired through `/scrape`, `/rank`, and `/apply`, with
|
||
`language_gate`/`language_note` persisted into `seen_jobs.json` alongside the existing
|
||
`location` veto so a re-read of the file (or a future debugging session) can recover why a
|
||
job did or didn't make the shortlist.
|
||
|
||
### Fixed
|
||
|
||
- **CV date fields now use ASCII hyphens, so the PDF text layer extracts cleanly** - the
|
||
stock template wrote date ranges as `[YYYY--YYYY]`, and on the repo's mandated `lualatex`
|
||
toolchain the `--` en-dash ligature extracts from the PDF as U+FFFD (`�`). The stock
|
||
template therefore failed the ATS checklist's own "no `�` replacement characters" item on
|
||
*every* date field, and did so silently: the rendered page looks correct, and no existing
|
||
check inspected the extracted text. `cv/main_example.tex` now uses `[YYYY-YYYY]` and
|
||
`[YYYY-Present]`, and `05-cv-templates.md` documents the failure mode and the check that
|
||
catches it (`framework_version` 1.3.0 to 1.4.0). The two-page layout budget is unaffected.
|
||
|
||
**Fork reconciliation note.** The five changed lines in `cv/main_example.tex` are the
|
||
`\cventry` date fields - three under Professional Experience, two under Education -
|
||
precisely the lines every fork personalizes. Rebasing forks should expect conflicts there,
|
||
resolve them in favour of *their own* dates, and then apply the same `--` to `-` change by
|
||
hand. To find remaining instances across your own CV variants:
|
||
|
||
```
|
||
grep -rn '\\cventry{[^}]*--' cv/
|
||
```
|
||
|
||
Verify afterwards by extracting the text layer and checking the date lines specifically:
|
||
`pdftotext -layout <file>.pdf - | grep '�'` - none of the hits may be a date field. (On
|
||
the stock template two benign hits remain either way: the decorative separators on the
|
||
contact and award lines, which are unrelated to dates and predate this fix.)
|
||
|
||
- `tools/convert_salary_excel.py` now parses localized numeric string cells - Excel
|
||
exports that store numbers as text (a Danish `"108,5"`, `"1.234,5"`, or space-separated
|
||
thousands) previously hit `float()`'s `ValueError` and were silently dropped from
|
||
`salary_data.json`. The ambiguous single-comma-plus-three-digits pattern (`"1,234"`,
|
||
thousands in one locale and a decimal in another) is deliberately skipped rather than
|
||
guessed, preserving the old safe behaviour for the one case that cannot be
|
||
disambiguated. (#272)
|
||
- `tools/check_upstream_updates.py` compares the template-repo slug case-insensitively -
|
||
GitHub serves repository paths case-insensitively, so a clone made from a lowercased
|
||
URL was a legitimate direct clone that nonetheless triggered #265's fork-vs-self
|
||
warning. (#273)
|
||
|
||
### Changed
|
||
|
||
- SETUP.md section 8 now shows the first-time `git remote add upstream ...` command
|
||
before telling you to `git fetch upstream`, which previously failed on any clone of a
|
||
personal fork with no explanation of the missing remote. (#274)
|
||
|
||
### Security & privacy
|
||
|
||
- **The gitignore guard now covers every personal-output rule** - `security_guards.py`
|
||
additionally requires the ignore rules for Gmail sync state (`gmail_sync/`), generated
|
||
dashboards (`reports/`), upskill reports (`upskill/*.md`), Notion sync state
|
||
(`**/job_scraper/notion_sync.json`), pasted postings (`documents/postings/**`), scraper
|
||
markdown output (`**/job_scraper/*.md`), and behavioral-report / LinkedIn-profile PDFs.
|
||
With these, every `.gitignore` rule outside the guard's required list is build tooling
|
||
noise, so any future weakening of the personal-data boundary fails CI. All rules were
|
||
already present in `.gitignore`; the guard now enforces the full set. (#271)
|
||
|
||
## [1.2.0] - 2026-08-01
|
||
|
||
### Added
|
||
|
||
- **`/rank` now persists `strengths` and `gaps` into `seen_jobs.json`** - Step 2's scoring
|
||
agents already produced both arrays per job; Step 4 previously kept only `rank_score`,
|
||
`rank_verdict`, and `rank_date`, so the honest per-posting findings were printed once in
|
||
Step 5 and then discarded. Both arrays are now stored verbatim and replaced (never
|
||
accumulated) on `--all` re-ranks, so downstream consumers of `seen_jobs.json` can read
|
||
real triage findings instead of re-deriving them. See
|
||
[discussion #258](https://github.com/MadsLorentzen/ai-job-search/discussions/258).
|
||
- **`/upskill` aggregate mode now ingests `/rank`'s recorded gaps** - previously it only
|
||
read `job_search_tracker.csv` and *guessed* required skills from the `role`/`sector`/
|
||
`notes` columns, even though `/rank` had already fetched and scored postings that never
|
||
made it into the tracker. Aggregate mode now also reads ranked entries
|
||
(`rank_score >= 45`) from `job_scraper/seen_jobs.json`, dedupes them against tracker rows
|
||
on case-insensitive company+role, and prefers a job's recorded `gaps` over an inferred
|
||
skill list wherever both exist. The heatmap's Gap Source column now shows the
|
||
recorded-vs-inferred split per skill, and the report header states how many jobs came
|
||
from each source. Depends on #263 (`/rank` persisting `gaps`/`strengths`); see
|
||
[discussion #258](https://github.com/MadsLorentzen/ai-job-search/discussions/258).
|
||
|
||
### Security & privacy
|
||
|
||
- **SETUP.md no longer calls a fork "private working space"** - forks of public GitHub
|
||
repositories are always public, so that wording invited exactly the personal-data
|
||
exposure it seemed to rule out. Section 8 now states the fork-is-public fact plainly and
|
||
documents the safe alternative (a private repository with this repo as `upstream`), and
|
||
`/setup` ends with a matching privacy note the moment profile data first lands in
|
||
tracked files. Prompted by
|
||
[discussion #266](https://github.com/MadsLorentzen/ai-job-search/discussions/266).
|
||
- **The gitignore guard now covers two more personal-data rules** - `security_guards.py`
|
||
requires `cover_letters/Cover_*.*` (the uppercase cover-letter naming variant `/apply`
|
||
recognizes) and `cv/*.txt` (ATS text extractions of tailored CVs) in `.gitignore`, so a
|
||
future change weakening either rule fails CI instead of silently making personal files
|
||
trackable. Both rules were already present in `.gitignore`; only the guard lagged.
|
||
|
||
### Fixed
|
||
|
||
- `tools/check_upstream_updates.py` no longer reports a false "up to date with upstream"
|
||
when it silently falls back to a fork's own `origin` remote - the default state of a
|
||
plain fork clone, where the script compared the fork against itself and could never
|
||
detect upstream updates. It now warns that the fallback remote is not the template repo,
|
||
shows the `git remote add upstream` command to fix it, and names the ref it actually
|
||
compared against. (#265)
|
||
- Removed the vestigial `cover_letters/OpenFonts/cover.cls` - an unreferenced remnant of
|
||
the original font bundle that, since #252's class rename, ambiguously declared the same
|
||
`cover` class as the real `cover_letters/cover.cls`.
|
||
- Added regression tests pinning #252's ragged-row bounds fix in
|
||
`tools/convert_salary_excel.py` (dimension-less workbooks read in `read_only` mode
|
||
yield rows shorter than the header).
|
||
|
||
### Changed
|
||
|
||
- CONTRIBUTING's "run what CI runs" list is now complete - it previously omitted
|
||
`tools/security_guards.py` and the exact `unittest` invocation, the precise checks a
|
||
contributor PR had already failed on. Prompted by
|
||
[issue #262](https://github.com/MadsLorentzen/ai-job-search/issues/262).
|
||
|
||
## [1.1.0] - 2026-07-30
|
||
|
||
### Security & privacy
|
||
|
||
- **Personalized custom-template files are now gitignored regardless of engine** - the
|
||
ignore rules broadened from `cv/main_*.tex` to `cv/main_*.*` (and likewise for cover
|
||
letters), so a fork using a Typst or other non-LaTeX template no longer commits
|
||
personalized `main_<company>.typ` files to a public fork. The `*_example.tex` files stay
|
||
tracked. If you registered a custom template before this release, check
|
||
`git status` once after updating. (#238)
|
||
- **Dependency review is live, for forks too** - the repo's Dependency graph is now enabled,
|
||
so the CI `dependency-review` job actually blocks PRs that introduce dependencies with
|
||
known high-severity vulnerabilities, and the job is no longer gated to the upstream repo:
|
||
forks get the same check, self-activating if the fork enables Dependency graph
|
||
(it warns-and-passes otherwise). (#254)
|
||
|
||
### Added
|
||
|
||
- **freehire-search: full descriptions come back with the search** - `search` now calls
|
||
freehire's agent search endpoint (`/api/v1/agent/jobs/search`), which serves each hit's
|
||
complete description instead of the search index's truncated preview. A 20-role search is
|
||
one request rather than 1 + 20 `detail` calls, and `/scrape`'s Step 2 no longer needs a
|
||
per-hit fetch for this portal. `--description-format markdown|text|html` (default
|
||
`markdown`) selects the rendering; `table` and `plain` output is unchanged. (#251)
|
||
- **Custom templates: any compile-to-PDF toolchain (Typst, ...)** - `/add-template` no longer
|
||
hardcodes a `lualatex`/`xelatex`/`pdflatex` engine enum. Custom templates now declare a
|
||
source extension and a full compile command, so Typst (`typst compile`) registers the same
|
||
way a custom LaTeX template does. Stock CV/cover letter templates stay LaTeX,
|
||
unchanged. (#238)
|
||
- **Application-form fields as an optional third `/apply` artifact** - when a posting's
|
||
application form asks screening questions, `/apply` can now offer a prep sheet of
|
||
grounded answers alongside the CV and cover letter. Opt-in; the default two-document
|
||
output never changes. (#212)
|
||
- **Confirmed facts write back to the profile** - when `/apply` or `/interview` surfaces a
|
||
fact the user confirms (a skill, a date, a project detail), it is written back to the
|
||
profile files in the same turn instead of being lost with the conversation. (#211)
|
||
- **CV methodology: in-progress qualifications and tenure-vs-output** - `05-cv-templates.md`
|
||
gains explicit rules for stating in-progress certifications/degrees honestly and for
|
||
checking claimed tenure against visible output (`framework_version` 1.2.1 -> 1.3.0). (#210)
|
||
- **Scraper flags mass-posting and recycled-listing patterns** - `/scrape` marks postings
|
||
that look bulk-posted or recycled so they don't eat evaluation effort. (#207)
|
||
- **Retry contract pinned in CI** - all six portal CLIs now carry 429/5xx retry-backoff
|
||
tests covering every fetch wrapper, so a silent regression in retry behavior trips
|
||
CI. (#246)
|
||
- **README: the extension model, documented** - new Customization subsection "Extending the
|
||
framework: portals, templates, criteria - and borrowing from other forks": the three
|
||
extension points, the copy-one-folder pattern for borrowing a portal skill from another
|
||
fork with a read-the-code-first checklist, and why there is deliberately no installer
|
||
(the manual copy is the security model). Prompted by discussion #249.
|
||
|
||
### Fixed
|
||
|
||
- `/rank` shortlist and below-threshold tables include each posting's URL. (#236)
|
||
- `convert_salary_excel.py`: count/index columns pair by category name instead of
|
||
adjacency (#219), standalone count columns store as counts (#230), and ragged rows from
|
||
dimension-less spreadsheets no longer crash with an IndexError (#252).
|
||
- `cover.cls`: duplicate package imports removed and the `\ProvidesClass` name fixed to
|
||
match the filename, silencing a class-name-mismatch warning. (#252)
|
||
- Portal CLI type-checking pinned to concrete `@types/bun` / `@bunli/*` versions to stop
|
||
environmental CI type-drift. (#226)
|
||
- `freehire-search` points at freehire.me after the service's domain migration. (#229)
|
||
- `verify_pdf.py`'s missing-poppler error now includes per-OS install hints. (#252)
|
||
|
||
## [1.0.0] - 2026-07-22
|
||
|
||
First tagged release. This marks the framework as stable and gives forks a described
|
||
checkpoint to update against instead of a moving `master`. It is a baseline of what
|
||
already exists rather than a set of new changes; subsequent releases will document
|
||
what changed since the previous tag.
|
||
|
||
At this baseline the framework provides:
|
||
|
||
- **Application workflow** - a drafter/reviewer `/apply` pipeline (CV + cover letter),
|
||
plus `/setup`, `/scrape`, `/rank`, `/interview`, `/outcome`, `/upskill`,
|
||
`/expand`, `/html-report`, `/gmail-sync`, `/notion-sync`, `/add-portal`,
|
||
`/add-template`, and `/reset`.
|
||
- **Portal search skills** - country-agnostic job-board CLIs (LinkedIn, freehire, and
|
||
the Danish boards) in the portable Agent Skills format under `.agents/skills/`,
|
||
discovered and orchestrated by `/scrape`, with an `enabled:` toggle for skipping
|
||
portals.
|
||
- **Framework versioning** - `framework_version` markers on methodology files plus
|
||
`tools/check_framework_version.py` (CI guard) and `tools/check_upstream_updates.py`
|
||
(fork-side update preview).
|
||
- **Privacy and safety guards** - `.gitignore` protection for personal data, the
|
||
`tools/security_guards.py` allowlist for `.gitignore` negations, and a CI policy of
|
||
making no live portal requests.
|
||
- **Cross-runtime support** - a root `AGENTS.md` pointer so Codex and Antigravity can
|
||
discover the portable portal skills, with Claude Code as the reference runtime.
|
||
|
||
[Unreleased]: https://github.com/MadsLorentzen/ai-job-search/compare/v1.7.3...HEAD
|
||
[1.7.3]: https://github.com/MadsLorentzen/ai-job-search/compare/v1.7.2...v1.7.3
|
||
[1.7.2]: https://github.com/MadsLorentzen/ai-job-search/compare/v1.7.1...v1.7.2
|
||
[1.7.1]: https://github.com/MadsLorentzen/ai-job-search/compare/v1.7.0...v1.7.1
|
||
[1.7.0]: https://github.com/MadsLorentzen/ai-job-search/compare/v1.6.0...v1.7.0
|
||
[1.6.0]: https://github.com/MadsLorentzen/ai-job-search/compare/v1.5.0...v1.6.0
|
||
[1.5.0]: https://github.com/MadsLorentzen/ai-job-search/compare/v1.4.0...v1.5.0
|
||
[1.4.0]: https://github.com/MadsLorentzen/ai-job-search/compare/v1.3.0...v1.4.0
|
||
[1.3.0]: https://github.com/MadsLorentzen/ai-job-search/compare/v1.2.0...v1.3.0
|
||
[1.2.0]: https://github.com/MadsLorentzen/ai-job-search/compare/v1.1.0...v1.2.0
|
||
[1.1.0]: https://github.com/MadsLorentzen/ai-job-search/compare/v1.0.0...v1.1.0
|
||
[1.0.0]: https://github.com/MadsLorentzen/ai-job-search/releases/tag/v1.0.0
|