CLAUDE.md · git:20261015.22780b4 · 2026-10-15 · sha256 79e410329ba2adf0

CLAUDE.md git:20261015.22780b4A

Immutable. This exact content never changes and is served at /api/v1/blob/79e410329ba2adf0.

# last-job-search: a job search that Claude runs end to end

## Role
This repo runs the whole job search, not one part of it. Claude operates it, and the
name is the claim: there should be nothing else you need open. The work it covers:
1. **Finding the work** - scrape every enabled portal, then filter, fetch, screen and score
   every posting 0-100 against the profile, and stop at a ranked shortlist for a person to choose from
2. **Job fit evaluation** - Assess job postings against your profile (skills, experience, behavioral traits)
3. **Resume tailoring** - Adapt existing resume templates (LaTeX/moderncv) to target specific roles,
   with bullets allocated per posting rather than picked from a fixed set
4. **Cover letter writing** - Draft targeted cover letters using existing templates (LaTeX)
5. **Verifying what it produced** - compile both documents, read the PDFs back, and check the
   embedded text layer an applicant tracking system actually parses
6. **Networking** - find the right people at target employers, draft the outreach, track every
   thread, and keep a cold application from landing on top of a warm introduction
7. **Interview preparation** - Prepare answers, questions, and talking points for interviews
8. **Career strategy** - Advise on positioning and personal branding

It never submits anything. Every document stops as a file on disk, and a person sends it.

## The person

Everything about the person this search is for lives in `profile/`, and its summary is
imported here:

@profile/CLAUDE.md

`profile/01-candidate-profile.md` is the source of truth for every fact about them. The
summary above is a working copy for quick reference; where the two disagree, the profile
wins, and the disagreement is a bug to fix rather than a choice to make. The rest of
`profile/` is the same person in the form the tools read (`person.json`, `roles.py`,
`bullets.py`, `skills.py`, `search.py`, `employers.py`, `boards.py`, `networking.py`,
`claims.py`, `screening.py`) and the documents written from it: `02-behavioral-profile.md`,
`04-job-evaluation.md`, `10-triage-blockers.md`, `12-scoring-brief.md`, `13-positioning.md`.
If there is no `profile/`, nothing here can run: start with `/setup`.

## Start of every session

This repo is either **the app** (`github.com/aranculleton/last-job-search`, public, no
`profile/`) or **a person's repo**, made from the app with "Use this template" and private.
Every session in a person's repo starts with the same five steps, before anything else:

1. **Pull master**: `git fetch origin master && git merge origin/master`. Another chat may have
   merged since this container was built.
2. **Take the app's updates**: `python3 tools/sync_app.py`. It fetches the app and merges what is
   new, and never sends anything back. The app is public, so nothing needs attaching; if it still
   cannot reach it (exit 2), attach it read-only with `add_repo` (owner and repo from the URL
   `https://github.com/aranculleton/last-job-search`, `access="read"`) and try once more, and if
   that fails too, say so in one line and carry on. Exit 1 means it stopped with nothing
   changed: show the person what it listed and ask before going further.
3. **No `profile/`?** Then nothing here can run yet. Start with `/setup`, before anything else.
4. **If the sync brought changes**, say in one line what changed, from the CHANGELOG lines it
   printed.
