feat(commands): add /gmail-sync - confirm-before-write status sync from Gmail (#170)

Scans Gmail (via the claude.ai Gmail connector) for status signals on open tracked applications - interview invites, assessments, offers, rejections - and proposes them as a sourced batch the user must approve before anything is written to job_search_tracker.csv or outcome.md. Never proposes hired/offer_declined (user's real-world decision), routes conflicting signals to manual /outcome, appends-only to Notes, idempotent by message ID, read-only against the mailbox, and degrades to one message + clean exit without the connector (the /notion-sync precedent). State is gitignored personal data.

By @chenyuan99, who raised the connector-dependency tension himself and restructured classification and writing into separate approval-gated steps.
This commit is contained in:
Yuan Chen
2026-07-19 19:38:15 +02:00
committed by GitHub
parent f51a766e72
commit 61d17d1bda
3 changed files with 190 additions and 1 deletions
+183
View File
@@ -0,0 +1,183 @@
# /gmail-sync - Sync Application Status from Gmail
You are scanning the user's Gmail for status signals on tracked job applications (interview invites, assessment links, offers, rejections) and, once approved, writing the detected changes into `job_search_tracker.csv` and `documents/applications/<company>_<role>/outcome.md` - the same two places `/outcome` writes to, in the same schema.
Unlike `/outcome` (which asks the user what happened), `/gmail-sync` classifies real emails on its own - but it never writes on its own. Every classified change is presented as a batch **before** anything touches the tracker or `outcome.md`, and only proceeds once the user approves it (approving the whole batch at once is fine; writing first and flagging it after is not). Because a wrong write silently corrupts application history that `/setup` later calibrates from, every proposed change must cite its source email and every uncertain case must be surfaced instead of guessed. Never treat this command's job as "notice something in an inbox" - it is "propose a correct, sourced line for a permanent record, and write it only once the user says yes."
Follow these steps **in order**.
---
## Step 0: Prerequisites
Confirm the Gmail MCP tools (`mcp__claude_ai_Gmail__*`) are available. If not, tell the user to connect the Gmail integration (claude.ai Settings → Connectors → Gmail) and stop - do not attempt this via Bash, IMAP, or any other channel.
---
## Step 1: Parse Input
`$ARGUMENTS` may contain:
- Nothing → default lookback (see Step 3)
- A company name, e.g. `/gmail-sync acme` → scope the search to that one tracked application
- `since <YYYY-MM-DD>` → override the lookback start date for this run only (does not change the persisted state file)
---
## Step 2: Load State
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.
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.
---
## Step 3: Build the Search Query
Lookback window: `since <date>` argument if given, else `state.last_sync` if set, else `newer_than:30d`.
1. Call `list_labels` and look for a user label whose name suggests job-search email (e.g. contains "job", "application", "career" case-insensitively). Note its `id` if found.
2. Normalize each open application's company name for matching later (lowercase; strip `inc`, `inc.`, `llc`, `ltd`, `a/s`, `corp`, `corporation`, `group`; strip punctuation; collapse whitespace).
3. Build a Gmail query combining (with `OR` groups via `{}`):
- `label:<id>` if a job-search label was found
- A quoted-name OR-group of the open applications' company names, e.g. `{"Acme Corp" "BigCo"}`
- A sender-domain OR-group of common ATS platforms: `{from:greenhouse.io from:lever.co from:myworkday.com from:ashbyhq.com from:smartrecruiters.com from:icims.com from:bamboohr.com}`
- The lookback bound, e.g. `newer_than:30d` or `after:2026/06/15`
- `in:inbox` (skip sent/drafts - status signals come from what employers send you, not what you sent them)
Example: `newer_than:30d in:inbox ({"Acme Corp" "BigCo"} OR {from:greenhouse.io from:lever.co from:myworkday.com from:ashbyhq.com})`
4. Call `search_threads` with `view: THREAD_VIEW_MINIMAL`, `pageSize: 50`, paginating via `pageToken` until exhausted or results are clearly outside the relevant window.
---
## Step 4: Filter to New Messages
For each returned thread, inspect its messages' IDs against `state.processed_message_ids`. Skip a thread entirely if every message in it is already processed. For threads with unprocessed messages, call `get_thread` with `messageFormat: FULL_CONTENT` to get full bodies - **classification in Step 5 must never be based on the snippet/subject alone**, since snippets truncate the exact phrase that distinguishes "we'd like to schedule a call" from "thanks for applying."
---
## Step 5: Classify Each Unprocessed Message
For each new message, first try to match it to one open application: compare the normalized sender domain / display name / subject / body against the normalized company names from Step 3. No confident match (company genuinely absent, or ambiguous between two tracked companies) → do not propose a write; record it in the Step 6 summary as "unmatched" and move to the next message.
For a matched message, classify by content (require the signal phrase in the subject or the first few lines - a company name appearing only deep in a forwarded thread or newsletter footer is not a signal):
| 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)* |
| 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. |
| Rejection | "moving forward with other candidates", "not selected", "unable to proceed", "decided not to continue" | `rejected` | Set `Status: rejected`, `Date resolved:` to the email's date |
**Conflict rule:** if the classified signal contradicts the application's current final-ness (e.g. a "moving forward" email arrives for a company whose tracker row briefly shows a `rejected`-adjacent recent write already, or a rejection arrives after an offer was already proposed this run) - do not propose overwriting it. Record it as a conflict in Step 6 for manual `/outcome` resolution instead.
---
## Step 6: Present Proposed Updates
**Nothing has been written yet.** Present every classified change from Step 5 as a single batch, so the user can review the full picture before anything touches the tracker or `outcome.md`:
```
## Gmail Sync - Proposed Updates - YYYY-MM-DD
Scanned N threads (M new messages) since <lookback date>.
### Proposed Changes (reply "approve all", or list which to skip, e.g. "skip 2")
| # | Company | Role | Signal | Current -> Proposed Status | Source Email (date) |
|---|---|---|---|---|---|
| 1 | ... | ... | Interview invite | applied -> interview | "Subject line" (2026-07-10) |
| 2 | ... | ... | Offer extended | interview -> offer | "Subject line" (2026-07-12) |
### Needs Manual Review (conflicting signal - not proposed, use /outcome)
- **<Company>** - <what conflicted and why it wasn't proposed>
### Unmatched Emails (no change proposed)
- "<subject>" from <sender> - looked job-related but couldn't be confidently linked to a tracked application.
### Stale Applications (30+ days, no activity)
- **<Company>** - last activity YYYY-MM-DD, still `<status>`.
```
If the Proposed Changes table would be empty, say so briefly and skip straight to Step 8 (Update State) - there is nothing to approve. Offers still land in the Proposed Changes table (the tracker moves to `offer`); it's only `hired`/`offer_declined` that are never proposed.
---
## Step 7: Wait for Approval
Stop here and wait for the user's reply. Do not write anything from this run's classification before an explicit response arrives.
- "approve all" / "yes" / equivalent → every row in the Proposed Changes table proceeds to Step 7a.
- A partial response, e.g. "approve 1, skip 2" or "just the interview one" → only the specified rows proceed.
- "no" / decline / no changes wanted → no rows proceed; go straight to Step 8 (Update State).
Approving the whole batch in one reply is expected UX - the requirement is that the reply happens first, not that the user approves row by row.
### Step 7a: Write Approved Updates
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.
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.
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.
---
## Step 8: Update State
Add every message ID processed this run - approved, skipped, unmatched, or filtered as noise - to `gmail_sync/state.json`'s `processed_message_ids`, and set `last_sync` to today's date. This makes re-running idempotent - the same email never produces a duplicate proposal, tracker note, or Notes entry.
---
## Step 9: Staleness Check
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.
---
## Step 10: Present Closing Summary
Confirm what actually happened, distinct from the Step 6 proposal:
```
## Gmail Sync - Done - YYYY-MM-DD
### Written
| Company | Role | Signal | Tracker Status | Source Email |
|---|---|---|---|---|
| ... | ... | Interview invite | applied -> interview | "Subject line", 2026-07-10 |
### Skipped (not written)
- **<Company>** - <signal> declined by user.
### Offers Requiring Your Decision
- **<Company>** - offer written 2026-07-12 ("<subject>"). Tracker set to `offer`; run `/outcome <company>` to record accept/decline once you decide.
### Stale Applications (30+ days, no activity)
- **<Company>** - last activity YYYY-MM-DD, still `<status>`.
```
If nothing was proposed this run, a brief note is enough instead of an empty summary.
If this run pushed the count of applications with a **final** `outcome.md` status to 3+ (or resolved a second application sharing a pattern), suggest the same `/setup` Path A calibration handoff `/outcome` suggests - do not duplicate that logic, just point the user there.
---
## Important Rules
1. **Classify from full email bodies, never snippets.** A status-changing proposal requires having actually fetched and read the message via `get_thread`/`get_message`.
2. **Nothing is written before the user approves the Step 6 batch.** Approving everything in one reply is fine UX; writing first and flagging it after is not.
3. **Never propose `hired` or `offer_declined`.** Those require the user's real-world decision; `/gmail-sync` stops at proposing `offer` and flags it.
4. **A conflicting signal against an already-final or already-written status is a manual-review flag, not a proposed overwrite.** When in doubt, don't propose it - surface it.
5. **Append-only to `outcome.md` Notes**, same as `/outcome`. Never rewrite or delete existing history.
6. **Idempotent by message ID.** Re-running must never re-propose, or duplicate a tracker note or Notes entry for, the same email.
7. **Never fabricate a match.** If the company can't be confidently identified from the email, it goes in "Unmatched," not a guess.
8. **Read-only against Gmail itself.** This command reads and classifies; it does not label, archive, or delete anything in the user's mailbox.
9. **All state is personal data.** `gmail_sync/state.json`, `job_search_tracker.csv`, and `documents/applications/**` are gitignored - never suggest committing them.
+3
View File
@@ -66,6 +66,9 @@ documents/postings/**
# Personal job search tracking
job_search_tracker.csv
# Gmail sync state (message IDs, subjects - personal data)
gmail_sync/
# Generated reports (personal output from /html-report)
reports/
+4 -1
View File
@@ -135,11 +135,12 @@ Postings are treated as untrusted input (the workflow follows no instructions em
## Other commands
`/setup`, `/scrape`, and `/apply` form the core workflow. Nine more commands extend it once your profile is in place:
`/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.
- **`/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.
- **`/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 and your tracked job postings (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.
@@ -163,6 +164,7 @@ ai-job-search/
│ │ ├── add-portal.md # /add-portal generate a job-portal search skill for your market
│ │ ├── rank.md # /rank triage scraped jobs into a ranked shortlist
│ │ ├── outcome.md # /outcome record application results, archive materials
│ │ ├── gmail-sync.md # /gmail-sync auto-detect application status from Gmail
│ │ ├── interview.md # /interview stage-specific prep pack + mock interview
│ │ ├── html-report.md # /html-report generate application tracker dashboard
│ │ ├── notion-sync.md # /notion-sync one-way pipeline view in a Notion database
@@ -210,6 +212,7 @@ ai-job-search/
│ ├── security_guards.py # CI guards: permission allowlist, gitignore rules, manifests
│ └── README_SALARY_TOOL.md # Salary tool setup instructions
├── job_scraper/ # Scraper state (seen jobs, results)
├── gmail_sync/ # /gmail-sync state (processed message IDs, last sync date)
├── upskill/ # /upskill report output (markdown reports per run)
├── job_search_tracker.csv # Application tracking spreadsheet
└── SETUP.md # Detailed setup guide