Files
ai-job-search/CHANGELOG.md
T
Rudy Mizrahi Celekli 31a3320fe9 docs(portal-clis): correct Danish CLI installation paths (#589)
* docs(portal-clis): correct Danish installation working directories

* docs(changelog): record fix for #589
2026-10-18 06:50:17 +02:00

153 KiB
Raw Blame History

Changelog

All notable changes to this project are documented here. The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.

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). 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 <url> --format plain command first, avoiding bot-detection 403s and login walls on aggregator boards before falling back to WebFetch. Escalation order in 09-web-research.md places portal CLI detail as the first rung (framework_version: 1.1.1 -> 1.1.2).

  • /apply Step 5e names explicit cleanup shell commands for build intermediates (#531, .claude/commands/apply.md) - the step previously described deleting .aux/.log/.out in prose, leaving artifact cleanup vulnerable to being skipped by agents. It now specifies explicit rm commands for stock/custom LaTeX toolchains and redirected build/ directories, timed after log-inspecting steps 5b-5d complete, and explicitly preserves template assets (cover.cls, OpenFonts/, custom template files).

  • /rank retries a posting it could not fetch instead of expiring it (#534, tools/rank_state.py, .claude/commands/rank.md) - a data-model change: scoring agents now return a third status, unreachable (with a short fetch_error), when retrieval still fails after the 09-web-research.md escalation order (401/403, login wall, 404, timeout, 5xx, empty page), and keep expired for positive evidence the posting is closed (closed page, 410, redirect to a listing page, passed deadline). Before, both became expired, which candidates never selects again, so a transient failure on ranking day removed a live job from the pipeline and the report presented it as closed. candidates now selects unreachable alongside new; apply increments fetch_attempts and stores last_fetch_error, and after 3 attempts with at least 48 hours between the first and the last it writes expired with expired_reason: "unreachable" and reports the job once under "Gave up, check manually" with its URL, so a login-walled live posting is still seen by a human. The 48-hour floor keeps a scheduled run plus a manual one on the same day from burning every attempt on one outage. A failed fetch on a posting whose stored deadline has already passed still expires at once, as on the scored path, rather than being retried and listed as possibly live. rank_state.py owns the status set (AGENT_STATUSES) and a test pins rank.md's JSON contract to it. 09-web-research.md's give-up step now marks a /rank entry unreachable rather than expired (framework_version: 1.1.2 -> 1.1.3), so an agent following it cannot write a 403 off as closed. No migration: no existing entry carries the new status. Reported by @ai-tvang, who also proposed the time floor and the give-up listing.

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 </ li >, and because node-html-parser does not recognise a closing tag with a space after the slash, the fragment reached the output verbatim: employmentType: ["Fuldtid </ li >"], a value that matches none of the documented job-type vocabulary. The same cleanText feeds every text field on both branches, so any of them could carry it. cleanText now drops the closing-tag shape (</ li >, </ li>, < /li>) before collapsing whitespace - and only that shape, because the parser decodes entities before cleanText runs, so author text the page encoded as &lt;div&gt; or &lt;T&gt; arrives as a literal <div> / <T> and must survive. Two fixtures: the stray tag in both the Jobtype item and a description paragraph (fails on master), and an entity-encoded <div> in a description that stays in the text.

  • /add-portal includes the .agents prefix in generated allowed-tools (#545, .claude/commands/add-portal.md, tests/test_add_portal_command.py) - the specification template for the new portal's SKILL.md frontmatter wrote allowed-tools: Bash(bun run skills/<name>/cli/src/cli.ts *) without .agents/, the one path in the file without it. A skill's allowed-tools grants the listed command when that skill is invoked, so a generated portal invoked on its own granted a command that does not exist and prompted instead; the string now names the real path, matching the six shipped SKILL.md files.

  • 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.

  • .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.

  • /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.

  • /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.

  • 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.

  • /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.

  • 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.mds) - /add-portal's contract says every search result has at least id, title, company, location, date, url, and the four other shipped CLIs emit id. When #340 added the company/location/date/url aliases to these two, id was left out: jobnet exposed the value only as jobAdId, jobdanmark only as slug, so a consumer reading every portal's JSON through one shape had to special-case both to call detail. Purely additive: id equals jobAdId on jobnet and slug on jobdanmark, the native keys stay, nothing is renamed. The additive-contract test in each CLI now asserts id and its equality with the native key; both fail on master.

  • Danish portal installation examples use the actual CLI directories (#589) - documented install commands now include the .agents/skills prefix.

  • jobdanmark-search detail renders a JSON-LD description as text instead of passing the markup through (.agents/skills/jobdanmark-search/cli/src/commands/detail.ts, tests/detail-jsonld.test.ts) - the JSON-LD branch emitted jobPosting.description verbatim, so detail --format plain printed description: <p>Vi søger en udvikler til R&D.</p><ul><li>Python & Go</li>... while the rendered-HTML branch of the same command printed one clean line per paragraph and bullet: two description shapes from one command depending on which page layout it hit, and the markup landed in /scrape's stored snippet and /rank's agent context as-is. The portal contract (/add-portal Step 4) asks for "readable text (entities decoded, tags stripped, paragraph breaks preserved)". The JSON-LD branch now renders the HTML through node-html-parser's structuredText - one line per block element, entities decoded, tags gone - which is the shape the fallback already produces. A plain-text description is unchanged, an absent one stays "". The JSON-LD test that pinned the raw <p>...</p> now expects the text, plus two new cases; the HTML case fails on master.

  • 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.

  • 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.

  • 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 2016<U+2013>2024, and told Step 5d to confirm every entry's years are joined by an ASCII hyphen. Nothing executed that: the natural probe, --contains "2016-2024", failed identically for "en-dashed" and "absent" before #458 and passes on both since, because the fold maps the en-dash back to - on purpose. The new flag reads the raw layer (never the fold), fails on a year joined to any Unicode dash or the minus sign, and names each hit with its line and code point. A year on either side is enough (Mar 2016 – Jul 2016, 2016 – Present); a numeric range with no year is not a date. Step 5d's extraction command now carries the flag (the dump is still written before the check fails), the "Dates recognizable" checklist item points at it and keeps the bare single-year half as a read-through, the guide's "add this to the step 5d checks" paragraph names the command, and the upstream-only CI assertion runs it on the stock CV. Demonstrated on the stock template: a \cventry{2016--2024} build fails with U+2013, the \cventry{2016-2024} build passes, and --contains "2016-2024" passes on both. Only horizontal whitespace may sit between the year and the dash, so a year ending one line is not joined to a dash that opens the next (a bullet, a wrapped prose line) - yang2632's catch in review, with the regex and fixture. Thirteen new test_verify_pdf.py cases. Proposed by 9scorp4 in Discussion #385.

  • Real Excel workbook integration tests for the salary converter (tests/test_convert_salary_excel_integration.py, .github/workflows/ci.yml) - generate temporary .xlsx files and invoke the documented converter CLI, checking multiple worksheets, metadata options, Unicode text, localized numbers, and compatibility with salary lookup. A workbook without salary headers must fail without creating an output file. CI installs openpyxl across the Python matrix; local runs without this optional dependency skip the two integration cases while retaining the existing dependency-free tests.

  • documents/projects/ portfolio ingestion in /setup (Path A) (documents/README.md, .claude/commands/setup.md, .claude/commands/reset.md, tests/test_setup_command.py) - onboards project writeups, case studies, and documentation (.md, .txt, .pdf) from documents/projects/, extracting structured summaries (problem domain, tech stack, technical challenges, and measurable outcomes) to populate ## Independent Projects in 01-candidate-profile.md.

  • Source host verification in /apply Step 1 (#431, .claude/commands/apply.md, tests/test_apply_host_check.py) - before proceeding to draft CV and cover letters, Step 1 verifies the posting URL's provenance against installed portal boards and the six standard ATS apex domains (greenhouse.io, lever.co, myworkdayjobs.com/workday.com, ashbyhq.com, smartrecruiters.com, workable.com). Look-alike prefix/suffix spoofing fails closed, and unrecognized hosts are plainly flagged as unverified in the evaluation output (⚠ Unverified source host: <hostname>) before drafting tokens are spent.

  • /expand project and portfolio expansion (.claude/commands/expand.md, tests/test_expand_command.py) - expands candidate discovery to technical projects from public GitHub repositories, extracting structured summaries (problem domain, tech stack, key technical challenges, and verifiable outcomes) to populate the ## Independent Projects section of 01-candidate-profile.md.

  • Stale sweep branch in /outcome (.claude/commands/outcome.md, tests/test_outcome_stale.py) - introduces /outcome stale [N] (and /outcome sweep [N]) to batch-resolve open applications quiet for 60+ (or N) days. Displays a numbered summary of qualifying applications, requires explicit user confirmation (all, select, or skip), resolves confirmed rows to no_response, logs dated entries to notes, updates archive outcome.md files, and hands off to calibration when 3+ applications are resolved.

  • Mechanical layout verification for compiled PDFs - tools/verify_layout.py measures what /apply Step 5b previously only eyeballed: per-page text extent, bottom whitespace, the largest internal vertical gap, footer collisions, and entry headers or section headings stranded at a page break. It exists for a failure that survives every existing check - a moderncv \cventry is an unbreakable tabular, so an entry that does not fit jumps to the next page and leaves a hole behind (observed at 273pt, roughly 19 blank lines) while the document still compiles, still reports the correct page count, and still passes tools/verify_pdf.py. Geometry comes from Poppler pdftotext -bbox; Poppler is optional repo-wide (since #369 verify_pdf.py prefers pypdf), and word bounding boxes have no pypdf equivalent, so this is the one step that still wants it. A missing Poppler

    • or the xpdf-based pdftotext Git for Windows puts ahead of it in PATH, which rejects -bbox - degrades to a skipped: exit 2 rather than reporting a phantom layout failure. Page count is deliberately left to verify_pdf.py --pages so that one rule keeps one implementation. Thresholds are calibrated for the stock moderncv and cover.cls geometry. Tests use synthetic page geometry, so they need neither Poppler nor a LaTeX toolchain.

Changed

  • /add-template keeps a registered template's intermediates in build/ (#473, .claude/commands/add-template.md, .gitignore, tests/test_add_template_build_dir.py) - the elicited compile command now redirects intermediates (.aux, .log, ...) to a build/ folder beside the source and moves the PDF back, so Step 4's test-compile cleanup deletes one folder instead of enumerating LaTeX extensions. The LaTeX command deletes the previous PDF first, so a failed compile leaves no stale PDF for /apply to inspect. Toolchains with nothing to redirect (typst compile) keep their command unchanged. The ACTIVE-TEMPLATE block now tells /apply to run the command from the output directory and to delete build/ in its Step 5e cleanup. Stock templates are unchanged.

Fixed

  • freehire-search titles no longer carry HTML entities or stray whitespace (.agents/skills/freehire-search/cli/src/helpers.ts) - titles are indexed as the source ATS wrote them and toResult passed them through untouched, so detail intern-fullstack-amp-ai-innovation-noise-edaawqq2 printed Intern - Fullstack &amp; 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 &amp; and 3 ended in whitespace. /scrape Step 4.75 reads undecoded entities in titles as a half-working parser, so by that rule a single such posting marks a healthy portal degraded. The title now goes through the CLI's existing decodeHtmlEntities (already used for detail descriptions) and is trimmed; an empty or whitespace-only title still falls back to (untitled). Pinned by three new parsing.test.ts cases, which fail on the old pass-through.

  • freehire-search detail keeps the salary period, so a yearly figure no longer reads as monthly (.agents/skills/freehire-search/cli/src/helpers.ts, SKILL.md) - formatSalary printed only the currency and amounts, although freehire's enrichment records the period: detail software-developer-trainee-codifi-fra635ux printed INR 300000–300000 while the API returns salary_period: "year" for that posting, so anyone screening pay against a monthly figure read a yearly salary as a monthly one, twelve times its real monthly value. The period is now appended when freehire records one (INR 300000–300000/year, INR 12000/month); a record without a period prints exactly as before. Pinned by two new parsing.test.ts cases, both failing on the old formatter.

  • robots_check decodes curl output as UTF-8, so a non-ASCII response no longer reads as UNCONFIRMED on Windows (tools/robots_check.py, tests/test_robots_check.py) - _fetch() ran curl with text=True and no encoding, so Python decoded the response with the ANSI code page. On a byte that code page leaves undefined (0x81 in cp1252 and cp1254, 0x9e in cp1254) subprocess's pipe reader thread died, stdout came back None, and the gate printed UNCONFIRMED (AttributeError) - failing closed, but blocking the browser-header retry 09-web-research.md permits. Reproduced through the CLI on real hosts: tr.indeed.com's robots.txt carries Disallow: /職涯貼士/ (職 is e8 81 b7), and kap.org.tr answers /robots.txt with a UTF-8 HTML 404 whose "A.Ş." carries c5 9e. The call now pins encoding='utf-8', errors='replace' (RFC 9309: robots.txt is UTF-8), as verify_pdf.py and verify_layout.py already do. That also closes a silent fail-open: a UTF-8 rule whose bytes the code page does define (Disallow: /şirket/ under cp1254) decoded as /ÅŸirket/, never matched, and read as allowed. Decision rules are unchanged. Two new tests swap curl for a child process that prints those bytes and pin cp1254 when no encoding is passed, so the failure does not depend on the host's locale; both fail without the fix.

  • The compile-warning check covers tests/*.py too (tests/test_verify_pdf.py, tests/test_verify_layout.py) - FindNonAsciiDateRangesTests' docstring quotes the regex \s* unescaped, so Python 3.12+ prints a SyntaxWarning for the invalid escape \s when the test module is compiled. It shows up in this repo's own CI log on 3.12, 3.13 and 3.14 while the run stays green, and under -W error::SyntaxWarning the module fails to import. Escaped in the docstring; ToolsCompileWithoutWarnings now compiles tests/*.py as well as tools/*.py and fails on the unescaped version.

  • linkedin-search detail no longer fetches an unrelated posting for a URL on another host - normalizeId took the first 6+-digit path segment from any URL, so a Greenhouse or Lever apply link (the kind a posting's own page hands out, and what a user pastes back into detail) was reduced to that number and the handler fetched jobPosting/<number> from LinkedIn: whatever job carried that id came back, printed with exit 0, or NOT_FOUND if none did - never an error about the input. Demonstrated by driving the real handler with a stubbed fetch: https://boards.greenhouse.io/acme/jobs/4567890 requested .../jobPosting/4567890. Every other portal CLI rejects an off-host detail URL with BAD_ID (the #447 shape); linkedin was the one still trusting the digits. URLs are now parsed for real: a linkedin.com host (apex or any subdomain, scheme optional) plus a /jobs/view/<slug-><id> path yields the id, and anything else - other hosts, look-alike and userinfo hosts, a linkedin.com profile or search URL - exits 1 with the stderr-JSON BAD_ID contract before any request. Bare ids, URNs, and slash-free title slugs are unchanged. Pinned by four new normalizeId cases and a new detail-input.test.ts that drives runDetail and the CLI with fetch stubbed; the off-host cases fail on the old pattern.

  • /rank tracker matching preserves Unicode company and role names (tools/rank_state.py, tests/test_rank_state.py) - ASCII-only normalization collapsed distinct non-Latin roles to the same empty value and dropped entirely non-Latin companies from tracker exclusions. Match using Unicode case folding and NFC normalization, retaining letters, numbers, and combining marks while continuing to ignore punctuation and spacing. CLI regressions use the standard tracker header and cover distinct names, tracked matches, equivalent accent encodings, and the existing ASCII matching behavior.

  • The Python tools no longer crash on Windows when a posting, company, CV line or file name falls outside the ANSI code page (tools/rank_state.py, tools/job_key.py, tools/verify_pdf.py, tools/verify_layout.py, tools/convert_salary_excel.py, salary_lookup.py, tests/test_tools_utf8_output.py) - a piped stdout on Windows defaults to the ANSI code page (cp1252 on most Western installs), and that is how Claude Code runs every tool. /rank's candidate listing printed titles and companies with ensure_ascii=False, so a single Cyrillic, CJK, Devanagari, Polish or Turkish posting ended the run with UnicodeEncodeError before any output reached the workflow; the key audit, the salary lookup, the salary converter and the layout report failed the same way. Each tool now switches stdout and stderr to UTF-8 at entry, which also stops Danish and other Western accents from arriving as cp1252 bytes. The regression tests run every tool in a child process with a cp1252 stdout forced through PYTHONIOENCODING, so the Linux CI job reproduces the Windows failure; all seven fail without the fix. The subprocess helpers in tests/test_rank_state.py and tests/test_job_key.py now decode child output as UTF-8 to match.

  • /rank rejects invalid score dimensions before updating an entry (tools/rank_state.py, tests/test_rank_state.py) - enforce the rubric's inclusive 0-100 range and reject booleans, NaN, and infinities. Invalid results now use the existing per-job error report, leaving the rejected entry intact while valid results in the same batch are saved. CLI tests cover every score dimension, oversized integers, boundary values, and fractional-score rounding.

  • Distinct non-Latin company names no longer share the unknown-company job key (tools/job_key.py, tests/test_job_key.py) - when a non-empty company name has no ASCII slug, derive its fallback from a hash of the normalized name. Missing names retain unknown-company, and existing ASCII keys are unchanged. CLI tests cover distinct companies, case and canonical Unicode equivalence, and stable keys across posting URLs. Existing state is not rewritten; /scrape already recognizes stored postings by URL regardless of their previous key.

  • /rank tracker exclusion handles UTF-8 BOMs on reordered CSV headers (tools/rank_state.py, tests/test_rank_state.py) - when company or role is the first column, a leading BOM becomes part of the header name and an already-tracked application is selected for ranking again. Read with utf-8-sig so both BOM-prefixed and plain UTF-8 trackers match correctly. The standard date-first header already worked; regression coverage checks all three column orders with and without a BOM.

  • verify_layout.py no longer emits a SyntaxWarning on every run (tools/verify_layout.py, tests/test_verify_layout.py) - the module docstring names the macro whose absence triggers the Poppler -bbox crash, \hypersetup{pdftitle=...}, and a bare \h in a non-raw docstring is an invalid escape sequence. Python 3.12+ prints SyntaxWarning: "\h" is an invalid escape sequence the first time the module is compiled (CPython gh-98401) - it shows up in this repo's own CI log, and in the middle of /apply Step 5b's layout report. The 3.15 language reference still documents the sequence as a SyntaxWarning, with a SyntaxError only in a future Python version. Escaped in the docstring; the new ToolsCompileWithoutWarnings case compiles the source of every tools/*.py with warnings captured and fails on the unescaped version, so the next docstring that quotes a LaTeX macro is caught too.

  • /apply Step 5b now actually runs the page-count check it claimed Step 5d ran (.claude/commands/apply.md, tests/test_apply_page_count.py) - the 5b prose said "Page count is not checked here - that is verify_pdf.py --pages's job, and Step 5d already runs it", and verify_layout.py's docstring declines to measure page count for the same reason. Step 5d's only verify_pdf.py call is --dump-text, and no step in the workflow passed --pages at all (only the upstream-only CI assertion on the stock examples does), so the hard 2-page CV and 1-page cover letter limits were enforced by nothing but the visual PDF read - the "measure first, then look" failure 5b was written to stop. 5b now runs verify_pdf.py --pages 2 on the CV and --pages 1 on the cover letter ahead of verify_layout.py, names the ACTIVE-TEMPLATE page limit as the substitute for a custom template, and the deferral sentence points at those lines. Four spec tests pin the invocations, their counts, their order relative to the layout measurement, and that no prose defers the check to a step that does not run it; all four fail on master.

  • salary_lookup.py prints the privacy footnote only when a row actually carries N/A*

    • the * N/A = Too few employees to publish (privacy) line was appended under every category table, including one where every row has an index, so the output asserted a suppression that never happened (the residual noted on #470). The footnote now follows a flag set by the N/A* branch; a table with a suppressed row renders exactly as before. Two FormatEntryTests cases pin both directions; the "omitted" one fails on master.
  • convert_salary_excel.py pairs a bare Count/Index column pair instead of splitting it, so salary_lookup.py no longer labels a published headcount as privacy-suppressed - the pairing loop required a non-empty derived category name on both sides, but a header with no category word (Count + Index, Danish Antal + Lønindeks) strips to an empty name, so the simplest layout the README advertises ("auto-pairs count/index columns") came out as two unrelated standalone categories: {"count": {"count": 500}, "index": {"index": 108.5}}. salary_lookup then rendered a Count 500 N/A* row above an Index - 108.5 row, and the footnote read the N/A* as "too few employees to publish (privacy)" - a false statement about a company whose headcount is in the file, shown during /apply's salary step. Demonstrated through the documented Excel -> JSON -> lookup path with openpyxl; adding any suffix (Antal alle) made pairing work, which is why the shipped tests, all suffixed, never saw it. Bare pairs now pair under the README's top-level category name (all_employees); a bare Antal with no bare index column still stays a standalone count, and named pairs alongside are untouched. Four new cases in test_convert_salary_excel.py, including one that renders the converter's output through salary_lookup.format_entry; all four fail on the old pairing rule.

  • tools/verify_layout.py's skipped: message named only one cause of a broken extractor when there are two (#451) - it blamed the xpdf-based pdftotext Git for Windows puts ahead of Poppler in PATH (no -bbox flag, exits 99), but a real Poppler can abort -bbox/-bbox-layout/-htmlmeta too: Poppler 26.0x before 26.05 crashes on any PDF whose Info dictionary carries an empty string in any field, and hyperref writes exactly that for every field it does not set. A lualatex/pdflatex document built with hyperref and no \hypersetup{pdftitle=...} - an ordinary /add-template CV template, not a malformed one - hits this with a working Poppler installed, and the old message sent the reader to check their PATH when nothing was wrong with it. The message now names both causes; behavior is unchanged, degrading to skipped: exit 2 either way, since a broken extractor is still not a broken document.

  • The template-placeholder guard in test_setup_command.py now skips on forks (#463) - TemplatesStillCarryThePlaceholders asserts that 05-cv-templates.md and 06-cover-letter-templates.md still contain [FIRST_NAME], [LAST_NAME], [YOUR_EMAIL], [YOUR_PHONE], [YOUR_NAME], and [YOUR_LINKEDIN_URL]. Running /setup - the documented path, and what Step 3.5/3.6 of that command exist to do - replaces exactly those tokens, so on a personalized fork python3 -m unittest discover -s tests fails both checks permanently and marks every push red. The class now uses the same @unittest.skipIf on GITHUB_REPOSITORY (defaulting to upstream when unset, so local pristine-template runs still execute the guards) that test_placeholder_integrity.py received in #407. The guard landed three days after that fix and did not pick up the pattern; the placeholder-integrity CI job is upstream-gated and does not cover the 05/06 tokens, so python-tests was their only check.

  • verify_pdf.py --contains now sees through LaTeX's typographic substitutions and the pdflatex text layer keeps accents precomposed (Discussions #385, #384) - the comparison folded whitespace only, but LaTeX ligatures ' into U+2019 and -- into U+2013, so on the stock CV compiled with the documented lualatex command --contains "Master's degree" and --contains "2016-2024" both reported the keyword missing from a document that plainly contains it (measured through both extractors; Six Sigma and Statistics on the same page passed). The documented remedy for a missing keyword is to add it, so the false negative nudged toward the one thing the ATS section forbids. normalize_text() now folds both sides - NFC, then curly apostrophes/quotes to ASCII, en/em dashes to -, no-break space to space - at comparison time only; --dump-text still writes the raw layer, because that is what an ATS parses and the date-range rule in 05-cv-templates.md needs the raw en-dash visible there. Separately, pdflatex without T1 font encoding stores accents decomposed (e + U+0300; pypdf reads it as a stray spacing accent), which NFC cannot fully repair - moderncv 2.5 loads T1 itself under pdflatex but the apt-packaged 2.3.1 does not, so cv/main_example.tex and the guide's preamble gain \ifpdftex\usepackage[T1]{fontenc}\fi, a no-op on the lualatex path. Pinned by ten new test_verify_pdf.py cases (the fold-through ones fail on the whitespace-only code) and a test_latex_guidance.py guard that the line exists and stays pdflatex-only. Reported and diagnosed by 9scorp4. Fork users: your personalized cv/main_example.tex gains the one guarded preamble line on rebase (a clean 3-way merge unless you edited the preamble); tailored CVs compiled with lualatex need nothing.

  • jobdanmark-search detail now backs off on 429/5xx like every other portal's detail command - the handler called fetch() directly instead of going through the CLI's own request wrappers, so it carried none of the three things apiFetch/apiPost guarantee: no 429/5xx retry loop (a rate-limited detail page wrote API_ERROR and exited after one attempt, where jobnet, jobbank, jobindex, linkedin, and freehire all retry up to six times), a hand-inlined User-Agent string that would drift from the exported USER_AGENT, and a timeout the wrappers' tests never saw. /scrape calls detail once per shortlisted posting, so a burst that tripped jobdanmark's rate limiter dropped those postings outright - no description, no deadline - while the same burst on any other portal rode it out. Demonstrated by driving the real command handler with a stubbed 429: 1 fetch attempt and exit 1 before, 7 attempts after (the contract's initial try plus six retries). Fixed by adding htmlFetch to helpers.ts with the same backoff schedule, timeout, and shared User-Agent as the JSON wrappers (404 returns null so detail keeps its NOT_FOUND contract) and routing detail through it. Pinned in the existing retry-backoff, user-agent, and request-timeout suites, which now cover all three wrappers, plus a new detail-backoff.test.ts that exercises the handler path itself - its retry cases fail against the bare fetch().

  • Free-form tracker notes no longer break the CSV row (#454) (.claude/commands/gmail-sync.md, .claude/commands/outcome.md, tests/test_tracker_notes_csv_safe.py) - two writers put free-form text into the notes column of job_search_tracker.csv: /gmail-sync Step 7a copied the raw subject of a received email, and /outcome Step 4 appended "a short dated note" with no constraint on its content. No writer in the framework emits a quoted tracker field, so an unescaped comma splits the row for a naive split and for csv.DictReader alike - the latter being what the repo's only machine reader of the tracker uses (tools/rank_state.py). notes is column 10 of 14, so a subject as ordinary as Re: Your application, Data Scientist, or a note as natural as rejected, no feedback given, shifted cv_file, cover_letter_file and source a column left. A line break is worse: it ends the row and starts a second one. Nothing validated the row afterwards, and the /gmail-sync half was written unattended, so the corruption was silent. Both append instructions now carry the rule themselves - /gmail-sync deletes commas, double quotes and line breaks from the subject, /outcome writes its note without them - rather than a general note a writer can miss. Nothing is lost on the /gmail-sync side: Step 7a item 2 still records the subject verbatim in the archive's outcome.md, which is Markdown and carries no such constraint. The fixed-format writers (followed up YYYY-MM-DD, stale resolved no_response (YYYY-MM-DD), redrafted) could never contain these characters and are unchanged.

  • jobindex-search detail no longer fetches arbitrary URLs or invents posting-shaped output (#447) - the command fetched any http(s) input verbatim (no host check) and, when the path didn't match its one pattern, silently used the whole input URL as the job id; the only net was "the fetched page has a title", so a non-posting page came back as a well-formed fake posting with exit 0 (demonstrated with jobindex's own homepage: id = the URL, title = the site's tagline, description = navigation chrome). Every other portal CLI rejects unparseable detail input with BAD_ID and constructs its fetch URL from the extracted id; jobindex was the one CLI trusting the raw string - and /scrape//rank agents feed it stored URLs, so a ghost or redirected URL (the #331 class) yielded plausible garbage instead of an error. buildUrl now requires a jobindex.dk host (apex or subdomain - look-alike and userinfo tricks rejected via real URL parsing) plus a /jobannonce/<id> path, rebuilds the fetch URL from the extracted id (the canonical short form the bare-id path always used), and exits 1 with the stderr-JSON BAD_ID contract otherwise; bare ids stay permissive scheme- and slash-free tokens (the jobnet precedent - the server 404s unknowns loudly). Pinned by eight cases in the new detail-input.test.ts; the five rejection/canonicalization cases fail against the verbatim unguarded extraction. Complementary to the /apply host-check rule proposed in #431, which stays with its proposer.

  • /outcome and /interview no longer confuse two roles at the same company (#443) (.claude/commands/outcome.md, .claude/commands/interview.md, tests/test_apply_records_application.py) - when a tracker row's cv_file / cover_letter_file columns are empty, both commands fell back to a company-prefix glob (cv/main_<company>*.tex). Two roles at one company both match it, so /outcome copied whichever the filesystem returned first into the archive as cv_draft.tex - the file whose purpose is to record what was actually submitted - and its own "leave an existing archived file" rule then made the wrong copy permanent. Both fallbacks now glob the full <company>_<role> stem, derived by the Subfolder naming rule in documents/README.md rather than restated, and skip with a note instead of widening the search. Dropping the hardcoded .tex also makes a template registered by /add-template findable.

  • jobnet-search detail no longer reports an externally hosted ad as not found (#432) - Jobnet's /FindJob/JobAdDetails/<id> returns 404 for ads with isExternal: true, so detail on an ad search had just listed exited 1 with NOT_FOUND, and /scrape read the posting as gone rather than hosted elsewhere (2 of 3 ads in a fresh sample). On that 404 the command now falls back to the search endpoint, which does carry the ad's description and the external application URL, and returns the record marked isExternal: true with a stderr note; fields the search payload does not carry (views, approvalStatus, the boolean flags) are null, never guessed. Verified live on two external ads.

  • 09-web-research.md's curl snippets no longer write into the repo when $SCRATCHPAD is unset - both runnable blocks in the 403-escalation path start with cd "$SCRATCHPAD", and nothing in the repository ever sets that variable (git grep 'SCRATCHPAD=' returns nothing). Unset, it expands to cd "", which succeeds and leaves the shell where it started, so the && chain proceeds and curl -o page.html writes to the working directory - in practice the checkout, which is exactly what the paragraph directly beneath the curl block forbids ("Write to the session scratchpad directory, never into the repo"). The file's instruction and its own snippet disagreed, and the snippet won silently. Both expansions are now guarded with ${SCRATCHPAD:?...}, turning a silent repo write into an immediate failure whose message names where the value comes from. Behaviour is unchanged wherever the variable is set. The same undefined reference in .claude/commands/rank.md was removed by #425 as a side effect of rewriting Step 2/4; this is the remaining instance.

  • seen_jobs.json keys are now a pure function of the posting - /scrape Step 4 described the key as prose ("<url_or_company_title_key>") and nothing said how to derive it, so each run slugified in its own way. Two failures followed, both observed in a live state file. Keys carried characters that break the path they later become: /apply and /outcome derive an archive folder from the same company+role pair, which is why documents/README.md has a subfolder rule, and keys like deloitte_junior-cybersecurity-analyst-(ot/iot) and neverhack-estonia_penetration-tester-/-red-teamer violate it. And the same posting was stored twice when two runs truncated one title at different points (deloitte_cyber-intelligence-center-security-analy and ...-security-analyst-at are one job, one URL, two entries) - which defeats the dedup the file exists for. tools/job_key.py now owns the derivation: the slug is normalised, and truncation is length-capped and disambiguated by a hash of the full slug, so a long title always produces the same key and two long titles sharing a prefix cannot collide. Step 4 calls the helper instead of describing it. --audit reports non-conforming entries in an existing state file and deliberately never rewrites them: stored keys are matched against job_search_tracker.csv by company+role elsewhere, so a silent rewrite would break the link between a stored job and its application record. Existing state files need no migration: Step 2's candidate filter matches a posting to a stored entry by URL regardless of that entry's key, so a workspace whose entries predate the helper does not see its still-live postings re-presented as new.

1.7.1 - 2026-09-06

Added

  • CHANGELOG structure guard (tests/test_changelog_structure.py) - every PR edits this one shared file by hand near the same line, and nothing checked the result: a second ### Fixed heading landed directly under [Unreleased], above ### Added, on #425 and was fixed by hand at merge time. The [Unreleased] section is now checked on every PR for duplicate headings, headings outside the Keep a Changelog set, entries above any heading, and leftover conflict markers. Released sections are history and are not inspected.

  • /rank now consumes the posted_date #391 persists (#390, the deferred second half) - Step 3 gains a staleness flag: a posting whose stored posted_date is more than 30 days old at rank time carries a visible ⚠ marker with its age spelled out alongside the score ("⚠ posted 2024-05-13, 27 months ago"), the same FLAG treatment as location and language - in the ranking, for the user to judge, never an exclusion (the #390 posting was 27 months old and still live; age is a signal, not a veto, and a future stored deadline outranks it). Costs no fetch: age is re-derived each run from the stored value and never persisted. Boundary rules carried over verbatim from the schema and rule 6: no posted_date or null means no flag and no guess (never inferred from first_seen), and unparseable values are treated as absent and reported once with their portal. Pinned by four new cases in test_rank_command.py, each verified to fail against the rule-less spec.

Security

  • settings.json no longer pre-approves bun run on arbitrary files (#396) - the template's permission allowlist granted Bash(bun run:*), which auto-approved bun run <any file on disk> in every fork. It is now one path-scoped entry per shipped portal CLI, matching what each portal SKILL.md already declares. /scrape is unaffected for all portals, including ones added by /add-portal - the job-scraper skill's own allowed-tools carries the path-scoped wildcard that covers them during the workflow. Running a portal CLI ad hoc outside a skill now prompts once, which is the intended behavior for anything not on the reviewed list. Thanks @vkotaru.

Fixed

  • /setup now fills the contact blocks inside 05-cv-templates.md and 06-cover-letter-templates.md, and /reset restores them - Step 3 personalised cv/main_example.tex but never the LaTeX contact blocks embedded in the two template files /apply actually compiles from, so a full Path B or C run left [YOUR_NAME], [YOUR_EMAIL] and [YOUR_PHONE] in both, and whether they reached a document depended on the drafter noticing (a real user ran /setup and then hand-edited both files, #420). 06-cover-letter-templates.md was not a Step 3 target at all. Step 3.5 now names the 05 contact tokens, a new Step 3.6 covers the 06 contact line and signature (Path A never fills it, so it runs for every path), the completion summary lists 06, and /reset clears both blocks instead of listing 06 as framework-only. Pinned by tests/test_setup_command.py; the existing /reset coverage test is what forced the reset.md half.

  • /rank no longer reads or rewrites the whole of seen_jobs.json on every run (#395) - Step 1 used to read the entire state file into the conversation to select candidates by eye, and Step 4 emitted it back to record scores: a cost paid on every run regardless of batch size, growing for the life of the workspace. tools/rank_state.py now owns that traffic - candidates selects and projects only the fields a scoring agent needs, sweep runs rule 6's expiry pass on disk, and apply writes results back atomically and prints the rows Step 5's report is built from. Preserves Step 4's existing write-back rules exactly: the location → location_verdict legacy migration, the deadline null-is-not-a-correction rule, and verbatim strengths/gaps persistence. No scoring policy changes - no new status value, no new persisted field.

  • jobbank-search, jobdanmark-search, and jobnet-search detail commands now accept full URLs - the portal contract specifies detail <id|url>. Passing a full posting URL (with or without trailing slashes, slug segments, or query parameters) previously caused jobbank-search and jobdanmark-search to construct invalid double-URL strings, and jobnet-search to interpolate the full URL into the API endpoint path. All three detail handlers now extract and normalize the underlying ID or slug via dedicated helper functions, and exit 1 with code BAD_ID on unparseable inputs, matching linkedin-search and freehire-search. Pinned by 24 unit tests across the three CLIs' detail-url-normalization.test.ts.

  • /rank now bounds each scoring batch (#395) - a bare run scores at most 10 eligible jobs instead of attempting the entire backlog. --limit <N> controls scoring independently of --top, and the report makes deferred work visible so re-running /rank can continue it.

  • The portal CLIs' unknown-flag guard no longer lets a single-dash flag through (#426) - the guard in the four bunli-based CLIs (jobnet, jobbank, jobindex, jobdanmark) inspected only tokens starting with --, so an undefined short flag bypassed it entirely: bunli discarded it, the search ran unfiltered, and the CLI exited 0 with no error. Live against jobnet, search -q "sygeplejerske" returned all 18,179 ads as a successful search against 667 for the real --search-string query - the same shape as review finding F13 (jobdanmark, 13,862 results) that motivated the guard in the first place, reached by the likelier route: -q is the documented short for the keyword search in linkedin-search, freehire-search and jobindex-search, so a cross-portal habit produces it. Both dash forms are now checked, with declared shorts (jobindex's -q) and bunli's built-in -h/-v still valid. A negative number is rejected too rather than skipped: bunli does not consume a --prefixed token as the previous flag's value, so --radius -5 silently fell back to the default radius instead of failing its own min(1) schema - erroring on it is the trade linkedin-search already makes, and a value that must begin with a dash uses the --flag=value form. linkedin-search and freehire-search were unaffected; they normalize -x to a long name before checking it. Pinned by thirteen new cases across the four CLIs' cli-flag-validation.test.ts, network-free because the guard runs before dispatch: eight bug-pinning cases (the short flag and the negative number, per CLI), each verified to fail on the unfixed guard, plus five regression guards that pass on both and exist to keep the fix from over-rejecting - -h in each CLI, and jobindex's declared -q.

  • jobdanmark-search autocomplete no longer dies over one suggestion without text (#421, closing out the #416/#418 audit - every other deref site in the six CLIs checked and confirmed guarded) - the filter derefed item.text.toLowerCase() from a cast API response on the same line that already guards g.items ?? [], so one item with a null or missing text threw TypeError and the whole command exited 1 as API_ERROR. The filter now lives in an exported filterAutocompleteGroups (the jobnet testability pattern), text is typed nullable so the compiler enforces the guard, and an item without usable text is skipped - it can never match the required non-empty query, so downstream output never sees one. Pinned by three cases in the new autocomplete-filtering.test.ts; the null-text case fails against the verbatim unguarded extraction with the exact production TypeError.

  • jobnet-search no longer dies over one ad with a null publication date (#418, the sibling of #416 from the same audit) - date: job.publicationDate.slice(0, 10) trusted a TypeScript interface claim (publicationDate: string) that nothing validates at runtime: apiFetch casts the JSON body, so one null threw TypeError inside the jobAds map and the whole search of a default-ON portal exited 1 as API_ERROR - while the neighboring applicationDeadline field was already null-guarded with a 1900-01-01 sentinel check. The field is now typed nullable (so the compiler enforces the guard) and degrades per-item to date: null, the shape the seen_jobs.json contract documents. Pinned by a new case in search-normalization.test.ts, verified to fail on the unfixed code with the exact production TypeError.

  • Placeholder-integrity tests in python-tests now skip on forks (#405) - the dedicated placeholder-integrity job already gates on the upstream repo name, but python-tests ran unittest discover with no such guard, so forks that personalized files via /setup failed three sentinel checks permanently. Both test classes now use @unittest.skipIf on GITHUB_REPOSITORY (defaulting to upstream when unset so local pristine-template runs still execute).

  • convert_salary_excel.py no longer mistakes a title/citation row for the header row (#414) - header-row detection accepted the first row in the first 10 where any cell merely contained a company-pattern word, with no check that the row actually looked like a header. A source-citation line above the real header table - standard in real Danish union/statistics exports, e.g. "Kilde: ... opdelt efter arbejdsgiver ..." - tripped it purely because "arbejdsgiver" (employer) appeared in prose. The real header row then got parsed as a data row (its "Firma" cell became a bogus company entry), and every genuine company lost all its salary data, silently: exit 0, "Done! Wrote N company entries," with categories: {} on every one. A candidate row is now accepted only when a different cell in the same row also matches a city/count/index pattern - same-cell corroboration doesn't count, since a citation sentence can pack a count-pattern word into the same sentence as the company-pattern one (e.g. "...opdelt efter arbejdsgiver, antal svar 1234"). Sheets whose only real header has purely untyped salary columns (e.g. "Base pay 2025" / "Bonus 2025", neither of which matches a known city/count/index pattern) have nothing to corroborate against in any row, so detection falls back to the original any-cell-mentions-company rule when the strict pass finds nothing in the first 10 rows. As a backstop independent of either pass, a sheet that ends up with zero detected salary columns now prints a warning instead of reporting success silently. Pinned by four cases in tests/test_convert_salary_excel.py: the original citation-row and zero-columns cases fail against the pre-fix script; the same-cell-corroboration and untyped-column-fallback cases each fail against the single-pass version of this fix that came before the fallback was added.

  • jobbank-search no longer dies over one malformed feed date (#416) - new Date() on a present-but-unparseable pubDate yields an Invalid Date whose toISOString() throws RangeError, and normalizeSearchItem runs inside an unguarded items.map(), so a single bad RSS item killed the entire search with {"error": "Invalid Date", "code": "API_ERROR"} and exit 1 - a whole default-ON portal lost to one item, with the error pointing at the API. The un-CDATA'd fallback capture in parseRssItems can deliver exactly such a value. An unparseable pubDate now degrades to the same shape as an absent one (posted empty, date: null, per the seen_jobs.json contract that #391 put this field on), and every other item survives. Pinned by three new cases in search-normalization.test.ts, each verified to fail on the unfixed code.

  • linkedin-search rejects fractional numeric flags instead of silently changing the query (#371) - bare parseInt truncated values before validation, so --jobage 0.5 became 0 and silently omitted LinkedIn's f_TPR freshness filter while the CLI reported no argument error. --jobage, --jobage-minutes, --page, and --limit now accept whole numbers >= 1 only and reject fractions and zero with the stderr-JSON BAD_ARG contract, matching the other portal CLIs. Pinned by eight cases verified to fail on the unfixed CLI. Reported by @Meet6338-X.

  • linkedin-search detail accepts LinkedIn job URLs with trailing slashes (#411) - passing a job URL with a trailing slash (e.g., https://www.linkedin.com/jobs/view/<id>/ or a slugged variant with or without query strings) failed validation and exited 1 with BAD_ID before any network request because the regex delimiter strictly expected ? or end-of-string immediately after the numeric ID. The boundary check now matches [\/?], correctly extracting IDs from browser-copied URLs, regional subdomains, and links with tracking parameters. Pinned by eleven new cases in parsing.test.ts.

  • The documents/interview/** ignore rule no longer claims interview prep is written there (#336). /interview saves its pack to documents/applications/<company>_<role>/interview_prep_<stage>.md, already ignored by documents/applications/**; nothing has ever written to documents/interview/. Nothing leaked - but it was the personal-data block's one dedicated line about interview material, so an auditor checking the framework's most sensitive artifact had every reason to read it and stop, at the only path in the block with no writer. The protection rationale now sits above documents/applications/**, the rule that actually provides it, so the next reader finds it where it lives; documents/interview/** stays, relabelled belt-and-braces rather than primary guard (REQUIRED_IGNORE_RULES pins it, so removing it from .gitignore alone fails the guard). Pinned by tests/test_security_guards.py, which derives the prep-pack path from /interview's own spec instead of hardcoding it - so moving that path fails CI rather than quietly re-staling the comment.

  • /scrape now persists each posting's publication date (#390) - Step 2's contract guarantees a date on every portal CLI's search output (CI enforces it in test_scrape_contract.py) and Step 1b uses that date to scope a run to the last 14 days, but Step 4's seen_jobs.json schema stored no posting date at all: first_seen is when the scraper saw an entry, not when the employer posted it. The freshness window was therefore unauditable the moment a run ended, and /rank - which reads the stored entry, not the run - had no age signal to weigh. A freehire-search posting dated 2024-05-13 was scraped 27 months later and ranked Strong Fit at position 1 of 133; the scoring note recorded that the listing "may be long stale" in prose nothing reads, and an /apply run drafted a tailored CV and cover letter against it. The schema gains posted_date (null when the portal returned no date, never inferred or backfilled). Pinned by three new cases in test_scrape_contract.py, each verified to fail on the unfixed spec. Reported and diagnosed from a real run by @sandunwijerathne.

  • salary_lookup.py no longer crashes on a null metadata or categories - --validate treats an explicit "metadata": null / "categories": null the same as an omitted key (the shape checks are "...must be an object when provided" and skip None), but the renderer read both through dict.get(key, {}), which only substitutes the default for an absent key - a present-but-null value passed straight through. format_entry then hit None.get("index_label", ...) (AttributeError) or, via the numeric-field fallback, None[key] = value (TypeError), so a hand-maintained salary_data.json using null for "no value here" died with an uncaught traceback right after printing Found 1 match(es). format_entry now coerces both to {} up front, so null, absent, and {} behave identically. Pinned by four cases in test_salary_lookup.py - two unit calls into format_entry and two end-to-end (main() --validate blesses the file, then the lookup path renders it), one per null shape, all verified to fail on the unfixed renderer.

1.7.0 - 2026-08-29

Fixed

  • Fork clones no longer point gh issue create at the upstream public tracker undetected (#389) - gh repo fork --clone, the exact command SETUP.md's fork step recommends, sets the upstream repo as gh's default repository, and gh uses the default for creating issues and PRs - so a user's own automation ("file a tracking issue per application") silently published personal job-search data on the upstream repo, under the user's identity, where they cannot delete it (four live instances from two users in one week). SETUP.md section 2 now adds gh repo set-default <your-username>/ai-job-search directly to the fork commands with a warning at the point of decision (the #348 pattern), and a new .github/ISSUE_TEMPLATE/ carries the same heads-up the PR template already had, for the web-UI path. Blank issues stay enabled - the template warns, it does not gatekeep.
  • freehire-search fractional numeric flags no longer silently change the query (#373) - parseIntFlag used bare parseInt, so a fractional value was truncated instead of rejected: --jobage 0.5 became 0, failed the jobage > 0 guard, and the posted_within_days freshness filter was silently omitted from the outbound request while the CLI exited 0 - on a default-ON /scrape portal, exactly the discarded-filter failure the CLI's own UNKNOWN_FLAG guard documents. Numeric flags (--jobage/--page/--limit) now accept whole numbers >= 1 only, mirroring the Danish CLIs' z.coerce.number().int().min(1) contract, and reject everything else with the stderr-JSON BAD_ARG error. The sibling of #371 (linkedin-search), which remains with its reporter. Pinned by five new cases in cli-flag-validation.test.ts, each verified to fail on the unfixed code.

Added

  • linkedin-search detail reports closed postings (#280, adopted with the original author's commit preserved) - a new isActive field: false when the posting page renders LinkedIn's own "No longer accepting applications" top-card banner. Detection is scoped to the top card and pinned by fixture tests in both directions, including the false-positive case the review required (recruiter boilerplate quoting the closed phrase in a description must not flag a live job - on the unscoped first version it did, and the new tests fail there). Only the two markers real closed pages carry are matched (closed-job__flavor and the banner text, verified against live guest pages); three speculative phrases from the first version were dropped as false-positive-only risk. /scrape Step 2 now consumes the signal: a closed-at-source job is recorded in seen_jobs.json as "status": "expired" - marked, never silently dropped, per the /rank pattern - which is the fix for the ghost-LinkedIn-jobs class in #331 (an expired LinkedIn URL redirects to a similar live job, so a stored hit can die unnoticed between scrape and click). isActive: true is documented as absence of the banner, not proof the posting is open.
  • pypdf ATS text-layer fallback - /apply Step 5d and tools/verify_pdf.py extract the CV PDF text layer with pypdf first (BSD, pip install pypdf) so Windows machines without Poppler still get a mechanical parseability check. Poppler pdftotext -layout -enc UTF-8 remains the fallback; if both are missing the check still degrades to a visual keyword review. No extra cache or installer. 05-cv-templates.md framework_version 1.4.2 → 1.4.3.
  • CI now tests the full documented Python range (#370) - the Python tool tests job runs a 3.10-3.14 version matrix instead of pinning 3.12, so both the documented 3.10 minimum and the newest Python are continuously verified. Grew out of an independent cross-platform verification (Windows + Linux, Python 3.14) contributed by @atiqur-rahman-pro, whose report also confirmed the suite's expected PyYAML-dependent skips in a clean container. Thanks!
  • Company-research cache for /apply and /interview - /apply Step 3's reviewer agent and /interview Step 2 each independently execute the Company Research Checklist (04-job-evaluation.md) for the same company, so applying and later prepping for an interview on the same application researches the company twice from scratch. A new company_research/<normalized-name>.json cache (30-day TTL, documented in 04-job-evaluation.md alongside the checklist it mirrors) lets either consumer reuse a recent result instead of repeating the search/fetch work. This does not change how a claim gets verified: cached research is a lead, exactly like reviewer-agent research already is under 03-writing-style.md rule 5 - only the discovery step is cached, never the final verification before a claim ships in a cover letter or prep pack. company_research/*.json added to .gitignore and security_guards.py's REQUIRED_IGNORE_RULES (a plain rooted pattern, not **/ -prefixed - the cache is referenced from commands, not a skill, so it resolves against the repo root normally). Pinned by the new tests/test_company_research_cache.py. Cache contents are documented as data, never instructions, for a later session reading the file - the same trust-boundary rule apply.md Step 0 states for the posting itself, since cache notes are written from the same fetched web content. The verification-still-applies restatement in both apply.md and interview.md's cache-check paragraphs is now pinned too.
  • CI now compiles the LaTeX examples on Debian bookworm's apt-packaged TeX Live (the separate-PR follow-up invited in #323's review). The latex-smoke job ran only texlive/texlive:latest - the environment that never had the #242 bug, so the moderncv-2.3.1 compile fix shipped guarded by nothing: the next edit to cv/main_example.tex could reintroduce a \firstnamestyle override or a top-level \usepackage{hyperref} and CI would stay green. The job is now a two-leg matrix, texlive-latest unchanged and debian-bookworm installing TeX Live 2022 from apt (moderncv 2.3.1, verified in a real bookworm container: both documents compile clean and the strict stock assertions - 2-page CV, 1-page cover letter, extractable text - pass on both legs unchanged). --no-install-recommends keeps the leg lean, which makes two font packages explicit requirements: texlive-fonts-extra (moderncv loads fontawesome5) and texlive-fonts-recommended (hyperref's xetex driver probes the pzdr metrics). Note for repo admins: the matrix renames the check from "Compile example CV and cover letter" to two leg-suffixed names, so a branch-protection rule requiring the old name needs updating once.

Fixed

  • /reset profile left candidate data in two of the skill files it claims to clear (#364) - /setup Step 3 populates six skill files; the profile scope cleared four. 04-job-evaluation.md was listed by name under "files NOT touched (they contain framework rules, not candidate data)" while Step 3.4 writes the user's match areas, career goals, energizing/draining tasks, financial situation and schedule constraints into it - and CI's placeholder-integrity job already guards it under "personal data may have been committed". job-scraper/search-queries.md, which Step 3.8 fills with their job boards, role titles, domain keywords, city and commute tiers, appeared nowhere in reset.md at all. Both are tracked and unignored, so the Step 1 preview asked the user to confirm a wipe list that omitted them and Step 4 then reported a blank profile while /rank kept scoring against the old skills and career goals and /scrape kept running the old city and queries. Both files are now previewed and cleared, restoring their /setup placeholders while preserving the scoring framework and the query structure; 04-job-evaluation.md is out of the preserved list, which keeps 03-writing-style.md and 06-cover-letter-templates.md (correctly - the latter's [YOUR_NAME] tokens are LaTeX scaffolding Step 3 never writes to). CLAUDE.md and cv/main_example.tex stay outside the profile scope, which covers skill files only, and the preview and Step 4 now say so instead of implying a full wipe. tests/test_reset_command.py gains a profile-scope guard alongside its documents-scope one, deriving the file list from /setup Step 3's own headings so a future /setup target that /reset forgets fails in CI; the third case pins that a personalized file is never labelled framework-only, which a filename search alone would have missed.
  • salary_lookup.py never stripped the dotted "A.M.B.A." legal suffix (#356) - the STRIP_PATTERNS regex ended in \.\b, and a word boundary can't sit between a literal dot and the space or end-of-string that follows it in real company names, so the pattern was dead code: "Arla Foods A.M.B.A." normalized differently from "Arla Foods amba" and fuzzy-matched at 86 instead of 100. The trailing dot is now optional (\.?\b), both forms normalize identically, and two regression tests pin it. Thanks @Ritik650.

1.6.0 - 2026-08-19

Added

  • Cross-portal /scrape contract pin (#344) - a repo-level test deriving the Step 2 search-output field list (title, company, location, date, url) from job-scraper/SKILL.md's own contract sentence and checking every installed portal CLI's search source for it, so a portal that quietly stops emitting a contract field (the failure class jobnet and jobdanmark actually shipped before #339/#340) fails CI with a clean diff instead of degrading every /scrape run silently. The pin survived the #347 output-shape changes unmodified - evidence the derived-from-spec design holds. Contributed by @oscarbol09, the invited follow-up from #342's review.

  • freehire-search gains --no-description for cheap discovery passes - a default search hydrates full description bodies (~73% of the payload, ~20k tokens per query) while /scrape is told to pre-filter by title before reading bodies. The new flag drops the bodies (a live 10-result search shrinks from ~58k to ~10k chars) while keeping every other field; hydration stays the default. The API currently returns bodies regardless of include_description=false, so the lean guarantee is enforced client-side. Pinned in tests/commands.test.ts.

  • Fixture coverage for linkedin's date/location and jobindex's parseSearchPage - linkedin's search-card fixture carried no <time> or location element, so deleting the date extraction (a /scrape contract field on a default-ON portal) left every test green; jobindex's Stash parser had no tests at all, so meta.total could stop using hitcount unnoticed. Four new linkedin cases (both listdate class variants, location, absent-element nulls) and a new jobindex search-page.test.ts (hitcount vs page count, contract-field mapping, deadline fallbacks). Both mutation-verified.

  • Tests for check_framework_version.py - the CI gate that stops a framework file from being edited without a framework_version bump had zero tests, so the one-line mutation return meaningful_changes > 0 -> return False disabled it while the suite stayed green. Four cases in the new tests/test_check_framework_version.py (clean tree, unbumped edit, bumped edit, missing marker), each running the real script inside an isolated git repo. Mutation-verified against that exact disable.

  • Tests for lint_skills.py's skill and command checks - only check_settings() had coverage; the linter's main job (frontmatter keys, allowed-tools targets existing, the # /<name> command title rule) was unasserted, so deleting the missing-allowed-tools error survived the whole suite. Four new cases in tests/test_lint_skills.py, with the fixture's yaml stub upgraded to parse the real frontmatter. Mutation-verified.

  • Discriminating tests for robots_check's tie-break and browser-UA fallback - the existing tie test put Disallow first, the one ordering that cannot detect deletion of the tie-break clause; and the browser-readback recovery that 09-web-research.md claims is covered had no test at all. Three new tests in tests/test_robots_check.py pin the Allow-first tie, the 403-to-honest/200-to-browser recovery, and that a browser-fetched policy is still obeyed strictly. Each was mutation-verified: deleting the tie-break clause or the UA fallback now fails the suite.

  • LaTeX special-character guidance for CVs (framework_version 1.4.1 -> 1.4.2 in 05-cv-templates.md, 1.0.1 -> 1.0.2 in 06-cover-letter-templates.md) - 05 gains a "LaTeX Special Characters" section and 06's existing one is completed beyond \_/\&. The load-bearing case is an unescaped % in a quantified achievement bullet: it starts a LaTeX comment, so "cut latency by 40% and saved DKK 2M" compiles with zero errors and renders as "cut latency by 40" - silent content loss in the deliverable, on exactly the content the guidance steers users to write. & in employer names (Bang & Olufsen, H&M) fails loudly at compile time and is now documented alongside. Pinned by tests/test_latex_guidance.py.

  • seen_jobs.json entries record which mechanism produced them - a new additive source field (cli for Step 1b portal-CLI output, websearch for the Step 1c fallback), a Step 1c rule tagging fallback results at collection time, and a fallback (websearch): line in the Step 5 run summary naming the portals that ran on the fallback. Motivated by the ghost-LinkedIn-jobs report (#331): when a stored job later turns out not to exist at its URL, triage hinges on whether the entry came from live CLI output or a search index that can be weeks stale - evidence that previously lived only in the run's scrollback. An entry that is missing source predates the field and is never backfilled; a presented job with no seen_jobs.json entry at all points at fabrication, which the scraper's Rule 1 forbids. Pinned by tests/test_scrape_provenance.py. job-scraper/SKILL.md sits outside the framework_version-marked set, so no version bump applies.

Changed

  • BREAKING (scripts passing stray flags): all six portal CLIs reject unknown flags with exit 1 and {"error", "code": "UNKNOWN_FLAG"} on stderr, instead of silently discarding them. A discarded filter changes what a search returns with no error - a wrong flag name on jobdanmark returned the entire database (13,862 results, none matching) as if it matched the query, and the six portals use four different names for the free-text flag, so cross-portal guessing is likely. add-portal.md already required contributed portals to exit 1 on a bogus flag; the reference CLIs now meet their own bar. Pinned by nine new cases across the six cli-flag-validation suites.

  • /rank persists its location verdict as location_verdict - the bare location key meant two incompatible things in seen_jobs.json: a place (scraper search output, driving the commute filter) and a PASS/FAIL/FLAG verdict (/rank Step 4), so ranking could overwrite "Aarhus, Denmark" with "PASS" and no reader could tell which meaning a stored value carried. Legacy entries are read compatibly (a PASS/FAIL/FLAG string in location counts as the verdict when location_verdict is absent) and migrated on re-write. The seen_jobs schema note in job-scraper/SKILL.md now also enumerates location_verdict/language_gate/language_note, so its "do not drop any of these fields" instruction finally covers the fields /rank calls as important as the score. Pinned by two new tests in tests/test_rank_command.py.

  • linkedin-search detail drops the applyUrl field - the extraction regex assumed class= before href= and never matched LinkedIn's real markup (null on every live posting since the markup ordering differs), and fixing the regex would only capture the job-view URL, a duplicate of the record's own url. The field and the SKILL.md "apply link" claim are removed; a test pins the removal.

  • jobdanmark-search search output drops presentation-only keys - coverImage, companyLogo, companyLogoSvgMarkup, overlayColor, and silhouetteLogo were ~40% of a live payload (a 30-result response shrinks from ~30k to ~20k chars), fed into agent context on every /scrape query, and unusable by an agent. The #340 compatibility duplicates (companyName, publishedDate, applicationDeadline) and slug stay. Pinned in tests/search-normalization.test.ts.

  • BREAKING (jobbank forks): jobbank-search search output emits deadline as YYYY-MM-DD - the feed's DD.MM.YYYY parenthetical was passed through raw, contradicting the /scrape contract, the other portals, and the same CLI's own detail command (which already emits ISO for the same job). 01.09.2026 is also ambiguous to a date parser (1 Sep vs 9 Jan). The known shape is now converted; "løbende" still maps to null, and an unrecognized shape passes through for /rank's defensive handling. Anything parsing the old DD.MM.YYYY output must update - though the README's own search example already showed the ISO form. Pinned in tests/rss-parsing.test.ts and tests/search-normalization.test.ts.

  • Job matching reframed around function, not title (framework_version 1.2.2 -> 1.2.3 in 04-job-evaluation.md) - title-lookalike matching throws away career capital that doesn't fit one job-title box (e.g. a background spanning research leadership, platform ownership, and program management gets collapsed into whichever single title sounds closest). /setup, search-queries.md, and 04-job-evaluation.md now guide the candidate to define priority categories by function - the kind of problem a role solves - and to list several plausible job titles as query variants within each category, rather than betting an entire priority tier on one exact title string.

  • CONTRIBUTING: invited PRs are reserved for the invitee - when a maintainer comment explicitly invites a named contributor to implement an issue they diagnosed or designed, the implementation is theirs for a stated window (default seven days, longer on request); a duplicate PR filed inside that window closes in the invitee's favor regardless of arrival order. Prospective from 2026-08-14. Sits alongside the existing credit norm.

Fixed

  • Onboarding warns about public forks at the point of decision (#345) - the quick start walked a new user into gh repo fork (forks of public repos are always public) and two steps later had /setup write personal data into tracked files, with the only complete warning sitting in SETUP.md section 8 - a section about pulling updates that a first-time user has no reason to open. A real user hit exactly this. The warning now sits adjacent to both fork commands (README step 1, SETUP.md section 2), and /setup checks the origin's visibility before writing anything: a public-fork origin gets a confirm-first warning instead of a note after every file is already on disk. Reported by @basilevs with a complete reproduction and fix analysis. Pinned by the new tests/test_onboarding_privacy.py.

  • jobindex-search detail rewritten against jobindex's current markup - every selector the old parser used is gone from live pages, so on 4 of 5 live postings it returned CSS-comment text as the deadline ("K \t\t... */"), an external ATS URL as its own id and url, null company/location/date, and a 160-char teaser as the description - exit 0 every time. The new parser handles both live shapes (the jobindex-native jd-* layout and the external-ATS passthrough), always keeps the jobindex id and jobannonce URL, requires a real date next to the deadline label and scans only visible markup (killing the CSS-comment capture), converts Danish long dates to ISO, and reports company: null honestly on passthrough pages instead of the ATS brand. Verified live on 5/5 postings (full descriptions of 5.5-9k chars, 4/5 ISO deadlines and locations). Fixture tests for both shapes, including the CSS-comment trap, in the new tests/detail-parsing.test.ts.

  • /scrape gains a recency fallback for portals with no recency flag - Step 1b.3 told every portal to scope to 14 days "using the portal's supported recency flag", but jobdanmark has none, leaving the instruction unsatisfiable there: the agent either silently skipped the scoping or invented a flag (which the CLIs now reject). Every portal emits a date field, so the instruction now says to filter client-side after the call, and stops presenting --order (a sort) as interchangeable with a filter. Pinned in tests/test_scrape_provenance.py.

  • /html-report's funnel counts stages from history; the rejection rate stops counting non-rejections - the funnel was computed from current status, which is a state, not a history: an application that interviewed and was then rejected never counted as reaching Interview, so a finished search rendered as though nobody ever interviewed. The funnel (Step 2 and chart 4) now derives stage-reached from current status plus the outcome.md stage checkboxes Step 1.2 already merges. And the rejection rate no longer counts offer_declined (a success) or withdrawn (candidate-initiated) as rejections, nor unresolved Interview/Offer rows in its denominator. Pinned by two new tests in tests/test_html_report_command.py.

  • jobdanmark-search detail's HTML fallback emits the same shapes as its JSON-LD branch - a posting without JSON-LD returned datePosted as the page's raw DD-MM-YYYY text, validThrough as free text (including the literal "Løbende", which would flow into stored data as a deadline), and a hardcoded null addressLocality. The fallback now converts overview dates to YYYY-MM-DD, maps Løbende to null (jobbank's precedent for the equivalent), and derives the locality from the workplace address with the same postcode extraction search uses. Pinned in tests/detail-parsing.test.ts.

  • jobnet-search detail no longer leaks the 1900-01-01 undisclosed-deadline sentinel - search maps the API's sentinel to null (with a test pinning it), but detail dumped the raw response, so a posting whose deadline is simply not disclosed contributed a deadline 126 years in the past to stored data, and /rank's expiry sweep would retire the job instantly. All three output formats now flow through a prepareDetail normalization that maps the sentinel to null. Pinned in tests/detail-formatting.test.ts.

  • CI's placeholder guard now watches the CV's actual personal-data lines - the sentinel for cv/main_example.tex was [YOUR_NAME], whose only occurrences are a header comment and the hyperref pdftitle; /setup's documented edit replaces the \name{}/\address{}/\phone{}/\email{} data and touches neither, so a fully personalized CV with a real name, address, phone and email passed the check (proven empirically in the review). The guard now asserts sentinels inside the \name{} and \email{} lines, and 01-candidate-profile.md's sentinel moves from the <!-- SETUP header comment onto the [YOUR_EMAIL] Identity field for the same reason. The new tests/test_placeholder_integrity.py simulates the /setup edit and requires the guard to fire on it.

  • jobindex-search maps ASAP postings' deadline to null - the portal's apply_deadline_asap flag was emitted as the literal string "ASAP" on roughly half of live results, contradicting the CLI's own README ("date string; null if not listed") and the /scrape schema, and breaking every consumer that does date arithmetic (/rank's urgency and expiry sweep, /outcome's deadline check, /notion-sync's typed date column). ASAP means "no stated deadline", which the contract already represents as null. Pinned in tests/search-page.test.ts.

  • /gmail-sync no longer restricts its search to the Inbox - the query used in:inbox to "skip sent/drafts", but that operator also excludes every archived message, and self-defeatingly the mail matched by the very job-search label Step 3.1 hunts for (the standard filter that applies such a label also archives). The query now uses -in:sent -in:drafts, which matches the stated intent exactly. The failure mode was silent under-detection: a missed rejection or interview invite read as "no updates". Pinned by the new tests/test_gmail_sync_command.py.

  • /upskill no longer divides by a blank fit_rating - /outcome creates tracker rows for applications made outside the workflow with no fit evaluation, so their fit_rating is blank, and Step 3.3's (100 - fit_rating) / 100 had no rule for that. The naive blank-as-0 reading yields weight 1.0 (the maximum), letting the one job the framework knows nothing about dominate the skill-gap heatmap. A blank or non-numeric fit_rating now falls back to a matched ranked entry's rank_score, else the row is skipped, counted, and reported once - mirroring the skill's own missing-gaps handling. Pinned by tests/test_upskill_skill.py.

  • /rank's expiry sweep parses stored deadlines defensively - the sweep changes status automatically from a date comparison against values on disk, but portals have shipped non-ISO shapes into seen_jobs.json ("ASAP", DD.MM.YYYY, free text), and /rank had no rule for them while the display-only /outcome already did. A stored deadline that is not YYYY-MM-DD is now treated exactly like an absent one wherever a stored deadline is compared (urgency and sweep), and reported once with its portal. Pinned by tests/test_rank_command.py.

  • Language Gate preamble no longer claims the gate is untracked (framework_version 1.2.3 -> 1.2.4 in 04-job-evaluation.md) - the paragraph still said the result "is not a field /scrape or /rank track", written before the gate was wired into both consumers. An agent reading the authoritative framework file learned the opposite of what rank.md itself insists on ("These veto fields are as important to persist as the score itself"). The preamble now names language_gate/language_note and how each consumer uses them; a coupling test in tests/test_rank_command.py keeps the framework text honest about the tracking.

  • /reset documents now clears documents/postings/ - the drop folder for hand-pasted job posting text was absent from the preview, the delete block, and the user-facing scope description, after which the command told the user "The documents/ folder is now empty" - false whenever postings were present, and they are exactly the personal residue a reset exists to clear. A new tests/test_reset_command.py derives the folder list from the git tree, so any future drop folder fails the test until /reset covers it.

  • convert_salary_excel.py no longer corrupts US/UK-formatted numbers 1000x - the both-separators branch always assumed European locale, so a "1,234.56" cell was silently converted to 1.23456 and written into salary_data.json. The rule is now "the separator that appears last is the decimal separator", which also makes multi-group values ("1,234,567.89", "1.234.567,89") parse instead of raising. And strip_type_patterns now strips COMPOUND_PATTERNS words as substrings, mirroring header_matches, so a Danish compound header pair ("Antal alle" / "Lønindeks alle") pairs into one category instead of two unpaired standalones - the exact locale the compound support was added for. Pinned by six new cases in tests/test_convert_salary_excel.py.

  • jobdanmark-search extracts the city when a comma follows the postcode - the location regex required whitespace after the 4-digit postcode, but live companyAddress values frequently read "2670, Greve"; those results emitted location: null (7 of 30 in a live sample), so /scrape's geography/commute filter (Rule 3) had nothing to act on. The extraction now accepts an optional comma, trims the captured city, and still refuses to mistake a 4-digit street number for the postcode. Pinned by three new cases in tests/search-normalization.test.ts.

  • Example-CV bullets no longer swallowed as LaTeX optional labels - every placeholder bullet written as \item [text] (11 in cv/main_example.tex, 3 in 06-cover-letter-templates.md's taught template) let LaTeX parse the bracketed text as \item's optional argument: the shipped example CV rendered all Professional Experience bullets clipped off the left page edge, with the word "Achievement" appearing 9 times in the source and 0 times in the PDF text layer - a clean compile, green CI. Bullets are now braced (\item {[text]}), the cover-letter guide teaches the braced form, and CI's stock PDF assertions additionally require Achievement to survive pdftotext. Pinned by tests/test_latex_guidance.py.

  • Documented ATS extraction commands pin -enc UTF-8 - pdftotext -layout without an encoding flag emits Latin-1 on Xpdf builds, so every non-ASCII character in a correct CV (Rambøll, Ingeniør, København) read back as a replacement character and failed the parseability checklist, steering the agent to "fix" a healthy document. The commands in apply.md, 05-cv-templates.md, and CLAUDE.md's verification checklist now carry -enc UTF-8, which is deterministic on both poppler and Xpdf. Pinned by tests/test_latex_guidance.py.

  • jobbank-search search output now carries the /scrape contract's date field (#342) - the CLI emitted posted (full ISO 8601) but not the cross-portal date key, the one Step 2 contract field it was missing. Search results now additively emit date as YYYY-MM-DD derived from posted (kept unchanged), null when the feed item carries no pubDate. The result mapping is extracted into an exported normalizeSearchItem so the derivation is pinned by tests. Completes the portal-contract series with #339 (jobnet) and #340 (jobdanmark).

  • jobdanmark-search search output now carries the /scrape contract fields - the CLI exposed the API-native schema (companyName, publishedDate in DD-MM-YYYY, …) with no company, location, date or deadline, so every /scrape run flagged jobdanmark as degraded and the seen_jobs.json dedupe lost the company. Search results now additively emit company, location (city after the postal code in companyAddress), and date/deadline in the YYYY-MM-DD convention, with null-safe handling of a missing address.

  • jobnet-search search output now carries the /scrape contract fields - the CLI emitted the raw Jobnet API schema (jobAdId, hiringOrgName, publicationDate, …) with no company, location, date or url, so every /scrape run flagged jobnet as degraded forever (CI stayed green), the seen_jobs.json dedupe fell back to company+title, and /rank lost the posting link. Search results now additively emit company, location, date, deadline and url (https://jobnet.dk/find-job/{jobAdId} - the /job/ route is login-walled); the API's 1900-01-01 "deadline not disclosed" sentinel maps to null.

  • A / in a company or role name no longer nests the application archive one level too deep (jakob1379/ai-job-search#22). Novo Nordisk A/S derived documents/applications/novo_nordisk_a/s_data_scientist/ - written and found by every command that derives the path, silently skipped by the two that enumerate it, so the application never appeared in /html-report's dashboard and /setup's calibration never learned from it. The Subfolder naming rule in documents/README.md now drops every character that is not a letter, digit or underscore (collapsing underscore runs, trimming the ends), and the derivation sites - /apply, /outcome, the direct application skill, /gmail-sync, /interview, and /notion-sync - cite that rule instead of paraphrasing it. An all-punctuation value that derives to an empty name now stops for user correction instead of writing into the archive root. The application assistant's framework_version moves 1.3.3 → 1.3.4. Already-nested archives are not migrated: an archive written under the old rule stays where it is until the user moves it; only newly derived names change. Thanks @jakob1379 for the report.

  • The /html-report dashboard now reads and renders the tracker's deadline (follow-up to #319). The tracker gained a fourteenth deadline column and every other consumer (/outcome, /upskill, /notion-sync) was updated to know it, but the dashboard's Step 1 field enumeration and Step 3 table columns still listed the original thirteen - the one surface where the column could not be seen at all, so a drafted application's clock stayed invisible in the report that reviews the pipeline end to end. The Step 1 enumeration now matches the canonical 14-column header and the applications table can show a Deadline column, subject to the existing empty-column rule. Pinned by tests/test_html_report_command.py so a future column addition cannot silently vanish from the dashboard again.

  • Application deadlines are written down at every moment the framework provably holds them (#319). /scrape fetched the deadline and rendered it in a table, /rank turned it into the 🔥 urgency marker and the expiry check, and nothing stored it - so the marker fired exactly once, every later run had to re-fetch a posting that might have expired to recover the date, and a drafted application (whose only applicable clock is its deadline) had no time-based signal at all. seen_jobs.json entries now carry a deadline (base field, written on first sight, refreshed by /rank Step 4, null vs missing distinguished and never guessed); /rank Step 3 re-derives urgency from the stored value with no re-fetch and sweeps already-ranked entries past their deadline into expired; the tracker gains a fourteenth deadline column appended last, with a header-line-only migration for existing trackers; /apply Step 0 extracts the deadline and Step 6b writes it (including the /scrape path via the assistant SKILL.md); /outcome surfaces it on open rows and flags near/passed deadlines on drafted rows without changing the no-follow-up rule; and the row-rewriting paths (/outcome Step 4, /gmail-sync Step 7a) now preserve every unparsed field so the new column survives the first status update. /notion-sync names the tracker as the Deadline source (tracker wins), /upskill's column list stays true, and job-application-assistant/SKILL.md bumps framework_version 1.3.2 → 1.3.3. Pinned by tests/test_rank_command.py, tests/test_apply_records_application.py, and tests/test_upskill_skill.py.

    The sweep's edges are stated rather than left to the reader: an entry with no stored deadline is left alone and never inferred from another field (the majority case, since most entries predate the column), --all re-scores any status including expired so a swept job is recoverable, and /rank Step 4's idempotency rule now names the sweep as its deliberate exception instead of contradicting it. Step 5 reports how many entries were swept and how many were retired, so an automated status change is never silent. /outcome Step 1 states that the header append is the one edit it may make outside a matched row, so it does not read as a violation of Step 4's own "never restructure the CSV". /notion-sync forbids reconciling two disagreeing deadlines by taking the earlier or later of them.

  • convert_salary_excel.py no longer misreads whole-thousands cells from a Danish-locale export - a cell like 60.000 (thousands separator, no decimal comma) was handed to float() and silently written as 60.0, a 1000x-wrong salary in salary_data.json that then rendered with a meaningless vs baseline percentage in /apply. The comma-side mirror (1,234) was already guarded as ambiguous and skipped; the dot side had no guard, and tests only pinned the both-separators form (1.234,5). \d+\.\d{3} is now rejected the same way, so the shared never-guess policy applies to both separators and the rows in between (e.g. 60.000,50, 108,5) keep parsing exactly as before. Pinned by tests/test_convert_salary_excel.py.

  • main_example.tex compiles on apt-packaged moderncv (#242) - the banking template set its name styling through \firstnamestyle/\lastnamestyle, which moderncv 2.3.1 (Debian/Ubuntu apt) does not have, so a fresh fork could not compile its own example CV on that toolchain. Name styling now routes through \namefont, the hook every name-style macro shares: live on every version (on 2.4+, head iii's \firstnamestyle/\lastnamestyle both route through \namefont, so the override is what sets the 34pt name there too), and the only option on 2.3.1 where those macros do not exist. Two review follow-ups landed in the same change: the \hypersetup comment now names the real clash mechanism (\RequirePackage[unicode]{hyperref} on < 2.4; \PassOptionsToPackage, introduced in 2.4.0, is what removes the clash), and the metadata block sets pdfpagemode=UseNone - a FullScreen value there would win over the class's own \AtEndPreamble default and make every CV open in fullscreen presentation mode. 05-cv-templates.md's preamble copy stays in lockstep (framework_version 1.4.0 -> 1.4.1). Verified on moderncv 2.5.1: exit 0, exactly 2 pages, rendering unchanged.

1.5.0 - 2026-08-12

Added

  • Commit-level upstream triage for forks (#305). A new tools/upstream_triage.py walks the commits a fork is behind upstream and sorts them into "worth reviewing" vs "probably skip": cherry-picks already applied drop off on their own (matched by git patch-id, so ported work needs no bookkeeping), commits that only touch files the fork removed are set aside, and SHAs in a flat .github/upstream-wontport.txt stop resurfacing. It's the commit-history companion to check_upstream_updates.py's version stamps - the two cross-reference each other in their output. Report-only by design: it prints ready-to-run git cherry-pick lines but never merges, pushes, or opens a PR, because on a fork "applies cleanly" isn't "correct". A .github/workflows/upstream-watch.yml runs it weekly into a rolling issue, guarded to no-op on the upstream template (pinned by a test) and scoped to the built-in GITHUB_TOKEN so it can never write outside its own fork. SETUP.md 8 introduces both tools side by side. Offline tests cover patch-id matching, relevance filtering, the won't-port list, and the workflow guard. Thanks @anjolok1997.

  • security_guards.py now holds .claude/settings.json hooks to an allowlist - the guard read permissions.allow and nothing else, so a hooks block in the same file passed silently. A hook is strictly more dangerous than a pre-approved permission: a permission pre-approves something Claude may choose to do, while a hook runs unconditionally when its event fires, with no prompt and no model decision in between. This is not hypothetical - it is the vector the Shai-Hulud worm used in its August 2026 wave, planting a SessionStart hook in .claude/settings.json that executed on session start (JFrog research). For a template thousands of people are invited to fork, that is the riskiest key in the file the guard already parses. ALLOWED_HOOKS ships empty (the template has no hooks), the check runs before the permissions shape guards so a malformed permissions block cannot return early and skip it, and unrecognised hook layouts fail closed rather than being skipped. Eight new HookGuardTests cases; 14 of the suite's 26 tests fail against the unpatched guard.

Changed

  • /add-portal now specifies how a generated skill handles an API token (#304) - the command could already scaffold a skill for a portal reachable only through a paid fetching service, but said nothing about the credential such a skill needs. It now checks for that case during reconnaissance and raises the per-call cost with the user before scaffolding. That check is explicitly subordinate to the robots.txt/terms decision in Step 2.4 - a paid fetching service never launders a refusal, and the credential path exists only for portals whose robots.txt permits access but whose bot protection blocks ordinary fetches. The portal-skill contract requires the token to come from a <SERVICE>_API_TOKEN environment variable (never a CLI flag, never a fixture) and to fail with MISSING_CREDENTIALS when unset; and such a skill's SKILL.md must carry a Setup section naming the service, the variable, and the billing. Spec only - no shipped portal needs a credential, so no existing skill changes. Thanks @Haseeb-1698.

  • /add-portal's fetching contract line now states the honest-UA posture - it read "browser User-Agent", predating the repo-wide shift to honest self-identification (#283, #277 and the portal-CLI fixes that followed). A generated skill now defaults to Mozilla/5.0 (compatible; <portal>-cli/1.0) - the convention every shipped portal CLI follows - and escalation to browser headers goes through the robots.txt gate in 09-web-research.md, never the CLI's default.

  • CI discovers portal CLIs instead of hardcoding them (#310). The cli-checks matrix is now emitted by a discover-clis job that finds every .agents/skills/*/cli/package.json, so a portal skill added with /add-portal gets its typecheck and test scripts run by CI automatically - on this repo and on any fork - without editing the workflow. Upstream coverage is unchanged (the discovered list on master is exactly the six shipped portals). /add-portal's Register step now says so. Thanks @ayobamiseun.

Fixed

  • /upskill reports are now gitignored at the path the skill actually writes them to. The ignore rule upskill/*.md is rooted (a middle slash anchors a gitignore pattern to the repo root), but /upskill is a skill, and skills resolve bare relative paths against their own directory - the same observed behavior the **/job_scraper/* rules exist for. A report written to .claude/skills/upskill/upskill/report-*.md was therefore not ignored (git check-ignore confirms it on the unpatched tree), and an upskill report is the candidate's skill gaps and weaknesses measured against named employers - among the most sensitive files the workflow generates. The obvious widening, **/upskill/*.md, would have ignored the template's own .claude/skills/upskill/SKILL.md (the skill directory shares the name), so the new rule pins the report-file prefix instead: **/upskill/report-*.md. Added to .gitignore and security_guards.py's REQUIRED_IGNORE_RULES, with a check-ignore-based test pinning both properties - reports ignored at both depths, SKILL.md still tracked - which presence checks alone cannot see.

  • Dropped the phantom evaluated value from seen_jobs.json's status vocabulary (#315). The schema block in the job-scraper skill documented new/skipped/evaluated/ranked/expired, but evaluated has had no writer and no reader since the initial release - new/skipped come from /scrape, ranked/expired from /rank, and nothing ever set or selected evaluated. Post-#269 the tracker owns all lifecycle state after drafting, so the value had no future role either; it is now removed rather than wired up. /rank Step 1's --all wording ("all non-applied entries") leaned on an applied status the schema deliberately lacks and now names what it means: entries of any status, minus the tracker exclusion set. Forks that wrote their own tooling against the documented vocabulary should note the value was never produced by any shipped command.

  • /apply archives the job posting while it still holds it (#306). /apply drafted two documents and a tracker row from the full posting, then let the text die with the session; /outcome Step 3.2 tried to recover it by re-fetching a source URL the spec itself expects to be dead, and a posting pasted from an email or a PDF had no source to re-fetch at all. Step 6b item 7 now writes the posting verbatim to documents/applications/<company>_<role>/job_posting.md, never a re-fetch or a reconstruction from memory; an existing file is left alone (a re-application to the same company and role keeps the earlier posting) and named in the report. Step 0 and the /scrape path (job-application-assistant SKILL.md Step 1) retain the full posting text, not a summary. Pinned by tests/test_apply_records_application.py.

  • Tracker status enum defined once; offer declined/no response now reach the correct /html-report bucket and /gmail-sync correctly marks them final (#298). The tracker CSV status column had no single authoritative definition. Six command files restated it independently with inconsistent spellings, producing two concrete bugs:

    • /outcome Step 4 wrote no response and offer declined (with spaces). /html-report Step 1 normalised only no_response / offer_declined (underscores), so any row written with spaces matched no bucket and was silently dropped from the rejection-rate denominator.
    • /gmail-sync Step 2 defined the "final" set with the space forms, so a row written with underscores was never recognised as final and the sync kept chasing closed applications.
    • /html-report included interview_only in the tracker bucket map; that value belongs to the archive outcome.md Status: field, not the CSV status column.

    Fix: a ## Tracker status vocabulary block in /outcome (the only writer of the CSV) now defines the canonical set once with underscore spellings and the Final set by explicit list — everything else, drafted included, is Open. The legacy space spellings are the same values, not separate statuses: equally Final, and every rule that names one form applies to the other — readers must accept them on read, and never write them. Every reader that makes final/open decisions references that block (/apply Step 6b, /interview Step 0, /gmail-sync Step 2, /html-report Step 1, /notion-sync Steps 3-4). /outcome Step 4 writes no_response / offer_declined; /notion-sync normalises both forms to the canonical spellings before setting the Status property; /html-report's bucket map loses interview_only, keeps both spellings, and gains a case-insensitive catch-all that maps unrecognised values to Rejected/Closed and names them once in the status breakdown. Pinned by tests/test_tracker_status_vocab.py.

    Fork heads-up: if your personalized /outcome adds no response or offer declined (space forms) to the tracker write path, swap them for the underscore forms. Existing rows keep working because every reader now accepts both spellings on read. If your Notion database already carries space-form Status options, they simply go unused — Notion never auto-removes select options.

1.4.0 - 2026-08-07

Added

  • --jobage-minutes on linkedin-search for sub-day freshness windows (#302) - LinkedIn filters its f_TPR parameter server-side at second granularity, so the CLI can now ask for postings from the last N minutes instead of whole-day windows only. Conflicts with --jobage are rejected explicitly (CONFLICTING_AGE_FLAGS). Useful for early-applicant freshness on high-volume searches; URL construction only, no parsing change.

  • README: video walkthrough link in Quick start - The Next New Thing's hands-on walkthrough of the workflow (recorded August 2026), for newcomers who want to see the setup-to-application flow before reading. Docs only.

  • Spec-pinning tests for the Language Gate's /rank contract (#278) - four regression guards in tests/test_rank_command.py pinning the language_gate/language_note fields through Steps 2-5 of /rank, including the Step 4 persistence rule that was live-debugged during #275 (vetoes reported in console output but language_gate: null on every persisted entry). Mirrors the existing gaps/strengths pinning pattern. No behavior change.

  • The jobnet and jobdanmark CLIs identify themselves on every API request (#283) - their apiFetch/apiPost wrappers now send an explicit User-Agent (jobnet-cli/1.0, jobdanmark-cli/1.0) instead of Bun's anonymous default token, matching the honest self-identification jobindex already uses on htmlFetch. The new user-agent.test.ts suites assert the header on every request wrapper. No response behavior observed to change.

Changed

  • The four Danish demo portals now ship disabled (#288) - jobindex-search, jobbank-search, jobdanmark-search, and jobnet-search default to enabled: false, and /setup's job-portals question now acts on the answer: it flips them to enabled: true when your market is Denmark, and leaves them off otherwise. Previously a non-Danish user's /scrape ran all four Danish boards by default, spending tokens fetching and filtering irrelevant listings. Fork heads-up: if you search the Danish market, set enabled: true in those four SKILL.md files after updating (or re-run /setup --section search); forks that already curated their portal set are unaffected.

Fixed

  • The linkedin-search CLI identifies honestly - its User-Agent was a full Chrome browser string, the last portal CLI still spoofing after #283 and the jobbank/jobdanmark fix. It now sends Mozilla/5.0 (compatible; linkedin-search-cli/1.0), the same token format as every other portal. Verified live on both the search and detail endpoints: identical 200 responses with full content under the honest token.

  • A .env was committable (.gitignore, tools/security_guards.py). /add-portal can generate a skill for a portal that only returns usable content through a paid fetching service, and such a skill reads an API token from the environment - but nothing stopped the .env holding that token from being committed. No shipped portal needs a credential, so upstream never hit this; a fork whose generated portals do hit it immediately. .env and .env.* are now ignored and pinned in REQUIRED_IGNORE_RULES, so the guard fails if the rule is ever dropped.

  • The robots gate did not fail closed (tools/robots_check.py, #277). Found by an adversarial review run over the merged file, not by inspection. Both cases are pinned in tests/test_robots_check.py as FAIL-OPEN REGRESSIONs:

    • A soft 200 granted permission. A host answering /robots.txt with an HTML error page at status 200 produces a body that parses to zero rules, and zero rules read as "allowed" - so the browser-header retry ran on permission that was never given. A non-empty body carrying no recognised directive is now treated as unreadable. A genuinely empty file stays allow-all, per RFC 9309.
    • Disallow patterns were never percent-decoded while the request path was, so Disallow: /foo%20bar never matched /foo bar and the rule was silently skipped - a fail-open on any site that encodes its own rules.
  • curl argument hardening (tools/robots_check.py). The curl argv had no -- terminator before the URL. gate() rebuilds the target as scheme://host/robots.txt before calling _fetch, so the gate path was never exposed; this is hardening for direct callers, with a test pinning the terminator, that a dash-leading argument fails closed end to end, and that gate() never passes a caller-supplied URL through to curl. --max-redirs 5 is set explicitly rather than left to curl's default.

  • Negative and fractional filter flags are rejected in the Danish portal CLIs (#281) - --jobage (jobindex), --radius (jobnet), --category/--jobtitle-id (jobdanmark), and --company (jobbank) now validate as positive integers, completing the page/limit/ per-page tightening from #191. Some portals silently ignore invalid filter values and return unfiltered results, so a mistyped ID produced wrong results instead of an error.

  • The upstream checker reports files missing from the upstream ref instead of a silent [OK] (#282) - if upstream renames or deletes a tracked framework file, a fork's check_upstream_updates.py now lists it under a [WARNING] summary instead of skipping it and printing a false all-clear.

  • 09-web-research.md is now tracked by the upstream checker - the file shipped in #277 but was never added to FRAMEWORK_FILES, so forks got no signal when it changed.

  • jobbank and jobdanmark CLIs identify honestly - jobbank's User-Agent was a full Chrome browser string and jobdanmark's detail command sent a bare Mozilla/5.0; both now use the Mozilla/5.0 (compatible; <portal>-cli/1.0) token the other portal CLIs use, matching the identification posture settled in #277. Verified live: both portals serve identical responses to the honest token.

  • A WebFetch 403 is no longer treated as a dead posting - WebFetch sends a bot user agent, and many bank and corporate sites answer it with HTTP 403 while serving the same page to a browser normally. Every command read that as "page unavailable" and degraded silently instead of failing loudly: /rank marked live postings expired, /apply fell back to search-result snippets or to vague cover-letter prose, and /scrape stored listing-page #fragment URLs that fetch fine but return unrelated jobs, breaking every later run on that entry. New 09-web-research.md (framework_version 1.0.0) is the single reference: the trust boundary, a curl browser-header retry with a tag-stripping extractor, a four-step escalation order, the login-wall case, why the employer's own careers posting beats an aggregator listing (the requisition ID and the grade survive there), and the rule that a search snippet is a lead rather than a source. Wired into /apply, /rank, /interview, /outcome, /notion-sync, the job-scraper skill, and writing-style rule 5 (03-writing-style.md 1.1.0 to 1.2.0).

    The retry is gated on robots.txt. WebFetch identifies itself as Claude-User and honors robots.txt, so a 403 means either a WAF default on a site whose published policy allows access, or a site that has actually declined. New tools/robots_check.py tells them apart and the escalation runs it before retrying: a disallow for * or Claude-User skips the retry entirely and goes straight to finding the employer's own posting. The rule is stated in the file so later edits do not erode it - the retry exists to get past bot-filtering firewalls on sites whose robots.txt permits access; it is never used to override a site that has said no. Two findings are pinned by tests/test_robots_check.py (15 offline cases): the WAF usually blocks robots.txt itself, so the policy is read as a browser when the honest request is refused and then obeyed strictly; and urllib.robotparser cannot be used, because it ends a record at a blank line and matches in file order, which reads a real-world policy as "everything allowed".

  • /apply now records the application in the tracker - the flagship command wrote a CV and a cover letter to disk and then wrote nothing to job_search_tracker.csv, so a drafted and submitted application was invisible to /gmail-sync, /html-report, /notion-sync, /interview, /upskill aggregate mode, and to /rank's dedup exclusion - and the safety net that would have caught it (/gmail-sync) refuses to create missing rows, so nothing detected the loss. A new Step 6b appends a drafted row carrying the two document paths, the fit rating and the posting URL, reusing /outcome's exact header so the two commands cannot diverge; re-running /apply updates that row rather than duplicating it, unless every matching row holds a final status, in which case a second application to the same role gets its own row. The same step is mirrored into job-application-assistant because /scrape Step 5 routes straight into the skill (framework_version 1.2.0 -> 1.3.0), and /scrape Step 6 now defers to it instead of adding a row of its own. seen_jobs.json is deliberately left alone. Forks: the bump means check_upstream_updates.py will flag the skill - reconcile the new Step 3b (and Step 6b in apply.md) into your personalized copies rather than skipping the flag.

    drafted is introduced into the tracker status vocabulary, and every reader that meant submitted now says so. These readers define "open" by exclusion from the final statuses, so a new non-final value would otherwise have joined all of them silently: /outcome's follow-up branch no longer drafts a chase email for an application that was never sent, /gmail-sync no longer reports unsent drafts as stale, /notion-sync leaves "Applied on" empty for them and says "not yet submitted" in the page body rather than calling drafts submitted documents, and /html-report gains a sixth Drafted bucket kept out of the funnel, the rejection rate and the headline count. /outcome Step 4 overwrites date with the submission date when a row leaves drafted, so the column keeps meaning "applied on".

    /gmail-sync deliberately keeps searching for drafted rows. /apply drafts but the user submits, and forgetting to run /outcome afterwards is the failure this issue is about. An employer reply arriving against a row still marked drafted is how that gets caught, so those rows stay in the search set, the application acknowledgement is promoted from noise to a drafted -> applied signal (it is the one email that proves a hand submission, and it arrives within a day of it), and an approved match corrects the date as well as the status. Only the staleness check skips them, since nothing was sent. (#269)

1.3.0 - 2026-08-03

Added

  • Language Gate - no dimension or gate anywhere in the framework checked a posting's language requirements against what the candidate actually speaks (not a Scoring Dimension, not a /scrape//rank field, nothing for /apply's existing generic language detection to report to). Adds that check, structured like the existing Eligibility Gate, on a new structured Languages table in CLAUDE.md / 01-candidate-profile.md (/setup asks, or infers it from a CV/LinkedIn export): a posting requiring a language you haven't declared at all is a hard FAIL; one requiring a higher level than you declared in a language you do work in is FLAG, not an auto-reject, so borderline cases (a strict "fluent" bar vs. your own B1/B2) get your judgment instead of a silent drop; a requirement at or below your declared level is a clean PASS. Wired through /scrape, /rank, and /apply, with language_gate/language_note persisted into seen_jobs.json alongside the existing location veto so a re-read of the file (or a future debugging session) can recover why a job did or didn't make the shortlist.

Fixed

  • CV date fields now use ASCII hyphens, so the PDF text layer extracts cleanly - the stock template wrote date ranges as [YYYY--YYYY], and on the repo's mandated lualatex toolchain the -- en-dash ligature extracts from the PDF as U+FFFD (�). The stock template therefore failed the ATS checklist's own "no � replacement characters" item on every date field, and did so silently: the rendered page looks correct, and no existing check inspected the extracted text. cv/main_example.tex now uses [YYYY-YYYY] and [YYYY-Present], and 05-cv-templates.md documents the failure mode and the check that catches it (framework_version 1.3.0 to 1.4.0). The two-page layout budget is unaffected.

    Fork reconciliation note. The five changed lines in cv/main_example.tex are the \cventry date fields - three under Professional Experience, two under Education - precisely the lines every fork personalizes. Rebasing forks should expect conflicts there, resolve them in favour of their own dates, and then apply the same -- to - change by hand. To find remaining instances across your own CV variants:

    grep -rn '\\cventry{[^}]*--' cv/
    

    Verify afterwards by extracting the text layer and checking the date lines specifically: pdftotext -layout <file>.pdf - | grep '�' - none of the hits may be a date field. (On the stock template two benign hits remain either way: the decorative separators on the contact and award lines, which are unrelated to dates and predate this fix.)

  • tools/convert_salary_excel.py now parses localized numeric string cells - Excel exports that store numbers as text (a Danish "108,5", "1.234,5", or space-separated thousands) previously hit float()'s ValueError and were silently dropped from salary_data.json. The ambiguous single-comma-plus-three-digits pattern ("1,234", thousands in one locale and a decimal in another) is deliberately skipped rather than guessed, preserving the old safe behaviour for the one case that cannot be disambiguated. (#272)

  • tools/check_upstream_updates.py compares the template-repo slug case-insensitively - GitHub serves repository paths case-insensitively, so a clone made from a lowercased URL was a legitimate direct clone that nonetheless triggered #265's fork-vs-self warning. (#273)

Changed

  • SETUP.md section 8 now shows the first-time git remote add upstream ... command before telling you to git fetch upstream, which previously failed on any clone of a personal fork with no explanation of the missing remote. (#274)

Security & privacy

  • The gitignore guard now covers every personal-output rule - security_guards.py additionally requires the ignore rules for Gmail sync state (gmail_sync/), generated dashboards (reports/), upskill reports (upskill/*.md), Notion sync state (**/job_scraper/notion_sync.json), pasted postings (documents/postings/**), scraper markdown output (**/job_scraper/*.md), and behavioral-report / LinkedIn-profile PDFs. With these, every .gitignore rule outside the guard's required list is build tooling noise, so any future weakening of the personal-data boundary fails CI. All rules were already present in .gitignore; the guard now enforces the full set. (#271)

1.2.0 - 2026-08-01

Added

  • /rank now persists strengths and gaps into seen_jobs.json - Step 2's scoring agents already produced both arrays per job; Step 4 previously kept only rank_score, rank_verdict, and rank_date, so the honest per-posting findings were printed once in Step 5 and then discarded. Both arrays are now stored verbatim and replaced (never accumulated) on --all re-ranks, so downstream consumers of seen_jobs.json can read real triage findings instead of re-deriving them. See discussion #258.
  • /upskill aggregate mode now ingests /rank's recorded gaps - previously it only read job_search_tracker.csv and guessed required skills from the role/sector/ notes columns, even though /rank had already fetched and scored postings that never made it into the tracker. Aggregate mode now also reads ranked entries (rank_score >= 45) from job_scraper/seen_jobs.json, dedupes them against tracker rows on case-insensitive company+role, and prefers a job's recorded gaps over an inferred skill list wherever both exist. The heatmap's Gap Source column now shows the recorded-vs-inferred split per skill, and the report header states how many jobs came from each source. Depends on #263 (/rank persisting gaps/strengths); see discussion #258.

Security & privacy

  • SETUP.md no longer calls a fork "private working space" - forks of public GitHub repositories are always public, so that wording invited exactly the personal-data exposure it seemed to rule out. Section 8 now states the fork-is-public fact plainly and documents the safe alternative (a private repository with this repo as upstream), and /setup ends with a matching privacy note the moment profile data first lands in tracked files. Prompted by discussion #266.
  • The gitignore guard now covers two more personal-data rules - security_guards.py requires cover_letters/Cover_*.* (the uppercase cover-letter naming variant /apply recognizes) and cv/*.txt (ATS text extractions of tailored CVs) in .gitignore, so a future change weakening either rule fails CI instead of silently making personal files trackable. Both rules were already present in .gitignore; only the guard lagged.

Fixed

  • tools/check_upstream_updates.py no longer reports a false "up to date with upstream" when it silently falls back to a fork's own origin remote - the default state of a plain fork clone, where the script compared the fork against itself and could never detect upstream updates. It now warns that the fallback remote is not the template repo, shows the git remote add upstream command to fix it, and names the ref it actually compared against. (#265)
  • Removed the vestigial cover_letters/OpenFonts/cover.cls - an unreferenced remnant of the original font bundle that, since #252's class rename, ambiguously declared the same cover class as the real cover_letters/cover.cls.
  • Added regression tests pinning #252's ragged-row bounds fix in tools/convert_salary_excel.py (dimension-less workbooks read in read_only mode yield rows shorter than the header).

Changed

  • CONTRIBUTING's "run what CI runs" list is now complete - it previously omitted tools/security_guards.py and the exact unittest invocation, the precise checks a contributor PR had already failed on. Prompted by issue #262.

1.1.0 - 2026-07-30

Security & privacy

  • Personalized custom-template files are now gitignored regardless of engine - the ignore rules broadened from cv/main_*.tex to cv/main_*.* (and likewise for cover letters), so a fork using a Typst or other non-LaTeX template no longer commits personalized main_<company>.typ files to a public fork. The *_example.tex files stay tracked. If you registered a custom template before this release, check git status once after updating. (#238)
  • Dependency review is live, for forks too - the repo's Dependency graph is now enabled, so the CI dependency-review job actually blocks PRs that introduce dependencies with known high-severity vulnerabilities, and the job is no longer gated to the upstream repo: forks get the same check, self-activating if the fork enables Dependency graph (it warns-and-passes otherwise). (#254)

Added

  • freehire-search: full descriptions come back with the search - search now calls freehire's agent search endpoint (/api/v1/agent/jobs/search), which serves each hit's complete description instead of the search index's truncated preview. A 20-role search is one request rather than 1 + 20 detail calls, and /scrape's Step 2 no longer needs a per-hit fetch for this portal. --description-format markdown|text|html (default markdown) selects the rendering; table and plain output is unchanged. (#251)
  • Custom templates: any compile-to-PDF toolchain (Typst, ...) - /add-template no longer hardcodes a lualatex/xelatex/pdflatex engine enum. Custom templates now declare a source extension and a full compile command, so Typst (typst compile) registers the same way a custom LaTeX template does. Stock CV/cover letter templates stay LaTeX, unchanged. (#238)
  • Application-form fields as an optional third /apply artifact - when a posting's application form asks screening questions, /apply can now offer a prep sheet of grounded answers alongside the CV and cover letter. Opt-in; the default two-document output never changes. (#212)
  • Confirmed facts write back to the profile - when /apply or /interview surfaces a fact the user confirms (a skill, a date, a project detail), it is written back to the profile files in the same turn instead of being lost with the conversation. (#211)
  • CV methodology: in-progress qualifications and tenure-vs-output - 05-cv-templates.md gains explicit rules for stating in-progress certifications/degrees honestly and for checking claimed tenure against visible output (framework_version 1.2.1 -> 1.3.0). (#210)
  • Scraper flags mass-posting and recycled-listing patterns - /scrape marks postings that look bulk-posted or recycled so they don't eat evaluation effort. (#207)
  • Retry contract pinned in CI - all six portal CLIs now carry 429/5xx retry-backoff tests covering every fetch wrapper, so a silent regression in retry behavior trips CI. (#246)
  • README: the extension model, documented - new Customization subsection "Extending the framework: portals, templates, criteria - and borrowing from other forks": the three extension points, the copy-one-folder pattern for borrowing a portal skill from another fork with a read-the-code-first checklist, and why there is deliberately no installer (the manual copy is the security model). Prompted by discussion #249.

Fixed

  • /rank shortlist and below-threshold tables include each posting's URL. (#236)
  • convert_salary_excel.py: count/index columns pair by category name instead of adjacency (#219), standalone count columns store as counts (#230), and ragged rows from dimension-less spreadsheets no longer crash with an IndexError (#252).
  • cover.cls: duplicate package imports removed and the \ProvidesClass name fixed to match the filename, silencing a class-name-mismatch warning. (#252)
  • Portal CLI type-checking pinned to concrete @types/bun / @bunli/* versions to stop environmental CI type-drift. (#226)
  • freehire-search points at freehire.me after the service's domain migration. (#229)
  • verify_pdf.py's missing-poppler error now includes per-OS install hints. (#252)

1.0.0 - 2026-07-22

First tagged release. This marks the framework as stable and gives forks a described checkpoint to update against instead of a moving master. It is a baseline of what already exists rather than a set of new changes; subsequent releases will document what changed since the previous tag.

At this baseline the framework provides:

  • Application workflow - a drafter/reviewer /apply pipeline (CV + cover letter), plus /setup, /scrape, /rank, /interview, /outcome, /upskill, /expand, /html-report, /gmail-sync, /notion-sync, /add-portal, /add-template, and /reset.
  • Portal search skills - country-agnostic job-board CLIs (LinkedIn, freehire, and the Danish boards) in the portable Agent Skills format under .agents/skills/, discovered and orchestrated by /scrape, with an enabled: toggle for skipping portals.
  • Framework versioning - framework_version markers on methodology files plus tools/check_framework_version.py (CI guard) and tools/check_upstream_updates.py (fork-side update preview).
  • Privacy and safety guards - .gitignore protection for personal data, the tools/security_guards.py allowlist for .gitignore negations, and a CI policy of making no live portal requests.
  • Cross-runtime support - a root AGENTS.md pointer so Codex and Antigravity can discover the portable portal skills, with Claude Code as the reference runtime.