5. **Profile upgrades apply themselves; nobody has to ask.** The sync ends by listing what
   this repo still needs (`tools/profile_upgrades.py`): parts of the app that switched on
   after this person set up (their job boards researched and built, a setting written for
   their field, a portal rebuilt), and facts in the profile still marked `[CONFIRM: ...]`.
   The person will not know which ones matter, so never offer them a list or wait for a yes:
   - **What Claude can do alone** ("Claude applies these itself"): start a background agent
     at once (Agent tool, `run_in_background: true`, `isolation: "worktree"`) running
     `/upgrade`, and go on with what the person asked. Their request never waits for it.
     When it reports, merge its branch into this session's work.
   - **At the end of your reply**, one line per upgrade it applied: what changed and
     `git revert <sha>` to undo it.
   - **What needs the person** ("These need you", unconfirmed facts): ask at most three,
     plainly, at the end of the session, never at the start; write each answer where its
     marker was and commit it. The rest wait for the next session.
   If the sync printed nothing about upgrades (its first run after this arrived is the old
   copy), run `python3 tools/profile_upgrades.py` yourself and do the same.
   - **One chat per update.** Only the chat whose own sync brought an update in applies its
     upgrades and asks its questions. Any other chat open on the repo is told "being applied
     by the chat that brought this update in" and starts nothing and asks nothing; if they
     are still pending two hours later, its next sync hands them over. So the chat that
     brought the update in lands that sync on master promptly, before its other work.

In the app itself, step 2 is a no-op and step 3 does not apply: the app has no profile and
must never get one.

## App-owned files and personal files

`tools/personal_paths.py` is the line. Everything it lists (the profile, the tracker, the
contact book and threads, documents, research) is the person's; everything else is the app's.

- **In a person's repo, never edit an app-owned file.** The next sync puts it back to the app's
  version and says so. A fix or a feature the app needs is made in the app (below) and arrives
  here through the sync. If the person is not the app's maintainer, offer to write the idea up
  as an issue on the app instead.
- **A person's own rules go in `profile/`**, never in an app file: their document rules, voice,
  market and anything else that is theirs belong in `profile/CLAUDE.md` and the profile's data
  files, which the tools and these instructions already read.

## Claude does the tailoring, and keeps doing it

`/setup` asks plain questions only: where they live and will work, their right to work there
(and whether they need sponsorship), the roles and fields they want, salary hopes,
deal-breakers, employers they love or will not touch, how they like to come across. It never
asks anyone to name a job board, an ATS, a spelling convention or a config value. Claude does
that research and writes the profile: which job boards serve their city and field (building
one with `/add-portal` when it is missing, after checking robots.txt the way
`tools/robots_check.py` does), which target employers post on Greenhouse, Lever, Ashby or
Workday (probed the way `tools/ats_boards.py --probe` does), the eligibility and visa wording
postings in their country use, salary norms for their role and city, local document
conventions (resume or CV, page length, spelling), and the market's place names. Everything
researched is cited and stored in `profile/`.

**And it keeps going without being asked.** When a round comes back thin, a good posting
arrives from a board that is not covered, an employer they mention has no board on file, or a
gate cuts something it should not for their market, propose the fix, do the research, and
record it in `profile/`.

## Developer rules: changing the app itself

**Before changing any app-owned file, read `CONTRIBUTING.md`, "Developer rules"**, whichever
repo the session started in. In short: the change is made in the app, on a branch, and merged
with CI green; the leak scan passes on the exact tree before anything is pushed, because the
app is public and so are its branches and history; no example is a disguised person; a change
an existing user would have to act on is a profile upgrade (`tools/profile_upgrades.py`); and
a change to a part of the build that leaves something behind is a new version in
`tools/build_versions.py`. Tests fail on the last two.

## Master is the trunk. Finish on it, start from it.

Several Claude chats run against this repo, each one in its own container, each one
handed its own branch. That works only if every branch lands on master before its
session ends: a chat that starts from master cannot see work sitting on another chat's
branch, and builds it a second time.

So, every session:

1. **Start by pulling master and taking the app's updates** ("Start of every session"
   above). Another chat may have merged since this container was built. Do this before
   writing anything, not before pushing.
2. **Finish by getting the work onto master.** Not "pushed to the branch": on master.
   Open the pull request, run `./tools/ci_local.sh`, merge it. A branch left behind at the end of a
   session is work the next chat cannot see and will duplicate.
3. **Never leave a merged branch around**, and never start new work on an old one.
4. If the work genuinely is not finishable this session, say so in the last message and
   name the branch, so the next chat is told where to look rather than left to find out.

