Rolls [Unreleased] into [1.7.1] - 2026-09-06 and moves the compare links. Keeps an empty [Unreleased] heading above the release, per Keep a Changelog, and teaches the CHANGELOG structure guard that an absent [Unreleased] section is nothing to check rather than an error. Claude-Session: https://claude.ai/code/session_013fqqLgQSnwgWkv98twQhHi Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
89 KiB
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
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### Fixedheading 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. -
/ranknow consumes theposted_date#391 persists (#390, the deferred second half) - Step 3 gains a staleness flag: a posting whose storedposted_dateis 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 storeddeadlineoutranks 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: noposted_dateornullmeans no flag and no guess (never inferred fromfirst_seen), and unparseable values are treated as absent and reported once with their portal. Pinned by four new cases intest_rank_command.py, each verified to fail against the rule-less spec.
Security
settings.jsonno longer pre-approvesbun runon arbitrary files (#396) - the template's permission allowlist grantedBash(bun run:*), which auto-approvedbun 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./scrapeis unaffected for all portals, including ones added by/add-portal- the job-scraper skill's ownallowed-toolscarries 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
-
/setupnow fills the contact blocks inside05-cv-templates.mdand06-cover-letter-templates.md, and/resetrestores them - Step 3 personalisedcv/main_example.texbut never the LaTeX contact blocks embedded in the two template files/applyactually 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/setupand then hand-edited both files, #420).06-cover-letter-templates.mdwas not a Step 3 target at all. Step 3.5 now names the05contact tokens, a new Step 3.6 covers the06contact line and signature (Path A never fills it, so it runs for every path), the completion summary lists06, and/resetclears both blocks instead of listing06as framework-only. Pinned bytests/test_setup_command.py; the existing/resetcoverage test is what forced thereset.mdhalf. -
/rankno longer reads or rewrites the whole ofseen_jobs.jsonon 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.pynow owns that traffic -candidatesselects and projects only the fields a scoring agent needs,sweepruns rule 6's expiry pass on disk, andapplywrites results back atomically and prints the rows Step 5's report is built from. Preserves Step 4's existing write-back rules exactly: thelocation→location_verdictlegacy 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, andjobnet-searchdetail commands now accept full URLs - the portal contract specifiesdetail <id|url>. Passing a full posting URL (with or without trailing slashes, slug segments, or query parameters) previously causedjobbank-searchandjobdanmark-searchto construct invalid double-URL strings, andjobnet-searchto 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 codeBAD_IDon unparseable inputs, matchinglinkedin-searchandfreehire-search. Pinned by 24 unit tests across the three CLIs'detail-url-normalization.test.ts. -
/ranknow 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/rankcan 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-stringquery - the same shape as review finding F13 (jobdanmark, 13,862 results) that motivated the guard in the first place, reached by the likelier route:-qis the documented short for the keyword search inlinkedin-search,freehire-searchandjobindex-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/-vstill 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 -5silently fell back to the default radius instead of failing its ownmin(1)schema - erroring on it is the tradelinkedin-searchalready makes, and a value that must begin with a dash uses the--flag=valueform.linkedin-searchandfreehire-searchwere unaffected; they normalize-xto 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 --hin each CLI, andjobindex's declared-q. -
jobdanmark-searchautocomplete 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 derefeditem.text.toLowerCase()from a cast API response on the same line that already guardsg.items ?? [], so one item with a null or missingtextthrewTypeErrorand the whole command exited 1 asAPI_ERROR. The filter now lives in an exportedfilterAutocompleteGroups(the jobnet testability pattern),textis 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 newautocomplete-filtering.test.ts; the null-text case fails against the verbatim unguarded extraction with the exact production TypeError. -
jobnet-searchno 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:apiFetchcasts the JSON body, so onenullthrewTypeErrorinside thejobAdsmap and the whole search of a default-ON portal exited 1 asAPI_ERROR- while the neighboringapplicationDeadlinefield was already null-guarded with a1900-01-01sentinel check. The field is now typed nullable (so the compiler enforces the guard) and degrades per-item todate: null, the shape theseen_jobs.jsoncontract documents. Pinned by a new case insearch-normalization.test.ts, verified to fail on the unfixed code with the exact production TypeError. -
Placeholder-integrity tests in
python-testsnow skip on forks (#405) - the dedicatedplaceholder-integrityjob already gates on the upstream repo name, butpython-testsranunittest discoverwith no such guard, so forks that personalized files via/setupfailed three sentinel checks permanently. Both test classes now use@unittest.skipIfonGITHUB_REPOSITORY(defaulting to upstream when unset so local pristine-template runs still execute). -
convert_salary_excel.pyno 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," withcategories: {}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 intests/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-searchno longer dies over one malformed feed date (#416) -new Date()on a present-but-unparseablepubDateyields an Invalid Date whosetoISOString()throwsRangeError, andnormalizeSearchItemruns inside an unguardeditems.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 inparseRssItemscan deliver exactly such a value. An unparseablepubDatenow degrades to the same shape as an absent one (postedempty,date: null, per theseen_jobs.jsoncontract that #391 put this field on), and every other item survives. Pinned by three new cases insearch-normalization.test.ts, each verified to fail on the unfixed code. -
linkedin-searchrejects fractional numeric flags instead of silently changing the query (#371) - bareparseInttruncated values before validation, so--jobage 0.5became0and silently omitted LinkedIn'sf_TPRfreshness filter while the CLI reported no argument error.--jobage,--jobage-minutes,--page, and--limitnow accept whole numbers >= 1 only and reject fractions and zero with the stderr-JSONBAD_ARGcontract, matching the other portal CLIs. Pinned by eight cases verified to fail on the unfixed CLI. Reported by @Meet6338-X. -
linkedin-search detailaccepts 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 withBAD_IDbefore 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 inparsing.test.ts. -
The
documents/interview/**ignore rule no longer claims interview prep is written there (#336)./interviewsaves its pack todocuments/applications/<company>_<role>/interview_prep_<stage>.md, already ignored bydocuments/applications/**; nothing has ever written todocuments/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 abovedocuments/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_RULESpins it, so removing it from.gitignorealone fails the guard). Pinned bytests/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. -
/scrapenow persists each posting's publication date (#390) - Step 2's contract guarantees adateon every portal CLI's search output (CI enforces it intest_scrape_contract.py) and Step 1b uses that date to scope a run to the last 14 days, but Step 4'sseen_jobs.jsonschema stored no posting date at all:first_seenis 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. Afreehire-searchposting 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/applyrun drafted a tailored CV and cover letter against it. The schema gainsposted_date(nullwhen the portal returned no date, never inferred or backfilled). Pinned by three new cases intest_scrape_contract.py, each verified to fail on the unfixed spec. Reported and diagnosed from a real run by @sandunwijerathne. -
salary_lookup.pyno longer crashes on anullmetadataorcategories---validatetreats an explicit"metadata": null/"categories": nullthe same as an omitted key (the shape checks are "...must be an object when provided" and skipNone), but the renderer read both throughdict.get(key, {}), which only substitutes the default for an absent key - a present-but-null value passed straight through.format_entrythen hitNone.get("index_label", ...)(AttributeError) or, via the numeric-field fallback,None[key] = value(TypeError), so a hand-maintainedsalary_data.jsonusingnullfor "no value here" died with an uncaught traceback right after printingFound 1 match(es).format_entrynow coerces both to{}up front, sonull, absent, and{}behave identically. Pinned by four cases intest_salary_lookup.py- two unit calls intoformat_entryand two end-to-end (main() --validateblesses 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 createat 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 addsgh repo set-default <your-username>/ai-job-searchdirectly 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-searchfractional numeric flags no longer silently change the query (#373) -parseIntFlagused bareparseInt, so a fractional value was truncated instead of rejected:--jobage 0.5became0, failed thejobage > 0guard, and theposted_within_daysfreshness filter was silently omitted from the outbound request while the CLI exited 0 - on a default-ON/scrapeportal, exactly the discarded-filter failure the CLI's ownUNKNOWN_FLAGguard 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-JSONBAD_ARGerror. The sibling of #371 (linkedin-search), which remains with its reporter. Pinned by five new cases incli-flag-validation.test.ts, each verified to fail on the unfixed code.
Added
linkedin-search detailreports closed postings (#280, adopted with the original author's commit preserved) - a newisActivefield:falsewhen 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__flavorand the banner text, verified against live guest pages); three speculative phrases from the first version were dropped as false-positive-only risk./scrapeStep 2 now consumes the signal: a closed-at-source job is recorded inseen_jobs.jsonas"status": "expired"- marked, never silently dropped, per the/rankpattern - 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: trueis documented as absence of the banner, not proof the posting is open.- pypdf ATS text-layer fallback -
/applyStep 5d andtools/verify_pdf.pyextract the CV PDF text layer with pypdf first (BSD,pip install pypdf) so Windows machines without Poppler still get a mechanical parseability check. Popplerpdftotext -layout -enc UTF-8remains the fallback; if both are missing the check still degrades to a visual keyword review. No extra cache or installer.05-cv-templates.mdframework_version1.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
/applyand/interview-/applyStep 3's reviewer agent and/interviewStep 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 newcompany_research/<normalized-name>.jsoncache (30-day TTL, documented in04-job-evaluation.mdalongside 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 under03-writing-style.mdrule 5 - only the discovery step is cached, never the final verification before a claim ships in a cover letter or prep pack.company_research/*.jsonadded to.gitignoreandsecurity_guards.py'sREQUIRED_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 newtests/test_company_research_cache.py. Cache contents are documented as data, never instructions, for a later session reading the file - the same trust-boundary ruleapply.mdStep 0 states for the posting itself, since cache notes are written from the same fetched web content. The verification-still-applies restatement in bothapply.mdandinterview.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-smokejob ran onlytexlive/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 tocv/main_example.texcould reintroduce a\firstnamestyleoverride or a top-level\usepackage{hyperref}and CI would stay green. The job is now a two-leg matrix,texlive-latestunchanged anddebian-bookworminstalling 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-recommendskeeps the leg lean, which makes two font packages explicit requirements:texlive-fonts-extra(moderncv loads fontawesome5) andtexlive-fonts-recommended(hyperref's xetex driver probes thepzdrmetrics). 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 profileleft candidate data in two of the skill files it claims to clear (#364) -/setupStep 3 populates six skill files; the profile scope cleared four.04-job-evaluation.mdwas 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 inreset.mdat 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/rankkept scoring against the old skills and career goals and/scrapekept running the old city and queries. Both files are now previewed and cleared, restoring their/setupplaceholders while preserving the scoring framework and the query structure;04-job-evaluation.mdis out of the preserved list, which keeps03-writing-style.mdand06-cover-letter-templates.md(correctly - the latter's[YOUR_NAME]tokens are LaTeX scaffolding Step 3 never writes to).CLAUDE.mdandcv/main_example.texstay outside theprofilescope, which covers skill files only, and the preview and Step 4 now say so instead of implying a full wipe.tests/test_reset_command.pygains a profile-scope guard alongside its documents-scope one, deriving the file list from/setupStep 3's own headings so a future/setuptarget that/resetforgets 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.pynever stripped the dotted "A.M.B.A." legal suffix (#356) - theSTRIP_PATTERNSregex 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
/scrapecontract pin (#344) - a repo-level test deriving the Step 2 search-output field list (title,company,location,date,url) fromjob-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/scraperun 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-searchgains--no-descriptionfor cheap discovery passes - a default search hydrates full description bodies (~73% of the payload, ~20k tokens per query) while/scrapeis 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 ofinclude_description=false, so the lean guarantee is enforced client-side. Pinned intests/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 thedateextraction (a/scrapecontract field on a default-ON portal) left every test green; jobindex's Stash parser had no tests at all, someta.totalcould stop usinghitcountunnoticed. Four new linkedin cases (both listdate class variants, location, absent-element nulls) and a new jobindexsearch-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 aframework_versionbump had zero tests, so the one-line mutationreturn meaningful_changes > 0->return Falsedisabled it while the suite stayed green. Four cases in the newtests/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 - onlycheck_settings()had coverage; the linter's main job (frontmatter keys,allowed-toolstargets existing, the# /<name>command title rule) was unasserted, so deleting the missing-allowed-tools error survived the whole suite. Four new cases intests/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 that09-web-research.mdclaims is covered had no test at all. Three new tests intests/test_robots_check.pypin 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_version1.4.1 -> 1.4.2 in05-cv-templates.md, 1.0.1 -> 1.0.2 in06-cover-letter-templates.md) -05gains a "LaTeX Special Characters" section and06'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 bytests/test_latex_guidance.py. -
seen_jobs.jsonentries record which mechanism produced them - a new additivesourcefield (clifor Step 1b portal-CLI output,websearchfor the Step 1c fallback), a Step 1c rule tagging fallback results at collection time, and afallback (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 missingsourcepredates the field and is never backfilled; a presented job with noseen_jobs.jsonentry at all points at fabrication, which the scraper's Rule 1 forbids. Pinned bytests/test_scrape_provenance.py.job-scraper/SKILL.mdsits outside theframework_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.mdalready 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 sixcli-flag-validationsuites. -
/rankpersists its location verdict aslocation_verdict- the barelocationkey meant two incompatible things inseen_jobs.json: a place (scraper search output, driving the commute filter) and a PASS/FAIL/FLAG verdict (/rankStep 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 inlocationcounts as the verdict whenlocation_verdictis absent) and migrated on re-write. Theseen_jobsschema note injob-scraper/SKILL.mdnow also enumerateslocation_verdict/language_gate/language_note, so its "do not drop any of these fields" instruction finally covers the fields/rankcalls as important as the score. Pinned by two new tests intests/test_rank_command.py. -
linkedin-search detaildrops theapplyUrlfield - the extraction regex assumedclass=beforehref=and never matched LinkedIn's real markup (nullon 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 ownurl. The field and the SKILL.md "apply link" claim are removed; a test pins the removal. -
jobdanmark-searchsearch output drops presentation-only keys -coverImage,companyLogo,companyLogoSvgMarkup,overlayColor, andsilhouetteLogowere ~40% of a live payload (a 30-result response shrinks from ~30k to ~20k chars), fed into agent context on every/scrapequery, and unusable by an agent. The #340 compatibility duplicates (companyName,publishedDate,applicationDeadline) andslugstay. Pinned intests/search-normalization.test.ts. -
BREAKING (jobbank forks):
jobbank-searchsearch output emitsdeadlineasYYYY-MM-DD- the feed'sDD.MM.YYYYparenthetical was passed through raw, contradicting the/scrapecontract, the other portals, and the same CLI's owndetailcommand (which already emits ISO for the same job).01.09.2026is also ambiguous to a date parser (1 Sep vs 9 Jan). The known shape is now converted; "løbende" still maps tonull, and an unrecognized shape passes through for/rank's defensive handling. Anything parsing the oldDD.MM.YYYYoutput must update - though the README's own search example already showed the ISO form. Pinned intests/rss-parsing.test.tsandtests/search-normalization.test.ts. -
Job matching reframed around function, not title (
framework_version1.2.2 -> 1.2.3 in04-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, and04-job-evaluation.mdnow 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/setupwrite 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/setupchecks 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 newtests/test_onboarding_privacy.py. -
jobindex-search detailrewritten 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 ownidandurl, 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-nativejd-*layout and the external-ATS passthrough), always keeps the jobindex id andjobannonceURL, 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 reportscompany: nullhonestly 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 newtests/detail-parsing.test.ts. -
/scrapegains 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 adatefield, so the instruction now says to filter client-side after the call, and stops presenting--order(a sort) as interchangeable with a filter. Pinned intests/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 theoutcome.mdstage checkboxes Step 1.2 already merges. And the rejection rate no longer countsoffer_declined(a success) orwithdrawn(candidate-initiated) as rejections, nor unresolved Interview/Offer rows in its denominator. Pinned by two new tests intests/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 returneddatePostedas the page's rawDD-MM-YYYYtext,validThroughas free text (including the literal"Løbende", which would flow into stored data as a deadline), and a hardcodednulladdressLocality. The fallback now converts overview dates toYYYY-MM-DD, mapsLøbendetonull(jobbank's precedent for the equivalent), and derives the locality from the workplace address with the same postcode extraction search uses. Pinned intests/detail-parsing.test.ts. -
jobnet-search detailno longer leaks the1900-01-01undisclosed-deadline sentinel -searchmaps the API's sentinel tonull(with a test pinning it), butdetaildumped 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 aprepareDetailnormalization that maps the sentinel tonull. Pinned intests/detail-formatting.test.ts. -
CI's placeholder guard now watches the CV's actual personal-data lines - the sentinel for
cv/main_example.texwas[YOUR_NAME], whose only occurrences are a header comment and the hyperrefpdftitle;/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, and01-candidate-profile.md's sentinel moves from the<!-- SETUPheader comment onto the[YOUR_EMAIL]Identity field for the same reason. The newtests/test_placeholder_integrity.pysimulates the/setupedit and requires the guard to fire on it. -
jobindex-searchmaps ASAP postings' deadline tonull- the portal'sapply_deadline_asapflag 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/scrapeschema, 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 asnull. Pinned intests/search-page.test.ts. -
/gmail-syncno longer restricts its search to the Inbox - the query usedin:inboxto "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 newtests/test_gmail_sync_command.py. -
/upskillno longer divides by a blankfit_rating-/outcomecreates tracker rows for applications made outside the workflow with no fit evaluation, so theirfit_ratingis blank, and Step 3.3's(100 - fit_rating) / 100had 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-numericfit_ratingnow falls back to a matched ranked entry'srank_score, else the row is skipped, counted, and reported once - mirroring the skill's own missing-gapshandling. Pinned bytests/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 intoseen_jobs.json("ASAP",DD.MM.YYYY, free text), and/rankhad no rule for them while the display-only/outcomealready did. A stored deadline that is notYYYY-MM-DDis now treated exactly like an absent one wherever a stored deadline is compared (urgency and sweep), and reported once with its portal. Pinned bytests/test_rank_command.py. -
Language Gate preamble no longer claims the gate is untracked (
framework_version1.2.3 -> 1.2.4 in04-job-evaluation.md) - the paragraph still said the result "is not a field/scrapeor/ranktrack", written before the gate was wired into both consumers. An agent reading the authoritative framework file learned the opposite of whatrank.mditself insists on ("These veto fields are as important to persist as the score itself"). The preamble now nameslanguage_gate/language_noteand how each consumer uses them; a coupling test intests/test_rank_command.pykeeps the framework text honest about the tracking. -
/reset documentsnow clearsdocuments/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 "Thedocuments/folder is now empty" - false whenever postings were present, and they are exactly the personal residue a reset exists to clear. A newtests/test_reset_command.pyderives the folder list from the git tree, so any future drop folder fails the test until/resetcovers it. -
convert_salary_excel.pyno longer corrupts US/UK-formatted numbers 1000x - the both-separators branch always assumed European locale, so a"1,234.56"cell was silently converted to1.23456and written intosalary_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. Andstrip_type_patternsnow stripsCOMPOUND_PATTERNSwords as substrings, mirroringheader_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 intests/test_convert_salary_excel.py. -
jobdanmark-searchextracts the city when a comma follows the postcode - thelocationregex required whitespace after the 4-digit postcode, but livecompanyAddressvalues frequently read"2670, Greve"; those results emittedlocation: 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 intests/search-normalization.test.ts. -
Example-CV bullets no longer swallowed as LaTeX optional labels - every placeholder bullet written as
\item [text](11 incv/main_example.tex, 3 in06-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 requireAchievementto survivepdftotext. Pinned bytests/test_latex_guidance.py. -
Documented ATS extraction commands pin
-enc UTF-8-pdftotext -layoutwithout 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 inapply.md,05-cv-templates.md, andCLAUDE.md's verification checklist now carry-enc UTF-8, which is deterministic on both poppler and Xpdf. Pinned bytests/test_latex_guidance.py. -
jobbank-searchsearch output now carries the/scrapecontract'sdatefield (#342) - the CLI emittedposted(full ISO 8601) but not the cross-portaldatekey, the one Step 2 contract field it was missing. Search results now additively emitdateasYYYY-MM-DDderived fromposted(kept unchanged),nullwhen the feed item carries nopubDate. The result mapping is extracted into an exportednormalizeSearchItemso the derivation is pinned by tests. Completes the portal-contract series with #339 (jobnet) and #340 (jobdanmark). -
jobdanmark-searchsearch output now carries the/scrapecontract fields - the CLI exposed the API-native schema (companyName,publishedDateinDD-MM-YYYY, …) with nocompany,location,dateordeadline, so every/scraperun flagged jobdanmark as degraded and theseen_jobs.jsondedupe lost the company. Search results now additively emitcompany,location(city after the postal code incompanyAddress), anddate/deadlinein theYYYY-MM-DDconvention, with null-safe handling of a missing address. -
jobnet-searchsearch output now carries the/scrapecontract fields - the CLI emitted the raw Jobnet API schema (jobAdId,hiringOrgName,publicationDate, …) with nocompany,location,dateorurl, so every/scraperun flagged jobnet as degraded forever (CI stayed green), theseen_jobs.jsondedupe fell back to company+title, and/ranklost the posting link. Search results now additively emitcompany,location,date,deadlineandurl(https://jobnet.dk/find-job/{jobAdId}- the/job/route is login-walled); the API's1900-01-01"deadline not disclosed" sentinel maps tonull. -
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/Sderiveddocuments/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 indocuments/README.mdnow 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'sframework_versionmoves 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-reportdashboard now reads and renders the tracker'sdeadline(follow-up to #319). The tracker gained a fourteenthdeadlinecolumn 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 adraftedapplication'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 aDeadlinecolumn, subject to the existing empty-column rule. Pinned bytests/test_html_report_command.pyso 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).
/scrapefetched the deadline and rendered it in a table,/rankturned 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 adraftedapplication (whose only applicable clock is its deadline) had no time-based signal at all.seen_jobs.jsonentries now carry adeadline(base field, written on first sight, refreshed by/rankStep 4,nullvs missing distinguished and never guessed);/rankStep 3 re-derives urgency from the stored value with no re-fetch and sweeps already-ranked entries past their deadline intoexpired; the tracker gains a fourteenthdeadlinecolumn appended last, with a header-line-only migration for existing trackers;/applyStep 0 extracts the deadline and Step 6b writes it (including the/scrapepath via the assistant SKILL.md);/outcomesurfaces it on open rows and flags near/passed deadlines ondraftedrows without changing the no-follow-up rule; and the row-rewriting paths (/outcomeStep 4,/gmail-syncStep 7a) now preserve every unparsed field so the new column survives the first status update./notion-syncnames the tracker as the Deadline source (tracker wins),/upskill's column list stays true, andjob-application-assistant/SKILL.mdbumpsframework_version1.3.2 → 1.3.3. Pinned bytests/test_rank_command.py,tests/test_apply_records_application.py, andtests/test_upskill_skill.py.The sweep's edges are stated rather than left to the reader: an entry with no stored
deadlineis left alone and never inferred from another field (the majority case, since most entries predate the column),--allre-scores any status includingexpiredso a swept job is recoverable, and/rankStep 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./outcomeStep 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-syncforbids reconciling two disagreeing deadlines by taking the earlier or later of them. -
convert_salary_excel.pyno longer misreads whole-thousands cells from a Danish-locale export - a cell like60.000(thousands separator, no decimal comma) was handed tofloat()and silently written as60.0, a 1000x-wrong salary insalary_data.jsonthat then rendered with a meaninglessvs baselinepercentage 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 bytests/test_convert_salary_excel.py. -
main_example.texcompiles 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/\lastnamestyleboth 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\hypersetupcomment 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 setspdfpagemode=UseNone- aFullScreenvalue there would win over the class's own\AtEndPreambledefault 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.pywalks 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 bygit 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.txtstop resurfacing. It's the commit-history companion tocheck_upstream_updates.py's version stamps - the two cross-reference each other in their output. Report-only by design: it prints ready-to-rungit cherry-picklines but never merges, pushes, or opens a PR, because on a fork "applies cleanly" isn't "correct". A.github/workflows/upstream-watch.ymlruns it weekly into a rolling issue, guarded to no-op on the upstream template (pinned by a test) and scoped to the built-inGITHUB_TOKENso 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.pynow holds.claude/settings.jsonhooks to an allowlist - the guard readpermissions.allowand nothing else, so ahooksblock 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 aSessionStarthook in.claude/settings.jsonthat 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_HOOKSships 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 newHookGuardTestscases; 14 of the suite's 26 tests fail against the unpatched guard.
Changed
-
/add-portalnow 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 therobots.txt/terms decision in Step 2.4 - a paid fetching service never launders a refusal, and the credential path exists only for portals whoserobots.txtpermits access but whose bot protection blocks ordinary fetches. The portal-skill contract requires the token to come from a<SERVICE>_API_TOKENenvironment variable (never a CLI flag, never a fixture) and to fail withMISSING_CREDENTIALSwhen unset; and such a skill'sSKILL.mdmust 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 toMozilla/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 in09-web-research.md, never the CLI's default. -
CI discovers portal CLIs instead of hardcoding them (#310). The
cli-checksmatrix is now emitted by adiscover-clisjob that finds every.agents/skills/*/cli/package.json, so a portal skill added with/add-portalgets itstypecheckandtestscripts run by CI automatically - on this repo and on any fork - without editing the workflow. Upstream coverage is unchanged (the discovered list onmasteris exactly the six shipped portals)./add-portal's Register step now says so. Thanks @ayobamiseun.
Fixed
-
/upskillreports are now gitignored at the path the skill actually writes them to. The ignore ruleupskill/*.mdis rooted (a middle slash anchors a gitignore pattern to the repo root), but/upskillis 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-*.mdwas therefore not ignored (git check-ignoreconfirms 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.gitignoreandsecurity_guards.py'sREQUIRED_IGNORE_RULES, with acheck-ignore-based test pinning both properties - reports ignored at both depths,SKILL.mdstill tracked - which presence checks alone cannot see. -
Dropped the phantom
evaluatedvalue fromseen_jobs.json's status vocabulary (#315). The schema block in the job-scraper skill documentednew/skipped/evaluated/ranked/expired, butevaluatedhas had no writer and no reader since the initial release -new/skippedcome from/scrape,ranked/expiredfrom/rank, and nothing ever set or selectedevaluated. 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./rankStep 1's--allwording ("all non-applied entries") leaned on anappliedstatus 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. -
/applyarchives the job posting while it still holds it (#306)./applydrafted two documents and a tracker row from the full posting, then let the text die with the session;/outcomeStep 3.2 tried to recover it by re-fetching asourceURL the spec itself expects to be dead, and a posting pasted from an email or a PDF had nosourceto re-fetch at all. Step 6b item 7 now writes the posting verbatim todocuments/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/scrapepath (job-application-assistantSKILL.md Step 1) retain the full posting text, not a summary. Pinned bytests/test_apply_records_application.py. -
Tracker status enum defined once;
offer declined/no responsenow reach the correct/html-reportbucket and/gmail-synccorrectly marks them final (#298). The tracker CSVstatuscolumn had no single authoritative definition. Six command files restated it independently with inconsistent spellings, producing two concrete bugs:/outcomeStep 4 wroteno responseandoffer declined(with spaces)./html-reportStep 1 normalised onlyno_response/offer_declined(underscores), so any row written with spaces matched no bucket and was silently dropped from the rejection-rate denominator./gmail-syncStep 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-reportincludedinterview_onlyin the tracker bucket map; that value belongs to the archiveoutcome.mdStatus:field, not the CSVstatuscolumn.
Fix: a
## Tracker status vocabularyblock 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,draftedincluded, 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 (/applyStep 6b,/interviewStep 0,/gmail-syncStep 2,/html-reportStep 1,/notion-syncSteps 3-4)./outcomeStep 4 writesno_response/offer_declined;/notion-syncnormalises both forms to the canonical spellings before setting the Status property;/html-report's bucket map losesinterview_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 bytests/test_tracker_status_vocab.py.Fork heads-up: if your personalized
/outcomeaddsno responseoroffer 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-minuteson linkedin-search for sub-day freshness windows (#302) - LinkedIn filters itsf_TPRparameter 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--jobageare 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
/rankcontract (#278) - four regression guards intests/test_rank_command.pypinning thelanguage_gate/language_notefields through Steps 2-5 of/rank, including the Step 4 persistence rule that was live-debugged during #275 (vetoes reported in console output butlanguage_gate: nullon every persisted entry). Mirrors the existinggaps/strengthspinning pattern. No behavior change. -
The jobnet and jobdanmark CLIs identify themselves on every API request (#283) - their
apiFetch/apiPostwrappers now send an explicitUser-Agent(jobnet-cli/1.0,jobdanmark-cli/1.0) instead of Bun's anonymous default token, matching the honest self-identification jobindex already uses onhtmlFetch. The newuser-agent.test.tssuites 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, andjobnet-searchdefault toenabled: false, and/setup's job-portals question now acts on the answer: it flips them toenabled: truewhen your market is Denmark, and leaves them off otherwise. Previously a non-Danish user's/scraperan all four Danish boards by default, spending tokens fetching and filtering irrelevant listings. Fork heads-up: if you search the Danish market, setenabled: truein those fourSKILL.mdfiles 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-Agentwas a full Chrome browser string, the last portal CLI still spoofing after #283 and the jobbank/jobdanmark fix. It now sendsMozilla/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
.envwas committable (.gitignore,tools/security_guards.py)./add-portalcan 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.envholding 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..envand.env.*are now ignored and pinned inREQUIRED_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 intests/test_robots_check.pyas FAIL-OPEN REGRESSIONs:- A soft
200granted permission. A host answering/robots.txtwith 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. Disallowpatterns were never percent-decoded while the request path was, soDisallow: /foo%20barnever matched/foo barand the rule was silently skipped - a fail-open on any site that encodes its own rules.
- A soft
-
curlargument hardening (tools/robots_check.py). The curl argv had no--terminator before the URL.gate()rebuilds the target asscheme://host/robots.txtbefore 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 thatgate()never passes a caller-supplied URL through to curl.--max-redirs 5is 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 thepage/limit/per-pagetightening 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'scheck_upstream_updates.pynow lists it under a[WARNING]summary instead of skipping it and printing a false all-clear. -
09-web-research.mdis now tracked by the upstream checker - the file shipped in #277 but was never added toFRAMEWORK_FILES, so forks got no signal when it changed. -
jobbank and jobdanmark CLIs identify honestly - jobbank's
User-Agentwas a full Chrome browser string and jobdanmark's detail command sent a bareMozilla/5.0; both now use theMozilla/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
WebFetch403 is no longer treated as a dead posting -WebFetchsends 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:/rankmarked live postingsexpired,/applyfell back to search-result snippets or to vague cover-letter prose, and/scrapestored listing-page#fragmentURLs that fetch fine but return unrelated jobs, breaking every later run on that entry. New09-web-research.md(framework_version1.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.md1.1.0 to 1.2.0).The retry is gated on
robots.txt.WebFetchidentifies itself asClaude-Userand honorsrobots.txt, so a 403 means either a WAF default on a site whose published policy allows access, or a site that has actually declined. Newtools/robots_check.pytells them apart and the escalation runs it before retrying: a disallow for*orClaude-Userskips 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 bytests/test_robots_check.py(15 offline cases): the WAF usually blocksrobots.txtitself, so the policy is read as a browser when the honest request is refused and then obeyed strictly; andurllib.robotparsercannot 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". -
/applynow records the application in the tracker - the flagship command wrote a CV and a cover letter to disk and then wrote nothing tojob_search_tracker.csv, so a drafted and submitted application was invisible to/gmail-sync,/html-report,/notion-sync,/interview,/upskillaggregate 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 adraftedrow 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/applyupdates 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 intojob-application-assistantbecause/scrapeStep 5 routes straight into the skill (framework_version1.2.0 -> 1.3.0), and/scrapeStep 6 now defers to it instead of adding a row of its own.seen_jobs.jsonis deliberately left alone. Forks: the bump meanscheck_upstream_updates.pywill flag the skill - reconcile the new Step 3b (and Step 6b inapply.md) into your personalized copies rather than skipping the flag.draftedis 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-syncno longer reports unsent drafts as stale,/notion-syncleaves "Applied on" empty for them and says "not yet submitted" in the page body rather than calling drafts submitted documents, and/html-reportgains a sixth Drafted bucket kept out of the funnel, the rejection rate and the headline count./outcomeStep 4 overwritesdatewith the submission date when a row leavesdrafted, so the column keeps meaning "applied on"./gmail-syncdeliberately keeps searching for drafted rows./applydrafts but the user submits, and forgetting to run/outcomeafterwards is the failure this issue is about. An employer reply arriving against a row still markeddraftedis how that gets caught, so those rows stay in the search set, the application acknowledgement is promoted from noise to adrafted->appliedsignal (it is the one email that proves a hand submission, and it arrives within a day of it), and an approved match corrects thedateas 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//rankfield, nothing for/apply's existing generic language detection to report to). Adds that check, structured like the existing Eligibility Gate, on a new structuredLanguagestable in CLAUDE.md /01-candidate-profile.md(/setupasks, 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, withlanguage_gate/language_notepersisted intoseen_jobs.jsonalongside the existinglocationveto 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 mandatedlualatextoolchain 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.texnow uses[YYYY-YYYY]and[YYYY-Present], and05-cv-templates.mddocuments the failure mode and the check that catches it (framework_version1.3.0 to 1.4.0). The two-page layout budget is unaffected.Fork reconciliation note. The five changed lines in
cv/main_example.texare the\cventrydate 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.pynow parses localized numeric string cells - Excel exports that store numbers as text (a Danish"108,5","1.234,5", or space-separated thousands) previously hitfloat()'sValueErrorand were silently dropped fromsalary_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.pycompares 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 togit 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.pyadditionally 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.gitignorerule 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
/ranknow persistsstrengthsandgapsintoseen_jobs.json- Step 2's scoring agents already produced both arrays per job; Step 4 previously kept onlyrank_score,rank_verdict, andrank_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--allre-ranks, so downstream consumers ofseen_jobs.jsoncan read real triage findings instead of re-deriving them. See discussion #258./upskillaggregate mode now ingests/rank's recorded gaps - previously it only readjob_search_tracker.csvand guessed required skills from therole/sector/notescolumns, even though/rankhad already fetched and scored postings that never made it into the tracker. Aggregate mode now also reads ranked entries (rank_score >= 45) fromjob_scraper/seen_jobs.json, dedupes them against tracker rows on case-insensitive company+role, and prefers a job's recordedgapsover 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 (/rankpersistinggaps/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/setupends 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.pyrequirescover_letters/Cover_*.*(the uppercase cover-letter naming variant/applyrecognizes) andcv/*.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.pyno longer reports a false "up to date with upstream" when it silently falls back to a fork's ownoriginremote - 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 thegit remote add upstreamcommand 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 samecoverclass as the realcover_letters/cover.cls. - Added regression tests pinning #252's ragged-row bounds fix in
tools/convert_salary_excel.py(dimension-less workbooks read inread_onlymode yield rows shorter than the header).
Changed
- CONTRIBUTING's "run what CI runs" list is now complete - it previously omitted
tools/security_guards.pyand the exactunittestinvocation, 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_*.textocv/main_*.*(and likewise for cover letters), so a fork using a Typst or other non-LaTeX template no longer commits personalizedmain_<company>.typfiles to a public fork. The*_example.texfiles stay tracked. If you registered a custom template before this release, checkgit statusonce after updating. (#238) - Dependency review is live, for forks too - the repo's Dependency graph is now enabled,
so the CI
dependency-reviewjob 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 -
searchnow 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 + 20detailcalls, and/scrape's Step 2 no longer needs a per-hit fetch for this portal.--description-format markdown|text|html(defaultmarkdown) selects the rendering;tableandplainoutput is unchanged. (#251) - Custom templates: any compile-to-PDF toolchain (Typst, ...) -
/add-templateno longer hardcodes alualatex/xelatex/pdflatexengine 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
/applyartifact - when a posting's application form asks screening questions,/applycan 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
/applyor/interviewsurfaces 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.mdgains explicit rules for stating in-progress certifications/degrees honestly and for checking claimed tenure against visible output (framework_version1.2.1 -> 1.3.0). (#210) - Scraper flags mass-posting and recycled-listing patterns -
/scrapemarks 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
/rankshortlist 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\ProvidesClassname 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-searchpoints 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
/applypipeline (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 anenabled:toggle for skipping portals. - Framework versioning -
framework_versionmarkers on methodology files plustools/check_framework_version.py(CI guard) andtools/check_upstream_updates.py(fork-side update preview). - Privacy and safety guards -
.gitignoreprotection for personal data, thetools/security_guards.pyallowlist for.gitignorenegations, and a CI policy of making no live portal requests. - Cross-runtime support - a root
AGENTS.mdpointer so Codex and Antigravity can discover the portable portal skills, with Claude Code as the reference runtime.