career-ops Documentation

Open-source AI job search: scan job portals, evaluate listings with a structured A-F rubric into a 1.0-5.0 score, tailor your CV, track applications — runs...

63,650 stars JavaScript JavaScript 1 file · ~32,471 tokens #ai#ai-agent#anthropic#ats#automation#beginner-friendly#career#careerops
RAW Doc

Docker & Self-Hosted Deployment Verified OCI

Deploy career-ops on any server, homelab, or cloud container platform.

1-Click Terminal Command
bash (docker run)
docker run -d --name career-ops --restart always career-ops:local
Configured Services & Ports
⚙️ career-ops
image: career-ops:local
docker-compose.yml
yaml (docker-compose.yml)
services:
  career-ops:
    build:
      context: .
      dockerfile: Dockerfile
    image: career-ops:local
    container_name: career-ops
    working_dir: /app
    # Long-running shell so `docker compose exec` is instant.
    command: ["tail", "-f", "/dev/null"]
    init: true
    # Mount whole project so edits on host are visible inside the container.
    # node_modules is kept inside the image to avoid host/container ABI mismatch
    # (host is ubuntu26.04 where playwright won't install; image is jammy).
    volumes:
      - .:/app
      - career-ops-node-modules:/app/node_modules
    environment:
      - NODE_ENV=development
      - PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
      # Forward common API keys if set on host. Empty values are ignored.
      - GEMINI_API_KEY=${GEMINI_API_KEY:-}
      - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
      - OPENAI_API_KEY=${OPENAI_API_KEY:-}
    # Chromium needs a larger /dev/shm than the default 64M.
    shm_size: "1gb"
    # Dashboard TUI is interactive; expose nothing by default but allow `-it`.
    tty: true
    stdin_open: true
    restart: unless-stopped

volumes:
  career-ops-node-modules:

File: README.md


What Is This

career-ops (career-ops.org, also known as careerops) turns any AI coding CLI into a full job search command center. Instead of manually tracking applications in a spreadsheet, you get an AI-powered pipeline that:

  • Evaluates offers with a structured evaluation -- blocks A-F scored across 5 weighted dimensions, plus block G, a separate posting-legitimacy assessment that never affects the 1-5 score
  • Generates tailored PDFs -- ATS-optimized CVs customized per job description
  • Scans portals automatically (Greenhouse, Ashby, Lever, company pages)
  • Processes in batch -- evaluate 10+ offers in parallel with sub-agents
  • Tracks everything in a single source of truth with integrity checks
  • Researches companies and finds the right person to contact -- applications get you in the queue; research gets you a conversation

Important: This is NOT a spray-and-pray tool. career-ops is a filter -- it helps you find the few offers worth your time out of hundreds. The system strongly recommends against applying to anything scoring below 4.0/5. Your time is valuable, and so is the recruiter's. Always review before submitting.

career-ops is agentic: whichever AI coding CLI you choose navigates career pages with Playwright, evaluates fit by reasoning about your CV vs the job description (not keyword matching), and adapts your resume per listing.

Heads up: the first evaluations won't be great. The system doesn't know you yet. Feed it context -- your CV, your career story, your proof points, your preferences, what you're good at, what you want to avoid. The more you nurture it, the better it gets. Think of it as onboarding a new recruiter: the first week they need to learn about you, then they become invaluable.

Built by someone who used it to evaluate 740+ job offers, generate 100+ tailored CVs, and land a Head of Applied AI role. Read the full case study.

The CareerOps Manifesto

career-ops is the first reference implementation of the CareerOps Manifesto. read it. if it says what you believe, sign it. your signature becomes a commit.

Features

Feature Description
Auto-Pipeline Paste a URL, get a full evaluation + PDF + tracker entry
A-H Evaluation Role summary, CV match, level strategy, comp research, personalization, interview prep (STAR+R) -- plus a Block G posting-legitimacy check that flags scams and ghost jobs, and a Work-Auth signal that flags an explicit no-sponsorship JD as a hard blocker
Interview Story Bank Accumulates STAR+Reflection stories across evaluations -- 5-10 master stories that answer any behavioral question
Negotiation Scripts Salary negotiation frameworks, geographic discount pushback, competing offer leverage
ATS PDF Generation Keyword-injected CVs with Space Grotesk + DM Sans design
Cover Letter Generator Research-backed cover letters with keyword mirroring, four interactive angle prompts (why/problems/approach/tone), draft-in-chat approval gate, and A4 PDF via the same HTML + Playwright pipeline as CVs. Auto-drafts on every evaluation; complete and generate on demand via /career-ops cover
Application Email Drafts Formal recruiter/referral/cold application emails from a report or pasted JD, with subject line, attachment checklist, source-backed fit points, and a profile-driven contact block. Draft-only -- career-ops never sends, submits, or clicks anything.
Portal Scanner 100+ companies pre-configured (Anthropic, OpenAI, ElevenLabs, Retool, n8n...) + custom queries across Ashby, Greenhouse, Lever, Wellfound
Funded Company Discovery Review-first company:funded command surfaces recently funded companies and source diagnostics from structured public feeds without editing your data
Batch Processing Parallel evaluation with headless CLI workers (claude -p / opencode run)
Dashboard TUI Terminal UI to browse, filter, and sort your pipeline
Human-in-the-Loop AI evaluates and recommends, you decide and act. The system never submits an application -- you always have the final call
Pipeline Integrity Automated merge, dedup, status normalization, health checks
Interview Suite Time-blocked prep plans, practice sessions with feedback, post-interview debriefs (interview/), and a company red-flag detector (interview-redflag)
Offer Stage Contract reading companion -- clause walk plus a lawyer question list (offer-prep) -- and a desired/advertised/actual salary-gap analyzer (salary-gap.mjs)
Follow-ups & Replies Follow-up cadence calculator and seeded reminders (followup-cadence.mjs, followup-seed.mjs); employer reply classification into tracker updates (reply-watch)
Pattern Analysis Rejection patterns and per-ATS-channel advance rates (analyze-patterns.mjs), lifetime funnel stats (stats.mjs), repost/ghost-job detection (detect-reposts.mjs)
Plugin System Opt-in integrations (Gmail, Notion, Apify + a community registry), disabled by default -- see docs/PLUGINS.md
Beyond the CV Company research (deep) surfaces AI strategy, recent moves, engineering culture, and the angle your profile should take. Contact discovery (contacto) identifies the hiring manager, recruiter, or team peer worth reaching out to and drafts a ≤300-character LinkedIn message tuned to each contact type. Formal application email drafts (email) turn an evaluated report or pasted JD into a subject line, body, and attachment checklist without sending, submitting, or clicking anything. Applications get you in the queue; research gets you a conversation.

Quick Start

Fastest way — one command:

bash
npx @santifer/career-ops init

💡 npx ships with Node.js — it runs the installer once,
without installing anything globally. No Node yet? Install it first.
(Already using a Claude Code / Gemini / Codex CLI? Then you already have it.)

This clones the latest release into ./career-ops and installs dependencies. Then:

bash
cd career-ops
claude   # or codex / qwen / opencode / agy / grok — open your AI CLI here

On first launch, career-ops walks you through setup — your CV, profile and target roles — just by chatting. Nothing to edit by hand.

Prefer to set it up manually? (git clone)

bash
git clone https://github.com/santifer/career-ops.git
cd career-ops && npm install
npx playwright install chromium   # only needed for PDF generation

# 2. Check setup
npm run doctor                     # Validates all prerequisites

# 3. Configure
cp config/profile.example.yml config/profile.yml  # Edit with your details
cp templates/portals.example.yml portals.yml       # Customize companies

# 4. Add your CV
# Create cv.md in the project root with your CV in markdown

# 5. Open your AI CLI in this directory
claude   # or codex / opencode / qwen / agy / grok

# Then ask your CLI to adapt the system to you:
# "Change the archetypes to backend engineering roles"
# "Translate the modes to English"
# "Add these 5 companies to portals.yml"
# "Update my profile with this CV I'm pasting"

# 6. Start using
# Paste a job URL or JD text to trigger auto-pipeline
# If your CLI supports slash commands, use /career-ops (or its CLI-specific alias)
# In Codex, ask for the same mode in plain language, e.g.:
# "Run the career-ops scan mode"
# "Run the career-ops pipeline mode for data/pipeline.md"
# "Run the career-ops pdf mode for the latest evaluated role"
# "Run the career-ops tracker mode and summarize the current statuses"

Global install

bash
npm i @santifer/career-ops

This installs the career-ops binary globally so you can run it directly instead of via npx. Unlike npx @santifer/career-ops init (which bootstraps a project directory), the global install gives you a persistent career-ops command available anywhere in your terminal.

Which one should you use?

  • npx @santifer/career-ops init — best for first use; creates a dedicated project folder.
  • npm i @santifer/career-ops — best once you have a project folder and want to run career-ops commands directly.

The system is designed to be customized by your AI coding CLI itself. Modes, archetypes, scoring weights, negotiation scripts -- just ask it to change them. It reads the same files it uses, so it knows exactly what to edit.