**Two chats changing the same state file is not a conflict to resolve by hand.** The
tracker, the contact book, the message log and `seen_jobs.json` merge row by row
(`tools/merge_state.py`, which every sync wires into git): rows either side added are both
kept, and notes both sides added are both kept. Git stops only where both sides changed the
same field of the same row to different values; keep the later one, by its date. Before
merging a pull request, `git fetch origin master && git merge origin/master`, so that merge
happens in the container, where the driver is set, and not on GitHub, where it is not.

The one thing that is **not** negotiable in the other direction: do not push to master
without CI green. It is the branch every other chat starts from, so a broken master
breaks all of them at once.

**On a private repo, green is established locally, and that is the rule:
`./tools/ci_local.sh`** (`--quick` skips the LaTeX compile). A clean run is the evidence and
merging on it is allowed; say so in the PR and name anything it reported as SKIP. Why, what
CI's Python 3.10 leg is for, and the one case worth a billed CI run: `CONTRIBUTING.md`, "CI on
a private repo". Batch the commits and push once, when a unit of work is finished.

## Persistence rule for a person's repo (deviates from upstream)

A person's repo is **private** and the search runs in ephemeral cloud sessions, so nothing
persists unless it is committed, and the state is committed there. (Upstream's "never commit
the tracker" rule is for public forks. The app itself holds none of it, and its CI fails on a
personal path.) **Do not edit `.gitignore`**: `tools/security_guards.py` enforces it in CI.
Force-add the specific artefacts instead. At the end of any session that produced work:

```bash
for p in profile networking; do      # not ignored: a plain add, so no cache file comes with it
  [ -e "$p" ] && git add -- "$p"
done
for p in job_search_tracker.csv network_contacts.csv network_threads.csv \
         job_scraper/seen_jobs.json job_scraper/do_not_target.json \
         documents/cv documents/applications documents/network documents/rounds \
         cv/*_Resume_*.tex cv/*_Resume_*.pdf cv/*_CV_*.tex cv/*_CV_*.pdf \
         cover_letters/*_Cover_Letter_*.tex cover_letters/*_Cover_Letter_*.pdf \
         company_research/*.json upskill/*.md; do
  [ -e "$p" ] && git add -f -- "$p"
done
```

One path at a time, and only what exists: a `git add` naming a path that does not exist
stages nothing at all, silently. `profile` and `networking` take a plain `git add`, never
`-f`, which would force in the caches under `profile/tests/`; `profile/` holds what the person
taught it and every setting changed. `company_research/*.json` is ignored but costs a fetch to
rebuild. `documents/rounds/<round>.json`, each round's top 60 without the posting bodies, sits
outside the ignored `rounds/` on purpose. `network_threads.csv` is the file that must never be
lost: status is derived from it, and without it every contact reverts to `not_contacted`
and the same people get messaged twice.
**Never force-add build noise** (`*.aux`, `*.log`, `*.out`, `*.fls`, `*.fdb_latexmk`,
`*.synctex.gz`, `cv/*.txt`, `__pycache__/`, `.pytest_cache/`), `salary_data.json` (licensed
third-party data) or `gmail_sync/state.json`.

## Environment notes for cloud sessions

- **`./tools/setup_env.sh` sets the container up**: LaTeX, Poppler and the Python side, one
  package at a time, and a no-op when they are present. The line in its header, pasted into
  the cloud environment's Setup script, caches it. It is not a SessionStart hook, on purpose:
  `tools/security_guards.py` keeps that allowlist empty.
- **`texlive-fonts-extra` is not installed**: moderncv's `fontawesome5` and `academicons` are
  vendored in `texmf/`. `File fontawesome5.sty not found` means `TEXMFHOME` was not set;
  `fit_to_page.py` sets it, and a compile by hand needs `export TEXMFHOME="$PWD/texmf"` first.
