System map
How a job flows through the system
Every interaction starts at one skill file. /career-ops with no args shows the command menu; /career-ops scan routes to the scan mode; pasting a raw JD or URL triggers the auto-pipeline (evaluate → report → PDF → tracker, end to end). The router then loads modes/_shared.md (system rules, scoring logic) plus the specific mode playbook, and — critically — modes/_profile.md last, so your personal overrides always win.
The same modes are exposed as /career-ops-* commands in OpenCode (.opencode/commands/) and Gemini CLI (.gemini/commands/*.toml) — different CLI, identical playbooks, identical files.
The scanner costs zero LLM tokens: it hits Greenhouse/Ashby/Lever/Workable/SmartRecruiters/Recruitee JSON APIs directly for every company in portals.yml (~111 tracked), then applies your title_filter and location_filter keywords locally. Survivors land in data/pipeline.md (the inbox); data/scan-history.tsv dedups across runs so you never see the same posting twice. Schedule it on cron — it's pure Node.
show the filter (scan.mjs:121)
// scan.mjs — case-insensitive SUBSTRING match function buildTitleFilter(titleFilter) { const positive = (titleFilter?.positive || []).map(k => k.toLowerCase()); const negative = (titleFilter?.negative || []).map(k => k.toLowerCase()); return (title) => { const lower = title.toLowerCase(); const hasPositive = positive.length === 0 || positive.some(k => lower.includes(k)); const hasNegative = negative.some(k => lower.includes(k)); // substring! "CTO" matches "direCTOr" — use " CTO" with a leading space
This is the heart. For each offer, the AI runs a seven-block evaluation and writes it to reports/###-company-date.md:
- A — Role Summary: archetype, seniority, remote policy, TL;DR
- B — Match with CV: every JD requirement mapped to exact lines in
cv.md, plus gaps with mitigation plans - C — Level & Strategy: detected level vs yours, "sell senior without lying" plan, downlevel playbook
- D — Comp & Demand: live WebSearch salary data, cited sources
- E — Customization Plan: top-5 CV changes + top-5 LinkedIn changes for this role
- F — Interview Plan: 6–10 STAR+R stories mapped to JD reqs, accumulated into
interview-prep/story-bank.md - G — Posting Legitimacy: ghost-job detection (posting age, repost patterns, layoff news, JD specificity) → High Confidence / Caution / Suspicious
Output is a global 1–5 score. The system is ethically opinionated: below 4.0 it explicitly recommends against applying — quality over spray-and-pray. Liveness is verified against the source (Playwright snapshot or ATS API status) before any work happens.
For offers scoring ≥ auto_pdf_score_threshold (default 3.0), the pdf mode tailors your CV to that JD: 15–20 keywords extracted, summary rewritten with them, bullets reordered by relevance, competency grid built from requirements — with a hard rule: never invent skills, only reword real experience using JD vocabulary. The HTML is rendered to PDF via Playwright, single-column and ATS-clean (selectable text, standard headers, unicode normalized to ASCII, A4 or US-letter by company geography). A LaTeX/Overleaf export (generate-latex.mjs) and optional Canva visual flow exist too.
show the run command
node generate-pdf.mjs /tmp/cv-steven-gonsalvez-{company}.html \
output/cv-steven-gonsalvez-{company}-{date}.pdf --format=a4
# → output/ is gitignored; PDFs are yours, named per company per day
Every evaluated offer becomes a row: number, date, company, role, score, status, PDF flag, report link, note. Statuses are canonical (templates/states.yml): Evaluated → Applied → Responded → Interview → Offer, or Rejected / Discarded / SKIP. Writes are protected by a merge protocol — evaluations write single-line TSVs to batch/tracker-additions/ and merge-tracker.mjs folds them in, preventing duplicate rows when parallel workers finish at once. Health tooling: verify-pipeline.mjs (lint everything), normalize-statuses.mjs, dedup-tracker.mjs, check-liveness.mjs (are my applied-to jobs still open?).
show the TSV contract
# batch/tracker-additions/{num}-{company}.tsv — 9 tab-separated cols {num} {date} {company} {role} {status} {score}/5 {✅|❌} [{num}](reports/…) {note} # status BEFORE score in TSV; merge script swaps for the tracker table
The dashboard is a terminal app, not a webpage. It parses data/applications.md, computes funnel metrics, and gives you three screens: pipeline table, full report viewer (enter on a row), and progress metrics. q quits, esc goes back. It is read-only — a lens over the same files, never a writer.
how to run it
cd ~/d/git/career-ops/dashboard go run . --path .. # run against the repo root # or build a binary once: go build -o ~/bin/career-dash . && career-dash --path ~/d/git/career-ops
apply— live application assistant: reads the form, drafts answers in your voice, fills fields — always stops before Submit (you click)contacto— LinkedIn outreach: finds likely hiring contacts, drafts the messagedeep— deep company research before an interview or offer callinterview-prep— company-specific prep doc + growing STAR story bankpatterns—analyze-patterns.mjsmines your rejections to fix targetingfollowup—followup-cadence.mjsflags overdue follow-ups, drafts the nudgeofertas— compare and rank multiple offers side by sidetraining/project— evaluate a course or portfolio idea against your goalsbatch—batch/batch-runner.shspawns parallel headless workers (claude -p) for bulk evaluation- Language packs: full native mode sets for DACH (
modes/de), French (fr), Japanese (ja), Turkish (tr), pluspt,ru,ua
The whole construct rests on a two-layer data contract. User layer (cv, profile, portals, tracker, reports, output, interview-prep) is never touched by updates. System layer (shared mode logic, scripts, templates, dashboard) is upgradeable: node update-system.mjs check runs at session start, apply upgrades with a backup, rollback restores. Your customizations live in modes/_profile.md and config/profile.yml precisely so that pulling v1.9 never overwrites who you are.