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