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>
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>
/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>
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>
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>
Title-lookalike matching collapses a multi-hat career into whichever single
job-title box sounds closest, then searches only inside that box. /setup
Section 9 now asks about the function before collecting search titles,
search-queries.md says to organize priority categories by function with title
variants under each, and 04-job-evaluation.md's Experience dimension matches on
the function and nature of work performed (framework_version 1.2.2 -> 1.2.3).
From discussion #327's field report and calibration example.
/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>
The schema documented new/skipped/evaluated/ranked/expired, but
'evaluated' has had no writer and no reader since the initial release,
and post-#269 the tracker owns all lifecycle state after drafting.
Also tightens /rank Step 1's '--all' wording, which leaned on an
'applied' status the schema deliberately lacks.
Closes#315
The contract's Fetching line still said "browser User-Agent", predating the
honest-UA posture (#283/#277). It now names the shipped convention and routes
browser-header escalation through the 09-web-research robots gate.
Also moves #304's CHANGELOG entry out of the released 1.4.0 section - the
branch predated the release cut, so the three-way merge landed it there -
into [Unreleased]/Changed where it belongs.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(add-portal): specify credential handling for portals needing an API token
/add-portal can already scaffold a skill for a portal that returns usable
content only through a paid fetching service, but the spec says nothing
about the credential such a skill needs: no guidance on where it lives, no
requirement to document it, no defined failure mode when it is unset, and
no point at which the user learns the portal costs money per query.
Three additions, all to the generator spec:
- Step 2 gains a reconnaissance point for whether the portal needs a
credential at all, to be raised with the user before scaffolding. A
portal that bills per query is a different proposition from a free one
and the user should get to decline it.
- The portal-skill contract gains a Credentials rule: environment variable
only, named <SERVICE>_API_TOKEN, never a CLI flag (flags leak into shell
history and process listings), never in url-reference.md or a fixture,
and a MISSING_CREDENTIALS exit rather than a fallthrough to an
unauthenticated request that fails confusingly.
- SKILL.md for such a skill must carry a Setup section naming the service,
the variable, and the per-call billing.
Spec only. All six shipped portals are free and unauthenticated, so no
existing skill changes and the zero-dependency default is untouched.
Evidence this is not speculative: running /add-portal against portals that
sit behind bot-detection produced four token-requiring skills in my own
fork (Bright Data Web Unlocker), each of which had to invent its own
convention for reading, documenting, and failing on the token, because the
spec defines none.
Verified: lint_skills, check_framework_version, security_guards all OK;
python3 -m unittest discover -s tests 196 passed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* docs(add-portal): subordinate the credential check to the robots.txt decision
Review catch: as written, Step 2.5 could be read as "robots.txt or the
terms said no, but a paid unlocker gets through, so here is the convention
for that" - which would let a paid fetcher launder a robots refusal.
That is not the intent and it contradicts the posture settled in #277/#286
(09-web-research.md, tools/robots_check.py): robots.txt is decided first
and honestly, and is never overridden by better fetching machinery.
State the subordination explicitly in 2.5 rather than leaving it implied by
step order, and record it in the changelog entry so the constraint survives
later edits to the step. The credential path is for portals whose robots.txt
permits access but whose bot protection blocks ordinary fetches - paying for
access a site allows, never paying past a site's no.
Wording follows the reviewer's suggestion.
Verified: lint_skills, check_framework_version, security_guards all OK;
python3 -m unittest discover -s tests 219 passed.
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
The cli-checks matrix hardcoded the six shipped portals, so a CLI
scaffolded by /add-portal in a fork shipped typecheck and test scripts
that fork CI never ran - while security_guards.py already globs
.agents/**/package.json and covers new portals automatically. A
discover-clis job now emits the matrix from
.agents/skills/*/cli/package.json; on upstream it resolves to the same
six portals, and a fork-added portal joins the matrix with no workflow
edit. /add-portal's Register step now says so.
/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.
A non-Danish user's /scrape ran all four Danish boards by default,
spending tokens on irrelevant listings. The portals stay in-tree as the
maintainer's demonstration instance, one flag away.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* 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>
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>
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>
/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.
* 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.
* feat(08): add application-form fields as a third /apply artifact
/apply produces a CV and a cover letter. Many applications need a third
thing: free-text typed into a portal. Graduate programs, large-employer
ATS systems and startup forms ask for self-introductions, structured
project entries, motivation questions and pitches under a hard character
limit - none of which either document covers, and all of which the
interviewer reads alongside the CV.
Governing rule: a form field selects from what is already true and
arranges it for the question asked. It never introduces a new claim.
All accuracy rules from 03 and 05 apply unchanged.
Covers three field types (self-introduction, structured project entries,
hard character limits), the output format (a plain .txt the candidate
pastes from, with counts stated and internal NOTE TO SELF blocks marked
as not-for-pasting), and a verification checklist.
Two places where form fields are stricter than a CV, because both are
easy to get wrong:
- Project entries carry a name and a role, so they read as ownership of
the whole project in a way a terse CV bullet does not. Contributory
work has to be scoped inside the description.
- Project dates are the dates of the project, not of the employment.
Narrowing them is more accurate where the candidate can say when the
project started - but never invent a boundary to improve the ratio.
Registers the file in SKILL.md (framework_version 1.0.1 -> 1.1.0) and in
the FRAMEWORK_FILES list in tools/check_upstream_updates.py, so it is
covered by the update check like every other framework file.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fixup(08,apply): wire as optional /apply offer; align grounding to three-source union
- apply.md Step 6: offer the third artifact after CV/cover letter are
produced, mirroring the /outcome house pattern for optional capabilities
(offer, act only on yes, default output unchanged).
- 08-application-forms.md: ground claims against the framework's
01-candidate-profile.md + master CV + CLAUDE.md union (per #185)
instead of only 01, in both the governing rule and the checklist.
Per MadsLorentzen review on PR #212. Rebase onto merged #210 (the
tenure-check reference this file cites) still pending — #210 hasn't
landed on upstream/master yet.
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* feat(apply,interview): write confirmed facts back to the profile in the same turn
The grounding audit added in #185 removes any claim the three sources do
not support. That is right, and it has a consequence the framework did
not close: the audit cannot tell a fabrication from a real fact the user
stated out loud in an earlier session. Both look identical to it - absent
from the sources - and both get stripped.
So a fact that surfaces in conversation and is never written down is lost
silently. A real metric the user confirmed disappears from every
subsequent CV, and nothing reports that it happened.
Adds a standing rule to /apply: when the user confirms, corrects or
supplies a fact not already in 01-candidate-profile.md, write it there in
the same turn and bump framework_version. 01 is one of the audit's three
sources, so the fact is grounded on the next run.
Adds the same exception to /interview rule 5, which previously forbade
touching profile files outright. Interview prep is where new facts
surface most often - a recalled metric, a corrected scope, a filled-in
STAR stub - and prep files are not a substitute for the profile.
Notes the source-consistency case explicitly: a fact added to 01 that
CLAUDE.md and the master CV do not mention is an absence, not a
contradiction, so it does not trip the audit's profile-consistency
warning. If the new fact corrects either of those, fix it there too.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fixup(apply,interview): drop framework_version bump from write-back rule
Bumping 01's framework_version on a personal fact write-back corrupts
check_upstream_updates.py's upstream_version > local_version signal, and
diverges from /setup and /expand precedent where version tracks the
file's structure, not personal-data edits to its content.
Per MadsLorentzen review on PR #211.
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* fix(rank): include job posting URL in shortlist/below-threshold/excluded tables
/rank's output tables listed title, company, and score but dropped the
posting link, forcing the user to go dig it out of seen_jobs.json to
open a job they wanted to act on. The key in seen_jobs.json is already
the URL, so this is a formatting fix, not a new lookup.
* fix(rank): link to entry's url field, not the seen_jobs.json key
Some portals key seen_jobs.json entries by a company+title composite
rather than the URL, so [Link](<key>) could render a broken link.
Every entry carries a dedicated url field, which is always valid.
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.
Scans Gmail (via the claude.ai Gmail connector) for status signals on open tracked applications - interview invites, assessments, offers, rejections - and proposes them as a sourced batch the user must approve before anything is written to job_search_tracker.csv or outcome.md. Never proposes hired/offer_declined (user's real-world decision), routes conflicting signals to manual /outcome, appends-only to Notes, idempotent by message ID, read-only against the mailbox, and degrades to one message + clean exit without the connector (the /notion-sync precedent). State is gitignored personal data.
By @chenyuan99, who raised the connector-dependency tension himself and restructured classification and writing into separate approval-gated steps.
Reviewer-side Factual Grounding Audit: every date, employer, job title, and quantitative metric in both drafts is checked against the union of 01-candidate-profile.md + cv/main_example.tex + CLAUDE.md's Candidate Profile section (grounded if ANY source supports it; inter-source mismatches surfaced as profile-consistency warnings; draft drift returned as Part A edits with reason "grounding"). Drafter-side rule makes those three sources the sole source of truth for facts - existing tailored CVs are structure/phrasing reference only. Fixes the structural drift loop where tailored output fed back as source material; companion to the maintainer-side #178 setup fix.
Reported, diagnosed, and implemented by @jovin-nicholas (#177); the failure mode was validated empirically by the output benchmark (blind judges found the exact escalation class in pre-audit outputs).
Closes#177
Seven improvements sourced from blind regression probes comparing current
outputs against real April-2026 baselines (each an area where the older
outputs scored better):
- every stated requirement addressed - matched or honestly gapped, never
silently omitted (the benchmark run omitted a stated Kubernetes
requirement entirely; omission reads as hiding under questioning)
- nice-to-haves engaged by name with honest adjacency framing; posting's
literal term preferred, including in CV section headings
- stated logistics/prerequisites addressed in the letter (clearances,
availability, job ID, multi-country language mapping)
- domain-transfer argument leads the CV profile statement for
domain-changers
- evidence links (href) on every verifiable named artifact
framework_version: 05 -> 1.2.0.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
The CV was hardcoded 'Always in English' - fine for the Danish/English
demonstration profile, a real disadvantage for fork users in markets
where applications are expected in the local language (cover letters
already match the posting's language). /setup now asks once and records
'CV language:' in CLAUDE.md's Identity section; /apply reads it with
English as the default, so existing users see zero behavior change.
The ATS keyword rule is reworded language-neutrally.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Completes the loop behind #177: /apply drift could be archived by
/outcome, then laundered into 05-cv-templates.md as a reusable template
by /setup Path A - promoting a one-off drifted claim into source
material for every future application. Path A now verifies extracted
statements' factual claims against 01-candidate-profile.md (keeping
framing only), and 05-cv-templates.md marks [Used for:] statements as
phrasing references, never fact sources. framework_version 1.1.0.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Prompt-injection hardening from the dataflow analysis in #173 by @Defaultuser361: data-not-instructions rules in /apply and /rank, reviewer research constrained to the user-confirmed company identity, writing-style verify rule tightened to independently located sources (framework_version 1.1.0), SECURITY.md private reporting channel, README note. Closes#173.
CVs from /apply were named cv/main_<company>.tex, so a second role at the same company overwrote the first (cover letters already carried the role). Aligns CV naming to main_<company>_<role>.tex across apply, add-template, the CV template guide, CLAUDE.md, and SETUP.md; /outcome and /interview fallbacks glob main_<company>*.tex to match both legacy and new names. framework_version bumped on both touched framework files.
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.
* feat(brand): tagline under the H1 and the hired-moment coffee line
Two items from the branding strategy: the positioning line lands in the
README itself (the repo description already carries it), and /outcome's
hired path gets its single, value-framed donation ask - once per hire,
never nagging, never effort-framed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(brand): pay-it-forward framing for the hired-moment line
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
07-interview-prep.md holds STAR examples, tough-question patterns,
questions to ask, and a roleplay protocol - and the job-application-
assistant skill names interview preparation as its Step 4 - but no
command operationalizes any of it. /apply ends at 'files ready', and the
moment an interview is scheduled, nothing picks the thread back up.
/interview does, drawing on the archive /outcome now maintains.
/apply optimizes what the company reads; /interview optimizes what the
company hears. The bridge is consistency: the interviewer read the
submitted CV and cover letter, so prep must match what they claim.
How it works:
- Loads the application context from documents/applications/
<company>_<role>/: the exact posting, the submitted drafts, and
outcome.md's stage + recorded feedback (stage-N feedback is the
highest-priority input for stage-N+1 prep). Graceful fallbacks for
applications that predate /outcome, stating plainly what is missing
- Runs the Company Research Checklist that 04-job-evaluation.md defines
but nothing executes, plus interviewer-angle notes from public
professional info only. Every company claim is independently verified
before it enters the pack - same rule as cover-letter claims
- Builds a stage-specific prep pack: likely questions (earlier-stage
feedback first, then fit-evaluation gaps with honest bridge answers
per 07's you-don't-have-X pattern, then posting requirements, then
stage type), STAR mapping via 07's Use-for tags with new drafts
grounded strictly in profile facts, a consistency brief of the
submitted documents' probeable claims, customized tough questions,
4-6 questions to ask, logistics tips
- Saves the pack to documents/applications/<company>_<role>/
interview_prep_<stage>.md - one per stage, kept as history,
gitignored with the rest of the archive
- Offers a mock interview following 07's Roleplay Guidelines verbatim,
with feedback calibrated against the behavioral profile (same
voice-consistency rule the /apply reviewer applies)
- Never edits framework or profile files, except appending
user-approved STAR examples to 07 on explicit request
Integration: /outcome suggests /interview when an interview stage is
recorded; documents/README.md documents the interview_prep_<stage>.md
files (and that /setup ignores them); README commands list and file
tree.
/setup Path A already mines documents/applications/<company>_<role>/
(job_posting.md, submitted drafts, outcome.md) to calibrate
04-job-evaluation.md and surface STAR candidates - but nothing in the
workflow systematically writes those folders, so the calibration machinery
only runs for users who hand-maintain the archive. /outcome closes the
loop: it writes the data /setup reads.
How it works:
- Identifies the application from job_search_tracker.csv (by argument, or
by listing open applications); applications made outside the workflow
get a new tracker row
- Records progress updates (interview stages, offers) and resolutions
using the exact status enum documents/README.md documents, plus one
additive value: in_progress, for open applications between updates.
/setup's calibration only draws conclusions from final statuses
- Archives the submitted cv_draft.tex / cover_letter.tex (copy, never
move; existing archived files are never overwritten - the archive is
what was actually submitted) and fetches job_posting.md from the
tracker's source URL while it is still alive; a dead URL gets a
user-pasted copy or an explicit unavailable stub, never a
reconstruction
- Updates the tracker row's status and notes; never restructures the CSV
- After 3+ resolved outcomes (or a repeating pattern), points the user
back to /setup Path A - /outcome writes data, /setup interprets it,
and this command never edits framework or profile files itself
- Idempotent: re-running appends stages and dated notes, never
duplicates folders, rows, or history
Also aligns the outcome.md status enum across docs: setup.md Step A3
listed hired/rejected/no_response/interview_only while documents/README.md
already had offer_declined; both now carry the full enum including
in_progress. documents/applications/** and the tracker are already
gitignored, so all recorded data stays personal.
Docs: README (commands list, file tree), documents/README.md (/outcome
cross-reference and in_progress semantics), one-line handoff at the end
of /apply Step 6.
/scrape finds and dedupes postings; /apply evaluates one at a time in
depth. Nothing connects the two ends: after a scrape returns 20 jobs, the
user eyeballs a table to decide where to spend /apply effort. /rank is the
bridge: batch-score every new posting against the fit framework and return
a ranked shortlist.
How it works:
- Selects jobs with status "new" from job_scraper/seen_jobs.json (--all
re-ranks everything unapplied; a focus argument filters), excluding
anything already in job_search_tracker.csv
- Dispatches parallel general-purpose agents (~5 jobs each) that WebFetch
each posting and score the five dimensions from 04-job-evaluation.md.
The rubric (skill match areas, career goals, deal-breakers) is passed
inline per the same token-efficiency rules /apply uses; agents score
only from actually fetched content and mark dead postings expired,
never guessing from a title
- Triage depth by design: posting text vs. profile only - no company
research, no salary lookups. /apply's Step 1 evaluation stays
authoritative and always re-runs on handoff
- Aggregates with the framework's 30/25/15/30 weighting and verdict
bands; location deal-breakers veto regardless of score; deadlines
within 7 days get urgency flags and win ties
- Updates seen_jobs.json additively (status "ranked"/"expired" plus
rank_score/rank_verdict/rank_date) so /scrape dedup keeps working;
the tracker is read-only. Re-running is idempotent
Integration: job-scraper SKILL.md documents the new status values and
suggests /rank after large scrape batches; README (commands list, file
tree, quick-start step 4).
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.
The README has always invited users outside Denmark to build equivalents of
the four Danish portal CLI skills, but doing so meant reverse-engineering
.agents/skills/*/cli/ by hand. /add-portal turns that invitation into a
guided workflow:
- Interviews the user for the portal URL, skill name, market/language
(trigger phrases in the local language, like the Danish skills), and a
realistic test query
- Investigates the portal before writing code: search-URL pattern, result
structure (JSON API preferred over HTML), detail-page pattern, robots.txt
and access rules. Auth-walled portals are declined; portals with
restrictive terms get a prominent personal-use-only warning in the
generated SKILL.md (same as linkedin-search)
- Scaffolds from the canonical structure with linkedin-search as the
zero-dependency reference, enforcing the shared portal-skill contract:
search/detail commands, common flags, {meta, results} JSON shape, stderr
JSON errors, backoff on 429/5xx, chunked parsing
- Mandatory live test-run (search + detail + test suite) before registering
- Optionally wires the portal into /scrape via search-queries.md
The generator is country-agnostic; its output is market-specific and stays
in the user's fork, matching the repo policy that upstream remains a
universal template.
Docs: README (commands list, file structure, Job search tools section) and
SETUP.md (CLI install section pointer).
Users could already swap the stock moderncv/cover.cls templates, but only by
hand-editing the guidance in 05-cv-templates.md and 06-cover-letter-templates.md.
/add-template automates that:
- Interviews the user for the template's instructions: compile engine, fonts
(bundled files or system), style rules to preserve, and hard page limit
- Stores the template profile-agnostic ([PLACEHOLDER] tokens) under templates/
with a TEMPLATE.md manifest, so templates are safe to commit and share
- Runs a mandatory test compile with dummy data before registering anything
- Activates via a single managed block in 05/06, which /apply already reads,
so no changes to the /apply workflow are needed; --use default is a clean
revert to the stock templates
- --list and --use <name> manage multiple registered templates
Docs: README (commands list, file structure, LaTeX templates section) and
SETUP.md (compile section pointer).
* refactor: fold /setup_docs into /setup as third onboarding path
- Auto-detect documents/ at /setup Step 0; offer three paths (documents
folder, single CV import, interview).
- Inline document-driven merge logic into setup.md as Path A: read-before-
write, additive vs. conflicting bucket merge with per-conflict prompts,
inference labeling for behavioral and style files, STAR-stub generation
instead of fabrication.
- Step 3 substeps for skill files skip when Path A populated them; non-
skill substeps (CLAUDE.md, cv/main_example.tex, search-queries.md)
always run. Path C interview preserved verbatim. /setup --section <name>
update flow preserved.
- Delete .claude/commands/setup_docs.md.
- Update /reset prompts to point only at /setup. Update documents/README.md
to reference /setup throughout. Update README.md to drop the standalone
/setup_docs section and add upskill/ to the skills tree.
- Remove the trailing comma left in .claude/settings.local.json after the
PR #6 revert (was technically invalid JSON).
Single onboarding entry point. documents/ folder convention unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(README): finish readme alignment for the /setup fold
- Update onboarding-paths tip to reflect three paths (was "Either"
before /setup_docs landed in PR #6).
- Add documents/ and upskill/ entries to the file tree, both relied
on by the new /setup Path A and by /upskill respectively.
- Add a brief "Other commands" section briefly describing /expand and
/upskill so users discover them without having to read individual
command files. /reset stays in the existing "Starting over"
subsection, now linked from the new section.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds four contributions from @Michael-Bach:
- /setup_docs - document-driven profile population from a documents/ folder (CV, LinkedIn export, diplomas, references, past applications). Idempotent merge with explicit additive vs. conflicting buckets and per-conflict prompts.
- /reset - typed-RESET confirmation gate for clearing profile data and/or documents folder contents.
- /expand - additive competency enrichment from documents and public URLs already in the profile (GitHub repos, portfolio sites), with web-searched syllabus lookups for named courses and certifications.
- /upskill - skill-gap analysis vs tracked jobs (or single URL), produces a prioritized heatmap and learning plan with year-tagged WebSearch queries.
Also adds the documents/ folder convention with README and gitignore entries for personal output files.
Closes#6
Brings 02-behavioral-profile.md back into the reviewer's reading list (it
was dropped in the earlier token-efficiency refactor). The reviewer now
reads four files: 01 (candidate profile), 02 (behavioral profile), 03
(writing style), 04 (job evaluation). The LaTeX template files (05, 06)
remain excluded, since those govern structural/LaTeX concerns the drafter
already applied.
The behavioral profile is there to let the reviewer cross-check whether
the cover letter's voice matches the candidate's natural register -
something 03-writing-style.md does not encode (03 has the universal rules
like "no em-dashes, no cliches"; 02 has the per-person register like
"Collaborator PI should not be given a combative solo-hero tone").
Also cross-references 02 explicitly in the Part B "Tone and style issues"
category so the reviewer actually uses it during critique rather than
just skimming it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Audit of master vs the branch found three real coverage gaps in the
reviewer prompt and drafter's revise step. This commit restores them
without giving up the Part A (JSON edits) + Part B (narrative) structure.
1. Action-oriented reframing is restored as a Part B narrative category.
The previous refactor folded reframing into Part A edit rationales, but
structural weakness (e.g. "the whole opening paragraph is passive") does
not fit a clean find-and-replace and needs prose critique to surface.
2. Tone and style phrasing is strengthened from "anything that conflicts
with 03-writing-style.md" back to the master's proactive "check
against... flag issues with tone, formality, or voice", with explicit
examples (cliches, hedging, over-humility, inconsistent register).
3. Step 4 revise guidance is expanded from "Apply Part A... Apply Part B
using judgment" (which could be read as a free-for-all) to an
enumerated sub-list under Part B covering every category the reviewer
returns: missed keywords, company angles, action-oriented reframing,
tone/style. Keeps the architectural change (Part A/B split) but
restores the master's explicit "apply every kind of fix" discipline.
Also adds a small instruction to the reviewer: produce each Part B
category even if the finding is "no issues" - silence on a category can
be mistaken for skipping it.
One remaining judgment call left unchanged in this commit: the reviewer
no longer reads 02-behavioral-profile.md. The refactor excluded it on the
reasoning that 03-writing-style.md carries the voice/tone rules. This is
a conscious trade-off; if voice-matching starts slipping in practice,
restoring 02 to the reviewer's reading list is a one-line fix.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>