mirror of
https://github.com/MadsLorentzen/ai-job-search.git
synced 2026-09-17 08:36:25 +00:00
/apply wrote a CV and a cover letter to disk and then wrote nothing to job_search_tracker.csv, so a drafted and submitted application was invisible to /gmail-sync, /html-report, /notion-sync, /interview, /upskill aggregate mode, and to /rank's dedup exclusion. The safety net that would have caught it - /gmail-sync - refuses to create missing rows, so the failure it exists to catch is the one that disables it. Nothing detected the loss afterwards. Step 6b appends a drafted row carrying the two document paths, the fit rating and the posting URL, reusing /outcome's exact header so the two commands cannot diverge. It runs immediately after "Files Created" and before the optional application-form offer, which ends the turn on a question - anything placed after that offer would be skipped whenever the user never answers, reproducing the bug. Re-running /apply updates the row rather than duplicating it, and never moves a row that already reached applied or beyond back to drafted. The step is mirrored into job-application-assistant, which defers to it rather than restating it, because /scrape Step 5 routes straight into the skill; /scrape Step 6 now defers to the same step instead of adding a row of its own. seen_jobs.json is deliberately left alone: drafting is not applying, and that file's vocabulary has no value for either. /rank builds its exclusion set from company+role in the tracker regardless of status. drafted is introduced into the status vocabulary, and every reader that meant "submitted" is updated to say so. These readers define their open set by exclusion from the final statuses, so a new non-final value would otherwise have joined all of them silently: /outcome's follow-up branch would have drafted a chase email to an employer who never received an application, /gmail-sync would have searched for mail about it and then flagged it as stale, /notion-sync would have published an "Applied on" date for it, and /html-report would have counted it in the headline application total. /outcome Step 4 also overwrites the draft date with the submission date when a row leaves drafted, so the date column keeps meaning "applied on". The wider vocabulary reconciliation - underscore versus space, the separate archive enum - stays a separate concern.
This commit is contained in:
@@ -311,6 +311,33 @@ List the files written:
|
||||
|
||||
Tell the user: "Both files are ready for your review. Open them to check the final output before compiling."
|
||||
|
||||
### Step 6b: Record the Application
|
||||
|
||||
Do this before the optional offer below, and before ending the turn for any other reason.
|
||||
|
||||
1. Read `job_search_tracker.csv`. If it does not exist, create it with the standard header (identical to `/outcome` Step 1.1, so the two commands never diverge):
|
||||
```
|
||||
date,company,sector,role,role_type,channel,status,contact_person,fit_rating,notes,cv_file,cover_letter_file,source
|
||||
```
|
||||
2. Match existing rows case-insensitively on company and role. **On no match, or when every match holds a final status, append a new row. On a match that is still open, update it.** When you append alongside a final row, say so — the earlier application to that role keeps its own row and its own outcome.
|
||||
3. Values for a new row:
|
||||
|
||||
| Column | Value |
|
||||
|---|---|
|
||||
| `date` | today |
|
||||
| `status` | `drafted` |
|
||||
| `fit_rating` | the overall score from Step 1 as a bare number, 0-100 — never `XX/100` or a verdict word, since `/upskill` does arithmetic on this column |
|
||||
| `cv_file`, `cover_letter_file` | the two paths listed under "Files Created" above |
|
||||
| `source` | the posting URL from `$ARGUMENTS`, empty when the posting was pasted as text |
|
||||
| `channel` | `portal` when the posting came from a job portal, `online` for a company careers page, empty when unknown |
|
||||
| `sector`, `role_type`, `contact_person` | from the posting when it states them, empty otherwise |
|
||||
|
||||
4. **Updating an open row: never move it backwards.** Refresh `cv_file`, `cover_letter_file`, `fit_rating` and `source`, and append an undated `redrafted` marker to `notes` (undated deliberately — `/outcome` reads the latest *dated* note as the last contact with the employer, and re-drafting a CV is not that). Leave `status` alone, and leave `date` alone unless the status is still `drafted`, in which case it becomes today.
|
||||
5. Never restructure the CSV, reorder rows, or touch other rows.
|
||||
6. **Do not modify `job_scraper/seen_jobs.json`.** Dedup runs off the tracker instead: `/rank` builds its exclusion set from company+role there regardless of status.
|
||||
|
||||
Name the tracker row in the "Files Created" report above.
|
||||
|
||||
### Application-Form Fields (Optional Third Artifact)
|
||||
|
||||
Check whether the posting or the portal it came from asks for free-text fields the CV and cover letter don't cover — a self-introduction paragraph, structured project entries, a character-limited pitch, or a motivation/competency question under a word cap (see `.claude/skills/job-application-assistant/08-application-forms.md`, "When this applies"). If it does, or the user has already mentioned the portal, offer it in the same turn:
|
||||
@@ -320,5 +347,5 @@ Check whether the posting or the portal it came from asks for free-text fields t
|
||||
**Only on yes**, read `08-application-forms.md` and draft the fields per its rules, grounded against the same three-source union as the CV and cover letter. Save per that file's "Output format" section. **On no, or when the posting has no such fields, say nothing further and move on** — this is an optional addition and never changes the default two-document output.
|
||||
|
||||
### Next Steps
|
||||
- **Submitted?** `/outcome <company>` logs it in the tracker and starts the per-application record that `/setup` later uses to calibrate the fit framework.
|
||||
- **Submitted?** `/outcome <company>` moves the `drafted` row to `applied` and starts the per-application record that `/setup` later uses to calibrate the fit framework.
|
||||
- **Interview scheduled?** `/interview` builds a stage-specific prep pack from this posting and the documents you just created.
|
||||
|
||||
@@ -29,6 +29,8 @@ Confirm the Gmail MCP tools (`mcp__claude_ai_Gmail__*`) are available. If not, t
|
||||
1. Read `job_search_tracker.csv`. If it does not exist, tell the user there is nothing to sync against yet (suggest `/outcome` or `/apply` first) and stop. Do not create it here - `/gmail-sync` never originates new applications, only updates existing ones.
|
||||
2. Read `gmail_sync/state.json` (create if missing: `{"last_sync": null, "processed_message_ids": []}`).
|
||||
3. Build the set of **open applications**: tracker rows whose `status` is not a final value (`hired`, `rejected`, `no response`, `offer declined`, `withdrawn`). For each, derive its archive folder `documents/applications/<company>_<role>/` (lowercase, underscores - same convention as `/outcome`) and check whether `outcome.md` exists there.
|
||||
|
||||
**`drafted` rows stay in this set, and are the reason it is worth searching.** `/apply` writes them but never submits; the user submits by hand and may not think to run `/outcome`. A reply arriving against a row still marked `drafted` is exactly that case, and the row holds the company name the search needs.
|
||||
4. If `$ARGUMENTS` named a company, filter this set to the matching row(s) (case-insensitive). No match → tell the user and stop, do not guess.
|
||||
|
||||
---
|
||||
@@ -66,7 +68,7 @@ For a matched message, classify by content (require the signal phrase in the sub
|
||||
|
||||
| Signal | Example phrasing | Tracker `status` | `outcome.md` action |
|
||||
|---|---|---|---|
|
||||
| Application ack | "we've received your application" | *(no change)* | *(no change - not a status signal, just noise)* |
|
||||
| Application ack | "we've received your application" | `drafted` -> `applied`, otherwise *(no change)* | On a `drafted` row this is the one email that proves the user submitted by hand, and it arrives within a day of them doing so - propose the move with `date` set to the email's date. On any other status it is noise. |
|
||||
| OA / assessment | "online assessment", "coding challenge", "complete your assessment", HackerRank/Codility links | `interview` | Tick nearest matching stage checkbox (or add a Notes line if no checkbox fits - assessments aren't always a listed stage) |
|
||||
| Interview invite/scheduled | "schedule a call", "phone screen", "technical interview", "next round", "onsite", "final round" | `interview` | Tick the matching stage checkbox with the email's date |
|
||||
| Offer extended | "pleased to offer", "extend an offer", "offer letter" | `offer` | Tick "Offer received" checkbox. **Never propose `hired` or `offer_declined` from an email** - accepting or declining is the user's decision, not something to infer. Flag prominently in the Step 6 summary as needing the user's decision, separate from the plain approve/skip table. |
|
||||
@@ -90,6 +92,9 @@ Scanned N threads (M new messages) since <lookback date>.
|
||||
|---|---|---|---|---|---|
|
||||
| 1 | ... | ... | Interview invite | applied -> interview | "Subject line" (2026-07-10) |
|
||||
| 2 | ... | ... | Offer extended | interview -> offer | "Subject line" (2026-07-12) |
|
||||
| 3 | ... | ... | Application ack | drafted -> applied, date -> 2026-07-02 | "Subject line" (2026-07-02) |
|
||||
|
||||
A row leaving `drafted` shows its date change in the status cell, as row 3 does: that row was never recorded as submitted, so Step 7a is about to replace the drafting date. Say that the date is taken from the email and ask whether the user knows the real submission date - approving the status move should not silently approve a date they can correct.
|
||||
|
||||
### Needs Manual Review (conflicting signal - not proposed, use /outcome)
|
||||
- **<Company>** - <what conflicted and why it wasn't proposed>
|
||||
@@ -120,11 +125,13 @@ Approving the whole batch in one reply is expected UX - the requirement is that
|
||||
For every row the user approved:
|
||||
|
||||
1. **Tracker (`job_search_tracker.csv`):** update the matched row's `status` column per the Step 5 table, and append to `notes`: `<date> gmail-sync: <signal> ("<email subject>")`. Never restructure the CSV, reorder rows, or touch unrelated rows - same rule `/outcome` follows.
|
||||
|
||||
**If the matched row was still `drafted`,** also set `date` to the email's date. The employer replying proves the user submitted by hand without running `/outcome`, so the drafting date now in that column is wrong. The email's date is an upper bound on the real submission date, tight for an ack and loose for a rejection weeks later, which is why Step 6 shows it and lets the user supply the actual date instead.
|
||||
2. **`outcome.md`:** tick the relevant stage checkbox (adding the date in parentheses) or update `Status`/`Date resolved` per the table. Append a dated entry to `## Notes`, never overwrite existing Notes history:
|
||||
```
|
||||
YYYY-MM-DD (via /gmail-sync): <one-line summary of what the email said>. Source: "<subject>" from <sender>, <email date>.
|
||||
```
|
||||
3. If no archive folder/`outcome.md` exists yet for a matched application (it was added to the tracker outside `/apply`/`/outcome`), create the folder and a minimal `outcome.md` following the exact format in `documents/README.md`, same as `/outcome` would.
|
||||
3. If no archive folder/`outcome.md` exists yet for a matched application, create the folder and a minimal `outcome.md` following the exact format in `documents/README.md`, same as `/outcome` would. This is the normal case for a row that was still `drafted`: `/apply` Step 6b writes the tracker row and only `/outcome` Step 3 ever creates the archive, so the folder legitimately does not exist yet. It is also the case for a row added by hand.
|
||||
|
||||
Rows the user skipped are left untouched - no tracker write, no `outcome.md` write - but their message IDs are still marked processed in Step 8, so the same email isn't re-proposed every run.
|
||||
|
||||
@@ -140,6 +147,8 @@ Add every message ID processed this run - approved, skipped, unmatched, or filte
|
||||
|
||||
For open applications with **no** matching activity found this run, check the tracker's `date` column and the most recent dated Notes entry in their `outcome.md`. If the most recent of those is 30+ days old, flag the application as "needs follow-up" in the closing summary below. This is surfaced only - never write anything for staleness.
|
||||
|
||||
**Skip `drafted` rows here** - nothing was sent, so no one is late replying.
|
||||
|
||||
---
|
||||
|
||||
## Step 10: Present Closing Summary
|
||||
|
||||
@@ -21,7 +21,8 @@ Read in parallel:
|
||||
|
||||
2. **`documents/applications/*/outcome.md`** — for each resolved application, read the outcome file to get the exact interview stages reached (the checkboxes) and any notes. Merge this into the matching tracker row by company+role fuzzy match (lowercase, ignore punctuation). If an archive exists for a row but there is no match, attach it as extra context anyway.
|
||||
|
||||
Status normalisation — map tracker values to five canonical buckets before computing stats:
|
||||
Status normalisation — map tracker values to six canonical buckets before computing stats:
|
||||
- `drafted` → **Drafted** (documents written by `/apply`, not yet submitted)
|
||||
- `applied` → **Active** (resume submitted, no further signal)
|
||||
- `interview` → **Interview**
|
||||
- `offer` → **Offer**
|
||||
@@ -34,10 +35,12 @@ Status normalisation — map tracker values to five canonical buckets before com
|
||||
|
||||
From the normalised data compute:
|
||||
|
||||
**Drafted rows are excluded from every statistic below** — they were never submitted. Report the Drafted count on its own, and include it only in the status breakdown.
|
||||
|
||||
- **Total applications**
|
||||
- **By status bucket:** count per bucket
|
||||
- **By sector:** count per unique sector value
|
||||
- **By channel:** online vs referral vs other
|
||||
- **By channel:** portal vs online vs referral vs other
|
||||
- **By year/season:** group by the `date` field (which may be a year like `2025` or a full date)
|
||||
- **Funnel rates:** what % progressed past resume screen (reached Interview or beyond)
|
||||
- **Rejection rate:** Rejected/Closed ÷ Total with a resolved status (exclude Active)
|
||||
@@ -55,10 +58,10 @@ Write a single self-contained HTML file. All CSS is inline in a `<style>` block.
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ 🔍 Job Search Dashboard Generated: DATE │
|
||||
├──────┬──────┬──────┬──────┬──────────────────┤
|
||||
│Total │Active│Inter-│Offer │Rejected/Closed │ ← stat cards
|
||||
│ N │ N │view N│ N │ N │
|
||||
├──────┴──────┴──────┴──────┴──────────────────┤
|
||||
├──────┬──────┬──────┬──────┬──────┬───────────┤
|
||||
│Sent │Draft │Active│Inter-│Offer │Rejected/ │ ← stat cards
|
||||
│ N │ N │ N │view N│ N │Closed N │
|
||||
├──────┴──────┴──────┴──────┴──────┴───────────┤
|
||||
│ Status breakdown (doughnut) │ By sector (bar)│ ← charts row
|
||||
├───────────────────────────────────────────── ┤
|
||||
│ By channel (bar) │ Funnel (horizontal bar) │ ← charts row
|
||||
@@ -72,6 +75,7 @@ Write a single self-contained HTML file. All CSS is inline in a `<style>` block.
|
||||
### Design spec
|
||||
|
||||
- **Colour palette:** CSS custom properties. Status colours:
|
||||
- Drafted: `#64748b` (slate)
|
||||
- Active: `#3b82f6` (blue)
|
||||
- Interview: `#f59e0b` (amber)
|
||||
- Offer: `#8b5cf6` (purple)
|
||||
@@ -118,11 +122,11 @@ Then present:
|
||||
> Open it in any browser — no server needed.
|
||||
>
|
||||
> **Summary:**
|
||||
> - Total applications: N
|
||||
> - Applications sent: N · drafted, not yet sent: N
|
||||
> - Active: N · Interview: N · Hired: N · Rejected/Closed: N
|
||||
> - Funnel: N% progressed past resume screen
|
||||
>
|
||||
> Re-run `/html-report` any time after adding new entries via `/outcome` to refresh the dashboard.
|
||||
> Re-run `/html-report` any time after adding new entries via `/apply` or `/outcome` to refresh the dashboard.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -62,19 +62,19 @@ Validate the cheap, local precondition before creating anything external. A run
|
||||
| Company | rich text | |
|
||||
| Score | number | 0-100 from `rank_score` |
|
||||
| Verdict | select | Strong Fit / Good Fit / Moderate Fit / Weak Fit / Poor Fit |
|
||||
| Status | select | ranked / applied / interview / offer / hired / rejected / no response / withdrawn / expired |
|
||||
| Status | select | ranked / drafted / applied / interview / offer / hired / rejected / no response / withdrawn / expired |
|
||||
| Fit | select | high / medium / low (scraper quick-fit) |
|
||||
| Deadline | date | omit when unknown |
|
||||
| First seen | date | |
|
||||
| Ranked | date | `rank_date` from `seen_jobs.json`; omit when not ranked |
|
||||
| Applied on | date | tracker `date` column; omit when not in the tracker |
|
||||
| Applied on | date | tracker `date` column; omit when not in the tracker, and omit when the status is `drafted` |
|
||||
| Channel | select | tracker `channel` column (e.g. portal / email / referral); options grow as values appear |
|
||||
| CV file | rich text | tracker `cv_file` column - the filename only, never document content |
|
||||
| Cover letter | rich text | tracker `cover_letter_file` column - the filename only, never document content |
|
||||
| URL | url | posting URL |
|
||||
| Key | rich text | the job's key in `seen_jobs.json` - dedup anchor, never edited by hand |
|
||||
|
||||
The tracker-sourced properties (Applied on, Channel, CV file, Cover letter) stay empty for jobs that have no tracker row - they fill in once `/outcome` records the application. Only filenames ever sync; document contents stay local.
|
||||
The tracker-sourced properties (Applied on, Channel, CV file, Cover letter) stay empty for jobs that have no tracker row. CV file and Cover letter fill in once `/apply` records the draft; Applied on stays empty until `/outcome` records the submission. Only filenames ever sync; document contents stay local.
|
||||
|
||||
4. **Existing database with missing properties:** if the located database predates a schema addition (a property from the table above does not exist), add the missing properties to the database before upserting. Never remove or retype existing properties.
|
||||
5. Write `job_scraper/notion_sync.json` with the database id and URL. This file is personal state and is gitignored - never commit it.
|
||||
@@ -98,7 +98,7 @@ Batch politely: if the MCP server rate-limits, back off and continue; report any
|
||||
|
||||
The page body is what makes a row worth clicking. Build it **only from stored data and actually fetched content**:
|
||||
|
||||
1. **Fit summary** - a short section from `seen_jobs.json` fields: score, verdict, quick-fit level, first-seen and ranked dates. If the job is in the tracker, add the application timeline (date applied, channel, current status, dated notes from the `notes` column) and name the submitted documents from `cv_file`/`cover_letter_file` (filenames only - the documents themselves never sync).
|
||||
1. **Fit summary** - a short section from `seen_jobs.json` fields: score, verdict, quick-fit level, first-seen and ranked dates. If the job is in the tracker, add the application timeline (date applied, channel, current status, dated notes from the `notes` column) and name the submitted documents from `cv_file`/`cover_letter_file` (filenames only - the documents themselves never sync). **When the status is `drafted`, write "drafted YYYY-MM-DD, not yet submitted" instead of a date applied, and call the files drafts rather than submitted documents** (page bodies are write-once - Step 4.3).
|
||||
2. **The posting** - WebFetch the job URL and write a readable digest: what the role is, key requirements, practical details (location, deadline, salary if stated). Retry a 403 with browser headers per `.claude/skills/job-application-assistant/09-web-research.md` first. If the fetch still fails or redirects to a listing page, write "Posting no longer available (checked YYYY-MM-DD)" - **never reconstruct a posting from memory**.
|
||||
3. **Links** - the posting URL; if `documents/applications/<company>_<role>/` exists locally, name it as the local archive path (plain text - the destination cannot link into the filesystem).
|
||||
|
||||
|
||||
@@ -33,6 +33,8 @@ Follow these steps **in order**.
|
||||
```
|
||||
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, 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.
|
||||
|
||||
**`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.
|
||||
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.
|
||||
|
||||
---
|
||||
@@ -63,7 +65,7 @@ Also collect, without interrogating - one or two open questions are enough:
|
||||
|
||||
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.
|
||||
**Candidates.** An application qualifies when its status is neither final nor `drafted`, 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.
|
||||
|
||||
@@ -121,7 +123,9 @@ Update rules: tick stage checkboxes as they are reached (add the date in parenth
|
||||
|
||||
## Step 4: Update the Tracker
|
||||
|
||||
Update the matched row's `status` column (e.g. `applied` → `interview` → `offer` → `hired` / `rejected` / `no response` / `offer declined` / `withdrawn`) and append a short dated note to the `notes` column. Never restructure the CSV, reorder rows, or touch other rows.
|
||||
Update the matched row's `status` column (e.g. `drafted` → `applied` → `interview` → `offer` → `hired` / `rejected` / `no response` / `offer declined` / `withdrawn`) and append a short dated note to the `notes` column. Never restructure the CSV, reorder rows, or touch other rows.
|
||||
|
||||
**Moving a row off `drafted`:** rows written by `/apply` Step 6b carry the date the documents were drafted, not the date they were sent. Whenever this step advances such a row to any other status - `applied`, or straight to `interview` or `rejected` when the user reports an outcome for something they submitted without recording it - overwrite its `date` column with the actual submission date. The `date` column is read as "applied on" by `/notion-sync` and drives `/html-report`'s year/season grouping and this command's own days-quiet count, so leaving the draft date in place would misreport the application.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ description: >
|
||||
and preparing for interviews. Triggers on keywords like: job posting, job application, CV,
|
||||
cover letter, resume, interview prep, job fit, career, application, apply, ansøgning, stilling
|
||||
allowed-tools: Read, Glob, Grep, WebFetch, WebSearch, Bash, Edit, Write, AskUserQuestion
|
||||
framework_version: 1.2.0
|
||||
framework_version: 1.3.0
|
||||
---
|
||||
|
||||
# Job Application Assistant
|
||||
@@ -37,6 +37,11 @@ When the user provides a job posting (URL or text), follow this workflow:
|
||||
- Create `cover_letters/cover_<company>_<role>.tex`
|
||||
- Ensure the letter connects specific experience to the role requirements
|
||||
|
||||
### Step 3b: Record the Application
|
||||
- Run this once both documents exist. A CV or cover letter drafted alone is not yet an application.
|
||||
- Follow **`/apply` Step 6b** (`.claude/commands/apply.md`) exactly: same header, same match-then-update rule, same `drafted` row, same prohibition on touching `job_scraper/seen_jobs.json`. It is stated there once so the two paths cannot drift. Two of its values are named in `/apply`'s own terms: `cv_file`/`cover_letter_file` are the paths written in Steps 2 and 3 here, and `source` is the posting URL from Step 1.
|
||||
- This step exists here because `/scrape` Step 5 routes straight into this skill. Without it, that path writes two documents and records nothing.
|
||||
|
||||
### Step 4: Interview Preparation
|
||||
- Follow the framework in `07-interview-prep.md`
|
||||
- Prepare STAR-format answers for likely questions
|
||||
|
||||
@@ -238,7 +238,7 @@ If the run found many new jobs (roughly 8+), also suggest `/rank` - it batch-sco
|
||||
|
||||
### Step 6: Update Tracker (Optional)
|
||||
|
||||
If the user decides to apply to any job, add a row to `job_search_tracker.csv`.
|
||||
If the user decides to apply to any job, the tracker row is written by **job-application-assistant Step 3b**, which Step 5 already routes into - do not add a second row here. Only when the user says they applied to something outside that path, add a row using the header and the match-then-update rule in `/outcome` Step 1.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -106,6 +106,39 @@ per-file diff commands.
|
||||
blank line and matches in file order, which reads a real-world policy as
|
||||
"everything allowed".
|
||||
|
||||
- **`/apply` now records the application in the tracker** - the flagship command wrote a CV
|
||||
and a cover letter to disk and then wrote nothing to `job_search_tracker.csv`, so a drafted
|
||||
and submitted application was invisible to `/gmail-sync`, `/html-report`, `/notion-sync`,
|
||||
`/interview`, `/upskill` aggregate mode, and to `/rank`'s dedup exclusion - and the safety
|
||||
net that would have caught it (`/gmail-sync`) refuses to create missing rows, so nothing
|
||||
detected the loss. A new Step 6b appends a `drafted` row carrying the two document paths,
|
||||
the fit rating and the posting URL, reusing `/outcome`'s exact header so the two commands
|
||||
cannot diverge; re-running `/apply` updates that row rather than duplicating it, unless every
|
||||
matching row holds a final status, in which case a second application to the same role gets
|
||||
its own row. The same
|
||||
step is mirrored into `job-application-assistant` because `/scrape` Step 5 routes straight
|
||||
into the skill (`framework_version` 1.2.0 -> 1.3.0), and `/scrape` Step 6 now defers to it
|
||||
instead of adding a row of its own. `seen_jobs.json` is deliberately left alone.
|
||||
|
||||
**`drafted` is introduced into the tracker status vocabulary**, and every reader that
|
||||
meant *submitted* now says so. These readers define "open" by exclusion from the final
|
||||
statuses, so a new non-final value would otherwise have joined all of them silently:
|
||||
`/outcome`'s follow-up branch no longer drafts a chase email for an application that was
|
||||
never sent, `/gmail-sync` no longer reports unsent drafts as stale, `/notion-sync` leaves
|
||||
"Applied on" empty for them and says "not yet submitted" in the page body rather than
|
||||
calling drafts submitted documents, and `/html-report` gains a sixth **Drafted** bucket
|
||||
kept out of the funnel, the rejection rate and the headline count. `/outcome` Step 4
|
||||
overwrites `date` with the submission date when a row leaves `drafted`, so the column
|
||||
keeps meaning "applied on".
|
||||
|
||||
**`/gmail-sync` deliberately keeps searching for drafted rows.** `/apply` drafts but the
|
||||
user submits, and forgetting to run `/outcome` afterwards is the failure this issue is
|
||||
about. An employer reply arriving against a row still marked `drafted` is how that gets
|
||||
caught, so those rows stay in the search set, the application acknowledgement is promoted
|
||||
from noise to a `drafted` -> `applied` signal (it is the one email that proves a hand
|
||||
submission, and it arrives within a day of it), and an approved match corrects the `date`
|
||||
as well as the status. Only the staleness check skips them, since nothing was sent. (#269)
|
||||
|
||||
## [1.3.0] - 2026-08-03
|
||||
|
||||
### Added
|
||||
|
||||
@@ -144,7 +144,7 @@ Postings are treated as untrusted input (the workflow follows no instructions em
|
||||
- **`/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.
|
||||
- **`/expand`** enriches your profile by scanning public sources you've already linked in it (GitHub repos, portfolio site, Kaggle, Google Scholar) and looking up syllabi for named courses and certifications. Discovered competencies are added to your profile with a source tag. Useful right after `/setup` to surface skills that documents alone don't make explicit.
|
||||
- **`/upskill`** analyzes the gap between your profile, your tracked job postings, and your ranked-but-untracked postings (`/rank`'s recorded gaps in `seen_jobs.json`) — or a single posting via `/upskill <URL>`. Produces a prioritized heatmap of skill gaps and a learning plan with web-searched study resources and time estimates. Useful for career planning between applications.
|
||||
- **`/html-report`** generates a self-contained HTML dashboard from `job_search_tracker.csv` and the application archives — stat cards, status/sector/channel/funnel charts (inline SVG, no external dependencies), and a filterable applications table. Opens directly in a browser, fully offline. Re-run it any time after `/outcome` adds new entries.
|
||||
- **`/html-report`** generates a self-contained HTML dashboard from `job_search_tracker.csv` and the application archives — stat cards, status/sector/channel/funnel charts (inline SVG, no external dependencies), and a filterable applications table. Opens directly in a browser, fully offline. Re-run it any time after `/apply` or `/outcome` adds new entries.
|
||||
- **`/add-template`** registers your own CV or cover letter template (LaTeX, Typst, or another toolchain) in place of the stock ones. It captures the template's instructions (source extension, compile command, fonts, style rules, page limit), runs a mandatory test compile, and wires the template into `/apply`. See [Custom templates](#custom-templates) below.
|
||||
- **`/add-portal`** generates a job-portal search skill for a job board in your market. It investigates the portal (search URL pattern, result structure, access rules), scaffolds the CLI skill from the same structure as the shipped ones, and test-runs a live query before registering. See [Job search tools](#job-search-tools) below.
|
||||
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
"""Guards for /apply's tracker recording step (Step 6b).
|
||||
|
||||
The step is part of the /apply markdown spec (the spec IS the
|
||||
implementation), so these tests pin the invariants that would break
|
||||
silently. Assertions are scoped to the section they belong to, following
|
||||
the pattern in test_upskill_skill.py: a whole-file `assertIn` for a word
|
||||
as common as `drafted` passes on any unrelated mention and guards nothing.
|
||||
|
||||
The CSV header is the one rule most easily lost: it must stay
|
||||
byte-identical to /outcome's, which is the entire reason for reusing it.
|
||||
How each reader treats `drafted` is pinned per reader below, because the
|
||||
right answer differs between them.
|
||||
"""
|
||||
import re
|
||||
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
|
||||
COMMANDS = REPO / ".claude" / "commands"
|
||||
APPLY = COMMANDS / "apply.md"
|
||||
OUTCOME = COMMANDS / "outcome.md"
|
||||
GMAIL_SYNC = COMMANDS / "gmail-sync.md"
|
||||
HTML_REPORT = COMMANDS / "html-report.md"
|
||||
NOTION_SYNC = COMMANDS / "notion-sync.md"
|
||||
SKILL = REPO / ".claude" / "skills" / "job-application-assistant" / "SKILL.md"
|
||||
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"
|
||||
)
|
||||
|
||||
|
||||
def section(path, heading):
|
||||
"""The body of one markdown section, up to the next heading of any depth."""
|
||||
text = path.read_text(encoding="utf-8")
|
||||
start = text.index(heading) + len(heading)
|
||||
rest = text[start:]
|
||||
end = re.search(r"^#{1,4} ", rest, re.MULTILINE)
|
||||
return rest[: end.start()] if end else rest
|
||||
|
||||
|
||||
class ApplyRecordsApplication(unittest.TestCase):
|
||||
"""/apply Step 6b writes the row that six other commands read."""
|
||||
|
||||
def setUp(self):
|
||||
self.step_6b = section(APPLY, "### Step 6b: Record the Application")
|
||||
|
||||
def test_step_writes_a_drafted_row_with_both_document_paths(self):
|
||||
for fragment in (
|
||||
"| `status` | `drafted` |",
|
||||
'| `cv_file`, `cover_letter_file` | the two paths listed under "Files Created"',
|
||||
):
|
||||
self.assertIn(
|
||||
fragment,
|
||||
self.step_6b,
|
||||
f"Step 6b's column table lost {fragment!r} - the row it writes would "
|
||||
"no longer identify itself as a draft or point at the documents",
|
||||
)
|
||||
|
||||
def test_tracker_header_matches_outcome(self):
|
||||
"""Byte-identical, or the two commands create incompatible CSVs."""
|
||||
self.assertIn(TRACKER_HEADER, OUTCOME.read_text(encoding="utf-8"))
|
||||
self.assertIn(
|
||||
TRACKER_HEADER,
|
||||
self.step_6b,
|
||||
"Step 6b's header drifted from outcome.md's - whichever command ran "
|
||||
"first would decide the schema",
|
||||
)
|
||||
|
||||
def test_step_runs_before_the_optional_offer_that_ends_the_turn(self):
|
||||
"""The optional application-form offer asks the user a question.
|
||||
|
||||
Anything after it only runs if the user answers, so recording the
|
||||
application there would reproduce the bug this step fixes.
|
||||
"""
|
||||
text = APPLY.read_text(encoding="utf-8")
|
||||
self.assertLess(
|
||||
text.index("### Step 6b: Record the Application"),
|
||||
text.index("### Application-Form Fields"),
|
||||
"Step 6b moved after the optional-artifact offer, which ends the turn "
|
||||
"on a question - the tracker row would be skipped whenever the user "
|
||||
"never answers",
|
||||
)
|
||||
|
||||
def test_matched_row_is_never_moved_backwards(self):
|
||||
self.assertIn(
|
||||
"never move it backwards",
|
||||
self.step_6b,
|
||||
"Step 6b lost the rule protecting a submitted row - re-running /apply "
|
||||
"to refresh a CV would reset a live interview back to drafted",
|
||||
)
|
||||
|
||||
def test_redraft_marker_is_undated(self):
|
||||
"""/outcome reads the latest dated note as the last activity."""
|
||||
self.assertIn(
|
||||
"undated `redrafted` marker",
|
||||
self.step_6b,
|
||||
"a dated redraft marker resets /outcome's days-quiet clock, hiding a "
|
||||
"genuinely quiet application from the follow-up offer",
|
||||
)
|
||||
|
||||
def test_seen_jobs_is_left_alone(self):
|
||||
self.assertIn(
|
||||
"Do not modify `job_scraper/seen_jobs.json`",
|
||||
self.step_6b,
|
||||
"drafting is not applying, and that file has no honest value for either",
|
||||
)
|
||||
|
||||
def test_skill_defers_to_apply_rather_than_restating(self):
|
||||
"""/scrape Step 5 routes into the skill, bypassing /apply entirely."""
|
||||
step_3b = section(SKILL, "### Step 3b: Record the Application")
|
||||
self.assertIn(
|
||||
"`/apply` Step 6b",
|
||||
step_3b,
|
||||
"the skill's recording step no longer points at the canonical rule, so "
|
||||
"the two copies can drift",
|
||||
)
|
||||
|
||||
|
||||
class DraftedMeansDraftedToEveryReader(unittest.TestCase):
|
||||
"""`drafted` is non-final, so readers that mean *submitted* must say so.
|
||||
|
||||
Each of these defines its set by exclusion from the final statuses, so
|
||||
a new non-final value joins them all silently. The one exception is
|
||||
/gmail-sync, which must keep searching for drafted rows: the user
|
||||
submitting by hand and not running /outcome is the failure #269 is
|
||||
about, and an employer reply is how it gets caught.
|
||||
"""
|
||||
|
||||
CASES = [
|
||||
(HTML_REPORT, None, "`drafted` → **Drafted**",
|
||||
"a status with no bucket is dropped from every statistic"),
|
||||
(HTML_REPORT, "## Step 2: Compute Summary Stats",
|
||||
"excluded from every statistic below",
|
||||
"the headline count would include applications that were never sent"),
|
||||
(OUTCOME, "## Step 2b: Follow-Up Branch", "neither final nor `drafted`",
|
||||
"it would chase an employer who received nothing"),
|
||||
(OUTCOME, "## Step 4: Update the Tracker",
|
||||
"overwrite its `date` column with the actual submission date",
|
||||
"the drafting date would be reported as the application date"),
|
||||
(GMAIL_SYNC, None, "`drafted` rows stay in this set",
|
||||
"excluding them discards the row that identifies a submitted-but-"
|
||||
"unrecorded application, which is the recovery #269 asks for"),
|
||||
(GMAIL_SYNC, "## Step 5", "`drafted` -> `applied`, otherwise",
|
||||
"the acknowledgement is the one email that proves a hand-submitted "
|
||||
"application was sent; classified as noise, the recovery never fires"),
|
||||
(GMAIL_SYNC, "### Step 7a", "also set `date` to the email's date",
|
||||
"the row would keep the drafting date after being proved submitted"),
|
||||
(GMAIL_SYNC, "## Step 9: Staleness Check", "Skip `drafted` rows here",
|
||||
"an unsent draft reported as a forgotten application"),
|
||||
(NOTION_SYNC, None, "omit when the status is `drafted`",
|
||||
"an 'Applied on' date for a job never applied to"),
|
||||
(NOTION_SYNC, None, "not yet submitted",
|
||||
"page bodies are write-once, so calling drafts 'submitted documents' "
|
||||
"is permanent even after /outcome records the real submission"),
|
||||
(SCRAPER, None, "do not add a second row",
|
||||
"/scrape would duplicate the row Step 3b just wrote"),
|
||||
(APPLY, "### Step 6b: Record the Application", "bare number, 0-100",
|
||||
"/upskill divides by fit_rating, so `72/100` or a verdict word breaks it"),
|
||||
(APPLY, "### Step 6b: Record the Application", "append a new row",
|
||||
"re-applying after a rejection would overwrite the old application"),
|
||||
]
|
||||
|
||||
def test_every_reader_handles_drafted(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)
|
||||
|
||||
@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