fix(tracker): persist the application deadline end to end (#319) (#324)

The deadline is written at every moment it is provably in hand and survives
every write that follows: seen_jobs.json base field, /rank stored-value urgency
+ expiry sweep with Step 4 persistence, tracker 14th column with header-line-only
migration for existing files, /scrape-path extraction (assistant SKILL.md 1.3.2
-> 1.3.3), preserve-unparsed-fields in /outcome and /gmail-sync, notion-sync
deadline precedence. Design, scope analysis, and the folded refinements by
jakob1379 (#319, #328).

Co-authored-by: Jakob Stender Guldberg <17257805+jakob1379@users.noreply.github.com>
This commit is contained in:
Oscar Madera
2026-08-16 19:56:45 +02:00
committed by GitHub
co-authored by Jakob Stender Guldberg
parent 5c6ffe8aa7
commit c855e11d22
12 changed files with 247 additions and 16 deletions
+85 -2
View File
@@ -35,7 +35,7 @@ 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"
"fit_rating,notes,cv_file,cover_letter_file,source,deadline"
)
@@ -67,7 +67,17 @@ class ApplyRecordsApplication(unittest.TestCase):
)
def test_tracker_header_matches_outcome(self):
"""Byte-identical, or the two commands create incompatible CSVs."""
"""Byte-identical, or the two commands create incompatible CSVs.
The exact-equality loop below is load-bearing, not decoration. `assertIn`
on its own cannot see an *additive* drift: a 13-column header is a
substring of a 14-column one, so appending a column to `/apply` and
forgetting `/outcome` passed this test cleanly until the loop was added.
It is also what makes the constant-only assertions in this class mean
anything: they reason about TRACKER_HEADER, and this is the test that
anchors TRACKER_HEADER to what both spec files actually say.
"""
self.assertIn(TRACKER_HEADER, OUTCOME.read_text(encoding="utf-8"))
self.assertIn(
TRACKER_HEADER,
@@ -75,6 +85,29 @@ class ApplyRecordsApplication(unittest.TestCase):
"Step 6b's header drifted from outcome.md's - whichever command ran "
"first would decide the schema",
)
for name, text in (("outcome.md", OUTCOME.read_text(encoding="utf-8")),
("apply.md Step 6b", self.step_6b)):
header = next(
(ln.strip() for ln in text.splitlines() if ln.strip().startswith("date,company,")),
None,
)
self.assertEqual(
header,
TRACKER_HEADER,
f"{name}'s header line is not exactly the canonical header - a column "
"appended to one file and not the other leaves both containing the "
"shorter header as a substring, which assertIn alone cannot catch",
)
def test_tracker_header_ends_with_deadline(self):
"""/apply appends rows with one field per header column, so inserting
`deadline` anywhere but the end shifts every value in every existing
row by one position."""
self.assertTrue(
TRACKER_HEADER.endswith(",deadline"),
"deadline must be the last column - a mid-header insert shifts every "
"existing row's values by one position",
)
def test_step_runs_before_the_optional_offer_that_ends_the_turn(self):
"""The optional application-form offer asks the user a question.
@@ -250,5 +283,55 @@ class ApplyArchivesThePosting(unittest.TestCase):
self.assertIn(needle, section(path, heading), why)
class DeadlineSurvivesEveryWrite(unittest.TestCase):
"""#319: the deadline is carried through the whole pipeline and never dropped.
The header migration must be header-line-only (inserting it mid-column
shifts every value of every existing row), and every path that rewrites
a tracker row (/outcome Step 4, /gmail-sync Step 7a) must preserve
fields it does not parse - the deadline is the first such field.
"""
CASES = [
(APPLY, "### Step 6b: Record the Application", "append `,deadline` to the header line only",
"a mid-header insert shifts every existing row's values by one position"),
(OUTCOME, "## Step 1: Load State and Identify the Application",
"append `,deadline` to the header line only",
"the two commands must migrate identically, or whichever runs first sets the schema"),
(APPLY, "## Step 0: Parse Input", "application deadline",
"Step 6b's value is supposed to come from Step 0's extraction, so the extraction "
"must be stated where the posting text is still held in full"),
(APPLY, "### Step 6b: Record the Application", "Never guess one",
"the deadline must stay empty when the posting states none - a guessed date is "
"the urgency clock firing on a date nobody set"),
(APPLY, "### Step 6b: Record the Application", "leave an existing deadline alone",
"absence is not a correction: a run that extracted no deadline must not blank "
"the one /apply already wrote"),
(OUTCOME, "## Step 1: Load State and Identify the Application", "Deadline urgency",
"a drafted row has nothing applied so the quiet clock must not run on it - the "
"deadline is the only clock that applies, and it must not be omitted"),
(OUTCOME, "## Step 1: Load State and Identify the Application", "never chased",
"surfacing the deadline must not drag drafted rows into the follow-up offer"),
(OUTCOME, "## Step 4: Update the Tracker", "preserve every other field of the row",
"a status update that rewrites the row would blank the deadline column"),
(GMAIL_SYNC, "### Step 7a: Write Approved Updates", "preserve every other field",
"the sync path rewrites the row too - it must carry the same preservation rule"),
(NOTION_SYNC, None, "**Deadline precedence: the tracker wins too**",
"the tracker's deadline (written from the posting the application was actually "
"built on) must override the scraper's stored value"),
(NOTION_SYNC, None, "tracker `deadline` column",
"the Deadine property must name the tracker column as its source"),
(SKILL, "### Step 3b: Record the Application", "`deadline` is the application deadline",
"the /scrape path reaches Step 3b without running /apply Step 0, so it must "
"still be told what the field is and where it comes from"),
]
def test_deadline_survives_every_write(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()
+102
View File
@@ -78,6 +78,30 @@ class RankCommandSpec(unittest.TestCase):
"schema note must say old entries lacking strengths/gaps are tolerated, never backfilled",
)
def test_job_scraper_schema_carries_deadline(self):
"""Pins the base field in the seen_jobs.json structure block and the
never-infer note. Step 2's detail fetch already extracts the deadline, so
/scrape writes it at first sight instead of leaving it to /rank (#319).
"""
text = SCRAPER_SKILL.read_text(encoding="utf-8")
self.assertIn(
'"deadline": "YYYY-MM-DD" | null',
text,
"the seen_jobs.json structure block must carry the deadline field, "
"or every later run has no stored value to re-derive urgency from",
)
self.assertIn(
"never infer a deadline",
text,
"the schema note must forbid guessing a deadline from null or from a missing key",
)
self.assertIn(
"base field rather than a `/rank` extension",
text,
"the note must say the deadline is written when the job is first seen, "
"not only when /rank re-scores it",
)
def test_step2_schema_includes_language_gate_fields(self):
sections = _sections(COMMAND.read_text(encoding="utf-8"))
step2 = sections.get("Step 2: Batch-Fetch and Score", "")
@@ -127,6 +151,84 @@ class RankCommandSpec(unittest.TestCase):
"Step 4 must call out that the veto fields (location/language_gate/language_note) are not optional extras",
)
def test_step4_persists_deadline(self):
"""Sibling of test_step4_persists_language_gate_and_language_note: the deadline was
computed in Step 2 and acted on in Step 3, but never written to seen_jobs.json, so
the urgency marker fired exactly once and a later run had to re-fetch the posting to
recover the date (#319). Pins the persistence in the Step 4 field list.
"""
sections = _sections(COMMAND.read_text(encoding="utf-8"))
step4 = sections.get("Step 4: Update State", "")
self.assertIn('"deadline"', step4, "Step 4 must persist the deadline into seen_jobs.json")
self.assertIn(
"from the same Step 2 JSON",
step4,
"Step 4 must source the persisted deadline from the scoring agent's JSON, not from a guess",
)
self.assertIn(
"absence is not a correction",
step4,
"Step 4 must keep an existing stored deadline when the agent returned null, "
"so a fresh run never blanks a date the scraper already recorded",
)
def test_step3_reads_stored_deadline_without_fetch(self):
"""Persisting alone does not re-fire the marker: Step 3 must read the stored
deadline back so urgency is re-derived on every run without re-reading the
posting (which is the dead-URL source the field exists to replace).
"""
sections = _sections(COMMAND.read_text(encoding="utf-8"))
step3 = sections.get("Step 3: Aggregate and Rank", "")
self.assertIn(
"stored `deadline`",
step3,
"Step 3 must take the deadline from seen_jobs.json for a job that already carries one",
)
self.assertIn(
"costs no fetch",
step3,
"Step 3 must state that the stored value costs no fetch - that is the entire point of persisting it",
)
def test_step3_documents_expiry_sweep_over_ranked_entries(self):
"""Rule 6: entries this run did not re-score still get their stored deadline checked,
enforcing the only-open-positions rule beyond the moment of fetching.
"""
sections = _sections(COMMAND.read_text(encoding="utf-8"))
step3 = sections.get("Step 3: Aggregate and Rank", "")
self.assertIn(
"Expiry sweep",
step3,
"Step 3 must document a sweep over already-ranked entries this run did not re-score",
)
self.assertIn(
"date comparison against values already on disk",
step3,
"the sweep must be a pure on-disk comparison - no fetch, no agent",
)
step5 = sections.get("Job Ranking - YYYY-MM-DD", "")
self.assertIn(
"Closing soon",
step5,
"Step 5's template must name the Closing soon heading rule 6 lists under",
)
def test_step4_persists_the_sweeps_expiry(self):
"""The sweep must write its result, or it reproduces the very bug it fixes.
Step 4's expiry line is scoped to what the Step 2 agents returned. The sweep
runs over entries this run did not re-score, so without its own persistence
line the transition happens in reasoning only and disk never changes.
"""
sections = _sections(COMMAND.read_text(encoding="utf-8"))
step4 = sections.get("Step 4: Update State", "")
self.assertIn(
"retired by Step 3's rule 6 sweep",
step4,
"Step 4 must persist the Step 3 rule 6 sweep's expiries, not just the ones "
"the scoring agents reported",
)
def test_step5_documents_language_flag_marker(self):
# Note: _sections() splits on every "\n## " line, including the "## Job
# Ranking - YYYY-MM-DD" line inside Step 5's own fenced example template -
+12
View File
@@ -53,6 +53,18 @@ class UpskillSkillSpec(unittest.TestCase):
"Step 2 must document the graceful-degradation clause for entries scored before gaps existed",
)
def test_step2_column_list_keeps_in_phase_with_tracker_header(self):
"""/upskill reads the tracker, so its enumeration of the columns must
match the header /apply writes - the deadline column (#319) is the
first column to be added since the list was written."""
sections = _sections(SKILL.read_text(encoding="utf-8"))
step2 = sections.get("Step 2: Load Data", "")
self.assertIn(
"source, deadline",
step2,
"Step 2's column list lost the deadline column the tracker header now ends with",
)
def test_step3_documents_dedupe_and_gap_precedence(self):
sections = _sections(SKILL.read_text(encoding="utf-8"))
step3 = sections.get("Step 3: Pass 1 — Hard Skill Diff", "")