fix(apply): record the drafted application in the tracker (#269) (#291)

/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.
This commit is contained in:
Jakob Stender Guldberg
2026-08-06 17:16:07 +02:00
committed by GitHub
parent cffacfdde0
commit 41b5fd857f
10 changed files with 296 additions and 20 deletions
+194
View File
@@ -0,0 +1,194 @@
"""Guards for /apply's tracker recording step (Step 6b).
The step is part of the /apply markdown spec (the spec IS the
implementation), so these tests pin the invariants that would break
silently. Assertions are scoped to the section they belong to, following
the pattern in test_upskill_skill.py: a whole-file `assertIn` for a word
as common as `drafted` passes on any unrelated mention and guards nothing.
The CSV header is the one rule most easily lost: it must stay
byte-identical to /outcome's, which is the entire reason for reusing it.
How each reader treats `drafted` is pinned per reader below, because the
right answer differs between them.
"""
import re
import subprocess
import sys
import unittest
from pathlib import Path
try:
import yaml # noqa: F401 - only probing availability for the lint integration test
_HAVE_YAML = True
except ImportError:
_HAVE_YAML = False
REPO = Path(__file__).resolve().parent.parent
COMMANDS = REPO / ".claude" / "commands"
APPLY = COMMANDS / "apply.md"
OUTCOME = COMMANDS / "outcome.md"
GMAIL_SYNC = COMMANDS / "gmail-sync.md"
HTML_REPORT = COMMANDS / "html-report.md"
NOTION_SYNC = COMMANDS / "notion-sync.md"
SKILL = REPO / ".claude" / "skills" / "job-application-assistant" / "SKILL.md"
SCRAPER = REPO / ".claude" / "skills" / "job-scraper" / "SKILL.md"
TRACKER_HEADER = (
"date,company,sector,role,role_type,channel,status,contact_person,"
"fit_rating,notes,cv_file,cover_letter_file,source"
)
def section(path, heading):
"""The 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 ApplyRecordsApplication(unittest.TestCase):
"""/apply Step 6b writes the row that six other commands read."""
def setUp(self):
self.step_6b = section(APPLY, "### Step 6b: Record the Application")
def test_step_writes_a_drafted_row_with_both_document_paths(self):
for fragment in (
"| `status` | `drafted` |",
'| `cv_file`, `cover_letter_file` | the two paths listed under "Files Created"',
):
self.assertIn(
fragment,
self.step_6b,
f"Step 6b's column table lost {fragment!r} - the row it writes would "
"no longer identify itself as a draft or point at the documents",
)
def test_tracker_header_matches_outcome(self):
"""Byte-identical, or the two commands create incompatible CSVs."""
self.assertIn(TRACKER_HEADER, OUTCOME.read_text(encoding="utf-8"))
self.assertIn(
TRACKER_HEADER,
self.step_6b,
"Step 6b's header drifted from outcome.md's - whichever command ran "
"first would decide the schema",
)
def test_step_runs_before_the_optional_offer_that_ends_the_turn(self):
"""The optional application-form offer asks the user a question.
Anything after it only runs if the user answers, so recording the
application there would reproduce the bug this step fixes.
"""
text = APPLY.read_text(encoding="utf-8")
self.assertLess(
text.index("### Step 6b: Record the Application"),
text.index("### Application-Form Fields"),
"Step 6b moved after the optional-artifact offer, which ends the turn "
"on a question - the tracker row would be skipped whenever the user "
"never answers",
)
def test_matched_row_is_never_moved_backwards(self):
self.assertIn(
"never move it backwards",
self.step_6b,
"Step 6b lost the rule protecting a submitted row - re-running /apply "
"to refresh a CV would reset a live interview back to drafted",
)
def test_redraft_marker_is_undated(self):
"""/outcome reads the latest dated note as the last activity."""
self.assertIn(
"undated `redrafted` marker",
self.step_6b,
"a dated redraft marker resets /outcome's days-quiet clock, hiding a "
"genuinely quiet application from the follow-up offer",
)
def test_seen_jobs_is_left_alone(self):
self.assertIn(
"Do not modify `job_scraper/seen_jobs.json`",
self.step_6b,
"drafting is not applying, and that file has no honest value for either",
)
def test_skill_defers_to_apply_rather_than_restating(self):
"""/scrape Step 5 routes into the skill, bypassing /apply entirely."""
step_3b = section(SKILL, "### Step 3b: Record the Application")
self.assertIn(
"`/apply` Step 6b",
step_3b,
"the skill's recording step no longer points at the canonical rule, so "
"the two copies can drift",
)
class DraftedMeansDraftedToEveryReader(unittest.TestCase):
"""`drafted` is non-final, so readers that mean *submitted* must say so.
Each of these defines its set by exclusion from the final statuses, so
a new non-final value joins them all silently. The one exception is
/gmail-sync, which must keep searching for drafted rows: the user
submitting by hand and not running /outcome is the failure #269 is
about, and an employer reply is how it gets caught.
"""
CASES = [
(HTML_REPORT, None, "`drafted` → **Drafted**",
"a status with no bucket is dropped from every statistic"),
(HTML_REPORT, "## Step 2: Compute Summary Stats",
"excluded from every statistic below",
"the headline count would include applications that were never sent"),
(OUTCOME, "## Step 2b: Follow-Up Branch", "neither final nor `drafted`",
"it would chase an employer who received nothing"),
(OUTCOME, "## Step 4: Update the Tracker",
"overwrite its `date` column with the actual submission date",
"the drafting date would be reported as the application date"),
(GMAIL_SYNC, None, "`drafted` rows stay in this set",
"excluding them discards the row that identifies a submitted-but-"
"unrecorded application, which is the recovery #269 asks for"),
(GMAIL_SYNC, "## Step 5", "`drafted` -> `applied`, otherwise",
"the acknowledgement is the one email that proves a hand-submitted "
"application was sent; classified as noise, the recovery never fires"),
(GMAIL_SYNC, "### Step 7a", "also set `date` to the email's date",
"the row would keep the drafting date after being proved submitted"),
(GMAIL_SYNC, "## Step 9: Staleness Check", "Skip `drafted` rows here",
"an unsent draft reported as a forgotten application"),
(NOTION_SYNC, None, "omit when the status is `drafted`",
"an 'Applied on' date for a job never applied to"),
(NOTION_SYNC, None, "not yet submitted",
"page bodies are write-once, so calling drafts 'submitted documents' "
"is permanent even after /outcome records the real submission"),
(SCRAPER, None, "do not add a second row",
"/scrape would duplicate the row Step 3b just wrote"),
(APPLY, "### Step 6b: Record the Application", "bare number, 0-100",
"/upskill divides by fit_rating, so `72/100` or a verdict word breaks it"),
(APPLY, "### Step 6b: Record the Application", "append a new row",
"re-applying after a rejection would overwrite the old application"),
]
def test_every_reader_handles_drafted(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)
@unittest.skipUnless(
_HAVE_YAML,
"PyYAML not installed (the CI Python-test job omits it; the lint job runs lint_skills.py directly)",
)
def test_lint_skills_passes(self):
result = subprocess.run(
[sys.executable, str(REPO / "tools" / "lint_skills.py")],
cwd=REPO,
capture_output=True,
text=True,
)
self.assertEqual(result.returncode, 0, f"lint_skills.py failed:\n{result.stdout}{result.stderr}")
if __name__ == "__main__":
unittest.main()