See docs/SETUP.md for the full setup guide, docs/RUNNING_ON_A_BUDGET.md for instructions on running career-ops cheaply using custom or local models (and docs/FREE_TIER.md for running it at zero cost on Antigravity CLI's free tier), docs/AUTOMATION.md for scheduling recurring scans and a zero-token triage-to-shortlist recipe, docs/APPLY_AUTOFILL.md for details on the ATS auto-fill flow, and docs/FAQ.md for answers to common setup questions. Design principles live in ARCHITECTURE.md; runtime flows in docs/ARCHITECTURE.md.

Antigravity CLI Integration

career-ops supports Antigravity CLI natively, the same way it supports Claude Code and OpenCode. All slash commands are available through the shared skill entrypoint, using the same modes/*.md evaluation logic.

Google has transitioned consumer Gemini CLI access to Antigravity CLI. GEMINI.md is now a no-op compatibility guard so Antigravity does not duplicate the full project instructions when it reads both AGENTS.md and GEMINI.md.

Native Antigravity CLI

bash
# 1. Run in the career-ops directory
cd career-ops
agy

# 2. Use the unified /career-ops command with subcommands:
/career-ops "Senior AI Engineer at Anthropic..."
/career-ops pipeline
/career-ops scan
/career-ops pdf
/career-ops tracker

The skill is defined using the open standard in .agents/skills/career-ops/SKILL.md and symlinked/referenced for each supported CLI (e.g. .claude/, .cursor/, .qwen/, .antigravitycli/, .grok/).

Codex Integration

career-ops supports Codex through the same shared router, but the invocation model is different from CLIs that auto-register slash commands. For the full guide, see docs/CODEX.md.

Interactive Codex

bash
cd career-ops
codex

Slash commands are not guaranteed in Codex. If /career-ops is unavailable, ask Codex to run the mode directly in plain language:

text
Evaluate this JD with career-ops auto-pipeline: https://company.com/jobs/123
Run the career-ops scan mode and summarize new matches.
Run the career-ops pipeline mode for data/pipeline.md.
Run the career-ops pdf mode for the latest evaluated role.
Run the career-ops tracker mode and summarize the current statuses.

One-shot Codex (`codex exec`)

bash
codex exec "Evaluate this JD with career-ops auto-pipeline: https://company.com/jobs/123"
codex exec "Run career-ops scan mode in this repo and summarize new matches."
codex exec "Run career-ops pipeline mode for data/pipeline.md."
codex exec "Run career-ops pdf mode for the latest evaluated role."
codex exec "Run career-ops tracker mode and summarize the current statuses."

Grok Build CLI Integration

career-ops supports Grok Build CLI natively, the same way it supports Claude Code and OpenCode. AGENTS.md is auto-loaded as project rules, and all slash commands are available through the shared skill entrypoint.

Native Grok Build CLI

bash
# 1. Run in the career-ops directory
cd career-ops
grok

# 2. Use the unified /career-ops command with subcommands:
/career-ops "Senior AI Engineer at Anthropic..."
/career-ops pipeline
/career-ops scan
/career-ops pdf
/career-ops tracker

For headless batch workers, use grok -p "prompt" (add --yolo to auto-approve tool executions).

Standalone Gemini API Script (No CLI install needed)

bash
# 1. Get a free API key at https://aistudio.google.com/apikey
cp .env.example .env
# Edit .env, set GEMINI_API_KEY=your_key_here

# 2. Install dependencies
npm install

# 3. Evaluate a job description
node gemini-eval.mjs "We are looking for a Senior AI Engineer..."
node gemini-eval.mjs --file ./jds/my-job.txt
node agent-inbox.mjs add "..."   # queue a request for the next session
npm run gemini:eval -- "JD text here"

Free tier: Both options work without billing. Native CLI uses Google OAuth; the API script uses gemini-3.6-flash (rate limits are model- and tier-dependent; see Google AI docs for current quotas).

Usage

career-ops uses a shared command router. In CLIs that register slash commands, it looks like this:

text
/career-ops                → Show all available commands
/career-ops {paste a JD}   → Full auto-pipeline (evaluate + PDF + tracker)
/career-ops scan           → Scan portals for new offers
/career-ops pdf            → Generate ATS-optimized CV
/career-ops cover          → Cover letter generator (paste JD or /career-ops cover {slug})
/career-ops email          → Formal application email draft (draft-only; never sends, submits, or clicks)
/career-ops batch          → Batch evaluate multiple offers
/career-ops tracker        → View application status
/career-ops apply          → Fill application forms with AI
/career-ops outcome        → Record application outcome & archive artifacts
/career-ops pipeline       → Process pending URLs
/career-ops contacto       → Find hiring manager / recruiter / peer + draft a ≤300-char LinkedIn message per contact type
/career-ops deep           → Generate a structured 6-axis research prompt (AI strategy, recent moves, culture, challenges, competitors, candidate angle)
/career-ops training       → Evaluate a course/cert
/career-ops project        → Evaluate a portfolio project

Or just paste a job URL or description directly -- career-ops auto-detects it and runs the full pipeline.

In Codex, slash commands are not guaranteed. Use the same mode names in a prompt instead, or call them from codex exec.

How It Works

text
You paste a job URL or description
        │
        ▼
┌──────────────────┐
│  Archetype       │  Classifies: LLMOps / Agentic / PM / SA / FDE / Transformation
│  Detection       │
└────────┬─────────┘
         │
┌────────▼─────────┐
│  A-H Evaluation  │  Match, gaps, comp research, STAR stories, legitimacy
│  (reads cv.md)   │
└────────┬─────────┘
         │
    ┌────┼────┐
    ▼    ▼    ▼
 Report  PDF  Tracker
  .md   .pdf  entry

Pre-configured Portals

The scanner comes with 100+ companies ready to scan and 45+ search queries across major job boards. Copy templates/portals.example.yml to portals.yml and add your own:

AI Labs: Anthropic, OpenAI, Mistral, Cohere, LangChain, Pinecone
Voice AI: ElevenLabs, PolyAI, Parloa, Hume AI, Deepgram, Vapi, Bland AI
AI Platforms: Retool, Airtable, Vercel, Temporal, Glean, Arize AI
Contact Center: Ada, LivePerson, Sierra, Decagon, Talkdesk, Genesys
Enterprise: Salesforce, Twilio, Gong, Dialpad
LLMOps: Langfuse, Weights & Biases, Lindy, Cognigy, Speechmatics
Automation: n8n, Zapier, Make.com
European: Factorial, Attio, Tinybird, Clarity AI, Travelperk

Job boards searched: 55+ provider modules cover ATS APIs, board-wide feeds, XML/RSS feeds, markdown feeds, and local parsers. See Supported job boards for the full table.

By default node scan.mjs (a.k.a. npm run scan) trusts what each ATS feed returns. Some companies leave stale postings in their public API even after the role is closed, so those expired entries can leak into pipeline.md. Pass --verify to launch Playwright after the API pass and drop expired postings before they hit the pipeline:

bash
node scan.mjs --verify          # zero-token discovery + Playwright liveness check

The verification is sequential and only runs against new offers (after dedup), so the cost stays bounded.

Dashboard TUI

The built-in terminal dashboard lets you browse your pipeline visually:

bash
npm run serve:dashboard   # launch the TUI
npm run build:dashboard   # optional: build the standalone binary

Features: 6 filter tabs, 4 sort modes, grouped/flat view, lazy-loaded previews, inline status changes.

There is also an experimental web UI (alpha, opt-in — nothing runs unless you start it): see web/README.md.

Project Structure

text
/* Detailed source-code truncated for AI context efficiency. */

Tech Stack

  • Agent: AI coding CLI with shared skills and modes (AGENTS.md + CLI wrapper)
  • PDF: Playwright + HTML template
  • Cover letters: HTML template + Playwright (A4 PDF, same pipeline as CVs)
  • Scanner: Playwright + Greenhouse API + WebSearch
  • Dashboard: Go + Bubble Tea + Lipgloss (Catppuccin Mocha theme)
  • Data: Markdown tables + YAML config + TSV batch files

Also Open Source

  • cv-santiago -- The portfolio website (santifer.io) with AI chatbot, LLMOps dashboard, and case studies. If you need a portfolio to showcase alongside your job search, fork it and make it yours.

FAQ

What is career-ops?
career-ops is an open-source, CLI-agnostic job-search command center. It turns any AI coding CLI into a pipeline that evaluates job offers against your CV, generates ATS-tailored PDFs, finds the right person to contact, and tracks everything in one place — while you keep the final decision. It is the first reference implementation of the CareerOps Manifesto. More at career-ops.org.

Can I run career-ops for free, or on a cheaper / local model?
Yes. career-ops is CLI-agnostic and runs on free and local models — via OpenRouter free models, Ollama, or any OpenAI-compatible endpoint — so you are not tied to a paid subscription. See docs/RUNNING_ON_A_BUDGET.md for the full setup.

I pay for Claude Pro/Max but career-ops is burning API credits. Why?
Because an ANTHROPIC_API_KEY in your environment takes precedence over your logged-in subscription: the CLI uses the key and bills per token. Run echo $ANTHROPIC_API_KEY, and if it prints anything, remove it from your shell profile, restart the terminal and run /login. Batch mode is the exception, since claude -p workers do not use the interactive login: run claude setup-token once and export the result as CLAUDE_CODE_OAUTH_TOKEN. Full walkthrough in docs/RUNNING_ON_A_BUDGET.md.

Which AI CLIs does career-ops work with?
career-ops runs on any major AI coding CLI — Claude Code, Codex, Gemini / Antigravity, OpenCode, Grok, Qwen and more — through the open Agent Skill Standard, so it is never locked to a single vendor. Use the CLI you already have.

How do I install career-ops on Windows?
career-ops runs on Windows. Platform-specific setup and the known sharp edges — Git Bash discovery, line endings, Task Scheduler — are in docs/WINDOWS.md. If skills fail to load with a symlink error during install, the fix is in docs/FAQ.md. Full steps are in docs/SETUP.md.

Does career-ops auto-apply to jobs for me?
No. career-ops is a filter, not a spray-and-pray auto-applier. The AI evaluates, ranks and drafts; you review and decide. It never submits, sends, or clicks anything — you always have the final call. That human-in-the-loop design is the whole point.

Is career-ops free and open source?
Yes. career-ops is free and open source, and for the candidate it always will be — it is the first reference implementation of the CareerOps Manifesto. Read it, and if it says what you believe, sign it.

About the Author

I'm Santiago Fernández de Valderrama Aparicio (santifer) -- Head of Applied AI, former founder (built and sold a business that still runs with my name on it). I built career-ops to manage my own job search. It worked: I used it to land my current role.

Curious how this repo is maintained in ~4 hours a week? Read Agentic maintenance: how career-ops is run by a fleet of AI agents.

My portfolio and other open source projects → santifer.io

Wikidata: Santiago Fernández de Valderrama Aparicio · career-ops.

Disclaimer

career-ops is a local, open-source tool, NOT a hosted service. By using this software, you acknowledge:

  1. You control your data. Your CV, contact info, and personal data stay on your machine and are sent directly to the AI provider you choose (Anthropic, OpenAI, etc.). We do not collect, store, or have access to any of your data.
  2. You control the AI. The default prompts instruct the AI not to auto-submit applications, but AI models can behave unpredictably. If you modify the prompts or use different models, you do so at your own risk. Always review AI-generated content for accuracy before submitting.
  3. You comply with third-party ToS. You must use this tool in accordance with the Terms of Service of the career portals you interact with (Greenhouse, Lever, Workday, LinkedIn, etc.). Do not use this tool to spam employers or overwhelm ATS systems.
  4. No guarantees. Evaluations are recommendations, not truth. AI models may hallucinate skills or experience. The authors are not liable for employment outcomes, rejected applications, account restrictions, or any other consequences.

See LEGAL_DISCLAIMER.md for full details. This software is provided under the MIT License "as is", without warranty of any kind.

Contributors

Every person who has shipped code, docs, translations or tests is listed in
CONTRIBUTORS.md — including non-code contributions, which
the graph above cannot show.

Got hired using career-ops? Share your story!

License & Trademark

The code is licensed under MIT. The "career-ops" name and
brand are governed by the Trademark Policy, permissive
for community use, reserved for commercial product naming and
endorsement.

Let's Connect






File: batch/README.md

Batch Processing

Process multiple job offers in parallel via headless workers. Each worker runs the full evaluation pipeline (A-F report + PDF + tracker line) autonomously. See the Headless / Batch Mode table in AGENTS.md for the correct command per CLI.

Quick Start

  1. Add offers to batch-input.tsv (tab-separated: id, url, source, notes):

    tsv
    id    url    source    notes
    1    https://jobs.example.com/role-a    LinkedIn    
    2    https://greenhouse.io/company/role-b    Greenhouse    priority
  2. Dry run to preview what will be processed:

    bash
    ./batch/batch-runner.sh --dry-run
  3. Run the batch:

    bash
    ./batch/batch-runner.sh
  4. Results are automatically merged into data/applications.md, processed offers are reconciled out of the data/pipeline.md inbox, and integrity is verified with verify-pipeline.mjs at the end of the run.

Options

Flag Default Description
--parallel N 1 Number of concurrent headless workers
--dry-run off Preview pending offers without processing
--retry-failed off Only retry offers marked as failed in state
--resume-paused off Resume offers paused after a Claude session/rate limit
--start-from N 0 Skip offers with ID below N
--limit N 0 Max number of offers to process in this run (0 = no limit)
--max-retries N 2 Max retry attempts per offer before giving up
--rate-limit-sleep N 300 Seconds to wait before retrying a transient rate-limited worker; use 0 to pause the batch immediately

Directory Layout

text
batch/
  batch-runner.sh          # Orchestrator script
  batch-prompt.md          # Prompt template sent to each worker
  batch-input.tsv          # Input offers (you create this)
  batch-state.tsv          # Processing state (auto-managed, resumable)
  logs/                    # Per-offer worker logs ({report_num}-{id}.log)
  tracker-additions/       # TSV lines produced by workers
    merged/                # TSVs already merged into applications.md

How It Works

  1. batch-runner.sh reads batch-input.tsv and batch-state.tsv to determine which offers need processing.
  2. For each pending offer, it assigns a report number and launches a headless worker with batch-prompt.md as the system prompt (placeholders like {{URL}}, {{REPORT_NUM}} are resolved).
  3. Each worker evaluates the offer, writes a report to reports/, generates a PDF to output/, and writes a tracker TSV to tracker-additions/.
  4. After all workers finish, batch-runner calls merge-tracker.mjs to merge TSVs into data/applications.md, reconcile-pipeline.mjs to move processed offers out of the data/pipeline.md inbox, and verify-pipeline.mjs to check integrity.

Tracker Merge

Workers write one TSV per offer to batch/tracker-additions/. The merge script (npm run merge) handles:

  • Deduplication by company + role fuzzy match and report number
  • Column order conversion (TSV has status before score; applications.md has score before status)
  • In-place updates when a re-evaluation scores higher than the existing entry
  • Moving processed TSVs to tracker-additions/merged/

Run npm run merge manually if you need to merge outside of a batch run.

Pipeline Reconcile

Batch mode reads offers from batch-input.tsv, but the data/pipeline.md inbox is a separate list. Without reconciliation, an offer evaluated by a batch run stays in the pipeline "Pendientes" section and gets surfaced again on the next scan or /career-ops pipeline run -- producing duplicate reports.

reconcile-pipeline.mjs (run as npm run reconcile) closes that gap: after the tracker merge, every completed or skipped offer in batch-state.tsv whose URL is still in pipeline "Pendientes" is moved to "Procesadas" with its report link and score (entries without a report file on disk are left in place). It is idempotent -- safe to run after every batch, or manually.

Resumability

batch-state.tsv tracks the status of every offer (pending, processing, completed, failed, skipped, rate_limited, paused_rate_limit). If the batch is interrupted, re-running batch-runner.sh picks up where it left off -- completed offers are skipped automatically. rate_limited is a non-completed state used while the runner waits before retrying, so interrupted rate-limited jobs are eligible on the next normal run.

paused_rate_limit is different: it means a worker hit a Claude session/usage limit, so the runner stopped scheduling new offers and preserved the retry count. Resume those rows explicitly after the limit resets:

bash
./batch/batch-runner.sh --resume-paused

A PID-based lock file (batch-runner.pid) prevents concurrent batch runs. If a previous run crashed, the stale lock is detected and removed automatically.

Prerequisites

  • Your CLI in PATH (see Headless / Batch Mode table in AGENTS.md)
  • Node.js >= 18, Playwright chromium installed (npm run doctor to verify)
  • batch-input.tsv with at least one offer

File: docs/ARCHITECTURE.md

Architecture

This file describes the runtime flows. Design principles and the
system/user data-contract layers live in ../ARCHITECTURE.md.

System Overview

text
┌─────────────────────────────────┐
                    │         AI Coding CLI Agent      │
                    │   (reads AGENTS.md + modes/*.md) │
                    └──────────┬──────────────────────┘
                               │
            ┌──────────────────┼──────────────────────┐
            │                  │                       │
     ┌──────▼──────┐   ┌──────▼──────┐   ┌───────────▼────────┐
     │ Single Eval  │   │ Portal Scan │   │   Batch Process    │
     │ (auto-pipe)  │   │  (scan.md)  │   │   (batch-runner)   │
     └──────┬──────┘   └──────┬──────┘   └───────────┬────────┘
            │                  │                       │
            │           ┌──────▼──────┐          ┌────▼─────┐
            │           │ pipeline.md │          │ N workers│
            │           │ (URL inbox) │          │ (headless)
            │           └─────────────┘          └────┬─────┘
            │                                          │
     ┌──────▼──────────────────────────────────────────▼──────┐
     │                    Output Pipeline                      │
     │  ┌──────────┐  ┌────────────┐  ┌───────────────────┐  │
     │  │ Report.md│  │  PDF (HTML  │  │ Tracker TSV       │  │
     │  │ (A-G eval)│  │  → Playwright)│ │ (merge-tracker)  │  │
     │  └──────────┘  └────────────┘  └───────────────────┘  │
     └────────────────────────────────────────────────────────┘
                               │
                    ┌──────────▼──────────┐
                    │  data/applications.md │
                    │  (canonical tracker)  │
                    └──────────────────────┘

Evaluation Flow (Single Offer)

  1. Input: User pastes JD text or URL
  2. Extract: Playwright/WebFetch extracts JD from URL
  3. Classify: Detect archetype (1 of 6 types)
  4. Evaluate: 7 blocks (A-G):
    • A: Role summary
    • B: CV match (gaps + mitigation)
    • C: Level strategy
    • D: Comp research (WebSearch)
    • E: CV personalization plan
    • F: Interview prep (STAR stories)
    • G: Posting legitimacy (scam / ghost-job signals)
  5. Score: Weighted average across 5 dimensions (1-5)
  6. Report: Save as reports/{num}-{company}-{date}.md
  7. PDF: Generate ATS-optimized CV (generate-pdf.mjs)
  8. Track: New entries via TSV in batch/tracker-additions/ merged by
    merge-tracker.mjs; status updates to existing rows via set-status.mjs

Batch Processing

The batch system processes multiple offers in parallel:

text
batch-input.tsv    →  batch-runner.sh  →  N × headless CLI workers
(id, url, source)     (orchestrator)       (self-contained prompt)
                           │
                    batch-state.tsv
                    (tracks progress)

Each worker is a headless AI CLI instance — the bundled batch-runner.sh currently runs claude -p workers only. See the Headless / Batch Mode table in AGENTS.md. Workers produce:

  • Report .md
  • PDF
  • Tracker TSV line

The orchestrator manages parallelism, state, retries, and resume.

Data Flow

text
cv.md                    →  Evaluation context
article-digest.md        →  Proof points for matching
config/profile.yml       →  Candidate identity
portals.yml              →  Scanner configuration
templates/states.yml     →  Canonical status values
templates/cv-template.html → PDF generation template

File Naming Conventions

  • Reports: {###}-{company-slug}-{YYYY-MM-DD}.md (3-digit zero-padded)
  • PDFs: cv-candidate-{company-slug}-{YYYY-MM-DD}.pdf
  • Tracker TSVs: batch/tracker-additions/{id}.tsv

Pipeline Integrity

Scripts maintain data consistency:

Script Purpose
merge-tracker.mjs Merges batch TSV additions into applications.md
verify-pipeline.mjs Health check: statuses, duplicates, links
dedup-tracker.mjs Removes duplicate entries by company+role
normalize-statuses.mjs Maps status aliases to canonical values
cv-sync-check.mjs Validates setup consistency

Dashboard TUI

The dashboard/ directory contains a standalone Go TUI application that visualizes the pipeline:

  • Filter tabs: All, Evaluada, Aplicado, Entrevista, Top >=4, No Aplicar
  • Sort modes: Score, Date, Company, Status
  • Grouped/flat view
  • Lazy-loaded report previews
  • Inline status picker

File: docs/CUSTOMIZATION.md

Customization Guide

Profile (config/profile.yml)

This is the single source of truth for your identity. All modes read from here.

Key sections:

  • candidate: Name, email, phone, location, LinkedIn, portfolio
  • target_roles: Your North Star roles and archetypes
  • narrative: Your headline, exit story, superpowers, proof points
  • compensation: Target range, minimum, currency
  • location: Country, timezone, visa status, on-site availability, and structured work authorization (authorized_in, needs_sponsorship) that drives the Work-Auth signal in job evaluation (flags an explicit no-sponsorship JD as a hard blocker)
  • culture_screen: Structural criteria for team culture (the deprioritize_if_absent strict flag caps the culture score at 2/5 if evidence is entirely missing)

Target Roles (modes/_profile.md)

The archetype table in _profile.md determines how offers are scored and CVs are framed. Edit the table to match YOUR career targets:

markdown
| Archetype | Thematic axes | What they buy |
|-----------|---------------|---------------|
| **Your Role 1** | key skills | what they need |
| **Your Role 2** | key skills | what they need |

Also update the "Adaptive Framing" table to map YOUR specific projects to each archetype.

Portals (portals.yml)

Copy from templates/portals.example.yml and customize:

  1. title_filter.positive: Keywords matching your target roles
  2. title_filter.negative: Tech stacks or domains to exclude
  3. search_queries: WebSearch queries for job boards (Ashby, Greenhouse, Lever)
  4. tracked_companies: Companies to check directly

CV Template (templates/cv-template.html)

The HTML template uses these design tokens:

  • Fonts: Space Grotesk (headings) + DM Sans (body) -- self-hosted in fonts/
  • Colors: Cyan primary (hsl(187,74%,32%)) + Purple accent (hsl(270,70%,45%))
  • Layout: Single-column, ATS-optimized

To customize fonts/colors, edit the CSS in the template. Update font files in fonts/ if switching fonts.

Negotiation Scripts (modes/_shared.md)

The negotiation section provides frameworks for salary discussions. Replace the example scripts with your own:

  • Target ranges
  • Geographic arbitrage strategy
  • Pushback responses

Hooks (Optional)

Career-ops can integrate with external systems via Claude Code hooks. Example hooks:

json
{
  "hooks": {
    "SessionStart": [{
      "hooks": [{
        "type": "command",
        "command": "echo 'Career-ops session started'"
      }]
    }]
  }
}

Save hooks in .claude/settings.json (Claude Code). OpenCode does not support hooks. For equivalent functionality, use custom commands (.opencode/commands/) or agents (.opencode/agents/) — see https://opencode.ai/docs/commands/.

States (templates/states.yml)

The canonical states rarely need changing. If you add new states, update:

  1. templates/states.yml
  2. normalize-statuses.mjs (alias mappings)
  3. modes/_shared.md (any references)

File: docs/local-parser-cookbook.md

Local Parser Cookbook

Local parsers let scan.mjs read SSR or static career pages without asking an agent to browse the page. The parser runs as a local command, prints normalized jobs JSON to stdout, and lets the scanner keep using the same title filtering, deduplication, and pipeline output flow.

When To Use This

Use scan_method: local_parser when a company career page has stable HTML, a documented endpoint, or another deterministic source that is easier to parse locally than with Playwright. The parser can be written in JavaScript, Python, shell, Go, or any executable available on the user's machine. career-ops does not bundle company-specific parser scripts; users bring their own script and point portals.yml at it.

Portal Configuration

Most local parsers are company-specific: the script already knows the source URL, selectors, endpoint quirks, pagination, and normalization rules. In that common case, the scanner only needs to know which command to run:

yaml
- name: Example Company
  careers_url: https://example.com/careers
  scan_method: local_parser
  parser:
    command: node
    script: scripts/parsers/example-company-jobs.js
    format: jobs-json-v1
  enabled: true

args are optional. Use them in whatever way helps the parser author: to make one script reusable across multiple companies, pass {careers_url} or {company}, enable a debug flag, store a JSON snapshot, or control any other script-specific behavior. scan.mjs executes the parser without shell interpolation and expands {careers_url} and {company} in parser arguments before execution.

Token savings

scan.mjs uses 0 LLM tokens for discovery: parsers run locally and only normalized job rows enter the pipeline.

In agent scan mode (/career-ops scan), Playwright and API niveles send large page or JSON payloads into the model. When Nivel 0 succeeds, modes/scan.md requires skipping those niveles for the same company (local_parser_ok).

Measured benchmarks (Cohere + Mobileye fixtures, tiktoken cl100k_base, Playwright vs parser vs API) live on branch feature/local-parser-integration-tests with npm run test:scan-tokens and full tables in that branch's copy of this cookbook.

Stdout Contract

The parser must print one of these JSON shapes to stdout:

json
[
  { "title": "Senior AI Engineer", "url": "https://example.com/jobs/123", "location": "Remote" }
]
json
{
  "jobs": [
    { "title": "Senior AI Engineer", "url": "https://example.com/jobs/123", "location": "Remote" }
  ]
}
json
{
  "results": [
    { "title": "Senior AI Engineer", "url": "https://example.com/jobs/123", "location": "Remote" }
  ]
}

title and url are required. company is optional; when omitted, the scanner uses the tracked_companies entry name. Relative URLs are resolved against careers_url.

Artifact Storage

The scanner only needs stdout. If a parser also writes full JSON snapshots for debugging or audit, store them under data/parser-output/{company}/. Generated JSON artifacts must stay out of git; .gitkeep placeholders are the only committed exception for preserving directory structure.

Failure Handling

Local parsers run before ATS API detection. If a local parser fails and the company has a detectable Greenhouse, Ashby, or Lever API source, scan.mjs records the parser failure and falls back to the API path for that company instead of dropping it from the scan.

Agent scan (`/career-ops scan`)

scan.mjs already uses one provider per company (local parser only, no duplicate API pass). In full agent scan mode (modes/scan.md), when Nivel 0 succeeds for a company, the agent must skip Playwright (Nivel 1) and API (Nivel 2) for that company, and filter Nivel 3 WebSearch hits that match the same company. General portal queries (site:jobs.ashbyhq.com, role keywords) still run for discovery of other employers.


File: docs/SCRIPTS.md

Scripts Reference

All scripts live in the project root as .mjs modules. Most are exposed via
npm run <name>; agent-invoked utilities (bottom section) run via
node <script> directly.

Quick Reference

Command Script Purpose
npm run doctor doctor.mjs Validate setup prerequisites
npm run verify verify-pipeline.mjs Check pipeline data integrity
npm run normalize normalize-statuses.mjs Fix non-canonical statuses
npm run dedup dedup-tracker.mjs Remove duplicate tracker entries
npm run merge merge-tracker.mjs Merge batch TSVs into applications.md
npm run pdf generate-pdf.mjs Convert HTML to ATS-optimized PDF
npm run jd:similarity jd-similarity.mjs Compare a new JD with a previous JD/CV and recommend reuse, edits, or regeneration
npm run img-to-pdf img-to-pdf.mjs Convert a single screenshot/image into a single-page PDF
node build-cv-latex.mjs build-cv-latex.mjs Build .tex from structured JSON payload
npm run sync-check cv-sync-check.mjs Validate CV/profile consistency
npm run patterns analyze-patterns.mjs Analyze tracker outcomes and report patterns
npm run upskill upskill.mjs Aggregate skill-gap map from tracked reports (or --url-text for a single-JD targeted gap analysis)
npm run add add-entry.mjs Dedup + insert a /career-ops add entry into cv.md / article-digest.md
npm run update:check update-system.mjs check Check for upstream updates
npm run update update-system.mjs apply Apply upstream update
npm run rollback update-system.mjs rollback Rollback last update
npm run liveness check-liveness.mjs Test if job URLs are still active
npm run extract browser-extract.mjs Headless read-only page extractor (opt-in scan.extractor: cli) — compact JSON for scan/JD
npm run scan scan.mjs Zero-token portal scanner
npm run scan:full scan-ats-full.mjs Reverse ATS discovery scanner
npm run company:funded company-funded.mjs Review-first discovery of recently funded companies
npm run validate:portals validate-portals.mjs Validate portals.yml shape before scanning
npm run tracker tracker.mjs SQLite derived index over applications.md — sync/query/history/export
npm run find find.mjs Resolve a report#/tracker#/company query to its full pipeline identity
npm run invite-match invite-match.mjs Fuzzy-match a pasted interview-invite email against data/applications.md
npm run application:init application-artifacts.mjs Initialize one versioned application-scoped JD/CV/PDF artifact bundle
npm run paste-reply paste-reply.mjs Manual/no-Gmail input into the reply-watch.mjs classification pipeline
npm run freshness check-table-freshness.mjs Staleness validator for jurisdiction data tables (as_of / next_effective watchdog)
npm run openai:tailor openai-tailor.mjs Tailor a CV via any OpenAI-compatible endpoint (headless companion to openai-eval.mjs)
npm run or openrouter-runner.mjs Run scan/evaluate/pipeline/apply on OpenRouter free models — no Claude CLI required
npm run reconcile reconcile-pipeline.mjs Remove batch-evaluated offers from pipeline.md "Pendientes"
npm run cover-letter generate-cover-letter.mjs Render a cover-letter JSON payload to PDF
npm run verify:portals verify-portals.mjs Probe ATS endpoints to confirm portals.yml slugs resolve (network)
node fix-slugs.mjs fix-slugs.mjs Write verify-portals.mjs's suggested ATS slug fixes back to portals.yml (dry run by default, --fix to write)
npm run reposts detect-reposts.mjs Flag re-listed (ghost) postings from scan history
node rank-pipeline.mjs rank-pipeline.mjs Opt-in LLM relevance re-ranker — annotates pending pipeline rows with a score + reason (off by default)
npm run gemini:eval gemini-eval.mjs Evaluate a JD with Google Gemini (free-tier alternative)
npm run ollama:eval ollama-eval.mjs Evaluate a JD with a local Ollama model
npm run openai:eval openai-eval.mjs Evaluate a JD via any OpenAI-compatible endpoint
npm run star match-star.mjs Match a behavioural question to your best STAR story (zero-LLM)
npm run archive archive-posting.mjs Save a live job posting as PDF before it disappears
npm run prepare:application prepare-application.mjs Print an ATS prefill summary (read-only, never POSTs)
npm run build:dashboard build-dashboard.mjs Build the Go TUI dashboard binary cross-platform
node upgrade-tests.mjs --pr-gate upgrade-tests.mjs Upgrade an install seeded from the newest old release to this commit and prove user data survived (CI gate; --canary proves the gate can fail)

doctor

Validates that all prerequisites are in place: Node.js >= 18, dependencies installed, Playwright chromium, required files (cv.md, config/profile.yml, portals.yml), fonts directory, and auto-creates data/, output/, reports/ if missing.

bash
npm run doctor

Exit codes: 0 all checks passed, 1 one or more checks failed (fix messages printed).


verify

Health check for pipeline data integrity. Validates data/applications.md against nine rules: canonical statuses (per templates/states.yml), no duplicate company+role pairs, all report links point to existing files, scores match X.XX/5 / N/A / DUP, rows have proper pipe-delimited format, no pending TSVs in batch/tracker-additions/, no markdown bold in scores, no two reports/*.md files covering the same company+role, and no orphan reports without a tracker row (#1425). The report checks are warning-level: duplicate reports can be legitimate (re-evaluation after a JD change), so they never fail the run.

bash
npm run verify

Exit codes: 0 pipeline clean (zero errors), 1 errors found. Warnings (e.g. possible duplicates) do not cause a non-zero exit.


normalize

Maps non-canonical statuses to their canonical equivalents and strips markdown bold and dates from the status column. Aliases like Enviada become Aplicado, CERRADA becomes Descartado, etc. DUPLICADO info is moved to the notes column.

bash
npm run normalize             # apply changes
npm run normalize -- --dry-run  # preview without writing

Creates a .bak backup of applications.md before writing.

Exit codes: 0 always (changes or no changes).


dedup

Removes duplicate entries from applications.md by grouping on normalized company name + fuzzy role match. Keeps the entry with the highest score. If a removed entry had a more advanced pipeline status, that status is promoted to the keeper.

bash
npm run dedup             # apply changes
npm run dedup -- --dry-run  # preview without writing

Creates a .bak backup before writing.

Exit codes: 0 always.


merge

Merges batch tracker additions (batch/tracker-additions/*.tsv) into applications.md. Handles 9-column TSV, 8-column TSV, and pipe-delimited markdown formats. Detects duplicates by report number, entry number, and company+role fuzzy match. Higher-scored re-evaluations update existing entries in place.

bash
npm run merge                 # apply merge
npm run merge -- --dry-run    # preview without writing
npm run merge -- --verify     # merge then run verify-pipeline

Processed TSVs are moved to batch/tracker-additions/merged/.

Exit codes: 0 success, 1 verification errors (with --verify).


validate:portals

Validates portals.yml before running the scanner. The validator is offline: it reads YAML, loads local provider IDs from providers/*.mjs, and checks common configuration mistakes without fetching any job boards.

It reports errors for invalid YAML shape, unknown explicit providers, malformed URLs, empty filter keywords, and invalid local parser blocks. Duplicate enabled company names are warnings because they may be intentional during migrations, but they are worth reviewing.

bash
npm run validate:portals
npm run validate:portals -- --file templates/portals.example.yml
node validate-portals.mjs --self-test

Exit codes: 0 no errors (warnings allowed), 1 one or more errors found.


upgrade-tests

The dynamic upgrade regression harness (#2358). update-system.mjs has the
largest blast radius in the repo — it rewrites system files in place on someone
else's install — and this is the only test that exercises a real upgrade
against a seeded user install instead of asserting on the updater's source.

It is hermetic: a temporary GIT_CONFIG_GLOBAL rewrites the canonical GitHub
URL to a local bare mirror whose main ref is forced to the commit under test,
so no leg ever reaches the network. The old install runs its own apply, which
self-reexecs into the target updater — so the migration code being tested is the
one the PR ships, not the one already installed.

Two modes:

bash
node upgrade-tests.mjs --pr-gate    # newest release tag that is an ancestor of HEAD -> this commit
node upgrade-tests.mjs --canary     # plant a user-file clobber; the harness MUST report it

--pr-gate picks the newest release tag that is an ancestor of HEAD, seeds an
install from that era's fixture state, and upgrades it to the commit under
review. The leg is red unless all of it holds: apply exits 0; a system file
that genuinely changed between the two revisions now carries the target's blob
(the non-vacuity oracle — VERSION is never used, since apply has no version
gate); every user file is byte-identical; every path the new manifest adds is
present; data/applications.md still parses with the expected row and status
counts; data/salary-observations.tsv still parses; and doctor.mjs --json
reports onboardingNeeded: false. It needs the release tags, which is why CI
checks out with fetch-depth: 0.

--canary exists because a gate never seen red proves nothing. It commits a
poisoned mirror — cv.md tracked and added to SYSTEM_PATHS, so the old
updater checks it out over the user's CV — and then requires the harness to
report that clobber. A canary that comes back green means the harness detected
the planted damage; a red canary means the gate is incapable of failing and its
green runs are worthless.

Both modes run on every PR, as the upgrade-gate job in
.github/workflows/test.yml.


fix-slugs

Write-side twin of verify-portals.mjs (#1703). verify-portals already probes every tracked company's ATS slug and, for a failing Greenhouse/Ashby/Lever entry, cross-probes slug variants across all three ATSes and attaches suggested: { ats, slug } when one resolves. That tool is read-only; this one patches the matching tracked_companies entry in portals.yml. It imports the same probe and suggestion logic rather than re-implementing it, so the two can never disagree about what a broken slug is (network, like verify-portals).

It is a dry run by default: writing requires an explicit --fix (or its alias --apply). A bare node fix-slugs.mjs prints the diff it would apply and changes nothing, so the safe invocation is also the shortest one. --dry-run exists only to say that out loud.

Only entries verify-portals classifies as missing and for which it found a suggested alternate are touched. Live entries, empty entries, and entries whose slug genuinely could not be resolved are left completely alone.

The file is edited as text — line-level surgery inside the matching company's block — rather than through a YAML parse-and-dump round trip, because portals.yml carries hand-written comments and documentation blocks that yaml.dump() would silently discard.

bash
node fix-slugs.mjs                            # dry run (default, safe): print the diff, write nothing
node fix-slugs.mjs --dry-run                  # same as above, explicit
node fix-slugs.mjs --fix                      # write the resolved slugs back to portals.yml
node fix-slugs.mjs --apply                    # alias for --fix
node fix-slugs.mjs --file templates/portals.example.yml

The default path is portals.yml, overridable with --file or the CAREER_OPS_PORTALS environment variable. A missing portals file is reported and treated as nothing to do, not as an error.

Exit codes: 0 on every normal run, 1 only if the run itself fails. Unlike check-table-freshness, pending fixes in a dry run do not fail the run, so this is a maintenance tool rather than a CI gate.


pdf

Renders an HTML file to a print-quality, ATS-parseable PDF via headless Chromium. Resolves font paths from fonts/, normalizes Unicode for ATS compatibility (em-dashes, smart quotes, zero-width characters), and reports page count and file size.

bash
npm run pdf -- input.html output.pdf
npm run pdf -- input.html output.pdf --format=letter   # US letter
npm run pdf -- input.html output.pdf --format=a4        # A4 (default)

Exit codes: 0 PDF generated, 1 missing arguments or generation failure.


img-to-pdf

Converts a single screenshot or image (PNG, JPEG, GIF, WEBP, BMP, SVG) into a single-page PDF via headless Chromium — for ATS upload fields that require a PDF specifically and reject images. Embeds the image as a base64 data: URI in a minimal HTML page and renders it with page.pdf(), sized to the image's own pixel dimensions so the page is neither cropped nor padded. Zero new dependencies — reuses the playwright dependency generate-pdf.mjs already uses, and is a deliberately standalone script: it does not go through generate-pdf.mjs, so it is never subject to that script's cv.md section-order validation.

bash
npm run img-to-pdf -- screenshot.png output.pdf
npm run img-to-pdf -- screenshot.png output.pdf --force   # overwrite an existing output file
node img-to-pdf.mjs --self-test

MVP scope: one image in, one PDF page out. Multi-image/multi-page conversion is not implemented.

Exit codes: 0 PDF generated, 1 missing arguments, unsupported image type, missing input file, existing output without --force, or generation failure.


build-cv-latex.mjs

Builds a .tex file from a structured JSON payload, handling template merge and LaTeX escaping automatically. The JSON is produced by the agent during evaluation — this script replaces the manual LaTeX generation step in modes/latex.md.

bash
node build-cv-latex.mjs input.json output.tex
node build-cv-latex.mjs --test

Exit codes: 0 file generated, 1 missing inputs, invalid JSON, unresolved placeholders, or template not found.


sync-check

Validates that the career-ops setup is internally consistent: cv.md exists and is not too short, config/profile.yml exists with required fields, no hardcoded metrics in modes/_shared.md or batch/batch-prompt.md, and article-digest.md freshness (warns if older than 30 days).

bash
npm run sync-check

Exit codes: 0 no errors (warnings allowed), 1 errors found.


patterns

Analyzes application outcomes, scores, archetypes, blockers, remote policy, and company size from data/applications.md and linked reports. New reports should include ## Machine Summary YAML; analyze-patterns.mjs uses it first and falls back to legacy markdown parsing for older reports.

bash
npm run patterns
npm run patterns -- --summary
npm run patterns -- --min-threshold 3
node analyze-patterns.mjs --self-test

Exit codes: 0 analysis succeeded, 1 insufficient data or parser self-test failure.


upskill

Aggregates skill gaps across every tracked report (#1520, phase 1). Extracts skill tokens from each report's Machine Summary hard_stops/soft_gaps and Gap table, removes skills already present in cv.md/config/profile.yml (exact-alias matching only — an umbrella term never suppresses a specific skill), and weights each gap by inverse report score (5.0 − score, counted once per report).

Comments do not count as known skills. YAML # comments and markdown `` comments are dropped before extraction, so a note like # not using Kubernetes anymore no longer suppresses Kubernetes from the gap map. There is one exception: if config/profile.yml cannot be parsed, extraction falls back to its raw text, comments included, rather than contributing nothing — which would flood the map with skills you already have. The fallback prints a warning to stderr naming the parse error, and stdout stays valid JSON.

Tiers (Critical/High/Medium/Low) use fixed thresholds over the share of low-fit (score < 4.0) reports naming the gap. Output carries schema_version so the upskill mode's diff-vs-previous section never compares across extraction-rule changes, plus coverage stats (reportsWithMachineSummary vs reportsRead). The script emits data only; the upskill mode reads the tiered gaps JSON and, in phase 2b (#1740), layers a web-searched learning plan (free-first resources per Critical/High gap — plus Medium when the map is small) onto the aggregate report. The plan is generated by the agent, not this script — no web-search logic lives in upskill.mjs.

bash
npm run upskill
npm run upskill -- --summary
npm run upskill -- --min-reports 3
node upskill.mjs --url-text https://boards.greenhouse.io/acme/jobs/123   # targeted: gaps for one JD
node upskill.mjs --url-text ./jds/my-job.txt                            # targeted: --url-text also takes a local file
node upskill.mjs --self-test

In targeted mode a local --url-text path is a required input, so it is read strictly: a path that is missing, a directory, unreadable, or empty prints one Fatal: line to stderr and exits 1. The optional cv.md/config/profile.yml reads keep the opposite contract — unreadable degrades to empty rather than aborting the run.

Exit codes: 0 analysis succeeded (including graceful {error} JSON for insufficient data), 1 self-test failure, a missing --url-text argument, unreadable targeted input, a failed JD fetch, a redirect blocked by the URL security check, or any other unexpected failure inside targeted analysis.


salary-gap

Folds compensation observations into per-application desired/advertised/actual values and gap aggregates. Sources: reports/*.md Machine Summary advertised_comp (advertised, source jd — historical reports backfill automatically), data/salary-observations.tsv (desired/actual/stated, append-only), and config/profile.yml compensation.target_range (desired default). Fold precedence: highest trust tier wins, then latest date (actual: contract > offer-letter > recruiter-verbal > user). Aggregates group by (company, role) and per currency — no FX conversion. Unparseable amounts, orphaned tracker numbers, sample sizes, and staleness are always reported.

bash
node salary-gap.mjs             # JSON
node salary-gap.mjs --summary   # table + data-quality section
node salary-gap.mjs --stated-for    # prior `stated` observations for one tracker#, JSON
node salary-gap.mjs --self-test

Observation line format (TSV, one per line, #-prefixed lines are comments):

text
{tracker#}\t{YYYY-MM-DD}\t{desired|advertised|actual|stated}\t{amount}\t{currency}\t{source}\t{note}\t{round}\t{interviewer}

Amounts: number + optional k/K suffix, ranges allowed ("80-90k"), annual gross unless noted. Sources: jd | profile | user | recruiter-verbal | offer-letter | contract.

stated observations are a narrower-purpose addition (#1852): a specific compensation number the candidate verbally committed to, in a specific interview round, to a specific interviewer — so a later round doesn't accidentally contradict it. round and interviewer are two optional trailing columns, meaningful only for stated rows (existing rows without them still parse — they default to ''). stated observations carry no trust tier and never participate in the desired/advertised/actual fold or gap math; look them up with getStatedObservations(observations, num) or --stated-for. Interview-prep modes (modes/interview/plan.md, modes/interview-prep.md) check this before generating comp-related prep content — see their Inputs sections.

Exit codes: 0 always (missing sources produce an explanatory empty result), 1 self-test failure.


funnel-velocity

Funnel calibration vs market benchmarks + stage velocity. Three payloads, decreasing availability: calibration — your funnel rates (canonical ever* definition imported from stats.mjs) vs candidate-side benchmark ranges from templates/benchmarks.yml (override: config/benchmarks.yml or --benchmarks ); waiting — in-flight Applied rows and elapsed days vs the typical first-response window (per-row factual reporting; applied-date priority: status-log observation > Applied YYYY-MM-DD in tracker notes > unknown, never guessed); velocity — median/p75 days per stage hop (Applied→Responded→Interview→Offer, Applied→Rejected separate) folded from data/status-log.tsv.

Statistical honesty is enforced in code: right-censored counts printed next to every median ("n still waiting, excluded"), same-day catch-up hops excluded and counted, no comparative multiplier claims below n=20 applied, above-range output carries a selection-bias note, every benchmark mention carries its year + "directional". Coverage, orphaned tracker numbers, unparseable lines, and unknown sources are always reported.

bash
node funnel-velocity.mjs             # JSON
node funnel-velocity.mjs --summary   # human-readable
node funnel-velocity.mjs --self-test
node funnel-velocity.mjs --benchmarks path/to/benchmarks.yml

Ledger line format (TSV, appended by set-status.mjs, #-prefixed lines are comments):

text
{tracker#}\t{YYYY-MM-DD}\t{from}\t{to}\t{source}\t{note}

from may be - (unknown prior state); to = - retracts the row's latest observation; a later correction-source line with the same (tracker#, to) replaces the earlier observation's date. Sources: set-status | correction | backfill | manual (only set-status/correction feed day-math).

Exit codes: 0 always (missing tracker/ledger produce an explanatory empty result), 1 self-test or benchmarks-load failure.


assessment-log

Logs "received a skills assessment" as a structured per-application event (eSkill, HackerRank, Criteria, Predictive Index, ...) instead of burying it in free-text notes. Each event records platform, subject tested, pass threshold vs score achieved (both optional — vendors often hide them), and a candidate-observed staleness note (e.g. "test content references Adobe Acrobat 9, a 2008-era version"; empty = no staleness observed). Events append to data/assessments.tsv (user layer, created on first add, never rewritten). Aggregates count events, pass/fail (only when both threshold and score are known), and stale-flagged events per platform; malformed lines are always reported, never dropped silently.

bash
node assessment-log.mjs add --company Acme --report 042 --platform eSkill --subject "MS Office" --threshold 70 --score 92 --stale "references Adobe Acrobat 9 (2008-era)"
node assessment-log.mjs             # JSON
node assessment-log.mjs --summary   # per-event + per-platform table
node assessment-log.mjs --self-test

Log line format (TSV, one per line, #-prefixed lines are comments; for report#, threshold%, and score%, - or an absent trailing cell = unknown; an empty stale_note means no staleness was observed, not unknown):

text
{YYYY-MM-DD}\t{company}\t{report#|-}\t{platform}\t{subject}\t{threshold%|-}\t{score%|-}\t{stale_note}

Exit codes: 0 success (a missing log produces an explanatory empty result), 1 invalid add arguments or self-test failure.


company-history

Read-only per-company evidence-card aggregator. Joins data/applications.md (tracker), data/follow-ups.md, and data/scan-history.tsv per company (and a funnel-velocity.mjs status-log source, loaded defensively via dynamic import() — probed for optional applied-date/median helpers and degrading to false when they are absent). Companies are joined on a normalized key (normalizeCompany); rows whose company normalizes to an empty key (e.g. non-Latin names that strip to nothing) are never merged into another company's card — they are excluded and counted in dataQuality.unjoinable instead.

Each card covers two independent fact axes, never combined into a single verdict:

  • responsiveness — has this company ever responded to you, or gone silent on an Applied row past the silence window? A rejection counts as a response (it's an answer, not silence). Labels: responded-before, silent-on-you, mixed, no-history. Rows younger than the silence window are pending — right-censored, never labeled silent. Facts older than 365 days are stale and excluded from label computation unless --include-stale is passed. Follow-ups sent never change the label — they only annotate a silent fact's confidence (confirmed-by-followups vs unconfirmed).
  • postingChurn — does this company repost the same role repeatedly (evergreen requisition / re-opened search), sourced from detect-reposts.mjs clusters over data/scan-history.tsv. Labels: reposts-detected, none-detected, no-scan-data.

The script deliberately reports facts, not verdicts — output is always descriptive and past-tense ("silent 34d since 2026-05-01"), never "ghosted" or "risk". Every silent fact carries a dated clearInstruction (the exact set-status.mjs command to run if the company actually did respond and it just wasn't logged), and every card with a silent fact is accompanied by an innocent-explanations line: high-volume inboxes, evergreen requisitions, re-opened searches, and the candidate's own unlogged responses all produce the same raw signals as genuine silence. Before trusting the output against real data, run a dry read (node company-history.mjs --summary) and sanity-check a few cards where you already know the real story.

bash
node company-history.mjs                        # full JSON evidence cards to stdout
node company-history.mjs --summary               # human-readable cards (hygiene nudge, then silent-first, window caveat printed once)
node company-history.mjs --company "Acme"         # single-card lookup (unknown company returns the minimal no-history/no-scan-data shape)
node company-history.mjs --silence-window 21      # override the default silence window in days
node company-history.mjs --include-stale          # include facts older than 365d in label computation
node company-history.mjs --self-test

Default silence window: templates/benchmarks.yml days_first_response.range_days[1] * 2 when that file exists, else 28 days.

Exit codes: 0 success, including empty/no-data runs (a missing tracker, follow-ups, or scan-history source degrades gracefully rather than failing), 1 unrecognized CLI flag or an unexpected runtime error.


contacts

Your job-search phonebook, exportable to your phone. Reads data/contacts.tsv (one contact per line — the schema is the vCard fields, nothing more) and emits vCard 3.0 (VERSION:3.0 for iOS/Android import compatibility) with CRLF line endings, byte-safe 75-octet line folding, and a stable deterministic UID careerops-{uidPart(name)}--{uidPart(company)} (double-dash boundary between the two parts). Each uidPart is the lowercase slug of the raw value (non-alphanumeric runs collapsed to single dashes, ends trimmed) suffixed with an 8-hex sha1 of the raw value — e.g. jane-doe-cac7bbb6; when the slug is empty — a fully non-ASCII value such as a CJK name — the part is the bare 8-hex hash. Hashing the raw value (not the lossy slug) keeps distinct inputs that slug identically — e.g. José and Josè both slug to jos (the accented char drops out), and Acme Inc and Acme, Inc. both to acme-inc — from colliding into one UID. Re-importing updates existing entries instead of duplicating them on platforms that honor vCard UID (iOS fallback: assign imports to a group, delete the group to bulk-remove). --caller-id renders the display name as Jane Doe (Acme recruiter) so the lock screen tells you which recruiter is calling — useful when a phone number is known (often it isn't). Malformed rows are reported in a quality block, never dropped silently.

bash
node contacts.mjs                    # JSON (contacts + quality + total)
node contacts.mjs --summary          # human-readable table
node contacts.mjs --vcf [path]       # write vCard file (default output/contacts.vcf)
node contacts.mjs --vcf --caller-id  # FN as "Jane Doe (Acme recruiter)"
node contacts.mjs --self-test

Contact line format (TSV, one per line, #-prefixed lines are comments):

text
{name}\t{company}\t{type}\t{title}\t{phone}\t{email}\t{linkedin}\t{tracker#|-}\t{notes}

type: recruiter | hiring-manager | peer | interviewer | other — optional; when present it must be one of the enum, else it is flagged in quality. Only name + company are required (>= 4 cells); all channels are optional; - for the tracker number when the contact precedes an application. Lines are updated in place when a contact's details change — unlike the append-only salary log. If two lines resolve to the same generated UID (careerops-{uidPart(name)}--{uidPart(company)} — normally rows with the same name + company), the LAST one wins the --vcf export (JSON keeps all rows and reports the clash in quality.duplicates). Import: send the .vcf to your phone (AirDrop/email/messaging) and open it — iOS Contacts offers "Add All Contacts", Android imports via Contacts → Fix & manage → Import.

Exit codes: 0 always (an empty/missing store prints an explanatory message and writes no file), 1 self-test failure or a --vcf path escaping the project directory.


weekly-digest

Rolls up interview-prep/sessions/*.md — the structured, machine-readable transcripts interview/debrief and interview/practice already write (schema in interview-prep/sessions/README.md) — into a single digest for a date range (default: the current ISO week, Monday–Sunday). Groups sessions by company/role into a per-company round rollup (round type + date per round), counts `` annotations across all sessions in range and flags any tag appearing 2+ times as recurring, and — best-effort, since interview-prep/question-bank.md has no fixed schema — attributes 🔴-tagged lines to whichever in-range company's heading they fall under. Purely mechanical: front-matter parsing, date filtering, and tag counting, no LLM judgment calls.

bash
node weekly-digest.mjs                                   # JSON, current ISO week
node weekly-digest.mjs --summary                          # human-readable digest
node weekly-digest.mjs --from 2026-07-13 --to 2026-07-19  # explicit date range
node weekly-digest.mjs --dir path/to/sessions             # override sessions dir (test isolation)
node weekly-digest.mjs --self-test

interview-prep/sessions/ is gitignored, and session content contains real interviewer names and companies — see the "Privacy — important" section of interview-prep/sessions/README.md for the source of that statement. A fresh clone or a week with no interviews reports "no interviews recorded in this range" and exits 0, never an error.

Exit codes: 0 always (missing sessions dir/question bank, or an empty range, produce an explanatory empty result), 1 invalid --from/--to or self-test failure.

check-table-freshness

Staleness validator for the jurisdiction data tables (umbrella #2026). The tables' correctness decays on a schedule — minimum wages adjust annually, pre-announced legal changes land on known dates — and every row already carries the metadata to watch: a mandatory as_of verification date and, for rate-style rows, next_effective. This script is the watchdog: zero LLM, zero network, zero writes.

Discovery is schema-agnostic: any templates/*.yml (non-recursive) whose parsed YAML contains at least one object row with an as_of field is treated as a jurisdiction table — rows may sit in a top-level array or in an array under any top-level key (e.g. covenants:). Files without as_of rows (states.yml, portals.example.yml, benchmarks.yml) are silently skipped, so new tables are picked up automatically with no per-table registration. On a checkout with no jurisdiction tables yet, the script reports zero tables and exits 0 — that is the designed empty state, not an error.

Two finding types:

  • expired (hard) — the row has a next_effective date, today ≥ next_effective, and the row was not re-verified on or after that date (as_of < next_effective): the pre-announced change has arrived and the table hasn't been updated.
  • review-due (soft) — as_of is older than the review threshold (default 12 months): nobody has re-verified the row in a legal cycle. Threshold precedence: --max-age-months flag > config/profile.yml table_freshness.max_age_months > default. Thresholds are strict positive integers — an invalid flag value is a usage error (exit 1, fail-fast, never a silent fallback); an invalid config value is reported as a warning and the default applies.

Each finding copies the row's sources, so whoever picks it up knows exactly where to re-verify. Malformed or missing dates produce a warning entry and the row is skipped — never a crash: once an array qualifies as a row-set (≥1 row with as_of), a sibling row that forgot its mandatory as_of warns too, instead of silently vanishing from validation. All date math is UTC-midnight calendar math (no time-of-day drift); dates in tables are quoted YYYY-MM-DD strings.

bash
npm run freshness
node check-table-freshness.mjs                    # JSON
node check-table-freshness.mjs --summary          # human-readable table
node check-table-freshness.mjs --max-age-months 6 # override review threshold
node check-table-freshness.mjs --today 2026-10-02 # deterministic date for tests
node check-table-freshness.mjs --self-test

Exit codes (CI-friendly): 1 if any expired finding or on invalid usage (bad --max-age-months / --today values), 0 otherwise — review-due alone never fails the run, so a scheduled job only goes red when a known legal change has actually landed unaddressed.


rejection-latency

Post-interview response-latency signal. Cross-references data/active-interviews.md (latest interview date per application — company + role, fuzzy role match via role-matcher.mjs) with data/applications.md (rows still in Interview state — i.e. no Responded/Offer/Rejected transition recorded since) and flags applications whose silence exceeds a soft courtesy threshold (30-day default, no legal claim attached) from rejection_latency.courtesy_days or --courtesy-days. (An earlier revision also shipped a jurisdiction-backed statutory tier; it was removed — the underlying legal threshold could change and the script has no way to re-verify it.) Each flag carries a ready-to-copy data/blacklist.md row (same suggestion-only bridge as modes/interview-redflag.md, #1854/#1856) — the script never writes to data/blacklist.md, data/applications.md, or data/active-interviews.md (#1742 opt-in guarantee). Surfaced by the followup mode.

bash
node rejection-latency.mjs             # JSON
node rejection-latency.mjs --summary   # human-readable table + suggested blacklist rows
node rejection-latency.mjs --courtesy-days 21
node rejection-latency.mjs --today 2026-07-17   # deterministic runs/tests
node rejection-latency.mjs --self-test

Exit codes: 0 always (missing data files produce an explanatory empty result), 1 self-test failure.


update:check

Checks whether a newer version of career-ops is available upstream. Outputs JSON to stdout:

bash
npm run update:check

Possible JSON responses:

status Meaning
up-to-date Local version matches remote
update-available Newer version exists (includes local, remote, changelog)
dismissed User dismissed the update prompt
offline Could not reach GitHub

Exit codes: 0 always.


update

Applies the upstream update. Creates a timestamped backup branch (backup-pre-update-<version>-<YYYYMMDDTHHMMSSZ>), fetches from the canonical repo, checks out only system-layer files, runs npm install, and commits. The timestamp is derived from UTC ISO time with separators and milliseconds removed (for example, backup-pre-update-1.8.1-20260608T071302Z). User-layer files (cv.md, config/profile.yml, data/, etc.) are never touched.

bash
npm run update

Exit codes: 0 success, 1 lock conflict or safety violation.


rollback

Restores system-layer files from the most recent backup branch created during an update. Rollback prefers the newest timestamped branch matching backup-pre-update-<version>-<YYYYMMDDTHHMMSSZ> and still accepts legacy backup-pre-update-<version> branches for older installs.

bash
npm run rollback

Exit codes: 0 success, 1 no backup branch found or git error.


liveness

Tests whether job posting URLs are still live. Two rungs: a zero-token ATS API check first (liveness-api.mjs — Greenhouse, Lever, Ashby, Workday), falling back to headless Chromium (liveness-browser.mjs) for non-ATS pages or when the API is inconclusive. The browser rung detects expired patterns (e.g. "job no longer available"), HTTP 404/410, ATS redirect patterns, and apply-button presence, and supports multi-language expired patterns (English, German, French).

Per-job ATS endpoints (Greenhouse, Lever, Workday) treat a 200 as proof the posting is live; Ashby's public API is org-level (the whole job board), so that rung parses the board and confirms the specific job id is still listed. A definitive 404/410 from any ATS API is authoritative and short-circuits the browser check entirely — zero tokens, no browser launch.

bash
npm run liveness -- https://example.com/job/123
npm run liveness -- https://a.com/job/1 https://b.com/job/2
npm run liveness -- --file urls.txt
npm run liveness -- --no-fallback https://a.com/job/1   # stay fully headless (no headed retry on anti-bot walls)
npm run liveness -- --throttle=5000 --file urls.txt      # jittered wait between checks (rate-based WAFs)

Each URL gets a verdict: active, expired, or uncertain with a reason.

Exit codes: 0 all URLs active, 1 any expired or uncertain.


scan

Zero-token portal scanner. Runs configured local parsers for SSR/static career pages and hits ATS APIs (Greenhouse, Ashby, Lever) directly — no LLM tokens consumed. Reads portals.yml for target companies, outputs matching listings to stdout, and optionally appends to data/pipeline.md.

scan_history.recheck_after_days in portals.yml lets old added URLs become eligible for recheck after the configured number of days. If absent, scan-history dedup keeps the historical behavior and dedups forever. Permanent invalid statuses such as blocked host and malformed URL remain permanent.

For custom SSR pages, configure a tracked company with scan_method: local_parser and a parser block. The parser can be written in JavaScript, Python, or any language available as a local executable. Company-specific parsers usually already know their source URL and only need to print JSON jobs to stdout:

yaml
parser:
  command: node
  script: scripts/parsers/example-company-jobs.js
  format: jobs-json-v1

Use args only for reusable parsers that intentionally accept runtime parameters such as {careers_url} or {company}.

If a parser writes full extraction artifacts for debugging or audit, store them under data/parser-output/{company}/. scan.mjs reads stdout and does not require those JSON files after parsing. Keep generated JSON artifacts out of git; .gitkeep placeholders are the only exception for preserving directory structure.

When the ATS provider's list API returns a description, each new offer is fingerprinted for cross-listing detection. See Cross-listing detection under scan:full for details.

Company blacklist (#1742): if data/blacklist.md exists (user layer, opt-in — see templates/blacklist.example.md), postings from listed companies are skipped, matched case- and punctuation-insensitively with the same company normalization the tracker scripts share. Skips are never silent: the run summary reports N skipped (blacklist) and the count is persisted to data/scan-runs.tsv as filtered_blacklist. Pass --include-blacklisted to bypass the filter for auditing — matching postings flow through annotated (note: blacklisted: {reason} in data/pipeline.md). No blacklist file = no filtering; nothing ever adds a company to the list automatically.

bash
npm run scan
node scan.mjs --include-blacklisted   # audit: let blacklisted companies through, annotated

Parallel search lanes (#2271): all four of scan.mjs's files are overridable by environment variable, so a second search with different targeting (a bridge/income track, a career-change track, or a partner sharing the checkout) can be fully self-contained in one clone:

Variable Default
CAREER_OPS_PORTALS portals.yml
CAREER_OPS_PROFILE config/profile.yml
CAREER_OPS_PIPELINE data/pipeline.md
CAREER_OPS_SCAN_HISTORY data/scan-history.tsv
bash
CAREER_OPS_PORTALS=portals.bridge.yml \
CAREER_OPS_PIPELINE=data/pipeline.bridge.md \
CAREER_OPS_SCAN_HISTORY=data/scan-history.bridge.tsv \
  node scan.mjs

Give a lane its own CAREER_OPS_SCAN_HISTORY, not just its own pipeline. That file is the dedup source, so lanes sharing it silently suppress each other: a posting surfaced in one lane counts as a duplicate in the other and never appears there, with only the Duplicates: skipped counter to show for it.

Defaults are unchanged, so a single-lane setup needs none of this. Note that the remaining outputs (data/scan-runs.tsv, data/portal-health.tsv, data/applications.md) are still shared across lanes, so stats.mjs and the other analytics scripts pool lanes together.

Exit codes: 0 scan completed, 1 configuration error or no portals.yml found.


scan:full

Reverse ATS discovery scanner. Where scan.mjs scans the companies you track in portals.yml, this inverts the direction: it walks public directories of companies per ATS (Greenhouse, Lever, Ashby, Workday) and surfaces fresh postings matching your portals.yml title_filter / location_filter — no manual company curation. Company directories come from the public job-board-aggregator dataset, cached in data/cache/ for 24 hours.

Postings without a usable publish date are skipped — a reverse scan is only useful for fresh postings. New matches are appended to data/pipeline.md and data/scan-history.tsv in the same format as scan.mjs.

data/blacklist.md is respected here too: blacklisted companies are skipped by default and reported in the summary. Pass --include-blacklisted to audit them instead; matching postings flow through annotated (note: blacklisted: {reason} in data/pipeline.md).

Cross-listing detection

data/scan-history.tsv carries a SimHash fingerprint of the JD text in its 8th column (jd_fingerprint), and the original posting date in its 9th column (postedAt). The fingerprint column exists to catch a specific double-submission hazard: the same role posted by the direct employer and by a recruitment agency, often with the employer name stripped from the agency listing. URL dedup and company+role dedup both miss this pair because the URLs and company names are different — but agencies rarely rewrite the requirements text, so a near-identical JD body is a reliable signal.

The 12th column (normalized_company) stores the canonical company key — the raw company (col 5) run through the shared normalizeCompanyName (lowercased, punctuation/whitespace folded, trailing legal-entity suffixes stripped), so Acme Inc., Acme, Inc. and ACME Inc all resolve to acme. It is written at scan time so repost/name matching (detect-reposts.mjs) keys on a stable value instead of re-deriving it or routing a legitimacy signal through script execution. The column is additive and trailing: rows written before it existed simply omit it, and consumers normalize the raw company on the fly for those rows (backward-compatible). All columns beyond col 7 are append-only — index-based readers (including the web parser, which reads only cols 0-6) are unaffected.

How it works:

  • When the ATS provider's list API returns a description field (e.g. Lever's descriptionPlain), the scanner computes a 64-bit SimHash of the normalized text and stores it as the 8th column.
  • SimHash is locality-sensitive: near-duplicate texts land within a few bits of each other. The scanner flags any two rows from different companies whose fingerprints are ≥ 92 % similar (at most 5 of 64 bits differ) and that appeared within a 90-day window.
  • The check is warn-only: nothing is dropped automatically. If one side is an agency, apply through ONE channel only — a double submission burns the candidate with both parties.
  • Postings without a usable description get an empty fingerprint and are never flagged. No body → no signal, no false positives.
  • The fingerprint is computed locally from the text already returned by the API. No extra network request is made and the JD body itself is not stored in the TSV.

Same detection logic applies to scan.mjs (the standard portal scanner) — the sub-section above is shared between both commands.

bash
npm run scan:full                              # all ATS directories, last 3 days
node scan-ats-full.mjs --since 7               # postings from the last 7 days
node scan-ats-full.mjs --ats greenhouse,workday # subset of sources
node scan-ats-full.mjs --limit 200             # max companies per ATS
node scan-ats-full.mjs --dry-run               # preview without writing
node scan-ats-full.mjs --liveness              # Playwright-verify matches first
node scan-ats-full.mjs --include-blacklisted   # audit blacklist matches instead of skipping
node scan-ats-full.mjs --md-out notes/scans    # also write a dated markdown digest
npm run scan:seeds                             # probe VC portfolio seed companies (--seeds yc,a16z)
npm run scan:yc                                # Y Combinator portfolio only (--seeds yc)

--seeds <list> fetches comma-separated VC portfolio sources (e.g. yc,a16z)
and probes those companies via the ATS providers instead of (or in addition
to) the directory walk. Other flags: --verbose, --json, --include-undated,
--shuffle.

DNS pacing

A full sweep resolves one hostname per Workday and iCIMS tenant — 13,889 distinct hostnames across the current datasets (3,781 Workday + 10,108 iCIMS), against 3 for Greenhouse, Lever and Ashby combined. Those lookups are irreducible (nothing to cache: every hostname is distinct), and issued unpaced they trip the per-client rate limit on a resolver like Pi-hole, which then refuses queries for the whole machine — the scan reports thousands of misleading fetch failed lines while the boards themselves are fine (#2229).

Uncached, non-coalesced lookups are therefore paced at 400 per minute by default. The token is spent before dns.lookup() runs, so a name answered locally — from /etc/hosts, say — still costs one; the ceiling meters what the process asks to resolve, not what leaves the machine.

How many upstream queries that becomes depends on the OS resolver: dns.lookup() delegates to getaddrinfo, which may answer without any query at all, but on a typical glibc host with autoSelectFamily it emits an A and an AAAA query — roughly 800 queries/minute, measured against a Pi-hole. That is under a stock Pi-hole's 1,000/minute with headroom for the rest of the machine; size it against your own resolver's limit.

Cache hits and lookups that coalesce onto an in-flight one are free, so only uncached, non-coalesced lookup keys count against the ceiling — a hostname not in the cache, or a cached one requested with different resolver options (the cache key is hostname plus family/all/hints/verbatim).

bash
CAREER_OPS_DNS_LOOKUPS_PER_MIN=800 npm run scan:full   # raise the ceiling
CAREER_OPS_DNS_LOOKUPS_PER_MIN=0 npm run scan:full     # no pacing (pre-#2229 behaviour)
CAREER_OPS_NO_DNS_CACHE=1 npm run scan:full            # no DNS cache AND no pacing

The cost is real: a full Workday + iCIMS sweep becomes DNS-bound at roughly 35 minutes. Raise the ceiling if your resolver has the budget — but if you see fetch failed in bulk from one ATS section, suspect the resolver before the boards.

Exit codes: 0 scan completed, 1 configuration error (no portals.yml, unknown --ats source) or fatal scan error.


company:funded

Review-first discovery for companies that recently raised funding. It reads structured public RSS/API sources and prints a candidate report for manual review. It never edits portals.yml and does not probe company websites.

bash
npm run company:funded -- --dry-run --limit 20
npm run company:funded -- --dry-run --limit 20 --months 3 --json
npm run company:funded -- --dry-run --sort score --limit 20
npm run company:funded -- --sources techcrunch,prnewswire,guardian,hn
npm run company:funded -- --self-test

Defaults: last 3 months, --sort date, sources techcrunch,prnewswire,guardian,hn. --sort score ranks by source and funding-detail confidence instead.

Runs without --dry-run write JSON under output/ and a Markdown report under reports/.

Source diagnostics are included in JSON output and surfaced in human output when a source has errors, is blocked, returns no items, or when no candidates are found.

Exit codes: 0 discovery completed, 1 invalid arguments or fatal runtime error.


tracker

SQLite derived index for the applications tracker (RFC #918, phase 1). data/applications.md stays the source of truth; data/applications.db is built from it by sync and is safe to delete at any time — it regenerates on the next sync. All writes keep going to the markdown exactly as today (merge-tracker.mjs, hand edits); the index is read-only infrastructure.

Why: at hundreds of rows a markdown table degrades structurally (encoding corruption, column drift, | inside cells shifting columns), and agents grepping it get model-dependent results. The index normalizes on sync, so a query returns the same rows for every model on every CLI — and corruption is detected at sync time instead of propagating silently.

Zero new dependencies — uses node:sqlite, built into Node ≥ 22.5.

bash
node tracker.mjs sync                     # (re)build applications.db from applications.md
node tracker.mjs sync --check             # diagnose corruption only, no write (exit 1 if issues found)
node tracker.mjs query --status Applied --since 2026-05-01
node tracker.mjs query --company acme --json
node tracker.mjs history --id 42          # status transitions observed across syncs (Applied → Interview → ...)
node tracker.mjs export                   # inverse: index → canonical markdown table on stdout
node tracker.mjs export --out repaired.md # write to a file (existing file backed up to .bak first)

query and history auto-resync when the markdown changed since the last sync, so the index can never serve stale reads.

sync detects and reports the corruption classes markdown accumulates — mojibake placeholder cells, scores stranded in the status column, non-canonical statuses (resolved via templates/states.yml aliases), missing/duplicate ids, stray pipes — and normalizes them in the index only; the markdown is never modified. Fix at the source with normalize-statuses.mjs / dedup-tracker.mjs, then re-sync. Status changes between syncs accumulate in a status_events table, which gives analyze-patterns.mjs a real funnel instead of only the current snapshot.

export is the inverse of sync (round-trip md → db → md is lossless for clean input — enforced by test-all.mjs). It writes to stdout by default and never touches applications.md unless you explicitly pass it as --out. Phase 2 of #918 (DB becomes source of truth, markdown becomes a rendered view) is a separate, explicit per-user opt-in — not part of this script yet.

Exit codes: 0 success, 1 validation error, missing prerequisites (Node < 22.5, no applications.md to index), or corruption found by sync --check.


find

Resolves a report number, tracker number, or company/role fragment to its full pipeline identity: company, role, tracker#, report#, canonical status, PDF path (from data/pdf-index.tsv), and report path. "Apply to #13" is ambiguous — report numbers and tracker row numbers diverge — and answering it used to require opening three files; this does it in one read-only lookup.

Zero dependencies, strictly read-only. Numeric queries match both the tracker # column and the report number from the Report link (012 and 12 are the same number), so collisions between the two numbering schemes surface as multiple rows instead of a silent wrong pick. Text queries match company/role by case-insensitive substring, with the shared fuzzy matcher (role-matcher.mjs) as fallback for multi-word phrases.

bash
node find.mjs 13                # report# OR tracker# 13 — shows both if they differ
node find.mjs acme              # company fragment
node find.mjs "data engineer"   # role phrase (fuzzy via role-matcher)
node find.mjs acme --json       # machine-readable output

Multiple matches print as a table; zero matches print a clean message.

Exit codes: 0 at least one match, 1 no match, missing query, or no applications.md.


paste-reply

Manual, no-Gmail input path into reply-watch.mjs's classification pipeline (#1802). reply-watch.mjs already classifies employer replies and matches them to tracker rows, but its only input is data/reply-candidates.json, and the only planned way to populate that file is a Gmail scanner (#1583, unbuilt, requires OAuth inbox-read access). paste-reply.mjs normalizes a pasted (or file-provided) email's subject/from/body into the exact candidate shape reply-watch.mjs expects and appends it — existing candidates are never overwritten. It does not classify the reply itself (that stays reply-watch.mjs's job) and never runs reply-watch.mjs or touches data/applications.md.

bash
npm run paste-reply                    # interactive: prompts for subject, from, body
node paste-reply.mjs --file email.txt  # read subject/from/body from a file

--file format (header lines optional, blank line separates headers from body):

text
Subject: <subject line>
From: <sender>

If no Subject:/From: header lines are found, the whole file is treated as the body. After appending, run node reply-watch.mjs to classify the new candidate and review suggested tracker updates.

Exit codes: 0 candidate appended, 1 missing --file argument, input file not found, or no subject/body text found.


or (OpenRouter runner)

Runs the pipeline on OpenRouter free models with automatic fallback — no
Claude Code CLI required.

bash
npm run or:scan                 # scan configured companies for new listings
npm run or:eval --         # evaluate a job by URL (no URL: paste interactively)
npm run or:pipeline             # process pending URLs
npm run or:apply                # application assistance

reconcile

Syncs the data/pipeline.md "Pendientes" section with batch/batch-state.tsv.
batch-runner.sh records evaluated offers in the state file but never writes
back to pipeline.md, so batch-processed offers would otherwise be
re-surfaced by every later scan or pipeline run.

bash
npm run reconcile

cover-letter

Renders a cover-letter JSON payload to PDF: fills
templates/cover-letter-template.html with the payload, then renders via the
same Playwright pipeline as CVs.

bash
npm run cover-letter -- payload.json
node generate-cover-letter.mjs --payload payload.json --out output/slug-cover.pdf

verify:portals

Online ATS-slug validator — complements the offline validate:portals. A
wrong slug in careers_url 404s silently on every future scan, so this
probes the public Greenhouse / Ashby / Lever endpoints to confirm each slug
actually resolves.

bash
npm run verify:portals

reposts

Repost detector. Reads data/scan-history.tsv, fuzzy-matches role titles per
company, and flags any company+role listed 2+ times with different URLs
within a 90-day window — a strong ghost-job / re-listing signal.

bash
npm run reposts                 # JSON
node detect-reposts.mjs --summary

rank-pipeline

Opt-in LLM relevance re-ranker for data/pipeline.md. Off by default and not
part of any scan
— scan.mjs stays 100% zero-token, and this costs nothing
unless you run it yourself.

It annotates, it does not filter: eligible pending rows can gain a labeled
rank: {score}/5 — {reason} segment, riding after posted:/trust:/note:
like any other labeled segment. No row is removed, reordered, or hidden — the
reason is there so you can disagree with the score. An entry the model scores
but cannot explain is left un-annotated rather than reduced to a bare number,
and a whole batch is left un-annotated if the CLI call fails or returns
unusable JSON.

Cost is bounded and reported. Only pending (- [ ]) rows that are not already
annotated are eligible, --limit caps each run (default 20, hard ceiling 200
that the flag cannot raise), and a summary prints the entries ranked, the number
of CLI calls, and elapsed time. Re-runs are idempotent — an already-annotated
row is skipped, so you can work through a large pipeline in bounded passes.

The ranking is done by whichever agent CLI you already have installed (the
Headless / Batch Mode table in AGENTS.md): claude, opencode, codex,
copilot, qwen, agy, grok — first one found wins. No API key, no new
dependency, no new network endpoint. Each call sends a cv.md excerpt (the
first ~2000 chars) and the selected postings through that CLI's own auth and
provider handling — review your chosen CLI's data-retention/provider settings
before running this on sensitive CV content.

bash
node rank-pipeline.mjs                  # rank up to 20 pending entries
node rank-pipeline.mjs --limit 10
node rank-pipeline.mjs --cli codex      # override auto-detection
node rank-pipeline.mjs --dry-run        # print annotations, write nothing

Writes go through pipeline-lock.mjs, the same lock scan.mjs and
scan-ats-full.mjs use, and the file is re-read inside the lock — so a
concurrent scan cannot lose rows to this script.


gemini:eval / ollama:eval / openai:eval

Standalone evaluators — run the same evaluation logic
(modes/oferta.md + modes/_shared.md + cv.md) without an interactive AI
CLI:

  • gemini:eval — Google Gemini free tier (GEMINI_API_KEY in .env)
  • ollama:eval — fully local and private via Ollama
  • openai:eval — any OpenAI-compatible endpoint (OpenAI, OpenRouter, Groq,
    DeepSeek, LM Studio, llama.cpp, vLLM, ...)
bash
npm run gemini:eval -- "We are looking for a Senior AI Engineer..."
node gemini-eval.mjs --file ./jds/my-job.txt
npm run ollama:eval -- "JD text"
npm run openai:eval -- "JD text"

star

Zero-LLM, zero-browser behavioural question matcher. Parses
interview-prep/story-bank.md, scores each STAR story against the question
text (optionally plus a JD file), and returns the top matches formatted to
ATS paste length (250-500 words).

bash
npm run star -- "Tell me about a time you disagreed with a decision"

archive

Saves a live job posting as PDF via Playwright before it disappears —
postings vanish once filled, and the original requirements matter for
interview prep and salary negotiation evidence.

bash
npm run archive -- https://example.com/job/123

prepare:application

ATS auto-fill helper for Greenhouse, Ashby, and Lever. Detects the ATS from
the apply URL, reads candidate data from config/profile.yml, and prints a
prefill summary to stdout. Never POSTs anything — you review the output,
open the apply URL, and submit yourself. See
APPLY_AUTOFILL.md.

bash
npm run prepare:application -- --url https://boards.greenhouse.io/acme/jobs/123

build:dashboard

Cross-platform build wrapper for the Go TUI dashboard: picks the
platform-correct output name (career-dashboard.exe on Windows, else
career-dashboard), since a bare go build -o writes an extension-less
binary on Windows. Requires Go 1.24+.

bash
npm run build:dashboard
npm run serve:dashboard    # or run the TUI directly without building

Agent-invoked utilities

These have no npm run binding — modes and agents call them with
node <script> directly. Each script's header comment documents its flags.

Invocation Purpose
node set-status.mjs <report#|company> <State> [--note] Canonical tracker write path: strict states.yml validation, shared lock, atomic write. Modes call this instead of hand-editing applications.md
node mark-pdf-ready.mjs <report#> [--dry-run] [--json] Mark the matched tracker's PDF cell ready after the web PDF render path finishes; resolves the report number, uses the shared tracker lock, and writes atomically
node followup-cadence.mjs [--summary] Follow-up cadence per active application; flags overdue entries
node followup-seed.mjs [--backfill] Seed data/follow-ups.md with a pinned first follow-up date when a row turns Applied
node reply-watch.mjs Classify employer replies from data/reply-candidates.json, match to tracker rows, print a review digest
node process-quality.mjs [--summary] Aggregate [process-friction] tags from data/active-interviews.md per company
node reserve-report-num.mjs [--count N] Atomically reserve report numbers for parallel workers (fixes the #749 race)
node agent-inbox.mjs add "..." Append a request to the queue the agent drains at the next session start
node generate-latex.mjs [output.pdf] Validate and compile a generated .tex CV via tectonic or pdflatex
node classify-tier.mjs Classify a job title into intern / entry / mid / senior
node plugins.mjs list|run [hook] CLI host for non-provider plugin hooks (see PLUGINS.md)
node plugin-install.mjs Clone/scaffold/validate community plugins (allowlisted URLs, pinned SHA)
node plugin-audit.mjs Static safety scan for community/registry plugins
node validate-plugin-registry.mjs Shape gate for plugins-registry/.json files

process-quality.mjs

Aggregates candidate-authored [process-friction] tags from the Notes column
of data/active-interviews.md into a per-company friction signal. The tag
stays free-text on purpose — there's no enforced taxonomy — but here are a
few example friction patterns worth tagging, illustrative and non-exhaustive:

  • [process-friction: call scheduled for a rejection with no info beyond what email would convey]
  • [process-friction: prescreen repeated info already given in a prior round]
  • [process-friction: interview rescheduled 2+ times same week]
  • [process-friction: no confirmation after stated timeline passed]

set-status.mjs

Canonical tracker write path: strict states.yml validation, shared lock, atomic write. Modes and agents call this instead of hand-editing applications.md.

bash
node set-status.mjs <report#|company> <state> [--note "..."] [--on YYYY-MM-DD] [--force] [--dry-run] [--json]
node set-status.mjs --row N <state> [--note "..."]          # explicit tracker row ID
node set-status.mjs --report N <state> [--note "..."]       # row whose Report cell links report #N
node set-status.mjs "Company Name" Applied --role "Role"    # narrow match by role fragment
node set-status.mjs --row 12 Applied
node set-status.mjs --report 345 Applied --on 2026-08-01

A bare number or company name is convenient, but becomes ambiguous when multiple tracker rows exist for a company or when tracker row IDs and report IDs diverge. That divergence is permanent once it starts: reserve-report-num.mjs treats tracker row IDs as occupied when it allocates a report number, so a row that never got a report still consumes a number the report sequence then skips — the two counters leapfrog each other and never realign. On a diverged tracker "5" may mean tracker row #5 or report #5, which are different applications. Base selectors resolve the main target, while explicit selectors and filters disambiguate the target row:

  • --row N: Selects the row whose # cell is N.
  • --report N: Selects the row whose Report cell links report N.
  • --role <role>: Narrowing selector that refines a company, report, row, or bare-number match when multiple tracker rows exist for a single target.
  • --on <date>: Specifies an explicit transition date (YYYY-MM-DD) for status logs and notes.
  • --json: Formats command output as structured JSON.

--row and --report are mutually exclusive. Because an explicit selector answers the report-mismatch guard rather than overriding it, --row bypasses that guard without needing --force (which silences the check while the ambiguity is still real).

This is worth preferring in practice, not just in principle. Once the counters have diverged, a bare number trips the guard whenever the row it matches links a report number other than its own #, or links no report at all while a different row claims that number as its report — so on a tracker with a wide gap the check keeps firing, and a check that keeps firing teaches callers to pass --force by reflex, which disables it everywhere including the cases it was written to catch. Reach for a selector (or the company name) instead.

Bare numbers vs. explicit selectors

  • Use a bare number when tracker row IDs and report IDs are identical or when querying interactively.
  • Use --row N or --report N in automated scripts, modes, or whenever row IDs and report IDs have diverged to avoid triggering report-number mismatch guards or ambiguous updates. Use --role alongside a base selector to narrow down multiple matching roles for a company.

Exit codes (the shared CLI_EXIT contract in tracker-utils.mjs, so these values are stable across every canonical tracker writer):

  • 0 success, including an idempotent no-op re-run that changed nothing.
  • 1 for an invalid or conflicting selector, or a non-canonical state.
  • 2 when the selector matches no tracker row.
  • 3 when a bare numeric selector triggers the report-number mismatch guard (report-number-mismatch), or a company matches several rows.
  • 4 when the shared tracker lock is busy — retryable, unlike the others.

Nothing is written on any non-zero exit.

To identify a row before writing to it, find resolves a number, company, or role fragment to its full identity and surfaces collisions between the two numbering schemes rather than picking one silently.

mark-pdf-ready.mjs

The web PDF render path calls this utility after a CV PDF has been generated so
the matching tracker row can be marked ready. It is not normally a manual
day-to-day command. The argument is the report number from the reports/NNN-...
filename or Report cell, not the tracker row's # value.

bash
node mark-pdf-ready.mjs <report#>                  # mark the matching row
node mark-pdf-ready.mjs <report#> --dry-run       # validate without writing
node mark-pdf-ready.mjs <report#> --json          # emit machine-readable output

The script resolves the report-to-row link, refuses ambiguous matches, and
leaves an already-ready row unchanged. Writes use the same shared tracker lock
and atomic replacement as set-status.mjs, so concurrent tracker updates do
not overwrite one another. Exit status 0 covers a successful mark and an
idempotent no-op; 1 is a usage, column, or write error; 2 means the tracker
or report row was not found; 3 means the report matched more than one row;
and 4 means the tracker lock timed out and the operation should be retried.


stats.mjs

Aggregates lifetime pipeline stats into one JSON report. Stats include tracker, scanner, portals, follow-ups and runs. Reads from data/applications.md, data/scan-history.tsv, portals.yml, data/follow-ups.md and data/scan-runs.tsv. If a file doesn't exist yet, the section turns into null.

bash
node stats.mjs --summary             # returns human-readable table
node stats.mjs                       # returns json

On a fresh clone, with no data yet, the JSON format is as follows:

text
{
  "metadata": {
    "generatedAt": "2026-07-07",
    "sources": {
      "tracker": false,
      "scanHistory": false,
      "followups": false,
      "portals": false,
      "scanRuns": false
    }
  },
  "tracker": null,
  "funnel": null,
  "scan": null,
  "portals": null,
  "followups": null,
  "runs": null
}

With --summary it returns:

text
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Pipeline Stats — 2026-07-07
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Tracker:    — no data (data/applications.md missing)
Scanner:    — no data (data/scan-history.tsv missing)
Portals:    — no data (portals.yml missing)
Follow-ups: — no data (data/follow-ups.md missing)
Runs:       — no data (data/scan-runs.tsv missing; created by the next scan)

data/scan-runs.tsv

scan.mjs appends one row to this file after each non-dry scan run, recording how many companies/boards it checked, how many postings it found vs. filtered out vs. flagged as duplicates vs. added, and how many errors occurred. --dry-run scans never write to this file. Stats appended include:

  • timestamp — ISO timestamp of the scan
  • status — always completed for now
  • companies — number of companies scanned this run
  • boards — number of job boards scanned this run
  • found — total postings found
  • filtered_title — filtered out by title mismatch
  • filtered_tier — filtered out by tier
  • filtered_location — filtered out by location
  • filtered_salary — filtered out by salary
  • filtered_content — filtered out by content
  • filtered_cooldown — skipped because you recently applied to the same company + role and are still in the waiting period
  • dupes — duplicate postings skipped
  • new_added — new postings actually added to the pipeline
  • errors — number of errors during the run
  • filtered_blacklist — skipped because the company is on your data/blacklist.md do-not-apply list (#1742)

As the project is in continuous development, to parse for a stat we recommend doing it by column header instead of position.


File: docs/SETUP.md

Setup Guide

Prerequisites

  • An AI coding CLI — Claude Code, Gemini CLI, Codex, Qwen Code, OpenCode, GitHub Copilot CLI, Antigravity CLI, or Grok Build CLI (see Supported CLIs)
  • Node.js 18+ and git (npx ships with Node — the installer refuses to run without them) — note: the Gemini CLI integration requires Node.js 20+
  • (Optional) Go 1.21+ (for the dashboard TUI)

Quick Start

bash
npx @santifer/career-ops init

npx ships with Node.js — it runs the installer once without installing anything globally. This clones the latest release into ./career-ops and installs dependencies. Then move into the workspace and open your AI CLI:

bash
cd career-ops
claude   # or codex / qwen / opencode / agy / grok

On first launch, career-ops walks you through setup by chatting — it asks for your CV, your details (name, target roles, salary), and sets up the job scanner with pre-configured companies. Nothing to edit by hand: just answer its questions. Then paste a job offer URL or description and it evaluates it, writes a report, generates a tailored PDF, and tracks it.

If you are using Codex, start the interactive session with codex. Slash commands are not guaranteed in Codex, so use the same mode names in a prompt if /career-ops is unavailable:

text
Evaluate this JD with career-ops auto-pipeline: https://company.com/jobs/123
Run the career-ops scan mode.
Run the career-ops pipeline mode.
Run the career-ops pdf mode.
Run the career-ops email mode for the latest evaluated role. Draft only; never sends, submits, or clicks.
Run the career-ops tracker mode.

For one-shot workers or batch tasks in Codex, use codex exec. See docs/CODEX.md for the full guide.

bash
codex exec "Evaluate this JD with career-ops auto-pipeline: https://company.com/jobs/123"
codex exec "Run career-ops scan mode in this repo."
codex exec "Run career-ops pipeline mode for data/pipeline.md."
codex exec "Run career-ops pdf mode for the latest evaluated role."
codex exec "Run career-ops email mode for the latest evaluated role. Draft only; do not send, submit, or click anything."
codex exec "Run career-ops tracker mode and summarize the current statuses."

Advanced — clone manually

Prefer to clone the repo yourself?

bash
git clone https://github.com/santifer/career-ops.git
cd career-ops
npm install

Then open your AI CLI in the folder — the same first-run onboarding applies. Use this path if you want to track a specific branch, contribute, or audit the code before installing dependencies.

PDF rendering (one-time)

PDFs are rendered with a headless Chromium. Install it once per machine:

bash
npx playwright install chromium

Available Commands

Action How
Evaluate an offer Paste a URL or JD text
Search for offers /career-ops scan or ask the agent to run scan
Process pending URLs /career-ops pipeline or ask the agent to run pipeline
Generate a PDF /career-ops pdf or ask the agent to run pdf
Draft application email /career-ops email or ask the agent to run email; draft-only, never sends, submits, or clicks
Batch evaluate /career-ops batch or use codex exec "Run career-ops batch mode ..."
Check tracker status /career-ops tracker or ask the agent to run tracker
Fill application form /career-ops apply or ask the agent to run apply

Verify Setup

bash
node cv-sync-check.mjs      # Check configuration
node verify-pipeline.mjs     # Check pipeline integrity

Build Dashboard (Optional)

bash
npm run serve:dashboard     # Opens TUI pipeline viewer
npm run build:dashboard     # Optional: build the standalone binary

File: examples/dual-track-engineer-instructor/README.md

Example -- dual-track engineer + instructor

This is for the case where one candidate has a real track record on two different archetypes at once. The example here is a senior AI engineer who is also a senior technical instructor, but the same structure works for backend + SRE, ML + research, IC + manager, or engineer + product manager.

The default career-ops examples assume one north-star archetype. Hybrid careers do not fit that mould. Bootcamp instructors, university lecturers, AI/ML educators inside companies, DevRel engineers, training architects, internal-enablement leads -- these people have two real jobs on the CV and the existing single-archetype config either flattens one side or the other.

This folder shows how to:

  1. Configure archetypes: in profile.yml with two fit: primary entries instead of one.
  2. Write a cv.md that puts measurable wins on both sides in front of the recruiter without diluting either.
  3. Set two compensation ranges. Engineering and teaching pay differently, often by 20-40%, and the evaluator needs to know which range applies to a given offer.
  4. At evaluation time, decide which track to lead with for the specific JD on the desk.

Files

File Purpose
cv.md Fictional dual-track CV (Sam Rivera). Use as structural reference for your own.
profile.yml Profile config with two primary archetypes and two comp ranges.
README.md This file.

The persona is fictional (Sam Rivera <[email protected]>). Do not copy values directly -- adapt them.


When to use the dual-track pattern

Use it only if all of these are true:

  1. You have measurable, recent (under 3 years old) wins on two different archetypes. Not "I taught a workshop once", but real numbers: hours taught, alumni placed, retention rates, NPS -- and on the engineering side, LOC shipped, systems owned, latency or cost moved.
  2. You will actually take an offer from either side. If you would only really accept one and grudgingly take the other, do single-track and treat the other side as one strong bullet in the summary.
  3. The two tracks are at roughly the same seniority. A junior teacher who is also a staff engineer is a single-track engineer who happens to mentor.

Use single-track instead if:

  • One side is hobby-grade. "I mentor on weekends" does not count as a teaching career.
  • The seniorities are mismatched.
  • You are early enough in your career that you don't actually know yet which side is the main one.
  • Your salary expectations on one track are non-negotiable. Pick that track and use the other as a differentiator in the cover letter.

How dual-track changes the rest of career-ops

`modes/_shared.md`

List both archetypes in the "North Star -- Target Roles" table with fit: primary. The skill applies equal rigor to all primary archetypes, which is what you need here.

`cv.md`

Two ways to structure it:

  • Layered (what I recommend): one Professional Summary that names both tracks in the first sentence, then experience entries that include both engineering and teaching bullets per role. Use this when the roles actually combined both. See the cv.md in this folder.
  • Sectioned: separate "Engineering Experience" and "Teaching Experience" headings. Use this when the two tracks happened at different employers and don't need to be told as one story.

Lead the Professional Summary with the combination itself -- "senior AI engineer who runs the curriculum", or whatever the equivalent is for your stack. The combination is the thing that's hard to hire. Either side alone is not.

`profile.yml` -- compensation

Put your engineering range in compensation.target_range (it's usually the higher one) and the teaching range in the optional compensation.alternate_ranges block. The evaluator picks the right one based on the JD.

Evaluation reports

When career-ops evaluates an offer, it should detect which archetype the JD targets and pick the matching salary range, the matching CV emphasis, and the matching STAR stories. With two fit: primary entries this mostly just works, but check the Archetype: line in the report header. If it picked wrong, the rest of the report is wrong too.


Interview objection handling

A dual-track CV triggers objections that single-track CVs do not. Three you should expect.

"Why are you applying for an engineering role if you also teach?"

The answer template I use:

"Teaching is how I keep the engineering sharp. I have to ship code that students can actually run, debug, and extend, so I cannot get away with hand-waving. The reason I am here for [role] is [specific reason about the team / product / scope]. Teaching stays as a side activity, not a competing job."

Lead with engineering wins. Mention teaching as a credibility signal ("I have explained transformers about 200 times so I know exactly which parts trip people up"), not as a co-equal commitment.

"Are you sure you want a teaching role? Your engineering background is intense, you will be bored."

"The students I want to teach are going to ship production systems, not pass a quiz. My engineering background is exactly why I can get them there. I have done both for [N] years. This is not a step down. It is the same work in a different format."

Lead with the teaching wins (hours, alumni placed, retention, NPS). Use the engineering background as proof of credibility, not as a fallback plan.

"Why not just pick one?"

"Because the combination is the actual value. Engineers who can teach end up leading onboarding, writing internal docs people actually read, and running technical interviews. Teachers who can ship get hired to design curriculum that survives contact with production. I am looking for roles where both matter."


Over/underqualified failure modes

Dual-track candidates get read as overqualified for pure teaching roles ("you will leave in six months for an engineering job") and as underqualified for pure engineering roles ("you have not been a full-time IC in two years"). Both kill applications. The mitigations:

Risk Mitigation in CV Mitigation in interview
Overqualified for teaching Lead the Summary with curriculum and outcomes, not LOC Tell a story about a course you redesigned that moved student outcomes. Show you care about pedagogy, not just shipping.
Underqualified for engineering Add a "Recent Engineering" section that lists shipping work from the last 12 months Bring code. Pull up a PR you wrote in the last month. Walk through the architecture decisions in plain language.

  • ../cv-example.md -- single-track CV example for comparison.
  • ../../config/profile.example.yml -- the canonical profile schema this example extends.
  • ../../modes/_shared.md -- where archetypes feed into framing logic.
  • ../../CONTRIBUTING.md -- this example was contributed under "Add example CVs for different roles".

File: examples/README.md

Examples

Reference files that demonstrate career-ops data formats and conventions. None of these are used at runtime -- they exist so you can see the expected structure before creating your own files.

Files

File Demonstrates
cv-example.md How to structure cv.md -- sections, metrics formatting, and proof-point style for a fictional AI engineer (Alex Chen)
resume-example.md Resume variant of cv-example.md -- same content, branded as "Resume" for US/industry markets. Use this as a structural guide when writing a resume (1–2 page targeted format) vs a CV (longer academic format)
article-digest-example.md How to write article-digest.md -- compact proof points with hero metrics, architecture summaries, and key decisions per project
sample-report.md The A-F evaluation report format produced by the evaluation pipeline, with all six blocks (Role Summary through Interview Plan)
ats-normalization-test.md Regression fixture for generate-pdf.mjs Unicode normalization -- lists every problematic codepoint and its ASCII-safe replacement
dual-track-engineer-instructor/ Complete profile config for a candidate with two primary archetypes (engineer + instructor), including cv.md, profile.yml, and a README explaining when and how to use the dual-track pattern

Usage

These files are read-only references. To set up your own career-ops instance:

  1. Run npm run doctor to check prerequisites.
  2. Use cv-example.md (or resume-example.md for US/industry contexts) as a structural guide when writing your cv.md.
  3. Use article-digest-example.md as a template for your article-digest.md (optional but improves evaluation quality).
  4. See the dual-track-engineer-instructor/ folder if your career spans two distinct archetypes.

File: modes/ar/README.md

Career-Ops -- الأنماط واللغة العربية (`modes/ar/`)

يحتوي هذا المجلد على الترجمة العربية الكاملة للأنماط (modes) الأساسية لنظام career-ops، والمصممة خصيصاً للمرشحين والمطورين في العالم العربي والشرق الأوسط، أو الذين يتقدمون لوظائف تتطلب التواصل باللغة العربية.

متى يُستخدم هذا النمط؟

استخدم modes/ar/ إذا تحقق شرط واحد على الأقل من الشروط التالية:

  • تتقدم بشكل أساسي إلى فرص عمل مكتوبة باللغة العربية (منصات مثل LinkedIn في العالم العربي، وظائف، بيت.كوم، إلخ).
  • ترغب في أن تكون سيرتك الذاتية باللغة العربية أو تريد التنقل بين العربية والإنجليزية حسب لغة الإعلان.
  • تحتاج إلى صياغة خطابات تغطية (Cover Letters) ورسائل تواصل احترافية بلغة عربية تقنية طبيعية وسلسة، بعيدة تماماً عن الترجمة الآلية الركيكة.
  • تحتاج إلى التعامل مع الميزات التعاقدية والتنظيمية في قوانين العمل العربية: مثل مكافأة نهاية الخدمة، التأمينات الاجتماعية، الراتب الإجمالي والصافي، فترة التجربة، مهلة الإخطار، البدلات (السكن، الانتقال، الطعام)، والتأمين الصحي.

إذا كانت معظم الفرص التي تتقدم إليها باللغة الإنجليزية، فاستمر في استخدام الأنماط القياسية في مجلد modes/.

كيفية التفعيل؟

الخيار 1 -- أثناء الجلسة (مؤقتاً)

أخبر الوكيل (Claude أو Gemini) في بداية المحادثة:

"استخدم الأنماط العربية الموجودة في مجلد modes/ar/."

سيقوم الوكيل بقراءة ملفات هذا المجلد بدلاً من المجلد القياسي modes/.

الخيار 2 -- بشكل دائم عبر الإعدادات

أضف السطور التالية إلى ملف الإعدادات config/profile.yml:

yaml
language:
  primary: ar
  modes_dir: modes/ar

ثم ذكّر الوكيل بذلك في أول محادثة ("انظر في ملف profile.yml لقد قمت بتعيين language.modes_dir"). سيستخدم النظام الأنماط العربية تلقائياً.


الأنماط المترجمة

تغطي هذه الترجمة الأنماط الأربعة الأكثر أهمية وتأثيراً في عملية البحث:

الملف الأصل المترجم منه دور الملف
_shared.md modes/_shared.md (EN) السياق المشترك، الأنماط الوظيفية (Archetypes)، القواعد العامة، والمصطلحات القانونية العربية
fursah.md modes/oferta.md (ES) التقييم الشامل والعميق لإعلان الوظيفة (الكتل A-G)
takdeem.md modes/apply.md (EN) المساعد الفوري لكتابة الردود أثناء تعبئة نماذج طلب التوظيف
pipeline.md modes/pipeline.md (ES) إدارة روابط الفرص المجمعة ومعالجتها

تبقى الملفات الأخرى باللغة الإنجليزية نظراً لاعتمادها بشكل أساسي على الأدوات وسطور الأوامر البرمجية التي يجب أن تظل مستقلة عن اللغة.


المصطلحات التقنية والتعريب

تم الإبقاء على بعض المصطلحات التقنية الشائعة باللغة الإنجليزية عمداً لضمان التوافق مع الأنظمة التقنية وثقافة العمل في الشركات الحديثة:

  • cv.md و pipeline و tracker و report و score و archetype و proof point.
  • أسماء الأدوات البرمجية (Playwright و WebSearch و WebFetch و Read و Write و Edit و Bash).
  • حالات الطلبات في جدول التتبع (Evaluated و Applied و Interview و Offer و Rejected و Discarded).
  • مسارات الملفات، الأوامر البرمجية، وقطع الشفرة المصدرية.

تستخدم هذه الأنماط لغة عربية مهنية مرنة: تُكتب النصوص والشروحات بلغة عربية فصحى راقية، مع استخدام المصطلحات التقنية الإنجليزية الشائعة في بيئة العمل التقنية (مثل استخدام Backend و Frontend و RAG و Pipelines كما هي متداولة في أوساط المطورين).


دليل المصطلحات المرجعي

لضمان الاتساق اللغوي عند كتابة أو تعديل الأنماط:

المصطلح الإنجليزي المقابل باللغة العربية (في هذا المشروع)
Job posting / vacancy فرصة عمل / إعلان وظيفة
Application طلب توظيف / تقديم
Cover letter خطاب تغطية / رسالة تعريفية
Resume / CV سيرة ذاتية
Salary راتب / أجر
Compensation تعويضات / الحزمة المالية
Skills مهارات
Interview مقابلة شخصية / مقابلة عمل
Hiring manager مدير التوظيف
Recruiter مسؤول توظيف
Requirements متطلبات
Career history التاريخ المهني / الخبرات المهنية
Notice period فترة الإخطار / مهلة الاستقالة
Probation period فترة التجربة / فترة الاختبار
Annual leave إجازة سنوية
Severance pay مكافأة نهاية الخدمة
Gross / Net salary راتب إجمالي / راتب صافي
Social security التأمينات الاجتماعية
Permanent contract عقد غير محدد المدة / عقد دائم
Fixed-term contract عقد محدد المدة
Remote / Hybrid عمل عن بعد / عمل هجين
Meal allowance بدل طعام
Health insurance تأمين صحي
Transportation allowance بدل انتقال / مواصلات
Inflation adjustment علاوة تضخم / تعديل سنوي

المساهمة والتطوير

لتعديل أو إضافة تحسينات على الترجمة العربية:

  1. افتح تذكرة (Issue) تشرح فيها اقتراحك وفقاً لملف CONTRIBUTING.md.
  2. التزم بجدول المصطلحات المذكور أعلاه لضمان اتساق الصياغة.
  3. اكتب بلغة عربية طبيعية وسلسة، وتجنب تماماً الترجمة الحرفية الجافة.
  4. حافظ على العناصر الهيكلية البرمجية (الكتل A-G، الجداول، أكواد التوثيق، وأوامر الأدوات) كما هي تماماً.
  5. اختبر الترجمة مع إعلان وظيفة حقيقي مكتوب بالعربية قبل تقديم طلب الدمج (PR).