feat(outcome): add follow-up branch to chase quiet applications (#198)

Adds Step 2b to /outcome: surfaces open applications gone quiet (default 10 days), drafts a brief channel-appropriate follow-up in the candidate's voice using only claims from the already-submitted materials, and logs it (followed up marker in notes + followup_YYYY-MM-DD.md in the archive). Reachable from the no-arg pipeline table (now showing days-quiet and follow-ups-sent) and via /outcome followup [N|company]. Draft-only never send, capped at two follow-ups terminating into Step 2's existing no_response path, and a thank-you note offered the moment Step 3 records a completed interview stage. Reads the contact_person column nothing previously consumed.

Folded into /outcome rather than a standalone command - dependency-free (unlike /gmail-sync) and reusing detection, the archive, the notes ledger, and the resolution path already there. The invited return of #46 (closed stale, not on merit). Guardrail tests mirror the /notion-sync pattern; 10-vs-30-day threshold contrast with /gmail-sync documented and test-pinned.

By @ayobamiseun.
This commit is contained in:
Ayobami Adegoke
2026-07-20 20:37:22 +02:00
committed by GitHub
parent b3b351605c
commit a0e81204da
3 changed files with 129 additions and 2 deletions
+38 -1
View File
@@ -7,6 +7,8 @@ You are recording what happened to a job application: progress updates (intervie
`/outcome` writes the data; `/setup` interprets it. This command never edits the evaluation framework or profile files itself.
The command also owns the stretch *before* there is an outcome to record: the **follow-up branch** (Step 2b) surfaces open applications that have gone quiet, drafts a brief follow-up note in the user's voice, and logs it - so the chase and the resolution it eventually leads to live in one flow.
Follow these steps **in order**.
---
@@ -17,6 +19,9 @@ Follow these steps **in order**.
- Nothing → list open applications and ask which one to update
- A company name (optionally with a role), e.g. `/outcome acme` or `/outcome acme ml engineer` → target that application
- `followup` → enter the follow-up branch (Step 2b) over every quiet open application, using the default threshold of **10 days**
- `followup <N>`, e.g. `/outcome followup 14` → follow-up branch with an N-day threshold
- `followup <company>`, e.g. `/outcome followup acme` → draft a follow-up for that application now, regardless of threshold
---
@@ -27,7 +32,7 @@ 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) and ask which to update. If every row is resolved, say so and stop.
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.
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.
---
@@ -54,6 +59,33 @@ Also collect, without interrogating - one or two open questions are enough:
---
## Step 2b: Follow-Up Branch (chase a quiet application)
Enter this branch from the `followup` argument (Step 0) or from the offer under the open-pipeline table (Step 1.3). Standard practice is a brief, polite follow-up one to two weeks after applying, at most twice; this branch operationalizes that.
**Candidates.** An application qualifies when its status is not final, the threshold has passed since its `date` (or since the last `followed up` marker in `notes`, if any), and it has fewer than **two** logged follow-ups. Parse dates defensively - skip rows whose dates do not parse and say so rather than guessing. Present qualifying applications as a table (company, role, days quiet, follow-ups sent, channel, contact person) and draft only for the ones the user picks.
**Threshold.** The 10-day default is deliberately earlier than `/gmail-sync`'s 30-day staleness flag (its Step 9): that check is a read-only alarm that a row has been forgotten entirely; this branch is the proactive nudge while a reply is still plausible. The two numbers serve different moments, which is why they differ.
**Drafting.** For each selected application:
1. Read the archive folder: the `job_posting.md`, `cv_draft.tex`, and `cover_letter.tex` that Step 3 maintains are the source of **every claim** the note may make - this is Rule 3 (never fabricate) widened to "no new claims": a follow-up that introduces skills or experience the submitted materials don't contain is a fabrication vector.
2. Apply the writing style rules from `03-writing-style.md` (no cliches, no em-dashes, warm but direct), and match the application's language - draw the register from the archived cover letter.
3. Write roughly **60 to 120 words**: address the `contact_person` from the tracker if present (otherwise the team, in the application's language); one sentence restating interest in the specific role; one concrete value-reminder drawn from the submitted materials; one polite question about the timeline. No pressure, no "just checking in" filler.
4. Shape it for the `channel` column: email (with a subject line reusing the application's headline), LinkedIn message (shorter, no subject), or portal message (plain text).
5. Present the draft and iterate until the user is happy.
**Logging.** Once the user confirms they will send it (or have sent it), log it in the same turn - an unlogged follow-up breaks the next run's quiet-days math:
- Append `followed up YYYY-MM-DD` to the row's `notes` column (Step 4's rule applies: append a dated note, never restructure the CSV).
- Save the final note as `followup_YYYY-MM-DD.md` in the application's archive folder. Safe by documented convention: `/setup` reads only the four named archive files and ignores extras (the same rule that covers `/interview`'s prep files), and `documents/applications/**` is gitignored personal data.
If the user decides not to send, log nothing.
**Termination.** When an application hits two follow-ups and stays silent, do not offer a third. This is the moment to continue in this same command's Step 2: note how long it has been since last contact and let the user decide whether to record `no_response` - as ever, no imposed cutoff. And if the user mentions an actual response while in this branch (an interview invitation, a rejection), drop out of the branch and record it through the normal Step 2 flow.
---
## Step 3: Archive the Application Materials
Create or update `documents/applications/<company>_<role>/`. All content here is personal data - the folder is already gitignored (`documents/applications/**`), so nothing needs redacting.
@@ -83,6 +115,8 @@ appended per update with a date, never overwritten>
Update rules: tick stage checkboxes as they are reached (add the date in parentheses), append dated entries to Notes, and only change `Status` from `in_progress` to a final value on resolution. Re-running `/outcome` on the same application is idempotent - it appends new information, never duplicates or rewrites history.
**Thank-you note trigger:** when this step ticks a newly completed interview stage, offer in the same turn: "Want a short thank-you note for the interviewer? A prompt one is standard practice." If accepted, draft it under Step 2b's drafting and logging rules (same voice, same no-new-claims boundary, same `followup_YYYY-MM-DD.md` archive convention). Recording the stage is the trigger - no scanning for recent stages is ever needed.
---
## Step 4: Update the Tracker
@@ -130,3 +164,6 @@ If the recorded status is `hired`, congratulate the user warmly first - this is
3. **Never fabricate.** A dead posting URL gets a user-pasted copy or an explicit "unavailable" stub, not a reconstruction. Feedback is recorded as the user reports it.
4. **Stay schema-compatible.** `outcome.md` follows the format in `documents/README.md` exactly (`in_progress` is the one addition, for open applications); the tracker keeps its columns.
5. **Idempotent updates.** Re-running on the same application appends new stages and notes; it never duplicates folders, rows, or history.
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.
+1 -1
View File
@@ -138,7 +138,7 @@ Postings are treated as untrusted input (the workflow follows no instructions em
`/setup`, `/scrape`, and `/apply` form the core workflow. Ten more commands extend it once your profile is in place:
- **`/interview`** preps you for a scheduled interview on a tracked application. It builds a stage-specific prep pack from the application's archive (the exact posting, the CV and cover letter the interviewer actually read, feedback recorded from earlier rounds), researches the company and interviewers with a verify-before-use rule, maps likely questions to your STAR examples, and offers a mock interview following the roleplay protocol in `07-interview-prep.md`. Gaps get honest bridge answers, never invented experience.
- **`/outcome`** records what happened to an application - interview stages, offers, rejections, silence. It archives the submitted CV, cover letter, and posting text into `documents/applications/<company>_<role>/`, keeps `outcome.md` in the format `/setup` Path A parses, and updates the tracker. Once a few applications resolve, it points you back to `/setup` to calibrate the fit framework from what actually got interviews.
- **`/outcome`** records what happened to an application - interview stages, offers, rejections, silence. It archives the submitted CV, cover letter, and posting text into `documents/applications/<company>_<role>/`, keeps `outcome.md` in the format `/setup` Path A parses, and updates the tracker. It also owns the stretch before there is an outcome to record: `/outcome followup` surfaces open applications that have gone quiet (default 10 days), drafts a short channel-appropriate follow-up in your writing style using only claims from the materials you already submitted (drafts only, never sends; at most twice per application), and offers a thank-you note in the same turn an interview stage is recorded. Once a few applications resolve, it points you back to `/setup` to calibrate the fit framework from what actually got interviews.
- **`/notion-sync`** publishes a one-way, read-only view of the pipeline into a Notion database via the official Notion MCP server (OAuth, no API keys) - one row per ranked job plus every tracked application, with a write-once briefing page per row. The repo files stay the system of record: nothing syncs back, and documents sync as filenames only. Complements `/html-report`: that is the deep offline dashboard you regenerate at your desk; this is the glanceable live view from anywhere Notion runs (desktop, web, phone).
- **`/gmail-sync`** reads your Gmail (via the Gmail connector) for status signals on your open applications - interview invites, assessment links, offers, rejections - and proposes them as a batch for you to approve before anything is written to the tracker or `outcome.md`, citing the source email on every proposed change. Offers stop short of proposing `hired`/`offer_declined` since that's your call; conflicting or unmatched signals get flagged for a manual `/outcome` pass instead of guessed.
- **`/rank`** bridges `/scrape` and `/apply`: it batch-scores all newly scraped postings against the fit framework (parallel agents fetch each posting and score the five evaluation dimensions) and returns a ranked shortlist with honest per-job strengths and gaps. Deal-breakers veto, deadlines get urgency flags, dead postings get marked expired. Pick a number and it hands off to the full `/apply` workflow.
+90
View File
@@ -0,0 +1,90 @@
"""Guards for /outcome's follow-up branch (Step 2b).
The branch is part of the /outcome markdown spec (the spec IS the
implementation), so these tests pin the invariants that would break
silently: the draft-only rule (the branch must never send anything on the
user's behalf), the no-new-claims rule that keeps follow-ups inside the
framework's never-fabricate boundary, the two-follow-up cap that terminates
into /outcome's own no_response flow, and the deliberate contrast with
/gmail-sync's 30-day staleness flag.
"""
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
COMMAND = REPO / ".claude" / "commands" / "outcome.md"
class OutcomeFollowupBranchSpec(unittest.TestCase):
def test_followup_branch_exists(self):
text = COMMAND.read_text(encoding="utf-8")
self.assertIn(
"## Step 2b: Follow-Up Branch",
text,
"outcome.md lost its follow-up branch section",
)
def test_followup_argument_documented(self):
text = COMMAND.read_text(encoding="utf-8")
self.assertIn(
"`followup <N>`",
text,
"spec lost the /outcome followup argument forms",
)
def test_draft_only_rule_present(self):
text = COMMAND.read_text(encoding="utf-8")
self.assertIn(
"draft only, never send",
text,
"spec lost the rule that the follow-up branch never sends anything on the user's behalf",
)
def test_no_new_claims_rule_present(self):
text = COMMAND.read_text(encoding="utf-8")
self.assertIn(
"no new claims",
text,
"spec lost the rule that follow-ups only reuse claims from the archived submitted materials",
)
def test_two_followup_cap_present(self):
text = COMMAND.read_text(encoding="utf-8")
self.assertIn(
"Maximum two follow-ups per application",
text,
"spec lost the cap that stops the follow-up branch from nagging indefinitely",
)
def test_threshold_contrast_with_gmail_sync_documented(self):
text = COMMAND.read_text(encoding="utf-8")
self.assertIn(
"30-day staleness flag",
text,
"spec lost the rationale for the 10-day nudge vs /gmail-sync's 30-day alarm",
)
@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()