- **A pyo3 panic from `tools/verify_pdf.py`** is a broken `cffi`: `pip3 install --upgrade cffi`.
- **A job board the session cannot reach** may be blocked by its egress policy: check
  `curl -sS "$HTTPS_PROXY/__agentproxy/status"` for `connect_rejected`. That is a policy
  denial, not a bug: do not route around it. `/apply <pasted posting text>` needs no network.

## Automation: what runs without anyone asking

**`automation/README.md` is the whole story; `profile/routines.json` is the registry**, and
when the tasks run and where they report is the person's (the automation section of
`profile/CLAUDE.md`). Two set-ups work, and the README walks a person through either: **(a)**
one chat per task ("Job search: morning brief", "Job search: shortlist"), each task bound to
its own chat, whose reply is the briefing; or **(b)** each task made in the Claude app's
Routines screen with the repo ticked and notifications on. Each task's stored prompt is a stub
that reads `automation/prompts/_common.md` and its own file from master, so **changing what a
task does is a normal PR**; only a schedule change needs `update_trigger` or the Routines
screen. The final reply is the message; nothing depends on `fire_trigger`. An older
shared-chat set-up that relays to the phone through a fresh-session task still works only
from a chat made before 14 October 2026: never archive, delete
or rebuild one, and never test a chat-bound task with `fire_trigger` (use a one-off
`run_once_at` task bound to the chat). `python3 tools/routines.py check` validates the
registry.

**Silence is the alarm**: the brief is delivered every day even when nothing is due, so a
morning with no brief means the automation is broken; `automation/README.md` has the set-up
steps if a chat or a task is lost.
**Every dated follow-up is automatic by construction**: the brief reads `next_action_due` from
`network_contacts.csv` and the nudge schedule from the tracker, so a reminder is a date
written into a state file, never another routine.

## Orchestration: workflows and model choice

**Which model, and how hard it thinks.** When a piece of work starts (a search, an
application, interview prep, outreach, setup, a change to the app), run
`python3 tools/model_guide.py <command or kind of work> --on "<this session's model>"`. It
prints nothing when the model fits. Otherwise its one line goes first in the reply, and the
work carries on. `--all` prints the whole table for anyone who asks which model to use.

Inside the work, models are pinned per stage to `11-cost-model.md`: reading and fetching go
to sonnet, and anything that could put a false claim in front of an employer runs on the
session's own model and is never downgraded. Two saved workflows in `.claude/workflows/` are
run by name with the Workflow tool:

| Workflow | Args | What it does |
|---|---|---|
| `draft-applications` | `{today, round_dir, picks}`, and `repo` when the session is not at the repository root | Per posting, pipelined: company research (sonnet), draft object (strongest), grounding and rules audit (strongest). Writes `round_dir/audit_<slug>.json`. |
| `repo-audit` | optional `{focus}` | Four sonnet readers over docs, code, networking state and records, a sceptic per slice that re-checks every finding against the cited lines, then one ranking pass. Read-only. |

Two **subagents** in `.claude/agents/` carry the same split for a single `/apply`, an
`/interview` or a networking call, where a workflow is too much:

| Subagent | Model | Use it for |
|---|---|---|
| `employer-researcher` | sonnet | One employer's facts from its own pages, located independently, quoted, cached in `company_research/<key>.json` |
| `grounding-auditor` | inherits the strongest | The last read before anything reaches an employer or a contact: profile grounding, `check_claims`, `check_numbers`, `voice_check`, `compose.validate()` |

`tools/lint_skills.py` checks their frontmatter, and a test pins the two models, so the
auditor cannot be quietly downgraded. The drafting path is three commands around one
workflow, for a batch from a round and a single `/apply` alike (`stage_picks.py single`
stages the one posting):

```bash
python3 tools/stage_picks.py stage "$SCRATCH/r1" "Acme::Demand Planning" 4 7     # picks.json, apps_in.json, fenced postings
# Workflow draft-applications with args {today, round_dir, picks: <picks.json>}
python3 tools/stage_picks.py assemble "$SCRATCH/r1"                              # drafts.json
```

