# 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] ### 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 --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. ### Fixed - **`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` 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. - **`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. - **`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 `, 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 "]`, 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>`) 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 `
` / `` and must survive. Two fixtures: the stray tag in both the Jobtype item and a description paragraph (fails on master), and an entity-encoded `
` in a description that stays in the text. - **`/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//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. - **`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. - **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:

Vi søger en udvikler til R&D.

  • Python & Go
  • ...` 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 `

    ...

    ` 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. - **`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. ## [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 `20162024`, 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: `) 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/` 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/` 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/` 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_*.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 `_` 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/` 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 (`""`) 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 ` 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 `. 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 ` 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//` 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/_/interview_prep_.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 /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/.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 `