diff --git a/.claude/commands/rank.md b/.claude/commands/rank.md index cf30218..5fbca36 100644 --- a/.claude/commands/rank.md +++ b/.claude/commands/rank.md @@ -76,6 +76,22 @@ Back in the main context, for each scored job: 5. **Deadline urgency:** a deadline within 7 days gets a 🔥 marker and wins ties. A deadline that has already passed moves the job to `expired`. Take the deadline from the scoring agent's Step 2 JSON for a job scored in this run, and from the stored `deadline` in `seen_jobs.json` for one that already carries it - a stored value costs no fetch, so urgency is re-derived on every run without re-reading the posting. When both exist and disagree, the freshly scored value wins and replaces the stored one. A stored value that does not parse as `YYYY-MM-DD` is skipped for urgency as well - rule 6's defensive-parse rule applies wherever a stored deadline is compared. 6. **Expiry sweep over already-ranked entries.** Before presenting, check the stored `deadline` of every `ranked` entry this run did not re-score. Any whose deadline has passed becomes `expired`; any within 7 days is listed under a short **Closing soon** heading in Step 5 with its 🔥 marker. This needs no fetch and no agent - it is a date comparison against values already on disk, and it is what finally enforces `/scrape`'s "only open positions" rule beyond the moment of fetching. **An entry with no stored `deadline` is left alone, never guessed at** - most entries predate the column, and inferring a deadline from `first_seen` would retire jobs on a date nobody set. **Parse stored deadlines defensively:** a stored value that is not a `YYYY-MM-DD` date is treated exactly like an absent one - left alone, never compared, never guessed at - and reported once in the Step 5 summary with its portal, so the bad value gets traced to its source instead of silently steering the sweep (portals have shipped `"ASAP"`, `DD.MM.YYYY`, and free-text deadline shapes into stored data). `--all` re-scores entries of any status including `expired`, so a job the sweep retired can still be revived by a later `--all` that re-fetches it and finds the posting live: the sweep is reversible, which is what makes an automated status change acceptable here at all. +7. **Staleness flag:** a job whose stored `posted_date` is more than **30 days** old at + rank time stays in the ranking but carries a visible ⚠ marker with its age spelled out + alongside the score (e.g. "⚠ posted 2024-05-13, 27 months ago") - same treatment as a + location or language FLAG, for the user to judge. Age is a signal, never a veto: the + posting that motivated this rule was 27 months old *and still live*, so excluding on + age would wrongly bury real openings - and a stale posting with a future stored + `deadline` is still open by the stronger signal, so the flag notes the deadline too + rather than contradicting it. This costs no fetch: `posted_date` is already on disk + (written by `/scrape` Step 4), and age is re-derived on every run, never persisted. + **An entry with no `posted_date` (or `null`) gets no flag and no guess** - entries + predating the field simply lack the signal, and inferring age from `first_seen` would + flag jobs on a date nobody posted. Rule 6's defensive-parse rule applies wherever a + stored `posted_date` is compared: a value that does not parse as `YYYY-MM-DD` is + treated exactly like an absent one and reported once in the Step 5 summary with its + portal. + Sort by overall score (descending), urgency as tiebreaker. --- diff --git a/CHANGELOG.md b/CHANGELOG.md index abfb62d..fa91af1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,21 @@ per-file diff commands. ## [Unreleased] +### Added + +- **`/rank` now consumes the `posted_date` #391 persists** (#390, the deferred second + half) - Step 3 gains a staleness flag: a posting whose stored `posted_date` is more + than 30 days old at rank time carries a visible ⚠ marker with its age spelled out + alongside the score ("⚠ posted 2024-05-13, 27 months ago"), the same FLAG treatment as + location and language - in the ranking, for the user to judge, never an exclusion (the + #390 posting was 27 months old *and still live*; age is a signal, not a veto, and a + future stored `deadline` outranks it). Costs no fetch: age is re-derived each run from + the stored value and never persisted. Boundary rules carried over verbatim from the + schema and rule 6: no `posted_date` or `null` means no flag and no guess (never + inferred from `first_seen`), and unparseable values are treated as absent and reported + once with their portal. Pinned by four new cases in `test_rank_command.py`, each + verified to fail against the rule-less spec. + ### Security - **`settings.json` no longer pre-approves `bun run` on arbitrary files** (#396) - the diff --git a/tests/test_rank_command.py b/tests/test_rank_command.py index d1c6095..583d480 100644 --- a/tests/test_rank_command.py +++ b/tests/test_rank_command.py @@ -419,5 +419,70 @@ class RankCommandSpec(unittest.TestCase): self.assertEqual(result.returncode, 0, f"lint_skills.py failed:\n{result.stdout}{result.stderr}") +class PostedDateStalenessSpec(unittest.TestCase): + """Step 3 must consume the posted_date #391 persists. + + The field exists because a 27-month-old posting ranked Strong Fit at + position 1 of 133 (#390): the scoring agent noticed the age and wrote it + into prose nothing reads. Persistence alone changes nothing - these pin + that /rank actually derives a signal from the stored date, and that the + signal keeps the schema's own boundary rules (flag never veto, no + inference for absent values, rule 6's defensive parse). + """ + + def setUp(self): + self.step3 = _sections(COMMAND.read_text(encoding="utf-8")).get( + "Step 3: Aggregate and Rank", "" + ) + self.assertTrue(self.step3, "Step 3 section missing from rank.md") + # The spec hard-wraps its prose; assertions match against collapsed + # whitespace so a rewrap never fails a pin the text still honors. + self.flat = " ".join(self.step3.split()) + + def test_step3_consumes_posted_date(self): + self.assertIn( + "`posted_date`", + self.step3, + "Step 3 never reads the posted_date /scrape persists, so a posting's " + "age is stored but still invisible at rank time - the exact #390 gap", + ) + self.assertIn( + "⚠", + self.step3.split("`posted_date`", 1)[1][:600], + "the staleness rule must surface age as a visible ⚠ marker, like the " + "location and language FLAG treatments", + ) + + def test_staleness_is_a_flag_never_a_veto(self): + self.assertRegex( + self.flat, + r"[Aa]ge is a signal, never a veto", + "staleness must keep FLAG semantics - the #390 posting was 27 months " + "old AND still live, so excluding on age would bury real openings", + ) + + def test_staleness_never_inferred_for_absent_values(self): + self.assertRegex( + self.flat, + r"no `posted_date`.*no flag and no guess", + "entries predating the field must get no staleness signal - inferring " + "age from first_seen would flag jobs on a date nobody posted", + ) + self.assertIn( + "`first_seen`", + self.step3, + "the rule must name first_seen as the forbidden inference source", + ) + + def test_staleness_parses_posted_date_defensively(self): + self.assertRegex( + self.flat, + r"defensive-parse rule applies wherever a stored `posted_date` is compared", + "posted_date comparisons must carry rule 6's defensive-parse rule - the " + "contract test pins the field's presence, not its format, and portals " + "have shipped free-text shapes into stored date fields before", + ) + + if __name__ == "__main__": unittest.main()