CLAUDE.md · git:20261018.339a072 · 2026-10-18 · sha256 5b4fe73d3b651bb5
CLAUDE.md git:20261018.339a072A
Immutable. This exact content never changes and is served at /api/v1/blob/5b4fe73d3b651bb5.
# 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 --on "<this session's model>"
--tools "<the browser tools this chat has: browser, chrome, enable, or none>"`. It
fetches the app and merges what is new (the newest release; the maintainer's own copy takes
master), never sends anything back, and ends with the day's model check: what this model
suits and what wants another, which goes first in the reply when the person's request is
one of the latter. Then the place check (`tools/place.py`), once per chat: when this chat
cannot do what the person set up (profiles read in their browser, from a chat with no
browser of theirs; tools missing on their computer), it prints what to tell them, which
goes at the end of the reply in its own words. It offers a choice and never makes it. 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.
**A chat that carries on for days does this every day, not once.** Most people keep one
chat going, so "the start of a session" may be days ago. Before each new piece of work (a
search, an application, outreach, a reply, interview prep), run
`python3 tools/sync_app.py --if-stale --on "<this session's model>" --tools "<...>"`. When today's check has
not run, it does steps 1 and 2 (master, then the app) and the rest follows as above;
otherwise it prints "already checked today" and changes nothing. `run_round.py` and `stage_picks.py` print a reminder
when it was skipped, and that reminder is code, so it reaches a chat whose instructions
were loaded before this rule existed.
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 `MAINTAINING.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.
**On the person's own computer** a chat has no GitHub tools: `gh` is the route, and
`place.py` lists it (and `gh auth login`, theirs) when it is missing. Never work on master
there: `git switch -c <branch>`, commit, `git fetch origin master && git merge origin/master`
(the sync sets the row-by-row driver on their computer too), `./tools/ci_local.sh`,
`git push -u origin <branch>`, `gh pr create --base master --head <branch> --fill`,
`gh pr merge <number> --merge --delete-branch`, then `git switch master && git pull --no-rebase
origin master`.
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.9 leg is for, and the one case worth a billed CI run: `MAINTAINING.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 (where: `python3 tools/claude_surfaces.py say
cloud-environment`), 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`). The default is one task, **a brief twice a week** that also takes the
app's updates; a scheduled job round is optional and off by default, since "find me jobs"
runs one in any chat. Two set-ups work, and the README walks a person through either: **(a)**
one chat per task, each task bound to its own chat, whose reply is the briefing; or **(b)**
each task made as a routine with the repo ticked, every run a new chat. 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 routine's
own page. 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.
**"Set up my brief" is set-up (a) in `automation/README.md`.** First `python3 tools/routines.py
brief` (with `--triggers <file>` holding the account's scheduled tasks, where the chat can list
them): a brief that exists, whatever it is called, is attached to and never made twice, and a
task that only looks like one is asked about. The brief always runs in the cloud. **In a cloud
chat**, the task's own chat comes next: start it yourself where the session can
(`create_session` on the person's private repo, titled "Job search: morning brief", with the
paste in (a) step 2 as its prompt), otherwise give them that paste for a new chat at
claude.ai/code; that chat asks its two questions, so say where to answer them, and creates the
task. **On their computer** (`python3 tools/claude_surfaces.py show <id>` for remote-trigger,
routines-cloud, routines-local and cli-cloud, each dated and sourced): with the `RemoteTrigger`
tool, make it yourself as (b), on the private repo, the stub as its prompt, notifications on,
recorded as `routines_screen`; without it, ask them once, in the words `claude_surfaces.py say
routines-cloud` prints. Never start a cloud chat from your own shell (cli-cloud: the command
refuses a shell that is not an interactive terminal, which yours is not), and never make a
desktop scheduled task for the brief (it runs on the computer). Say the line `routines.py brief` prints. (b) is also for someone who
would rather have each run arrive as a new chat and a notification.
**Silence is the alarm**: the brief is delivered on each of its days even when nothing is
due, so one of those mornings 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.** The day's sync prints the check (step 2, `--on`).
For one piece of work, `python3 tools/model_guide.py <command or kind of work> --on "<this
session's model>"` 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.
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 of the **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;
`ats_boards.py --find "<Employer>"` finds a board, through the vendored
[openroles](https://github.com/aranculleton/openroles) in `tools/openroles/` (never edited
here). `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/reading.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.** The hard cuts are only the ones no framing survives: an
eligibility bar the profile cannot clear, a language requirement, a professional credential
gate, a role whose core is a discipline the person does not hold, a posting open to existing
staff only, and a stated pay range whose top is below the person's cut line (or a knockout
on their own triage card). 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 wherever the log can know it**, so
it cannot drift from the evidence. What the log cannot know (a call booked, a referral in
flight, a conversation gone cold, a close) is stored on the contact row by whoever learns
it, and wins over the log.
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:
- A fixed search budget: at most `sources.SEARCHES_PER_COMPANY` (four) searches per employer
across every kind of source, `sources.SEARCHES_PER_RUN` (five) for the kinds searched once a
run, and inside that at most two LinkedIn title searches per employer, three a hard ceiling
never meant to be reached (`discovery.queries_for()`). `sources.py` prints the run's searches.
- Alumni and referral routes before cold. The response-rate gap is large.
- Tier 1 requires a **quoted** live hiring signal, shown as evidence.
- **Read each person before writing to them** (`tools/network/reading.py`, `/network` step 5):
their profile, in the person's own browser when `PROFILE_READS` is `"browser"` or pasted
by them, and their public work. A note's common ground rests on what was read
(`first_note(reading=)` refuses one that does not), and no two notes say the same thing.
LinkedIn is never fetched from a session.
- **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 200 characters on a free LinkedIn plan and 300 on Premium
(`schema.LIMITS`, from `LINKEDIN_PLAN`), 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
`python3 tools/exclude.py add <url> --basis person --reason "<their words>"`. 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. **Rulings change, and that is
normal:** when they change their mind about a kind of job, a sector or an employer,
undo the ruling in the same turn (`exclude.py lift "<words of the reason>"`, the sector or
employer out of `search.py` `BAR_EXCLUDED` or `BAR_DECLINED`, the entry out of
`job_scraper/do_not_target.json`). A ruling made by their screening rules lapses by
itself when the part of the rules it rests on changes (one blocker row, one gate setting);
when you change a screening rule, run `exclude.py lapsed` in the same turn and `restamp`
the groups that do not rest on what changed.
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`, through `tools/exclude.py add` (a reason, and what it rests on) |
| 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.