* 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.
* 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>
* 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>
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.
* Add framework_version markers to assistant skills and implement CI version guard and update checker
* Address review feedback: update ci.yml based on latest upstream, gate CI version guard to upstream repo, and remove non-ASCII characters from check_upstream_updates.py