Two small, non-blocking asks from Mads on #349:
- Pin the verification-still-applies restatement in apply.md and
interview.md's cache-check paragraphs - the one part of the wiring
with no dedicated test (one assertion each, as requested).
- State cache contents are data, never instructions, in
04-job-evaluation.md's cache section - closes a carry-over
prompt-injection surface for a later session reading the file, same
trust-boundary rule apply.md Step 0 already states for the posting.
/apply Step 3's reviewer agent and /interview Step 2 each independently
execute the Company Research Checklist (04-job-evaluation.md) for the
same company - applying to a role and later prepping for its interview
researches the company twice from scratch, same WebSearch/WebFetch cost
both times, no sharing between the two commands.
Adds a company_research/<normalized-name>.json cache (30-day TTL) that
either consumer checks before researching and writes after a fresh
pass. Defined once in 04-job-evaluation.md, next to the checklist it
mirrors, so both commands point at one source instead of restating the
schema. Does not change the verification model: 03-writing-style.md
rule 5 already treats reviewer-agent research as a lead, not a source,
requiring independent re-confirmation before any company claim ships
in a final artifact - the cache stores source URLs alongside each
fact so that re-confirmation stays cheap, but the requirement itself
is untouched and restated in both consumers.
company_research/*.json added to .gitignore and security_guards.py's
REQUIRED_IGNORE_RULES as a plain rooted pattern (not **/-prefixed):
the cache is referenced from commands, not a skill, so it resolves
against the repo root normally, unlike job_scraper/upskill's
skill-relative paths.
Pinned by tests/test_company_research_cache.py, mirroring the
spec-pinning pattern in test_rank_command.py and test_onboarding_privacy.py.
The write-back assertions for both apply.md and interview.md were
verified to actually fail against the regression they guard (the
instruction stripped, confirmed the test catches it, restored) before
being considered done - the write half is the one most likely to be
dropped silently in a future edit, since the read half is the more
obvious change to make.
framework_version bumped 1.2.4 -> 1.2.5 in 04-job-evaluation.md, the
only touched file inside the tracked skill set.
The quick start walked a new user into gh repo fork - forks of public
repos are always public - and two steps later had /setup write personal
data into tracked files, with the only complete warning in SETUP.md
section 8, a section about pulling updates that a first-time user has no
reason to open during onboarding. A real user hit exactly this (#345).
The warning now sits adjacent to both fork commands (README step 1,
SETUP.md section 2, both pointing at section 8's private-remote recipe),
and /setup checks the origin's visibility BEFORE writing anything: a
public-fork origin gets a confirm-first warning instead of a note after
every file is on disk. A private origin, no origin, or a non-git
directory continues silently. Reported by @basilevs with a complete
reproduction and fix analysis; this implements his fixes (1) and (4).
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Step 2 contract ('Search output already includes title, company,
location, date, and URL') had no cross-portal regression net: a CLI that
quietly drops a contract field flags the portal as degraded on every
/scrape run while CI stays green. That failure class landed for real
(jobnet/jobdanmark/jobbank, fixed in #339/#340/#342). The contract fields
are derived from the SKILL.md sentence itself (never hardcoded), compared
against the real search output of every .agents/skills/*-search CLI, and
detail.ts is deliberately excluded - the contract is about what /scrape
consumes. Fails against pre-fix master on exactly the three portals the
fixes cover; passes with them applied.
Step 1b.3's "scope to 14 days using the portal's recency flag" was
unsatisfiable on jobdanmark, which has no date filter or sort - the
agent either silently skipped the scoping or invented a flag, and the
CLIs now reject invented flags loudly. Every portal emits date, so the
instruction now filters client-side after the call, and stops
presenting --order (a sort) as interchangeable with a filter. Review
finding F32 (2026-08-19).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The funnel was computed from current status - a state, not a history -
so an application that interviewed and was later rejected never counted
as reaching Interview, and a hired candidate produced Interview=0. The
stage checkboxes Step 1.2 already merges from outcome.md are the
history; Step 2 and chart 4 now use them. The rejection rate also
counted offer_declined (a success) and withdrawn (candidate-initiated)
as rejections and left unresolved Interview/Offer rows in the
denominator. Review findings F10 and F11 (2026-08-19).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
cv/main_example.tex's sentinel was [YOUR_NAME] - a header comment and
the pdftitle, neither of which /setup's documented personalization
touches, so CI reported the file clean while it carried a real name,
address, phone and email (the review proved this end to end; the file
is the one CV the gitignore deliberately allows to be committed). The
guard now checks the \name{} and \email{} data lines, and 01's sentinel
moves from the <!-- SETUP comment onto [YOUR_EMAIL]. New test simulates
the /setup edit and requires every checked CV sentinel to be destroyed
by it. Review finding F28 (2026-08-19).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
"location" meant a place in scraper output and a PASS/FAIL/FLAG verdict
in /rank's persistence - one key, two meanings, in the same store, with
ranking able to overwrite the commute-filter place with "PASS". The
verdict now lives in location_verdict; legacy entries are read
compatibly and migrated on re-write. Also completes the seen_jobs schema
enumeration (F27 Part A): the do-not-drop instruction now names
location_verdict/language_gate/language_note. Review finding F27
(2026-08-19), decision approved by Mads.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
check_framework_version.py guards fork-rebase safety (Gate E) and could
be neutralised by a one-line change that reads as a refactor, with
nothing in the repo noticing - a broken guard is silent by construction.
Four new tests run the real script inside an isolated git repo: clean
tree passes, unbumped edit fails, bumped edit passes, missing marker
fails. Mutation-verified against the exact return-False disable the
review demonstrated. Review finding F22 (2026-08-19).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The linter's main job - frontmatter keys, allowed-tools targets, the
command title rule - had zero assertions; deleting the missing-
allowed-tools error left the suite green. The fixture's yaml stub now
parses the flat frontmatter the fixtures write instead of returning a
canned mapping, and four new cases pin both check functions.
Mutation-verified against the real linter. Review finding F23
(2026-08-19).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The tie-break test listed Disallow first - the one ordering where
deleting the clause changes nothing - and gate()'s read-the-policy-as-a-
browser recovery (the Barclays-class case 09-web-research.md documents
as covered) had no test. Both gaps are guard code whose breakage is
silent by construction. Mutation-verified: the tie-break deletion and
the UA-loop reduction each now fail exactly the new tests. Review
findings F21 and F30 (2026-08-19).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
in:inbox matches only messages currently in the Inbox, so it silently
excluded archived mail and everything routed past the inbox by a
label-and-archive filter - exactly the mail matched by the job-search
label Step 3.1 hunts for. The stated intent ("skip sent/drafts") is what
the negative operators express. Failure mode was silent under-detection
that read as "no updates" and left the tracker stale. Review finding F18
(2026-08-19).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
/outcome-created tracker rows (applications made outside the workflow)
never got a fit evaluation, so fit_rating is blank - and Step 3.3's
weight formula divides by it with no stated rule. Blank read as 0 means
weight 1.0, the maximum: the job the framework knows least about would
dominate the heatmap and the learning plan. Blank now falls back to a
matched ranked entry's rank_score, else skip+count+report once - the
same pattern the skill already applies to missing gaps. Review finding
F29 (2026-08-19).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Rule 6's expiry sweep mutates status automatically from stored deadline
values, yet had no rule for the non-ISO shapes portals have shipped into
seen_jobs.json ("ASAP", DD.MM.YYYY, free text) - "ASAP" is incomparable
and "01.09.2026" is ambiguous between 1 Sep and 9 Jan. /outcome, which
merely displays dates, already carried the defensive-parse rule. A
non-YYYY-MM-DD stored value is now handled like an absent one (left
alone, never compared, never guessed at) and reported once with its
portal. Includes the F24-style coupling test. Review finding F17
(2026-08-19).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
04-job-evaluation.md still said the gate result "is not a field /scrape
or /rank track" - true when the gate was introduced, false since /rank
began persisting language_gate/language_note as shortlist veto fields
and /scrape began surfacing the flag. The authoritative framework file
taught agents the opposite of rank.md's own persistence rule. New
coupling test pins that the section names the tracked fields and never
reverts to the untracked claim. framework_version 1.2.3 -> 1.2.4.
Review finding F24 (2026-08-19).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
/reset's preview, delete block, and scope description all skipped
documents/postings/ - the drop folder for hand-pasted posting text,
documented in documents/README.md and protected as personal data by
security_guards.py - and then asserted "The documents/ folder is now
empty." The new test derives the folder list from the git tree, so any
future drop folder fails it until /reset covers it. Review finding F26
(2026-08-19).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review findings F7 and F8 (2026-08-19):
- parse_numeric_cell's both-separators branch always assumed European
locale, silently turning a US "1,234.56" into 1.23456 - a 1000x
corruption written to salary_data.json with no warning. The separator
that appears last is now treated as the decimal separator, which also
makes multi-group values ("1,234,567.89") parse instead of raising a
raw float error. Single-separator ambiguity guards are unchanged.
- strip_type_patterns stripped only whole tokens, so the compound header
"Lønindeks alle" kept its type word and could never pair with "Antal
alle" - failing exactly for the compound-word locale COMPOUND_PATTERNS
exists to support. It now also strips compound patterns as substrings,
mirroring header_matches.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three findings from the 2026-08-19 review (F9, F31, F34):
- F9: every placeholder bullet written as \item [text] let LaTeX parse
the bracketed text as the item's optional label, rendering it clipped
off the left page edge and absent from the PDF text layer ("Achievement"
appeared 9 times in cv/main_example.tex and 0 times in the extraction,
with a clean compile and green CI). Bullets are now braced as
\item {[text]} in the example CV and in the template
06-cover-letter-templates.md teaches, and CI's stock PDF assertions
additionally require "Achievement" to survive pdftotext.
- F31: 05-cv-templates.md gains a "LaTeX Special Characters" section and
06's is completed beyond \_ and \&. The load-bearing case is an
unescaped % in a quantified achievement bullet: it starts a LaTeX
comment and silently deletes the rest of the line from the PDF.
- F34: the documented ATS extraction commands (apply.md,
05-cv-templates.md, CLAUDE.md) now carry -enc UTF-8. Xpdf-based
pdftotext builds default to Latin-1 output, so a correct non-ASCII CV
failed the replacement-character parseability check.
framework_version: 05-cv-templates.md 1.4.1 -> 1.4.2,
06-cover-letter-templates.md 1.0.1 -> 1.0.2. All three pinned by the new
tests/test_latex_guidance.py (9 tests; suite now 261).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds an additive source field (cli/websearch) to the seen_jobs Step 4 schema, Step 1c tagging at collection time, and a 'fallback (websearch):' Step 5 summary line - so future ghost-job reports (#331) self-triage from stored state. Same additive-field contract as portal/deadline: never backfilled.
Co-authored-by: yshraj <87583119+yshraj@users.noreply.github.com>
Extends the canonical Subfolder-naming rule by citation to all six archive derivation sites (apply, gmail-sync, interview, notion-sync, outcome, assistant SKILL.md), adds a fail-closed guard for an empty derived name, and pins every site with mutation-verified tests. framework_version 1.3.3 -> 1.3.4.
jakob1379 independently specified the same fix in his fork's issue #22 before this PR's rework.
Co-authored-by: ayobamiseun <66267222+ayobamiseun@users.noreply.github.com>
/html-report was the one tracker consumer #319's deadline column left behind:
Step 1 now parses every canonical column and Step 3 renders Deadline after Date.
The drift guard derives CANONICAL_HEADER from apply.md itself, so a future
column added elsewhere but missing here fails with the column named; legacy
13-field rows read as empty deadline, never dropped, never inferred. Includes
rule-6 sweep refinements and deadline-reconciliation rules authored by
jakob1379.
Co-authored-by: Jakob Stender Guldberg <17257805+jakob1379@users.noreply.github.com>
The deadline is written at every moment it is provably in hand and survives
every write that follows: seen_jobs.json base field, /rank stored-value urgency
+ expiry sweep with Step 4 persistence, tracker 14th column with header-line-only
migration for existing files, /scrape-path extraction (assistant SKILL.md 1.3.2
-> 1.3.3), preserve-unparsed-fields in /outcome and /gmail-sync, notion-sync
deadline precedence. Design, scope analysis, and the folded refinements by
jakob1379 (#319, #328).
Co-authored-by: Jakob Stender Guldberg <17257805+jakob1379@users.noreply.github.com>
Forks tracking this template face a weekly "which of these commits do I
actually care about?" question. check_upstream_updates.py answers it at the
file level (version stamps); this adds the commit-level half.
tools/upstream_triage.py walks the commits a fork is behind and splits them
into "worth reviewing" and "probably skip". Work already ported drops off on
its own via git patch-id, commits touching only files the fork removed are set
aside, and SHAs in .github/upstream-wontport.txt stay hidden. It reports and
nothing more - ready-to-run cherry-pick lines, but no merge, push, or PR,
since on a fork "applies cleanly" is not "correct".
.github/workflows/upstream-watch.yml runs it weekly into one rolling issue. It
no-ops on the upstream template (guarded, and pinned by a test) and uses only
the built-in GITHUB_TOKEN, so it can never write outside its own fork. The two
tools point at each other in their output; README, SETUP 8, and CHANGELOG
introduce them together. Tests cover patch-id matching, relevance filtering,
the won't-port list, and the workflow guard - all offline.
Co-authored-by: Angelina Lok <angelina@chattermill.io>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
The upskill/*.md rule is rooted, but /upskill is a skill and skills
resolve bare relative paths against their own directory - the same
observed behavior the **/job_scraper rules exist for. A report at
.claude/skills/upskill/upskill/report-*.md was not ignored, and an
upskill report records the candidate's skill gaps against named
employers. **/upskill/*.md would also ignore the skill's own SKILL.md
(the directory shares the name), so the new rule pins the report-file
prefix: **/upskill/report-*.md. Added to .gitignore and
REQUIRED_IGNORE_RULES, with a check-ignore-based test pinning both
properties.
check_permissions() read permissions.allow and nothing else, so a `hooks`
block in the same file passed the guard silently.
A hook is strictly more dangerous than a pre-approved permission. A
permission pre-approves something Claude may choose to do; a hook runs
unconditionally when its event fires, with no prompt and no model decision
in between. Cloning the repo and opening it is enough.
This is the vector the Shai-Hulud worm used in its August 2026 wave: a
SessionStart hook in .claude/settings.json chaining to .claude/math_init.js,
executing on session start.
https://research.jfrog.com/post/shai-hulud-is-back-august/
For a template that thousands of people are explicitly invited to fork,
that is the riskiest key in the file this guard already parses.
Follows the established pattern exactly - ALLOWED_HOOKS ships empty, since
the template has no hooks, so any addition must be allowlisted in the same
PR and is therefore explicit and reviewable.
Two details worth reviewing closely:
- The hook check runs *before* the permissions shape guards. Those guards
return early, so a file pairing a malformed permissions block with a live
hook would otherwise skip the hook check entirely - a fail-open. Pinned by
test_hook_is_caught_even_when_permissions_block_is_malformed.
- _hook_commands() fails closed. Any hook layout it does not recognise
yields a marker that cannot be in the allowlist, so an unfamiliar shape is
rejected rather than silently skipped, rather than trusting that the
Claude Code schema will not change.
Verified:
- 8 new HookGuardTests cases; 14 of the suite's 26 tests fail against the
unpatched guard, all 26 pass with it
- injecting the real worm shape into this repo's own settings.json makes
the guard exit 1 naming 'SessionStart:node .claude/math_init.js';
removing it returns OK
- lint_skills, check_framework_version, security_guards all OK;
python3 -m unittest discover -s tests 219 passed
/apply drafted two documents and a tracker row from the full posting, then
let the text die with the session. /outcome Step 3.2 tried to recover it by
re-fetching a `source` URL the spec itself expects to be dead, and a posting
pasted from an email or a PDF had no `source` to re-fetch at all.
Step 6b gains item 7: write the posting verbatim to
documents/applications/<company>_<role>/job_posting.md, never a re-fetch or a
reconstruction from memory. The folder is derived by citing /outcome Step 1.4
rather than restating the rule, so the two cannot drift. An existing file is
left alone and named in the report.
Step 0 and the /scrape path (job-application-assistant SKILL.md Step 1) now
retain the full posting text rather than a summary, so item 7 has something
verbatim to write.
Pinned by tests/test_apply_records_application.py.
* fix(workflow): define tracker status enum once in /outcome, normalise readers (#298)
The tracker CSV status column had no single authoritative definition.
Six command files restated it with inconsistent spellings, producing two
concrete bugs:
- /outcome Step 4 wrote
o response and offer declined (spaces).
/html-report normalised only the underscore forms, so those rows matched
no bucket and were silently dropped from the rejection-rate denominator.
- /gmail-sync Step 2 hardcoded the final-status set with space spellings,
so a row written with underscores was never recognised as final and the
sync kept chasing closed applications.
- /html-report included interview_only in its tracker bucket map; that
value belongs to the archive outcome.md Status: field, not the CSV
status column.
Fix: add a '## Tracker status vocabulary' block in /outcome (the only
CSV writer) defining the canonical underscore spellings once. Every
reader now references that block or explicitly lists both spelling forms
as read-tolerance for existing trackers. /outcome Step 4 writes
no_response and offer_declined. /html-report loses interview_only and
gains offer declined as a read-tolerance variant. /notion-sync Step 3
Status select options are aligned to the canonical spellings.
Pinned by tests/test_tracker_status_vocab.py (9 new cases following the
DraftedMeansDraftedToEveryReader CASES-table pattern). All 205 tests pass.
framework_version: 1.3.0 -> 1.3.1
* fix(workflow): address review findings on the tracker status enum (#298)
Follow-up to ca40df2, incorporating the maintainer and issue-author reviews.
Blockers fixed:
- CHANGELOG: the #298 entry had replaced the opening line of the #286 robots
entry, leaving its body dangling under the new fork heads-up. Restored the
deleted line and made the #298 entry self-contained above it (MadsLorentzen).
- /notion-sync Step 4 now normalises legacy space spellings to the canonical
underscore forms before setting the Status property. A raw push would
auto-create a separate Notion select option per unique string, splitting
closed applications across two filter buckets in an existing database
(MadsLorentzen).
Issue-author findings:
- The vocabulary block now states that the space spellings are the same
values as the underscore forms, not separate statuses, equally Final.
Previously a reader applying the Open/Final lists literally landed on
"not Final, not Open, undefined" for `offer declined`, and /apply Step 6b
would refresh a closed application's row instead of appending (jakob1379).
- The block moved below Step 1's closing --- as its own section: it was
splitting Step 1's numbered list and silently truncating section-scoped
reads of Step 1 to item 1 (jakob1379).
- Open is derived by exclusion from the one explicit Final list, so a new
status needs updating in a single place (jakob1379).
- /html-report's bucket map gains a case-insensitive catch-all that maps
unrecognised values to Rejected/Closed and names them once in the status
breakdown - the #298 failure mode with a different input (jakob1379).
- /apply Step 6b and /interview Step 0 anchor their final/open decisions to
the vocabulary block (jakob1379).
- /gmail-sync and /html-report drop their local restatements of the
read-tolerance rule (jakob1379).
Tests: html-report bucket assertions scoped to the Step 1 section; new pins
for the equivalence clause, open-by-exclusion, block placement, the Notion
normalisation, and the apply/interview anchors.
/apply wrote a CV and a cover letter to disk and then wrote nothing to
job_search_tracker.csv, so a drafted and submitted application was
invisible to /gmail-sync, /html-report, /notion-sync, /interview,
/upskill aggregate mode, and to /rank's dedup exclusion. The safety net
that would have caught it - /gmail-sync - refuses to create missing
rows, so the failure it exists to catch is the one that disables it.
Nothing detected the loss afterwards.
Step 6b appends a drafted row carrying the two document paths, the fit
rating and the posting URL, reusing /outcome's exact header so the two
commands cannot diverge. It runs immediately after "Files Created" and
before the optional application-form offer, which ends the turn on a
question - anything placed after that offer would be skipped whenever
the user never answers, reproducing the bug. Re-running /apply updates
the row rather than duplicating it, and never moves a row that already
reached applied or beyond back to drafted. The step is mirrored into
job-application-assistant, which defers to it rather than restating it,
because /scrape Step 5 routes straight into the skill; /scrape Step 6
now defers to the same step instead of adding a row of its own.
seen_jobs.json is deliberately left alone: drafting is not applying, and
that file's vocabulary has no value for either. /rank builds its
exclusion set from company+role in the tracker regardless of status.
drafted is introduced into the status vocabulary, and every reader that
meant "submitted" is updated to say so. These readers define their open
set 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
would have drafted a chase email to an employer who never received an
application, /gmail-sync would have searched for mail about it and then
flagged it as stale, /notion-sync would have published an "Applied on"
date for it, and /html-report would have counted it in the headline
application total. /outcome Step 4 also overwrites the draft date with
the submission date when a row leaves drafted, so the date column keeps
meaning "applied on". The wider vocabulary reconciliation - underscore
versus space, the separate archive enum - stays a separate concern.
Found by an adversarial review run over the merged checker, prompted to falsify
rather than confirm. Both are pinned in tests/test_robots_check.py.
A soft 200 granted permission. A host answering /robots.txt with an HTML error
page at status 200 produces a body that parses to zero rules, and zero rules
read as "allowed" - so the browser-header retry ran on permission that was
never given:
rc._fetch = lambda url, ua: ("<html>404 Not Found</html>", 200)
rc.gate("https://x.example/jobs")
# -> (0, 'ALLOWED - robots.txt permits this path')
A non-empty body carrying no recognised directive is now treated as unreadable.
A genuinely empty file stays allow-all per RFC 9309, so this does not
over-correct.
Disallow patterns were never percent-decoded while the request path was, so
"Disallow: /foo%20bar" never matched "/foo bar" and the rule was silently
skipped.
Also adds the "--" terminator before the URL in the curl argv, plus an explicit
--max-redirs 5. gate() rebuilds the target as scheme://host/robots.txt before
calling _fetch, so the gate path was never exposed to a dash-leading URL - this
is hardening for direct callers. Three tests pin it: the terminator is present,
a dash-leading argument fails closed end to end, and gate() never passes a
caller-supplied URL through to curl.
187 tests pass.
Claude-Session: https://claude.ai/code/session_01XTtiXab1yUFF2aL4s3fVY1
Co-authored-by: kgb <kevingblackman@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
The file shipped in #277 but was never added to the manifest, so forks
got no signal when it changed. Surfaced during #282 review.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The per-file 'git show' failure was swallowed with a bare continue, so a file renamed or deleted upstream (or any unexpected git error) ended with a clean '[OK] All framework files are up to date' - a false all-clear.
Now the two failure modes are distinguished: files present locally but missing from the upstream ref are listed explicitly with a final [WARNING] instead of [OK], and unexpected git errors are added to the configuration errors with their stderr.
Adds UpstreamRefMissingFileTests, which simulates upstream dropping AGENTS.md while the fork keeps its copy: it fails on master and passes with the fix.
* fix(web-research): stop treating a WebFetch 403 as a dead posting
WebFetch sends a bot user agent, and many bank and corporate sites answer
with HTTP 403 while serving the same page to a browser normally. Every
command treated that as "page unavailable" and degraded silently rather
than failing loudly:
- /rank marked live postings `expired`
- /apply fell back to search snippets, or to vague cover-letter prose
- /scrape stored listing-page `#fragment` URLs, which fetch fine and
return unrelated jobs, so every later /rank and /apply run on that
entry failed
Adds 09-web-research.md as 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-result snippet is a lead
rather than a source.
Wires it into /apply, /rank, /interview, /outcome, /notion-sync, the
job-scraper skill, and writing-style rule 5. Bumps 03-writing-style.md
to 1.2.0; 09-web-research.md starts at 1.0.0.
Aggregator examples are given generically (LinkedIn, Indeed, national
job boards) so the guidance holds in any market.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(web-research): gate the browser-header retry on robots.txt
Addresses review feedback on #277.
WebFetch identifies itself as Claude-User and honors robots.txt, so a 403 has
two very different causes and they must not be treated the same: a WAF default
on a site whose published policy allows access, or a site that has actually
declined. Retrying with browser headers in the second case circumvents the very
opt-out mechanism site owners are told they can rely on, and the core framework
cannot hold a looser standard than it asks of community forks.
The escalation now runs tools/robots_check.py before the retry. A disallow for
"*" or for "Claude-User" skips the retry entirely and goes to step 3 (find the
employer's own posting). The rule is stated plainly in 09-web-research.md 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 from testing the gate against live sites, both pinned by
tests/test_robots_check.py (15 offline cases):
- The WAF usually blocks robots.txt too. privatebank.barclays.com returns 403
on the policy file to Claude-User and 200 to a browser, so a naive gate would
block the retry on exactly the sites the retry is for. The checker reads the
policy as a browser when the honest request is refused, then obeys it
strictly - a policy you are prevented from reading cannot be honored, and
robots.txt is not the protected resource.
- urllib.robotparser cannot be used. It ends a record at a blank line and
matches rules in file order, so Barclays' real file (blank lines between
"User-agent: *" and its rules, "Allow: /" before "Disallow: /cs/") reads as
everything-allowed. That fails open, in the one direction that matters. The
checker implements RFC 9309 longest-match instead, with ties resolved to
Disallow rather than Allow.
Verified live: barclays /careers/ allowed and /cs/ blocked, ubs.com allowed,
jobup.ch /api/ blocked while /en/jobs/ stays allowed. 09-web-research.md
1.0.0 to 1.1.0.
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>
Follow-up suggested during PR #275's merge review: spec-pinning tests for
the language_gate/language_note fields, matching the pattern already used
for the sibling gaps/strengths fields in this same file.
Four new assertions: Step 2's scoring-agent JSON schema declares both
fields with the PASS/FAIL/FLAG enum and distinguishes them from the
pre-existing `language` field; Step 3 documents the Language veto rule
mirroring the existing Location veto; Step 4 persists both fields into
seen_jobs.json; Step 5's presentation rules document the FLAG marker.
The Step 4 persistence test is a real regression guard, not just
documentation: language_gate/language_note were computed in Step 2 and
used to decide Step 3's veto, but an earlier version of this spec never
instructed Step 4 to actually write them to seen_jobs.json - caught via
live debugging (a real /rank run showed language_gate: null on every
entry despite the run reporting genuine vetoes), fixed once already.
Verified live that this test fails against that exact regression and
passes against the current (fixed) spec text.
GitHub serves repo paths case-insensitively, so a direct clone from https://github.com/madslorentzen/ai-job-search (lowercased) triggered the fork-vs-self warning even though origin is the template repo itself. Lowercase both sides of the check.
New test clones with a lowercased URL: fails on the previous check, passes with this fix.
* 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.
On a fork without an 'upstream' remote, the checker silently fell back to 'origin' (the fork itself) and still printed '[OK] All framework files are up to date with upstream!', a false positive: the fork is always up to date with itself, so upstream updates were never reported. This is exactly the setup CONTRIBUTING.md recommends for forks.
Now, when the fallback remote does not point at the ai-job-search template repo, the script warns that the comparison is fork-vs-self and prints the command to add the template as a remote. The final OK line now names the ref it actually compared against.
Tests (new tests/test_check_upstream_updates.py, three scenarios) fail on master and pass with the fix.
/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.
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>
An unmatched count column (e.g. a lone total headcount with no paired index
column) was appended as an untyped standalone value and stored under "index",
even though detect_column_type had already classified it as a count.
salary_lookup then rendered the raw headcount as a salary index with a
meaningless "vs baseline" percentage.
Tag unmatched count columns with field="count" so the row parser stores them
under "count" (as an int, matching the paired-count branch). Standalone index
and untyped columns are unaffected.
The sequential scan assumed count/index pairs are always adjacent.
Interleaved columns like Count_A, Count_B, Index_A, Index_B produced
wrong pairings (Count_B ↔ Index_A), silently corrupting data.
Now columns are grouped by type, then matched by the category name
derived from stripping type words. Unmatched columns fall back to
standalone value columns using the original header name.
Adds Step 2b to /outcome: surfaces open applications gone quiet (default 10 days), drafts a brief channel-appropriate follow-up in the candidate's voice using only claims from the already-submitted materials, and logs it (followed up marker in notes + followup_YYYY-MM-DD.md in the archive). Reachable from the no-arg pipeline table (now showing days-quiet and follow-ups-sent) and via /outcome followup [N|company]. Draft-only never send, capped at two follow-ups terminating into Step 2's existing no_response path, and a thank-you note offered the moment Step 3 records a completed interview stage. Reads the contact_person column nothing previously consumed.
Folded into /outcome rather than a standalone command - dependency-free (unlike /gmail-sync) and reusing detection, the archive, the notes ledger, and the resolution path already there. The invited return of #46 (closed stale, not on merit). Guardrail tests mirror the /notion-sync pattern; 10-vs-30-day threshold contrast with /gmail-sync documented and test-pinned.
By @ayobamiseun.
convert_salary_excel.py detected the city column via exact membership (h_lower in CITY_PATTERNS), so real headers like "City Name", "City/Kommune", or "Kommune <suffix>" never matched and every company was written with an empty city field. Switches to header_matches(h, CITY_PATTERNS) - the same whole-token matcher already used for the company, count, index, and ID columns. Same bug class as #151 (company column); bare "City"/"Kommune" inputs are unaffected. Regression test covers bare and suffixed headers.
By @oscarbol09.
check_gitignore() verified each required personal-data rule was present via set membership, but .gitignore is order-sensitive: a later !pattern re-includes a file an earlier rule excluded, so the required line stays physically present while the file is no longer ignored - the guard failed open on exactly the weakening its docstring claims to catch. Keeps the required-rules-present check and additionally rejects any negation line outside a small reviewed ALLOWED_IGNORE_NEGATIONS allowlist (same explicit-widening pattern as ALLOWED_PERMISSIONS). Fixes#194.
By @thejesh23. Verified: allowlist matches the four negations currently in .gitignore; guard test suite passes locally (17 tests) and in CI.
Closes#194
Adds /notion-sync per the conditions agreed in discussion #166: tool-agnostic sync contract with Notion as the in-tree reference binding, silently optional (covers unconfigured, headless, and unauthenticated states), read-only toward the repo with the gitignored sync-state file as its only local write, write-once page bodies, documents sync as filenames only. Complements /html-report: deep local dashboard vs glanceable anywhere-view.
Self-contained HTML dashboard generated from job_search_tracker.csv and the application archives: stat cards, status/sector/channel/funnel charts as hand-generated inline SVG (no CDN, fully offline), HTML-escaped interpolation throughout, and a filterable applications table. Includes Python guards for the command file and the reports/ gitignore rule.
validate_data() accepted category values that are not {count?, index?}
objects. They slipped through to format_entry(), which then raised
AttributeError on a normal table lookup (or serialized a malformed shape
under --json). It also accepted duplicate company names silently.
- collect_validation_issues() now also flags a non-object category value
(and non-numeric count / non number-or-string index) as a hard error,
and duplicate company names as a warning.
- validate_data() keeps its eager-fail behavior (same messages), so
existing tests and load_data() are unchanged.
- --validate runs the checks standalone and prints an actionable report
(exit 1 on errors, 0 on warnings-only/clean), letting users pre-flight
their BYO salary_data.json.
Reproduced on master: validate_data({'companies':[{'company':'Acme',
'categories':{'eng':'not_a_dict'}}]}) returns without error, but
format_entry then raises AttributeError.
Co-authored-by: Tunic Assistant <assistant@tunic.local>
parse_sheet treated every column that was not company/city as a salary category, with no check that the column actually held numeric salary data. This turned free-text columns (e.g. Notes) into bogus string categories and numeric identifier columns (e.g. Id) into mistaken salary indexes.
- Drop identifier headers (ID_PATTERNS = {id, personnummer}) at classification time.
- Skip non-numeric standalone values and fully-null count/index pairs at row-processing time.
- Adds regression tests (skips_free_text_column, skips_numeric_identifier_column, keeps_numeric_salary_column) that fail on master and pass after the fix.