Then RUNBOOK.md step 3. `stage` **never reuses a name another application was sent under**:
a second role at an employer gets a role token (`Acme_DemandPlanning`), and `fit_to_page.py`
refuses the whole batch if a draft arrives under a name that belongs to a different role.

## Commands and skills: what routes where

Several names are close enough to pick wrongly, so this is the map. **Skills** are
knowledge that loads when a topic comes up. **Commands** are the things typed with a
slash. A command and a skill can share a stem; they are not alternatives to each other.

| Skill | Commands it owns |
|---|---|
| `job-application-assistant` | `/apply` `/rank` `/expand` `/outcome` `/interview` `/setup` `/reset` `/add-template` |
| `job-scraper` (declared name: **`scrape`**) | `/add-portal` |
| `networking` | `/network` `/reply` `/threads` `/prep` |
| `upskill` | (none) |

Also `/gmail-sync`, `/notion-sync`, `/html-report` and `/upgrade` (which a session starts
itself, CLAUDE.md step 5), which belong to no skill.

**Two things are typed with a slash.** A file in `.claude/commands/` is one. A skill's
own declared `name:` is the other, which is why **`/scrape` and `/upskill` work with no
command file behind them**: `job-scraper/SKILL.md` declares `name: scrape` and
`upskill/SKILL.md` declares `name: upskill`. This is the whole source of the confusion
the map exists to settle. `networking` does not do that, so `/networking` is not a
command; its four actions are the command files `/network`, `/reply`, `/threads` and
`/prep`.

**The pairs that get confused:**

- **`networking` (skill) vs `/network` (command).** The skill is the rules: tiers, tone,
  limits, playbooks. The command is one action, a discovery run. Typing `/network` runs a
  discovery; asking anything about outreach loads the skill. `/reply`, `/threads` and
  `/prep` are its other three actions.
- **`/prep` vs `/interview`.** `/prep` prepares a **networking call** that came out of a
  thread, and takes a contact name. `/interview` prepares a **real interview on a tracked
  application**, and takes a company. "Prep me for <employer>" is almost always `/interview`. If
  it is ambiguous, ask which.
- **`job-scraper` vs `scrape`.** One skill. The directory is `job-scraper`, the declared
  `name:` is `scrape`, and `/scrape` is the command. Not three things.
- **`.claude/skills/` vs `.agents/skills/`.** The first is the four skills above. The
  second is the job-portal CLIs that rounds and `/scrape` call; they are search tools, not
  advice, and they are never invoked directly.
- **Portals and employer boards.** The app ships `linkedin-search` and `freehire-search`; a
  person turns either off in `profile/search.py` `PORTALS`, never in its `SKILL.md`. Every
  other board is the person's, built by `/add-portal` into their repo. Employer ATS boards
  (`tools/ats_boards.py`, named in `profile/boards.py`) are a third source. `python3
  tools/portals.py` prints what the next round searches; the rest is in the job-scraper skill.

**Tools run directly, not through a slash command**: `run_round.py`, `job_alerts.py`,
`stage_picks.py`, `verify_batch.sh`, `record_drafts.py`, `open_actions.py` (`--due`,
`--company`), `followups.py`, `what_works.py`, `bar.py`, `network/state.py`, `network/prep.py
--all`, `network/profile_facts.py` and `bridges.py`. What each does, its flags and its exit
codes are in RUNBOOK.md, "Tools run directly". `run_round.py` exits 0 when the yield target is
met, 2 when it is missed (a thin market) and 1 when a stage broke.

## Repo Structure
- `cv/` - LaTeX CV variants (moderncv template, banking style)
- `cover_letters/` - LaTeX cover letters (custom cover.cls template)
- `.claude/skills/` - AI skill definitions for the application workflow
- `.agents/skills/` - Job search CLI tools
- `networking/connection_targets.md` - research notes only. **Not a contact store**:
  `network_contacts.csv` is, and anyone named in that file is a row in it

