diff --git a/.claude/commands/outcome.md b/.claude/commands/outcome.md index 7aac0cf..64da40f 100644 --- a/.claude/commands/outcome.md +++ b/.claude/commands/outcome.md @@ -22,6 +22,8 @@ Follow these steps **in order**. - `followup` → enter the follow-up branch (Step 2b) over every quiet open application, using the default threshold of **10 days** - `followup `, e.g. `/outcome followup 14` → follow-up branch with an N-day threshold - `followup `, e.g. `/outcome followup acme` → draft a follow-up for that application now, regardless of threshold +- `stale` or `sweep` → enter the stale application sweep branch (Step 2c) over open applications quiet for **60+ days** +- `stale ` or `sweep `, e.g. `/outcome stale 90` → stale sweep branch with an N-day threshold --- @@ -33,7 +35,7 @@ Follow these steps **in order**. ``` **If the file exists and its header does not end in `,deadline`, append `,deadline` to the header line only** - no data row is touched. Legacy rows then read as an empty deadline. This is the one edit to an existing tracker this command may make outside a matched row, and Step 4's "never restructure the CSV" governs that row, not this header line. 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 (see **Tracker status vocabulary** below) as a numbered table (company, role, date applied, current status, deadline, 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, deadline, 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 any open rows are 60+ days quiet, also offer: "You have applications quiet for 60+ days — run `/outcome stale` to batch-resolve them (Step 2c)." 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. @@ -110,6 +112,51 @@ If the user decides not to send, log nothing. --- +## Step 2c: Stale Sweep Branch (batch-resolve quiet applications) + +Enter this branch from the `stale` or `sweep` argument (Step 0), or from the suggestion under the open-pipeline table in Step 1.3. In an extended job hunt, applications that received no response accumulate and clutter the tracker, `/html-report` funnel metrics, and `/notion-sync`. This branch operationalizes batch-cleaning old quiet applications while keeping the user in full control. + +**Candidates.** An application qualifies when its tracker `status` is open and submitted (`applied` or `interview`), the threshold has passed since its `date` (or since the latest dated entry in `notes`, whichever is more recent), and its status is neither final nor `drafted` (`drafted` applications were never submitted and cannot receive a response). Parse dates defensively — skip unparseable rows with a note. + +**Threshold.** The default threshold is **60 days** quiet. If the user specified an integer `` (e.g. `/outcome stale 90` or `/outcome sweep 45`), use N days instead. + +**Presentation.** If no open applications exceed the threshold, report: +> "No open applications exceed the -day quiet threshold. Your tracker is up to date!" +and stop. + +Otherwise, present qualifying applications as a numbered table: + +``` +## Stale Applications ([K] quiet for [N]+ days) + +| # | Company | Role | Date Applied | Days Quiet | Follow-ups Sent | Current Status | Proposed Status | +|---|---------|------|--------------|------------|-----------------|----------------|-----------------| +| 1 | Acme | SWE | 2026-05-10 | 118 | 2 | applied | no_response | +| 2 | Beta | MLE | 2026-06-01 | 96 | 1 | applied | no_response | +``` + +Then ask: + +> **How would you like to resolve these applications?** +> +> - **`all`** — Mark all [K] applications as `no_response` and update archives +> - **`select`** — Specify which numbers to resolve (e.g. "1, 3" or "1-4") +> - **`skip`** — Cancel without making any changes + +Wait for the user's explicit response before writing anything. + +**Execution.** For each application the user confirms: + +1. **Update Tracker:** update the row's `status` column to `no_response` (using the canonical spelling from **Tracker status vocabulary**). Append `stale resolved no_response (YYYY-MM-DD)` to `notes`. Follow Step 4's rule: never restructure the CSV, preserve all other columns intact. +2. **Update Archive:** derive `documents/applications/_/` per the **Subfolder naming** rule. If the folder exists, update or write `outcome.md` with: + - `**Status:** no_response` + - `**Date resolved:** YYYY-MM-DD` + - Append to `## Notes`: `- Stale resolution: marked no_response after [N] days quiet (YYYY-MM-DD)` + +**Calibration Handoff.** If 3 or more applications were resolved in this sweep, continue to Step 5 to offer calibration handoff. Otherwise present a summary of resolved applications and stop. + +--- + ## Step 3: Archive the Application Materials Create or update `documents/applications/_/`. All content here is personal data - the folder is already gitignored (`documents/applications/**`), so nothing needs redacting. @@ -193,3 +240,4 @@ If the recorded status is `hired`, congratulate the user warmly first - this is 6. **Follow-ups: draft only, never send.** The follow-up branch produces text for the user to send themselves. It never emails, messages, or submits anything, and it must not be wired to tools that do. 7. **Follow-ups: no new claims.** Every substantive statement in a follow-up or thank-you note comes from the archived submitted materials. Rule 3 applies with no exceptions. 8. **Maximum two follow-ups per application.** After the second silent follow-up, the honest move is recording the resolution, not persistence. +9. **Stale sweep: user confirms before writing.** The stale sweep branch never marks applications as no_response automatically. It always presents the qualifying candidate list and waits for explicit user confirmation (all, select, or skip). diff --git a/CHANGELOG.md b/CHANGELOG.md index 7dfa0de..8887848 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,13 @@ per-file diff commands. ### Added +- **Stale sweep branch in `/outcome`** (`.claude/commands/outcome.md`, + `tests/test_outcome_stale.py`) - introduces `/outcome stale [N]` (and `/outcome sweep [N]`) + to batch-resolve open applications quiet for 60+ (or N) days. Displays a numbered summary + of qualifying applications, requires explicit user confirmation (`all`, `select`, or `skip`), + resolves confirmed rows to `no_response`, logs dated entries to `notes`, updates archive + `outcome.md` files, and hands off to calibration when 3+ applications are resolved. + - **CHANGELOG structure guard** (`tests/test_changelog_structure.py`) - every PR edits this one shared file by hand near the same line, and nothing checked the result: a second `### Fixed` heading landed directly under `[Unreleased]`, above `### Added`, on #425 and was fixed by hand diff --git a/tests/test_outcome_stale.py b/tests/test_outcome_stale.py new file mode 100644 index 0000000..dd5a517 --- /dev/null +++ b/tests/test_outcome_stale.py @@ -0,0 +1,65 @@ +"""Guards for /outcome's stale sweep branch (Step 2c). + +Pins the invariants for batch-resolving quiet applications: +- Step 0 documents `stale` / `sweep` and `stale ` / `sweep `. +- Step 1.3 offers stale sweep when open rows exceed 60 days quiet. +- Step 2c defines the Stale Sweep Branch. +- Drafted applications are strictly excluded (never submitted). +- The default threshold is 60 days quiet. +- User confirmation (all, select, skip) is strictly required before writing. +- Status is resolved to canonical 'no_response' spelling. +""" + +import re +import unittest +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent +COMMAND = REPO / ".claude" / "commands" / "outcome.md" + + +class OutcomeStaleBranchSpecTests(unittest.TestCase): + def setUp(self): + self.text = COMMAND.read_text(encoding="utf-8") + + def test_stale_argument_documented_in_step0(self): + self.assertIn("`stale` or `sweep`", self.text) + self.assertIn("`stale ` or `sweep `", self.text) + + def test_step1_suggests_stale_sweep(self): + self.assertIn("/outcome stale", self.text) + self.assertIn("60+ days", self.text) + + def test_step2c_section_exists(self): + self.assertIn("## Step 2c: Stale Sweep Branch", self.text) + + def test_drafted_rows_excluded_from_stale_candidates(self): + match = re.search(r"## Step 2c: Stale Sweep Branch(.*?)(?=## Step 3:)", self.text, re.DOTALL) + self.assertTrue(match, "Step 2c must exist") + step2c = match.group(1) + self.assertIn("neither final nor `drafted`", step2c) + self.assertIn("never submitted and cannot receive a response", step2c) + + def test_default_60_day_threshold(self): + match = re.search(r"## Step 2c: Stale Sweep Branch(.*?)(?=## Step 3:)", self.text, re.DOTALL) + self.assertTrue(match) + step2c = match.group(1) + self.assertIn("60 days", step2c) + + def test_user_confirmation_options_required(self): + match = re.search(r"## Step 2c: Stale Sweep Branch(.*?)(?=## Step 3:)", self.text, re.DOTALL) + self.assertTrue(match) + step2c = match.group(1) + self.assertIn("`all`", step2c) + self.assertIn("`select`", step2c) + self.assertIn("`skip`", step2c) + + def test_resolves_to_canonical_no_response(self): + match = re.search(r"## Step 2c: Stale Sweep Branch(.*?)(?=## Step 3:)", self.text, re.DOTALL) + self.assertTrue(match) + step2c = match.group(1) + self.assertIn("no_response", step2c) + + +if __name__ == "__main__": + unittest.main()