* 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.
16 KiB
/outcome - Record the Result of an Application
You are recording what happened to a job application: progress updates (interview invitations, stages completed, offers) and final resolutions (hired, rejected, no response). The data lands in two places the framework already reads but nothing systematically writes:
job_search_tracker.csv- the status column that/scrapeand/rankuse for dedup and exclusiondocuments/applications/<company>_<role>/- the per-application archive (posting, submitted drafts,outcome.md) that/setupPath A mines to calibrate04-job-evaluation.mdand surface STAR candidates
/outcome writes the data; /setup interprets it. This command never edits the evaluation framework or profile files itself.
The command also owns the stretch before there is an outcome to record: the follow-up branch (Step 2b) surfaces open applications that have gone quiet, drafts a brief follow-up note in the user's voice, and logs it - so the chase and the resolution it eventually leads to live in one flow.
Follow these steps in order.
Step 0: Parse Input
$ARGUMENTS may contain:
- Nothing → list open applications and ask which one to update
- A company name (optionally with a role), e.g.
/outcome acmeor/outcome acme ml engineer→ target that application followup→ enter the follow-up branch (Step 2b) over every quiet open application, using the default threshold of 10 daysfollowup <N>, e.g./outcome followup 14→ follow-up branch with an N-day thresholdfollowup <company>, e.g./outcome followup acme→ draft a follow-up for that application now, regardless of threshold
Step 1: Load State and Identify the Application
-
Read
job_search_tracker.csv. If it does not exist, create it with the standard header:date,company,sector,role,role_type,channel,status,contact_person,fit_rating,notes,cv_file,cover_letter_file,source -
With an argument: match rows case-insensitively on company (and role, if given). One match → proceed. Several → list them and ask. None → the application was made outside the workflow; collect company, role, date applied, channel, and posting URL from the user and add a tracker row.
-
Without an argument: list all rows whose status is not final (see Tracker status vocabulary below) as a numbered table (company, role, date applied, current status, days quiet, follow-ups sent) and ask which to update. The two derived columns come straight from existing data: days quiet counts from the row's
dateor the latest dated entry innotes, whichever is more recent; follow-ups sent counts thefollowed up YYYY-MM-DDmarkers innotes. If any open row is 10+ days quiet with fewer than two follow-ups sent, add one line under the table: "Some of these have gone quiet - want a follow-up draft? (Step 2b)". If every row is resolved, say so and stop.draftedrows are listed but never counted as quiet - nothing was sent, so nobody is late replying. List them under their own heading ("Drafted, not yet submitted"), leave days quiet and follow-ups sent blank, and keep them out of the follow-up offer above. -
Derive the archive folder name:
documents/applications/<company>_<role>/- lowercase, underscores for spaces (the convention documented indocuments/README.md). Check whether the folder and anoutcome.mdalready exist - if so, you are updating, not creating.
Tracker status vocabulary
Canonical spellings for the tracker CSV status column (underscores, never spaces):
drafted | applied | interview | offer | hired | rejected | no_response | offer_declined | withdrawn
- Final (application closed):
hired,rejected,no_response,offer_declined,withdrawn - Open: everything else,
draftedincluded — a row is active until its status is one of the Final values. draftedis open but distinct — nothing was sent, so no follow-up is ever due.- Readers must also accept the legacy space spellings
no responseandoffer declinedon read, so that existing trackers keep working without a migration. Never write them — they are the same values asno_responseandoffer_declined, not separate statuses, equally Final, and every rule that names one applies to the other.
Distinct from the archive
Status:enum indocuments/README.md(in_progress|hired|offer_declined|rejected|no_response|interview_only), which describes the per-applicationoutcome.mdfile, not this column. The two enums are never written to the same field.
Step 2: Collect What Happened
Ask the user what happened, then classify:
Progress updates (application still open):
- Interview invitation / stage scheduled or completed (phone screen, technical, case, final round)
- Offer received (not yet accepted or declined)
Resolutions (application closed) — these map to the archive Status: enum in documents/README.md that /setup parses (distinct from the tracker CSV column; see Tracker status vocabulary above):
hired- accepted an offeroffer_declined- received an offer, turned it downrejected- explicit rejection at any stageno_response- no reply; if the user is unsure whether to call it, note how long it has been since the last contact and let them decide - do not impose a cutoffinterview_only- reached interviews but the process stalled or was abandoned without an explicit rejection
Also collect, without interrogating - one or two open questions are enough:
- Dates for the stages reached
- Any feedback received, verbatim where the user remembers it
- What they'd do differently, and any signal about what the company valued (these feed
/setup's calibration and STAR-candidate mining, so concrete beats polished)
Step 2b: Follow-Up Branch (chase a quiet application)
Enter this branch from the followup argument (Step 0) or from the offer under the open-pipeline table (Step 1.3). Standard practice is a brief, polite follow-up one to two weeks after applying, at most twice; this branch operationalizes that.
Candidates. An application qualifies when its status is neither final nor drafted, the threshold has passed since its date (or since the last followed up marker in notes, if any), and it has fewer than two logged follow-ups. Parse dates defensively - skip rows whose dates do not parse and say so rather than guessing. Present qualifying applications as a table (company, role, days quiet, follow-ups sent, channel, contact person) and draft only for the ones the user picks.
Threshold. The 10-day default is deliberately earlier than /gmail-sync's 30-day staleness flag (its Step 9): that check is a read-only alarm that a row has been forgotten entirely; this branch is the proactive nudge while a reply is still plausible. The two numbers serve different moments, which is why they differ.
Drafting. For each selected application:
- Read the archive folder: the
job_posting.md,cv_draft.tex, andcover_letter.texthat Step 3 maintains are the source of every claim the note may make - this is Rule 3 (never fabricate) widened to "no new claims": a follow-up that introduces skills or experience the submitted materials don't contain is a fabrication vector. - Apply the writing style rules from
03-writing-style.md(no cliches, no em-dashes, warm but direct), and match the application's language - draw the register from the archived cover letter. - Write roughly 60 to 120 words: address the
contact_personfrom the tracker if present (otherwise the team, in the application's language); one sentence restating interest in the specific role; one concrete value-reminder drawn from the submitted materials; one polite question about the timeline. No pressure, no "just checking in" filler. - Shape it for the
channelcolumn: email (with a subject line reusing the application's headline), LinkedIn message (shorter, no subject), or portal message (plain text). - Present the draft and iterate until the user is happy.
Logging. Once the user confirms they will send it (or have sent it), log it in the same turn - an unlogged follow-up breaks the next run's quiet-days math:
- Append
followed up YYYY-MM-DDto the row'snotescolumn (Step 4's rule applies: append a dated note, never restructure the CSV). - Save the final note as
followup_YYYY-MM-DD.mdin the application's archive folder. Safe by documented convention:/setupreads only the four named archive files and ignores extras (the same rule that covers/interview's prep files), anddocuments/applications/**is gitignored personal data.
If the user decides not to send, log nothing.
Termination. When an application hits two follow-ups and stays silent, do not offer a third. This is the moment to continue in this same command's Step 2: note how long it has been since last contact and let the user decide whether to record no_response - as ever, no imposed cutoff. And if the user mentions an actual response while in this branch (an interview invitation, a rejection), drop out of the branch and record it through the normal Step 2 flow.
Step 3: Archive the Application Materials
Create or update documents/applications/<company>_<role>/. All content here is personal data - the folder is already gitignored (documents/applications/**), so nothing needs redacting.
cv_draft.texandcover_letter.tex- copy (never move) the submitted files. Locate them via the tracker row'scv_file/cover_letter_filecolumns; if those are empty, look forcv/main_<company>*.texandcover_letters/cover_<company>_*.tex. If a file already exists in the archive, leave it - the archived version is what was actually submitted. If no draft files exist (application made outside/apply), skip with a note.job_posting.md- if it already exists, leave it. Otherwise try WebFetch on the tracker row'ssourceURL and save the posting text, retrying a 403 with browser headers per.claude/skills/job-application-assistant/09-web-research.md. If the URL is dead (postings expire fast - this is exactly why the archive matters), ask the user to paste the posting, or write a stub noting the posting is unavailable. Never reconstruct a posting from memory.outcome.md- write or update it in exactly the format documented indocuments/README.md, so/setupPath A parses it without special cases:
# Outcome: <Company> — <Role>
**Status:** in_progress | hired | offer_declined | rejected | no_response | interview_only
**Date resolved:** YYYY-MM-DD <- only when resolved; omit while in_progress
## Interview stages reached
- [x] Phone screen (YYYY-MM-DD)
- [ ] Technical interview
- [ ] Case interview
- [ ] Final round
- [ ] Offer received
## Notes
<feedback received, what to do differently, signals about what they valued -
appended per update with a date, never overwritten>
Update rules: tick stage checkboxes as they are reached (add the date in parentheses), append dated entries to Notes, and only change Status from in_progress to a final value on resolution. Re-running /outcome on the same application is idempotent - it appends new information, never duplicates or rewrites history.
Thank-you note trigger: when this step ticks a newly completed interview stage, offer in the same turn: "Want a short thank-you note for the interviewer? A prompt one is standard practice." If accepted, draft it under Step 2b's drafting and logging rules (same voice, same no-new-claims boundary, same followup_YYYY-MM-DD.md archive convention). Recording the stage is the trigger - no scanning for recent stages is ever needed.
Step 4: Update the Tracker
Update the matched row's status column using the canonical spellings from Tracker status vocabulary above (e.g. drafted → applied → interview → offer → hired / rejected / no_response / offer_declined / withdrawn) and append a short dated note to the notes column. Never restructure the CSV, reorder rows, or touch other rows.
Moving a row off drafted: rows written by /apply Step 6b carry the date the documents were drafted, not the date they were sent. Whenever this step advances such a row to any other status - applied, or straight to interview or rejected when the user reports an outcome for something they submitted without recording it - overwrite its date column with the actual submission date. The date column is read as "applied on" by /notion-sync and drives /html-report's year/season grouping and this command's own days-quiet count, so leaving the draft date in place would misreport the application.
Step 5: Calibration Handoff
Count the outcome.md files under documents/applications/ with a final status (not in_progress).
- If 3 or more are resolved (or 2+ share a pattern - same role type rejected twice, same sector going silent), suggest:
"You now have resolved applications on record. Run
/setup(Path A) to fold them into your evaluation framework - it calibrates fit scoring from what actually got interviews, and mines your interview feedback for STAR examples." - Do not write anything into
04-job-evaluation.mdor other skill files yourself./setupPath A owns that merge - it is read-before-write and idempotent, and duplicating its logic here would race it.
Step 6: Confirm
Summarize what was recorded:
Outcome recorded for at .
documents/applications/<company>_<role>/outcome.md- status: ,- Archived: <which of cv_draft.tex / cover_letter.tex / job_posting.md were copied or fetched, and which were skipped and why>
- Tracker: status →
[Calibration suggestion from Step 5, if triggered]
If the update recorded an upcoming or newly scheduled interview stage, also suggest:
"Interview coming up?
/interview <company>builds a prep pack for that stage from this application's archive - the posting, the documents you submitted, and any feedback recorded from earlier rounds."
If the recorded status is hired, congratulate the user warmly first - this is the moment the whole framework exists for. Then add this single line (once; never on re-runs for the same application, and never for any other status):
"If this framework helped you get there, consider buying it a coffee - it keeps this free for the next job-seeker out there. ☕"
Important Rules
- Write data, don't interpret it. The archive and tracker are the outputs; calibration belongs to
/setup. This command never edits profile or framework files. - The archived version is the submitted version. Existing files in the application folder are never overwritten by fresher drafts.
- Never fabricate. A dead posting URL gets a user-pasted copy or an explicit "unavailable" stub, not a reconstruction. Feedback is recorded as the user reports it.
- Stay schema-compatible.
outcome.mdfollows the format indocuments/README.mdexactly (in_progressis the one addition, for open applications); the tracker keeps its columns. - Idempotent updates. Re-running on the same application appends new stages and notes; it never duplicates folders, rows, or history.
- Follow-ups: draft only, never send. The follow-up branch produces text for the user to send themselves. It never emails, messages, or submits anything, and it must not be wired to tools that do.
- Follow-ups: no new claims. Every substantive statement in a follow-up or thank-you note comes from the archived submitted materials. Rule 3 applies with no exceptions.
- Maximum two follow-ups per application. After the second silent follow-up, the honest move is recording the resolution, not persistence.