mirror of
https://github.com/MadsLorentzen/ai-job-search.git
synced 2026-09-17 08:36:25 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user