## Workflow for New Job Applications
1. User provides a job posting (URL or text)
2. **Always evaluate fit first** against `profile/04-job-evaluation.md`: run the gates, score the four weighted dimensions, report the 0-100 score and band. Present this before drafting anything.
3. If good fit: create a targeted resume (`cv/<Prefix>_Resume_<ABBR>.tex`) and cover letter (`cover_letters/<Prefix>_Cover_Letter_<ABBR>.tex`), where `<Prefix>` is `person.json` `identity.file_prefix`. Filenames are human-looking, never machine-shaped: see the File naming table in `05-cv-templates.md`
   - Bullets are **allocated per posting** by `tools/allocate.py` from the full themed library in `tools/bullet_library.py`; never hand-pick from a fixed set. The renderer enforces a fixed section order (Summary, Experience, Skills, Education, Certifications), reverse-chronological experience, a Skills block of a few lines under the person's own headings (`profile/skills.py` `LABELS`), and no Core Competencies section (see "Layout rules the generator enforces" in `05-cv-templates.md`)
   - Also write `documents/applications/<slug>/job_posting.md` and `outreach.md` via `python3 tools/outreach.py <drafts.json> <apps_in.json>`. The outreach note is a sub-80-word LinkedIn message to the hiring manager or recruiter, built from the letter's verified company fact; sending it is the user's call, but every application gets one
   - The same command keeps `draft.json` in that folder, the object both documents are rendered from, with the posting named rather than copied. After a renderer fix, `python3 tools/fit_to_page.py documents/applications/<slug>/draft.json` re-renders an unsent application from it
4. **Verify both documents** (the Verification Checklist, below)
5. Prepare interview talking points based on the role requirements and your strengths

**Important:** the profile can add document rules of its own (`profile/CLAUDE.md`). They are
followed exactly like the ones here.

**A resume for somebody other than the candidate**: the repo has no pipeline for it. Read
RUNBOOK.md, "Documents for someone else", before starting.

## Job Search Behaviour

**When the user asks to run a job search, scan, or scrape, run the search end to end and
stop at the ranked shortlist.** Scrape every enabled portal; rank every surviving posting
0-100 against `04-job-evaluation.md`, gates first; expand and repeat, up to 3 rounds, until
the yield target is met (at least 5 postings at 60+, 2 of them 75+); then present the ranked
table, highest first, with the scores, the salary read, deadlines, referral links, a
clickable apply link on every row and what needs checking. **Stop there and ask** which to
take forward: draft nothing unless that request asked for it, and before drafting anything,
ask whether they have already applied and record the answer.

**Rank down, do not cut.** Only four things are hard cuts: an eligibility bar the profile
cannot clear, a language requirement, a professional credential gate, and a role whose core
is a discipline the person does not hold. A missing skill, a year of experience, a preferred
degree or a posted range under target is a scoring input, and a posting with no stated
salary loses nothing. Never pad a thin run with sub-60 postings, and never relax a gate or
the location filter to hit the number; say what was tried instead. **The rules every search
keeps, and why** (exclusions written back, the do-not-target list, warm routes and referrals,
the company throttle, salary, tiered scoring): the job-scraper skill, which loads with
`/scrape`. Read that section before running or presenting a round by hand.

## Networking and Outreach

The other half of the search. `.claude/skills/networking/` and `tools/network/`
handle finding the right people at target employers, composing every outbound
message type, managing live threads, and preparing for the calls that come out
of them.

It is an **extension of this system, not a parallel one**. `tools/company_key.py`
is the join: a contact at an employer's initials and an application to its legal name
resolve to one key, so threads and applications at the same employer link
automatically. Company findings cache in the existing `company_research/`.

