* fix(rank): move seen_jobs.json read/write off the state file's own critical path (#395)
/rank's Step 1 read the whole of seen_jobs.json into the conversation to
select candidates by eye, and Step 4 emitted it back to record scores.
That cost is paid on every run regardless of how many jobs are scored,
and it grows for the life of the workspace, since the file is
append-only and most stored entries are `skipped`.
tools/rank_state.py moves that traffic into code:
- `candidates` selects entries per Step 1's existing rules (status
filter, tracker exclusion, focus filter, `--limit`/`--all` from #424)
and projects only the fields a scoring agent needs.
- `sweep` runs rule 6's expiry pass over entries the run did not
re-score - a stored-date comparison, no fetch, no agent - preserving
its defensive parsing of non-ISO deadlines and its `--all`
reversibility.
- `apply` writes scoring results back atomically and prints the
ranked/vetoed/expired rows Step 5's report is built from, preserving
Step 4's existing write-back rules exactly: the `location` ->
`location_verdict` legacy migration, the deadline
null-is-not-a-correction rule, verbatim strengths/gaps persistence,
and idempotent re-scoring.
Step 1, Step 3's rule 6, and Step 4 now route through the tool instead
of describing a manual read/write. Nothing about scoring policy changes
- no new status, no new persisted field, no change to what counts as a
veto. The tracker stays read-only and every write is atomic (temp file
+ rename).
tests/test_rank_state.py (25 tests) covers the three subcommands
directly. The new spec-guard class in test_rank_command.py derives the
fields Step 4 must preserve from Step 2's own JSON schema block rather
than retyping them as a second list, so a future edit to that contract
is what the test reads instead of something that can drift from it.
* fix(rank): add CHANGELOG entry and remove the undefined $SCRATCHPAD reference
Two mechanical fixes from review:
- Step 4 named the results hand-off file via $SCRATCHPAD, a variable
nothing in the repo defines - a reader following the spec literally
has no path to substitute. Named the location in prose instead (a
temporary file outside the repo tree, never committed) and replaced
the shell-variable-looking path in the example command with an
explicit placeholder.
- Added the [Unreleased] entry this change was missing; the one
already in the diff belongs to #424.
* changelog: fold the #395 entry into the existing Fixed section
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013fqqLgQSnwgWkv98twQhHi
---------
Co-authored-by: nox <nox@Mac.home>
Co-authored-by: Mads Lorentzen <madslorentzen17@gmail.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
The upstream template pre-approves `Bash(bun run:*)`, which auto-approves
`bun run <any file>` — arbitrary TypeScript from anywhere on disk — on every
fork. Each portal SKILL.md already declares the tight form in its own
allowed-tools; this makes settings.json agree with them.
Blast radius drops from "any file on the machine" to the repo's own CLIs,
with no new prompts in the /scrape path. tools/security_guards.py's
ALLOWED_PERMISSIONS is updated in the same commit, as its docstring requires.
Local: security_guards OK, lint_skills OK, 318 tests pass.
Claude-Session: https://claude.ai/code/session_01HHqEAQqGS2KKXASiYcrAHQ
* feat(ats): extract PDF text with pypdf before Poppler
Lead the ATS text-layer check with pypdf (BSD, optional pip install). Fall back to pdftotext -layout -enc UTF-8. No cache directory, no installer, no AGPL pymupdf. Windows users without Poppler still get a mechanical parseability check; visual review remains the last resort.
* Update verify_pdf.py
* Update apply.md
* Update verify_pdf.py
* Update verify_pdf.py
An ATS reads the compiled PDF's embedded text layer, not the rendered page,
and LaTeX can silently produce PDFs whose text extracts as garbage: icon
glyphs where contact details should be, (cid:*) markers from fonts without
Unicode mappings, interleaved lines from multi-column layouts. This matters
more now that /add-template lets users bring arbitrary templates. The
existing Step 5 loop verifies what a human sees; this adds verification of
what a parser sees.
New Step 5d in /apply (CV only - cover letters rarely go through keyword
screening; cleanup renumbered to 5e):
- Extract the CV PDF's text layer with pdftotext -layout. pdftotext
(poppler) is an optional dependency: if missing, the mechanical check is
skipped with a warning and keyword coverage falls back to the visual PDF
read - the same graceful-skip pattern as salary_lookup.py
- Parseability checks verified against a real extraction of the stock
template: email/phone must survive as literal text (fontawesome icons
extract as harmless glyph-name noise like MOBILE-ALT/Envelope, but a
contact detail carried only by an icon or hyperlink is invisible to ATS),
no (cid:*) or replacement-character garbage, reading order matching
visual order, dates present
- Keyword coverage reuses the required/preferred list from Step 1, matched
in the posting's language, reported as covered / synonym-only /
missing-have-it / missing-gap. Honesty rule enforced: keywords the
profile genuinely supports get added to experience bullets; genuine gaps
stay visible, never stuffed
Integration: CLAUDE.md verification checklist section, ATS Parseability
guidance in 05-cv-templates.md, narrow Bash(pdftotext:*) entry in the
pre-approved permissions (keeping with the tightened scope from #27),
cv/*.txt gitignored (extraction is personal data; also deleted by the
step itself), and optional-dependency docs in README and SETUP.
* fix: move scoped permissions to settings.json, drop curl, untrack settings.local.json
Addresses #23:
- Remove pre-approved Bash(curl:*) - no agent workflow uses curl, and a
toolkit that routinely feeds untrusted job postings to the model should
not ship a pre-approved exfiltration-capable command
- Move shared permissions to .claude/settings.json (committed by
convention) and scope them tighter: Bash(bun run:*) for the job portal
CLIs, Bash(python/python3 salary_lookup.py:*) for salary lookups
- Untrack .claude/settings.local.json - it was committed despite being
listed in .gitignore; the file stays local for personal overrides
Reported-by: @josealfonsomora
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(setup): warn existing cloners about stale settings.local.json
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>