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.
This commit is contained in:
Oscar Madera
2026-08-07 20:47:24 +02:00
committed by GitHub
parent b8514b7ed1
commit e09d3eb37b
9 changed files with 421 additions and 9 deletions
+1 -1
View File
@@ -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 |
+1 -1
View File
@@ -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/<company>_<role>/` (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/<company>_<role>/` (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.
+6 -1
View File
@@ -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.
---
+1 -1
View File
@@ -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 <company>` 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.
+3 -1
View File
@@ -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.
---
+21 -3
View File
@@ -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/<company>_<role>/` - 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.
@@ -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
+34
View File
@@ -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
+353
View File
@@ -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()