State lives in `network_contacts.csv` and `network_threads.csv` beside the
tracker. **Status is derived from the messages, never stored independently**, so
it cannot drift from the evidence.

Commands: `/network` (discovery), `/reply` (draft inside a thread), `/threads`
(what is owed), `/prep` (call prep).

**The rules that matter most**, all enforced in code rather than remembered:

- One search per company, two only if the first is empty or the company has several relevant teams; three is a hard ceiling never meant to be reached. `discovery.queries_for()` caps it.
- Alumni and referral routes before cold. The response-rate gap is large.
- Tier 1 requires a **quoted** live hiring signal, shown as evidence.
- **Outreach is for referrals, in three stages that are never compressed into one:** a
  first note that asks for a conversation (`compose.first_note()`), the conversation, then
  a referral ask for one linked role (`compose.referral_ask()`). A first note never names
  the role the candidate wants, never asks if they are hiring and never states work authorization
  unless the reader is a recruiter. A cold note in the application shape draws almost no
  replies; see "Composition" in the networking skill.
- Connection notes are 300 characters hard. `compose.py` raises, never truncates.
- A warm intro leads with the referrer's name in the first sentence.
- Follow up after 5 to 7 **business** days, one nudge only.
- After two nos, stop.
- Never surface someone already contacted.
- Never claim anything not in `profile/01-candidate-profile.md`.

Commit these alongside application artefacts:

```bash
git add -f network_contacts.csv network_threads.csv documents/network/
```

## Every output is shown, never only filed

Whatever the person asked for arrives **in the reply**. That covers a message to send,
a draft, a resume, a cover letter, an outreach note, an interview pack, a prep doc and
a shortlist. Nobody should have to go looking in the repo. Text they will read or send
(messages, notes, questions, a pack) is pasted in full, ready to copy. A document (a
PDF, a long file) is sent with the session's file-sending tool (SendUserFile or its
equivalent) so it opens in one tap, with the part they need to judge it pasted beside
it. The file path is the record, written underneath, and never the answer on its own:
"I've put it in documents/..." is not done. The same goes for anything updated: show
the new version, not where it went.

## Learning the person: it grows with them

The app's defaults (the voice of a first note, the questions an interview pack asks,
who is worth contacting, how roles rank) are where everyone starts, and they stay. But
the person's own reactions outrank every default, and **they are kept for good in
`profile/learned.json`** through `tools/learned.py`. No sync touches it, and no upgrade
changes a setting it records.

**Whenever the person reacts to anything**: a message, a draft, a person or a job that
was surfaced, the voice, the questions, a word choice, how much detail. Phrases like
"I don't like this", "too formal", "not this kind of company", "stop suggesting
recruiters", or an edit they make to a draft before sending it all count. Then, in the
same turn:

1. Apply it now: redraft, drop the person or the job, change the setting.
2. Make it stick where it governs. That means the profile setting: `networking.py`
   (`OPENER`, `ABOUT_ME`, `ASK`, `GREETING`, `REFERRAL_LINE`, `REFERRAL_CLOSE`,
   `TITLE_SETS`), `search.py` (the bar, the scoring words), `profile/CLAUDE.md` (how they
   want to come across, document rules), `07-interview-prep.md`. A job they dislike is a
   `seen_jobs.json` exclusion with their words as the reason. A person they do not want
   contacted is a `closed` contact with their reason. A *kind* of job or person becomes
   the rule that covers all of them.
3. Record it: `python3 tools/learned.py record --area <voice|messages|questions|people|jobs|documents|search|interview|other> --said "<their words>" --rule "<what is different from now on>" --applied-to "<file SETTING>"`. Add `--never-say "<phrase>"` for wording they never want; `tone.py` and `voice_check.py` then flag it in everything drafted.
4. Say in one line what will be different from now on.

**Read before drafting.** Every step that writes something the person will read or
send (`/apply`, `/network`, `/reply`, `/interview`, `/prep`, a shortlist, outreach) first
runs `python3 tools/learned.py brief <area>` and follows every rule it lists over any
default in the app.

