From e09d3eb37b11a5bfbb63dae3c3c502a108b23f3c Mon Sep 17 00:00:00 2001 From: Oscar Madera <80536682+oscarbol09@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:47:24 -0500 Subject: [PATCH] fix(workflow): define tracker status enum once in /outcome, normalise readers (#299) * 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. --- .claude/commands/apply.md | 2 +- .claude/commands/gmail-sync.md | 2 +- .claude/commands/html-report.md | 7 +- .claude/commands/interview.md | 2 +- .claude/commands/notion-sync.md | 4 +- .claude/commands/outcome.md | 24 +- .../skills/job-application-assistant/SKILL.md | 2 +- CHANGELOG.md | 34 ++ tests/test_tracker_status_vocab.py | 353 ++++++++++++++++++ 9 files changed, 421 insertions(+), 9 deletions(-) create mode 100644 tests/test_tracker_status_vocab.py diff --git a/.claude/commands/apply.md b/.claude/commands/apply.md index 0505d57..a0d6aee 100644 --- a/.claude/commands/apply.md +++ b/.claude/commands/apply.md @@ -319,7 +319,7 @@ Do this before the optional offer below, and before ending the turn for any othe ``` date,company,sector,role,role_type,channel,status,contact_person,fit_rating,notes,cv_file,cover_letter_file,source ``` -2. Match existing rows case-insensitively on company and role. **On no match, or when every match holds a final status, append a new row. On a match that is still open, update it.** When you append alongside a final row, say so — the earlier application to that role keeps its own row and its own outcome. +2. Match existing rows case-insensitively on company and role. **On no match, or when every match holds a final status, append a new row. On a match that is still open, update it.** "Final" and "open" are defined by the **Tracker status vocabulary** in `/outcome` — the legacy space spellings `no response` / `offer declined` count as final, so a closed application never gets its row overwritten. When you append alongside a final row, say so — the earlier application to that role keeps its own row and its own outcome. 3. Values for a new row: | Column | Value | diff --git a/.claude/commands/gmail-sync.md b/.claude/commands/gmail-sync.md index 26ebbf5..280d161 100644 --- a/.claude/commands/gmail-sync.md +++ b/.claude/commands/gmail-sync.md @@ -28,7 +28,7 @@ Confirm the Gmail MCP tools (`mcp__claude_ai_Gmail__*`) are available. If not, t 1. Read `job_search_tracker.csv`. If it does not exist, tell the user there is nothing to sync against yet (suggest `/outcome` or `/apply` first) and stop. Do not create it here - `/gmail-sync` never originates new applications, only updates existing ones. 2. Read `gmail_sync/state.json` (create if missing: `{"last_sync": null, "processed_message_ids": []}`). -3. Build the set of **open applications**: tracker rows whose `status` is not a final value (`hired`, `rejected`, `no response`, `offer declined`, `withdrawn`). For each, derive its archive folder `documents/applications/_/` (lowercase, underscores - same convention as `/outcome`) and check whether `outcome.md` exists there. +3. Build the set of **open applications**: tracker rows whose `status` is not **Final** (per the **Tracker status vocabulary** in `/outcome`). For each, derive its archive folder `documents/applications/_/` (lowercase, underscores - same convention as `/outcome`) and check whether `outcome.md` exists there. **`drafted` rows stay in this set, and are the reason it is worth searching.** `/apply` writes them but never submits; the user submits by hand and may not think to run `/outcome`. A reply arriving against a row still marked `drafted` is exactly that case, and the row holds the company name the search needs. 4. If `$ARGUMENTS` named a company, filter this set to the matching row(s) (case-insensitive). No match → tell the user and stop, do not guess. diff --git a/.claude/commands/html-report.md b/.claude/commands/html-report.md index 78bb4a2..4d871b8 100644 --- a/.claude/commands/html-report.md +++ b/.claude/commands/html-report.md @@ -27,7 +27,12 @@ Status normalisation — map tracker values to six canonical buckets before comp - `interview` → **Interview** - `offer` → **Offer** - `hired` → **Hired** -- `rejected` / `no_response` / `no response` / `offer_declined` / `interview_only` / `withdrawn` → **Rejected/Closed** +- `rejected` / `no_response` / `no response` / `offer_declined` / `offer declined` / `withdrawn` → **Rejected/Closed** +- anything else → **Rejected/Closed**, and name the unrecognised value once in the status breakdown — matching is case-insensitive + + The bucket map tolerates the legacy space spellings on read so nothing written before + the canonical forms were locked drops out of the stats; the **Tracker status vocabulary** + in `/outcome` is the authoritative set. --- diff --git a/.claude/commands/interview.md b/.claude/commands/interview.md index 98370da..149f17f 100644 --- a/.claude/commands/interview.md +++ b/.claude/commands/interview.md @@ -13,7 +13,7 @@ Follow these steps **in order**. `$ARGUMENTS` may contain a company name (optionally with a role), e.g. `/interview acme`. - **With an argument:** match against `job_search_tracker.csv` rows (case-insensitive on company, then role). One match → proceed. Several → list and ask. None → this application isn't tracked; suggest `/outcome ` to register it first, or accept the posting and role details directly if the user wants to prep anyway. -- **Without an argument:** list tracker rows whose status suggests a live process (`interview`, `offer`, or recently `applied`) and ask which one. If the tracker is empty, ask for the company, role, and posting. +- **Without an argument:** list tracker rows whose status suggests a live process — an open status per the **Tracker status vocabulary** in `/outcome` (`interview`, `offer`, or recently `applied`; `drafted` is open but nothing was sent, so it never qualifies) — and ask which one. If the tracker is empty, ask for the company, role, and posting. v1 preps for a **specific application**. Generic no-target practice is out of scope - if asked, prep against a real tracked application instead. diff --git a/.claude/commands/notion-sync.md b/.claude/commands/notion-sync.md index 6742b1c..2c5f777 100644 --- a/.claude/commands/notion-sync.md +++ b/.claude/commands/notion-sync.md @@ -62,7 +62,7 @@ Validate the cheap, local precondition before creating anything external. A run | Company | rich text | | | Score | number | 0-100 from `rank_score` | | Verdict | select | Strong Fit / Good Fit / Moderate Fit / Weak Fit / Poor Fit | - | Status | select | ranked / drafted / applied / interview / offer / hired / rejected / no response / withdrawn / expired | + | Status | select | `ranked` / `drafted` / `applied` / `interview` / `offer` / `hired` / `rejected` / `no_response` / `offer_declined` / `withdrawn` / `expired` — canonical tracker spellings per **Tracker status vocabulary** in `/outcome`; Notion options grow to match as values appear | | Fit | select | high / medium / low (scraper quick-fit) | | Deadline | date | omit when unknown | | First seen | date | | @@ -90,6 +90,8 @@ For each job in the sync set: 3. **Match** → update **properties only**: Status, Score, Verdict, Deadline, Ranked, Applied on, Channel, CV file, Cover letter. Properties are the always-current surface (bodies are write-once), so tracker updates recorded by `/outcome` reach the destination exclusively through them. Do not touch the page body - the user may have added their own notes there, and clobbering them breaks trust in the whole view. (`--rebuild` is the sole exception.) 4. Never delete or archive pages, even for jobs that turned `expired` - set Status to `expired` instead. Rows the user added to the database by hand (no `Key` value) are invisible to this command. +**Normalise the Status value before writing.** The tracker may hold legacy space spellings (`no response`, `offer declined`) from before the canonical forms were locked. Map them to `no_response` / `offer_declined` per the **Tracker status vocabulary** in `/outcome` before setting Status on create or update - never push a space form to Notion, which would auto-create a separate select option per unique string. Pre-existing space-form options in an existing database simply go unused; Notion never auto-removes select options. + Batch politely: if the MCP server rate-limits, back off and continue; report any page that failed rather than retrying indefinitely. --- diff --git a/.claude/commands/outcome.md b/.claude/commands/outcome.md index 61104d3..bed86b8 100644 --- a/.claude/commands/outcome.md +++ b/.claude/commands/outcome.md @@ -32,13 +32,31 @@ Follow these steps **in order**. date,company,sector,role,role_type,channel,status,contact_person,fit_rating,notes,cv_file,cover_letter_file,source ``` 2. **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. -3. **Without an argument:** list all rows whose status is not final (not hired / rejected / no response / withdrawn / offer declined) 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 `date` or the latest dated entry in `notes`, whichever is more recent; **follow-ups sent** counts the `followed up YYYY-MM-DD` markers in `notes`. 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. +3. **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 `date` or the latest dated entry in `notes`, whichever is more recent; **follow-ups sent** counts the `followed up YYYY-MM-DD` markers in `notes`. 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. **`drafted` rows 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. 4. Derive the archive folder name: `documents/applications/_/` - lowercase, underscores for spaces (the convention documented in `documents/README.md`). Check whether the folder and an `outcome.md` already 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, `drafted` included — a row is active until its status is one of the **Final** values. +- **`drafted`** is open but distinct — nothing was sent, so no follow-up is ever due. +- Readers must also accept the legacy space spellings `no response` and `offer declined` on read, so that existing trackers keep working without a migration. Never write them — they are the same values as `no_response` and `offer_declined`, not separate statuses, equally **Final**, and every rule that names one applies to the other. + +> Distinct from the archive `Status:` enum in `documents/README.md` +> (`in_progress` | `hired` | `offer_declined` | `rejected` | `no_response` | `interview_only`), +> which describes the per-application `outcome.md` file, 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: @@ -47,7 +65,7 @@ Ask the user what happened, then classify: - 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 status enum in `documents/README.md` that `/setup` parses: +**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 offer - `offer_declined` - received an offer, turned it down - `rejected` - explicit rejection at any stage @@ -123,7 +141,7 @@ Update rules: tick stage checkboxes as they are reached (add the date in parenth ## Step 4: Update the Tracker -Update the matched row's `status` column (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. +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. diff --git a/.claude/skills/job-application-assistant/SKILL.md b/.claude/skills/job-application-assistant/SKILL.md index 76ae8c5..fdc03da 100644 --- a/.claude/skills/job-application-assistant/SKILL.md +++ b/.claude/skills/job-application-assistant/SKILL.md @@ -5,7 +5,7 @@ description: > and preparing for interviews. Triggers on keywords like: job posting, job application, CV, cover letter, resume, interview prep, job fit, career, application, apply, ansøgning, stilling allowed-tools: Read, Glob, Grep, WebFetch, WebSearch, Bash, Edit, Write, AskUserQuestion -framework_version: 1.3.0 +framework_version: 1.3.1 --- # Job Application Assistant diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a077b7..2fc4f56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,40 @@ per-file diff commands. ## [Unreleased] +### Fixed + +- **Tracker status enum defined once; `offer declined`/`no response` now reach the correct + `/html-report` bucket and `/gmail-sync` correctly marks them final** (#298). The tracker + CSV `status` column had no single authoritative definition. Six command files restated it + independently with inconsistent spellings, producing two concrete bugs: + + - `/outcome` Step 4 wrote `no response` and `offer declined` (with spaces). `/html-report` + Step 1 normalised only `no_response` / `offer_declined` (underscores), so any row written + with spaces matched no bucket and was silently dropped from the rejection-rate denominator. + - `/gmail-sync` Step 2 defined the "final" set with the space forms, so a row written with + underscores was never recognised as final and the sync kept chasing closed applications. + - `/html-report` included `interview_only` in the tracker bucket map; that value belongs to + the archive `outcome.md` `Status:` field, not the CSV `status` column. + + Fix: a `## Tracker status vocabulary` block in `/outcome` (the only writer of the CSV) + now defines the canonical set once with underscore spellings and the **Final** set by + explicit list — everything else, `drafted` included, is **Open**. The legacy space + spellings are the same values, not separate statuses: equally **Final**, and every rule + that names one form applies to the other — readers must accept them on read, and never + write them. Every reader that makes final/open decisions references that block (`/apply` + Step 6b, `/interview` Step 0, `/gmail-sync` Step 2, `/html-report` Step 1, `/notion-sync` + Steps 3-4). `/outcome` Step 4 writes `no_response` / `offer_declined`; `/notion-sync` + normalises both forms to the canonical spellings before setting the Status property; + `/html-report`'s bucket map loses `interview_only`, keeps both spellings, and gains a + case-insensitive catch-all that maps unrecognised values to **Rejected/Closed** and names + them once in the status breakdown. Pinned by `tests/test_tracker_status_vocab.py`. + + **Fork heads-up:** if your personalized `/outcome` adds `no response` or `offer declined` + (space forms) to the tracker write path, swap them for the underscore forms. Existing rows + keep working because every reader now accepts both spellings on read. If your Notion + database already carries space-form Status options, they simply go unused — Notion never + auto-removes select options. + ## [1.4.0] - 2026-08-07 ### Added diff --git a/tests/test_tracker_status_vocab.py b/tests/test_tracker_status_vocab.py new file mode 100644 index 0000000..b7423da --- /dev/null +++ b/tests/test_tracker_status_vocab.py @@ -0,0 +1,353 @@ +"""Guards for the tracker status vocabulary (issue #298). + +The tracker CSV `status` column has a single authoritative definition in +/outcome's "Tracker status vocabulary" block. Every reader that mentions +final or open statuses must defer to that block or explicitly accept both +the canonical underscore spellings and the legacy space spellings on read. + +These tests pin the two concrete bugs that opened #298: + +1. `offer declined` (space form, written by the old /outcome Step 4) landed + in no /html-report bucket, silently shrinking the rejection-rate denominator. +2. `interview_only` was listed as a tracker bucket value in /html-report, but + it belongs to the archive outcome.md Status: enum, never the CSV column. + +They also pin the review findings on the fix itself: + +3. The space spellings are the same statuses as the underscore forms (equally + Final), so a reader applying the lists literally cannot land on "not Final, + not Open, undefined" - which would otherwise misroute a closed application + in /apply's append-vs-update decision. +4. The vocabulary block must not split Step 1's numbered list: section-scoped + reads of Step 1 must still see items 2-4. +5. /html-report's bucket map needs a catch-all so no tracker value drops out of + the stats silently, and /notion-sync must normalise space forms before + writing Status (Notion auto-creates a select option per unique string). +6. /apply and /interview make status decisions and must anchor them to the + block, not restate an ad-hoc set. + +They follow the CASES-table pattern from test_apply_records_application.py so +that adding a new reader is a one-line addition to READER_CASES. +""" +import re +import unittest +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent +COMMANDS = REPO / ".claude" / "commands" + +OUTCOME = COMMANDS / "outcome.md" +GMAIL_SYNC = COMMANDS / "gmail-sync.md" +HTML_REPORT = COMMANDS / "html-report.md" +NOTION_SYNC = COMMANDS / "notion-sync.md" +APPLY = COMMANDS / "apply.md" +INTERVIEW = COMMANDS / "interview.md" + +VOCAB_ANCHOR = "## Tracker status vocabulary" + + +def section(path: Path, heading: str) -> str: + """Body of one markdown section, up to the next heading of any depth.""" + text = path.read_text(encoding="utf-8") + start = text.index(heading) + len(heading) + rest = text[start:] + end = re.search(r"^#{1,4} ", rest, re.MULTILINE) + return rest[: end.start()] if end else rest + + +class VocabularyBlockExists(unittest.TestCase): + """The canonical definition must live in /outcome and nowhere else.""" + + def test_outcome_has_vocabulary_block(self): + self.assertIn( + VOCAB_ANCHOR, + OUTCOME.read_text(encoding="utf-8"), + "/outcome must contain the ## Tracker status vocabulary block — " + "that block is the single source of truth for tracker CSV spellings", + ) + + def test_vocabulary_block_lists_underscore_canonical_spellings(self): + vocab = section(OUTCOME, VOCAB_ANCHOR) + for canonical in ("no_response", "offer_declined"): + self.assertIn( + f"`{canonical}`", + vocab, + f"The vocabulary block must list `{canonical}` as a canonical spelling", + ) + + def test_vocabulary_block_has_read_tolerance_line(self): + vocab = section(OUTCOME, VOCAB_ANCHOR) + self.assertIn( + "no response", + vocab, + "The vocabulary block must mention the legacy space spelling 'no response' " + "so readers know to accept it on read", + ) + self.assertIn( + "offer declined", + vocab, + "The vocabulary block must mention the legacy space spelling 'offer declined' " + "so readers know to accept it on read", + ) + + def test_vocabulary_block_states_equivalence_of_space_forms(self): + """The space spellings are the same statuses as the underscore forms, not + separate values. Without this, a reader applying the Open/Final lists + literally lands on "not Final, not Open, undefined" for `offer declined`, + and /apply Step 6b would take the update branch for a closed application + instead of appending a fresh row - losing the earlier document trail.""" + vocab = section(OUTCOME, VOCAB_ANCHOR) + self.assertIn( + "same values", + vocab, + "The vocabulary block must state that the space spellings are the same " + "values as the canonical underscore forms", + ) + self.assertIn( + "not separate statuses", + vocab, + "The vocabulary block must state that the space spellings are not " + "separate statuses", + ) + self.assertIn( + "equally", + vocab, + "The vocabulary block must state that the space spellings are equally " + "Final, so finality decisions cover them", + ) + + def test_vocabulary_block_defines_open_by_exclusion(self): + """Open is derived by exclusion from the one explicit Final list, so a new + status needs updating in a single place and unknown values stay open until + declared final.""" + vocab = section(OUTCOME, VOCAB_ANCHOR) + self.assertIn( + "everything else", + vocab, + "The vocabulary block must define Open as everything not in the Final " + "list, not as a second explicit list that can drift", + ) + + def test_step1_section_contains_all_items(self): + """The vocabulary block must live as its own section below Step 1's closing + ---, not between Step 1's numbered items. A block inside the list truncates + section-scoped reads of Step 1 to item 1, and a future test scoped to Step 1 + would pass against a stub.""" + step1 = section(OUTCOME, "## Step 1: Load State and Identify the Application") + for needle in ("With an argument", "Without an argument", "Derive the archive"): + self.assertIn( + needle, + step1, + f"Step 1's numbered list must be intact - '{needle}' must sit inside " + "Step 1, not under the vocabulary heading", + ) + + def test_outcome_step4_writes_underscore_forms(self): + """The writer must use canonical underscore spellings, never space forms.""" + step4 = section(OUTCOME, "## Step 4: Update the Tracker") + # The canonical forms must be present as the write target + self.assertIn( + "no_response", + step4, + "Step 4 must write `no_response` (underscore), not `no response` (space)", + ) + self.assertIn( + "offer_declined", + step4, + "Step 4 must write `offer_declined` (underscore), not `offer declined` (space)", + ) + + +class ReadersBucketMap(unittest.TestCase): + """Each reader that classifies tracker values must handle both spellings + and must not include archive-only values in tracker buckets.""" + + def test_html_report_bucket_includes_space_and_underscore_forms(self): + """Read-tolerance: both spellings must reach the Rejected/Closed bucket.""" + # Scope to the bucket-map section, not the whole file, so the assertion + # proves the mapping exists where stats are computed - a stray mention + # anywhere else in the file would otherwise satisfy it. + step1 = section(HTML_REPORT, "## Step 1: Collect Data") + self.assertIn( + "no response", + step1, + "/html-report must accept the legacy 'no response' (space) form so that " + "existing trackers are not silently excluded from stats", + ) + self.assertIn( + "no_response", + step1, + "/html-report must accept the canonical 'no_response' (underscore) form", + ) + self.assertIn( + "offer declined", + step1, + "/html-report must accept the legacy 'offer declined' (space) form", + ) + self.assertIn( + "offer_declined", + step1, + "/html-report must accept the canonical 'offer_declined' (underscore) form", + ) + + def test_html_report_bucket_map_has_catch_all(self): + """No tracker value may drop out of the stats silently: unrecognised values + fall to Rejected/Closed and are named once in the status breakdown.""" + step1 = section(HTML_REPORT, "## Step 1: Collect Data") + self.assertIn( + "anything else", + step1, + "The bucket map must have a catch-all line for unrecognised tracker values", + ) + self.assertIn( + "unrecognised", + step1, + "The catch-all must name the unrecognised value once so the drop is " + "visible instead of silent", + ) + + def test_html_report_bucket_does_not_contain_interview_only(self): + """`interview_only` is the archive outcome.md Status: enum value, + never a tracker CSV status. Listing it in the tracker bucket map + confuses the two enums and would classify archive-only values + that should not appear in the CSV.""" + # We only care about the bucket map section, not the whole file, + # to avoid false positives from comments or this test file itself. + step1 = section(HTML_REPORT, "## Step 1: Collect Data") + self.assertNotIn( + "interview_only", + step1, + "`interview_only` must not appear in /html-report's tracker bucket map — " + "it is part of the archive `outcome.md` Status: enum, not a tracker CSV value", + ) + + def test_gmail_sync_references_vocabulary_block(self): + """gmail-sync must defer to /outcome's vocabulary block for the + open-application set, not hardcode the final-status set with + space spellings that diverge from the writer.""" + step2_text = section(GMAIL_SYNC, "## Step 2: Load State") + self.assertIn( + "Tracker status vocabulary", + step2_text, + "/gmail-sync Step 2 must reference the /outcome vocabulary block " + "instead of restating the final-status set with its own spellings", + ) + self.assertNotIn( + "no response", + step2_text, + "/gmail-sync Step 2 must not restate the space spellings locally - " + "the vocabulary block is the single source for what counts as final, " + "and a second local list is what drifted in #298", + ) + + def test_notion_sync_normalises_status_before_write(self): + """Step 4 must map legacy space spellings to canonical before setting + Status. Notion auto-creates a select option per unique string, so pushing + a space form would give an existing database two options for one status + and split closed applications across two filter buckets.""" + step4_text = section(NOTION_SYNC, "## Step 4: Upsert Database Rows") + self.assertIn( + "never push a space form", + step4_text, + "/notion-sync Step 4 must never write a space-form status to Notion", + ) + self.assertIn( + "Tracker status vocabulary", + step4_text, + "/notion-sync Step 4 must map space forms per the /outcome vocabulary block", + ) + + def test_notion_sync_uses_underscore_status_spellings(self): + """Notion Status select options must match canonical tracker spellings + so that upserted values are consistent with what /outcome writes.""" + step3_text = section(NOTION_SYNC, "## Step 3: Load Sync State and Locate the Database") + self.assertIn( + "no_response", + step3_text, + "/notion-sync Step 3 must list `no_response` (underscore) as a Status " + "option so it matches what /outcome writes to the tracker", + ) + self.assertIn( + "offer_declined", + step3_text, + "/notion-sync Step 3 must list `offer_declined` (underscore) as a Status " + "option so it matches what /outcome writes to the tracker", + ) + self.assertNotIn( + "no response", + step3_text, + "/notion-sync Step 3 must not list 'no response' (space) as the primary " + "option — Notion creates a distinct select value for each unique string, " + "so mixing spellings creates duplicate options in the database", + ) + + +class ReaderCases(unittest.TestCase): + """Spot-checks across readers that prove the vocabulary block is reachable + from each command that makes decisions based on tracker status. + + Format: (path, heading_or_None, needle, failure_message) + """ + + CASES = [ + # /outcome owns the vocabulary; its readers must find it there + ( + OUTCOME, + VOCAB_ANCHOR, + "underscores, never spaces", + "The vocabulary block must state that underscores are canonical and " + "spaces are not to be written", + ), + ( + OUTCOME, + VOCAB_ANCHOR, + "Final", + "The vocabulary block must define the final-status set explicitly", + ), + # /html-report Step 2 excludes drafted from stats + ( + HTML_REPORT, + "## Step 2: Compute Summary Stats", + "excluded from every statistic below", + "drafted rows must be excluded from every statistic, not counted as sent", + ), + # /gmail-sync staleness check skips drafted + ( + GMAIL_SYNC, + "## Step 9: Staleness Check", + "Skip `drafted` rows here", + "the staleness check must skip drafted rows — nothing was sent, " + "so nobody is late replying", + ), + # /apply's append-vs-update decision anchors to the vocabulary + ( + APPLY, + "### Step 6b: Record the Application", + "Tracker status vocabulary", + "apply.md Step 6b must anchor its final-status decision to the /outcome " + "vocabulary block — a closed application must never be treated as open " + "and get its row updated instead of appended", + ), + # /interview's live-process set anchors to the vocabulary + ( + INTERVIEW, + "## Step 0: Parse Input", + "Tracker status vocabulary", + "interview.md Step 0 must anchor its live-process statuses to the " + "/outcome vocabulary block instead of restating an ad-hoc set", + ), + ] + + def test_all_reader_cases(self): + for path, heading, needle, why in self.CASES: + with self.subTest(file=path.name, rule=needle): + haystack = ( + section(path, heading) + if heading + else path.read_text(encoding="utf-8") + ) + self.assertIn(needle, haystack, why) + + +if __name__ == "__main__": + unittest.main()