Commit Graph
14 Commits
Author SHA1 Message Date
Mads LorentzenandClaude Fable 5 d18ca52723 docs(changelog): scope the date-fix verification check to date lines
The stock template's contact and award lines carry two decorative separator
glyphs that also extract as U+FFFD, so 'grep -c' returns 2, not 0, on a CV
that is fully fixed. Point the check at date lines and name the benign hits.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 21:11:13 +02:00
befaaf5eef fix(cv-template): use ASCII hyphens and explicit ranges in CV date fields (#276)
* fix(cv-template): use ASCII hyphens and explicit ranges in CV date fields

LaTeX ligatures `--` into an en-dash (U+2013), so a `\cventry` date written
`2016--2024` reaches the PDF text layer as `2016<U+2013>2024`. Many ATS
parsers split date ranges only on an ASCII hyphen and therefore extract no
range at all. Separately, a bare single year gives the parser a start date
with no end.

Confirmed against a real Workday resume import: a CV built from this
template lost the end date of a short contract role and imported no
education entries whatsoever, forcing manual re-entry. The failure is
silent - extraction was otherwise clean, with literal contact details, no
(cid:) markers and correct reading order, so every existing check in the
ATS Parseability section passed.

- main_example.tex: date placeholders now use a single hyphen
- 05-cv-templates.md: document both causes, with examples, and add the
  check to step 5d

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFNdaj4fB6Xrd29PQ6hc9B

* chore(cv-template): bump framework_version to 1.4.0

The ATS date-format guidance added in the previous commit modifies a
framework template, which the CI framework version guard requires to be
accompanied by a version bump. Minor bump: new documented subsection,
no breaking change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(changelog): record the CV date-field hyphen fix and fork reconciliation

Documents the U+FFFD extraction failure under lualatex, the fix, and the
framework_version bump. Includes the fork reconciliation note requested in
review: the five changed lines in cv/main_example.tex are the \cventry date
fields every fork personalizes, so rebasing forks should expect conflicts
there, resolve in favour of their own dates, and reapply the -- to - change
by hand. Adds a grep to locate remaining instances and a pdftotext check to
verify.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: kgb <kevingblackman@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 21:10:00 +02:00
Gabriel Ignacio MensiandClaude Sonnet 5 6392ca1628 Add a Language Gate - no dimension currently checks language requirements (#275)
Nothing in the framework checks a posting's language requirements
against what the candidate actually speaks. It is not one of the five
Scoring Dimensions in 04-job-evaluation.md, it is not checked in
/scrape's Step 3 fit assessment, and it is not a field in /rank's JSON
output - even though /apply's Step 1 already extracts a posting's
required language generically, with nowhere to report a mismatch to.

This adds a Language Gate, structured like the existing Eligibility
Gate (read the posting, classify against profile data, hard-stop on a
real mismatch), built on a new structured Languages table in CLAUDE.md
/ 01-candidate-profile.md. /setup now asks for it directly (Path C), or
infers it from a CV/LinkedIn export (Paths A/B - LinkedIn exports
already carry a self-rated Languages section).

The gate compares a posting's stated language requirements against
that table with three outcomes:

- Requires a language not declared at all -> hard FAIL, never
  presented.
- Requires a higher level in a language that is declared (e.g. "fluent
  English" against a declared B1/B2) -> FLAG, not an auto-reject -
  scored and drafted normally, with the gap surfaced so the candidate
  judges it themselves (a "fluent" bar reads very differently from a
  strict employer vs. one that's flexible on it).
- Requires a language at or below the declared level -> clean PASS.

Wired through the three places that need it: /scrape (Step 3), /rank
(new language_gate/language_note fields alongside the existing
location veto - both are now persisted to seen_jobs.json, not just
used transiently to decide one run's shortlist), and /apply (Step 1's
language extraction now has somewhere to report to).

Out of scope, deliberately: this does not touch the free-form
Deal-breakers list or how it's used elsewhere (e.g. Scoring Dimension
4's relocation check) - that's a separate question this change takes
no position on.

Validated with two live-testing passes against real, unfetched
postings (not fabricated text) across 3 portals and 3 market languages
(Danish, German, Spanish/Argentina): 8/8 postings gated correctly in
the first pass, including ambiguous real-world wording ("you
communicate well in English") a rigid rule would have gotten wrong. A
second pass, run specifically to force a hard-FAIL case, found one
(a Danish posting requiring the ability to read Danish) and confirmed
it persists correctly and would be excluded from /rank's shortlist.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 18:46:59 +02:00
NotAbdelrahmanelsayed bdf6d0ac45 feat(upskill): aggregate mode ingests ranked jobs and their recorded gaps (#264)
* feat(upskill): aggregate mode ingests ranked jobs and their recorded gaps

/upskill's aggregate mode only read job_search_tracker.csv and guessed
required skills from the role/sector/notes columns, even though /rank
already fetches and scores postings that never make it into the tracker.
Aggregate mode now also reads ranked entries (rank_score >= 45, the
Moderate Fit floor) from job_scraper/seen_jobs.json, dedupes them against
tracker rows on case-insensitive company+role (reusing the match
tools/auto_mode_browser.py's _tracker_keys already implements), and
prefers a job's recorded gaps over an inferred skill list wherever both
exist. The heatmap's Gap Source column and report header now show the
recorded-vs-inferred / tracked-vs-ranked split.

Depends on #263. Discussed in #258.

* fix(upskill): cite only upstream precedent for the aggregate dedupe key

tools/auto_mode_browser.py's _tracker_keys does not exist upstream and
does not exist in this fork either, so the dedupe bullet in Step 3.1
of the upskill skill pointed at a phantom implementation. Drop that
reference and keep only the /notion-sync precedent, which is verified
present in upstream/master. Re-pin the pinned test assertion to the
surviving citation so the dangling reference can't silently return.

Addresses the CHANGES_REQUESTED review on #264.
2026-08-02 10:01:30 +02:00
Mads LorentzenandClaude Fable 5 a65a7167ef docs(changelog): entry for #271's gitignore-guard completion
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 22:21:58 +02:00
Mads LorentzenandClaude Fable 5 d6b2c4039e chore(release): CHANGELOG for 1.2.0
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 22:07:16 +02:00
Mads LorentzenandClaude Fable 5 d4b406efff docs(privacy): stop calling a fork 'private working space'; warn at /setup time
Forks of public GitHub repositories are always public, so SETUP.md
section 8's 'your fork is private working space, so commit them' invited
exactly the personal-data exposure it seemed to rule out - the observed
failure mode behind several real forks that pushed filled-in profiles to
public master. Section 8 now states the fork-is-public fact plainly and
documents the safe alternative (private repo + template as upstream
remote), and /setup's completion summary carries a matching privacy note
at the exact moment profile data first lands in tracked files.

Also backfills the CHANGELOG entry for #265.

Prompted by rasstamann's discussion #266.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 22:03:22 +02:00
NotAbdelrahmanelsayed 1cdaf9497f feat(rank): persist triage gaps and strengths into seen_jobs.json (#263)
/rank's Step 2 scoring agents already return strengths and gaps per job,
but Step 4 only persisted rank_score/rank_verdict/rank_date - both arrays
were printed once in Step 5 and then discarded. Store them verbatim in
seen_jobs.json (replaced, not accumulated, on --all re-ranks) so downstream
consumers can read real triage findings instead of re-deriving them.

Discussed in #258.
2026-07-31 17:41:41 +02:00
Mads LorentzenandClaude Fable 5 2c41210019 fix(security-guards): sync gitignore guard with the Cover_*.* and cv/*.txt rules
Two personal-data ignore rules existed in .gitignore but not in
REQUIRED_IGNORE_RULES, so a change weakening either would have passed CI:
cover_letters/Cover_*.* (the uppercase naming variant /apply recognizes)
and cv/*.txt (ATS text extractions of tailored CVs).

Also: regression tests pinning #252's ragged-row bounds fix in
convert_salary_excel.py (mutation-verified), and removal of the vestigial
cover_letters/OpenFonts/cover.cls, which since #252's rename ambiguously
declared the same class as the real cover.cls (zero references; cover
letter re-compiled and page-verified after removal).

Guard-list gap surfaced by CodeRabbit's review on jakob1379's Nix demo
fork PR (jakob1379/ai-job-search#1).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 12:55:09 +02:00
Mads LorentzenandClaude Fable 5 9bf9a65212 chore(release): CHANGELOG for 1.1.0
Backfills the release span since v1.0.0 (16 commits): the Typst
personal-data gitignore fix and live dependency review under a
Security & privacy heading, plus the added features and fixes that
had no Unreleased entries.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 16:37:25 +02:00
Mads LorentzenandClaude Fable 5 7a753f3cd4 docs(readme): document the extension model - portals, templates, criteria, borrowing from forks
Prompted by the extension-system question in discussion #249: the three
extension points existed as folklore across #78, /add-portal, and closed
PRs. Now stated plainly, with a read-the-code-first checklist for
borrowing portal skills and the rationale for why there is no installer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 11:04:45 +02:00
Ilya Strelov e3af401087 feat(freehire-search): search returns each hit's full description (#251)
The skill queried /api/v1/jobs/search, whose `description` is the search
index's truncated preview — and the CLI dropped it entirely, so a result
carried only title/company/location/date/url. Reading a posting therefore
meant a `detail` call per hit, which is exactly what job-scraper's Step 2
prescribes: "fetch full detail with that portal's `detail` command".

freehire exposes a search endpoint for programmatic consumers,
/api/v1/agent/jobs/search: same query, ranking, facets and pagination, but
asked to (`include_description=true`) it replaces the preview with the
posting's full description read from the database, rendered as
`description_format=markdown|text|html`. Reproduce the difference:

  curl -s "https://freehire.me/api/v1/jobs/search?q=golang&limit=1" \
    | jq -r '.data[0].description | length'          # preview, capped
  curl -s "https://freehire.me/api/v1/agent/jobs/search?q=golang&limit=1\
&include_description=true&description_format=markdown" \
    | jq -r '.data[0].description | length'          # full text

So `search` now calls that endpoint, always asking for full descriptions,
and each JSON result carries `description` verbatim — no client-side HTML
stripping, since the API already rendered it. Markdown is the default
because it preserves the headings and requirement lists /rank reasons over;
`--description-format text|html` selects the others. The flag is validated
client-side: the API answers an unrecognized format with raw HTML rather
than an error, so a typo would silently change the output instead of
failing.

`table` and `plain` stay description-free — a full posting body would swamp
a scannable list — and `detail` is untouched, for looking one posting up by
slug (including a closed one, absent from search).

One behaviour change beyond the endpoint: a 404 from the search path used
to be folded into an empty result set. On the agent endpoint a 404 means
the instance predates it — a self-hosted freehire behind FREEHIRE_API_URL —
so it is now reported as an error naming the path, instead of a plausible
"no results" that hides the misconfiguration.

Tests cover the requested URL and params, verbatim (unstripped) markdown,
the null-when-absent case, the 404-is-an-error contract, and the flag
validation. All network-free.
2026-07-28 21:19:24 +02:00
Novica Nakov 82a60300b6 feat(add-template): make /add-template engine-agnostic (#238)
* feat(add-template): make /add-template engine-agnostic so Typst can register alongside LaTeX

/add-template hardcoded a lualatex|xelatex|pdflatex engine enum and .tex
assumptions, so custom templates could only be LaTeX. Replace the enum with a
declared source extension + compile command, so any toolchain (Typst via
`typst compile`, or others) registers the same way stock LaTeX templates did.

Stock CV/cover-letter pipeline stays LaTeX and untouched (per #181).

Also fixes a latent bug this surfaced: apply.md's compile step ignored the
ACTIVE-TEMPLATE block and always ran lualatex/xelatex on .tex regardless of
the active template, and .gitignore's cv/main_*.tex pattern would not have
ignored a non-.tex draft (personal-data leak). Both now resolve from the
declared extension/command.

* fix(add-template): satisfy security_guards on the .gitignore Typst fix

security_guards.py pins the personal-data ignore rules by exact string and
gates negations through an allowlist, so broadening cv/main_*.tex and
cover_letters/cover_*.tex to *.* (for .typ drafts) needed a matching update
to REQUIRED_IGNORE_RULES.

Also tighten the .gitignore itself per review: keep the re-include
negations at .tex instead of widening them to *.* too. The stock example
files are always LaTeX, so .tex is enough to re-include them, and a
wildcard negation would have also re-included build artifacts
(main_example.pdf/.aux) that should stay ignored. ALLOWED_IGNORE_NEGATIONS
needs no change since the negations are unchanged.

Also adds a CHANGELOG entry under Unreleased for the Typst/custom-template
support.
2026-07-26 16:21:33 +02:00
Mads LorentzenandClaude Opus 4.8 905f6e0946 docs(releases): add CHANGELOG + release-based update guidance; sharpen real-path bar (#225)
Addresses #213 (how to keep up with a fast-moving upstream) and closes the
verification loophole surfaced in the 2026-07-22 triage audit.

- Add CHANGELOG.md (Keep a Changelog + semver), with v1.0.0 as the first
  tagged baseline and an Unreleased section for going forward.
- SETUP.md section 8: recommend updating to a tagged release (a vetted,
  described checkpoint) over pulling raw master; fetch --tags and merge a tag.
- README: add a "Staying up to date" pointer to Releases, the CHANGELOG, and
  check_upstream_updates.py.
- CONTRIBUTING.md: sharpen "Claims get verified" - a test that distinguishes
  master from the fix is necessary but not sufficient; the failing input must
  be one the workflow actually produces, not one the test hand-builds. Fixes
  demonstrated only through a synthetic input the real code path never receives
  get declined even when their test is green.

Note: the git tag / GitHub Release for v1.0.0 is intentionally left for the
maintainer to cut.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 11:36:47 +02:00