**Ask, lightly.** After output whose taste is theirs, end with one short line inviting
change: a batch of messages, a draft, a shortlist, a set of questions. For example:
"Anything about the tone, the people or the questions you'd have different? It sticks
from now on." Once per kind of output per session, and never in place of the work.

**Updates and what they have taught it.** A sync's genuine fixes apply: they change
code, not their settings. When an update would change a setting `learned.py locked`
lists, `/upgrade` changes nothing. It records `learned.py offer` instead, and the
person is asked once, with the session's other questions, whether to keep theirs or try
the new way. A kept choice is never offered again for that app version.

### What works: it learns from results, not only from what they say

`tools/what_works.py` counts what happened, from the state files and with no model call:
which messages drew a reply, which applications reached an interview. **Before drafting**,
beside the learned brief: `brief messages` (with `--channel` and `--route`) for a networking
message, `brief documents` for an application; match the shape of what worked, never its
words or another person's facts. **When a result arrives**: `event --company "<employer>"`
from `/outcome`, `event --contact <id>` from `/reply`, one line shown in the reply. A
difference is reported only when both groups are big enough and the gap is unlikely to be
chance. A resume that reached an interview gives its bullets a small, capped lift in
`tools/allocate.py`, which decides between bullets a posting rates alike and never outranks
one the posting asks for. A message logged without its `full_text` can never be an example.

## Memory: the repo remembers, the chat does not

Sessions here are **ephemeral cloud containers**. The conversation is gone when the
session ends. Anything that matters has to be written to a file and committed, or it
did not happen.

**The standing rule: whenever something changes, write it down before replying.** Not at
the end of the session, not when asked. A session that learns a fact and only says it in
chat has lost it.

### What changes, and where it goes

| What happened | Write it here |
|---|---|
| Applied to something, anywhere, including outside this repo | `job_search_tracker.csv`, a new row |
| An application changed status (interview, offer, rejection, silence) | `job_search_tracker.csv` `status`, via `/outcome` |
| Anything learned about a role, employer or process | `job_search_tracker.csv` `notes`, appended with the date |
| Messaged someone, or they replied | `network_threads.csv`, one row per message |
| A new person worth contacting | `network_contacts.csv` |
| A contact's situation changed, or something is now owed | that contact's `status` and `next_action` |
| An employer turns out to have no relevant presence at all | `job_scraper/do_not_target.json`, with a `review_after` |
| A posting was ruled out | `job_scraper/seen_jobs.json`, with `status: excluded` and a reason |
| A fact about the candidate: a skill, a gap, a date, a constraint | `01-candidate-profile.md`, and `CLAUDE.md` if it belongs in the summary |
| A reaction or preference: a message, a person, a job, the voice, the questions | the setting it governs, and `profile/learned.json` via `tools/learned.py record` |
| Company research that cost a fetch | `company_research/<key>.json` |

`python3 tools/open_actions.py` renders all of it as one view, employers first, each
carrying its applications and its contacts together. `--company <name>` answers "what is
happening at <employer>" in one command. It is **derived, never stored**, so it cannot go stale:
if something is missing there, fix the state file, not the view.

**Commit and push whenever a unit of work finishes**, not once at the end (the `git add`
lines are in the persistence rule above): a session that runs out of context mid-task loses
everything uncommitted. **The tracker only knows what was recorded.** A candidate applies to
things directly, so before drafting anything ask whether they have already applied, and
record the answer either way.

## Verification Checklist

After creating or updating a CV or cover letter, go through **every** item in
`.claude/skills/job-application-assistant/verification-checklist.md` and report it as a
pass/fail checklist before presenting anything: factual accuracy, targeting, consistency,
quality (including every document rule `profile/CLAUDE.md` adds), both documents compiled and
read back as PDFs (mandatory, never skipped), and the resume's text layer as an ATS parses it.