# Mode: ats -- ATS-Friendliness Check ## Purpose career-ops already *generates* ATS-optimized CVs (see `modes/pdf.md`) and guards *what* they claim (`verify-cv-facts.mjs`). This mode answers the other half of the question users keep asking: **"is my CV actually ATS-friendly?"** — i.e. will an Applicant Tracking System's parser read it correctly at all. `verify-ats.mjs` is a **deterministic, read-only** checker (no LLM, no network, no writes). It reads a generated CV's HTML — the output of `pdf` mode, before PDF rendering — and reports an **ATS-friendliness score (0-100 + letter grade)** plus a list of concrete, fixable issues. Think `verify-cv-facts.mjs`, but for structure and parseability instead of facts. It is **advisory**: it is not wired into the `pdf` pipeline and never blocks CV generation. The mode has **two stages**, because ATS parseability is decided in two places: | Stage | Tool | Operates on | |-------|------|-------------| | Before the HTML exists | `ats-payload.mjs` | the render **payload** — one safe transform, three lints | | After the HTML exists | `verify-ats.mjs` | the generated **HTML** — score, grade, issues | **The score cannot tell you whether the payload stage was needed.** `verify-ats.mjs` reads the document it is given and judges its structure; a payload-level problem produces a structurally perfect document that still parses wrong. A CV whose competencies ship as CSS-separated tags under a header parsers do not recognise scores **97/A with zero issues** — identical to the same CV after the transform. Run the payload stage because the score cannot see it, not because the score complained. ## Inputs - For the payload stage: a `build-cv-html.mjs` render payload (the compact JSON the `pdf` mode writes — see `modes/pdf.md` § "JSON Input Schema"). - For the HTML stage: a generated CV HTML file (e.g. `output/cv-{candidate}-{company}.html`). - Optional target keywords (`--keywords` / `--role`) for an advisory keyword-coverage read. ## Payload preparation (`ats-payload.mjs`) A template owns the DOM; it does not own what is in the payload, and some of what an ATS parser mishandles is in the payload. By the time a template sees `competencies: [...]` the decision that breaks parsing has already been made — so no template swap can fix it, and the HTML-stage score cannot see it. `ats-payload.mjs` is **zero-LLM, deterministic, offline and read-only** with respect to user-layer files: payload JSON in, payload JSON out on stdout, findings on stderr. It never writes back to `cv.md` or `config/profile.yml`. ```bash node ats-payload.mjs payload.json > payload-ats.json node ats-payload.mjs payload.json --summary > payload-ats.json # human findings on stderr cat payload.json | node ats-payload.mjs - > payload-ats.json ``` **stdout is the payload and only the payload** — every human-facing word goes to stderr (JSON by default, a formatted report with `--summary`). That is what makes `> payload-ats.json` safe and lets it compose into a pipe. Flags: `--summary` (human findings on stderr) · `--self-test` · `--help` / `-h`. A bare `-` reads the payload from stdin. **Exit code:** `0` whenever the transform succeeded, findings or not — the lints are advisory and the payload on stdout is always usable. `1` only for an unreadable or malformed input, and in that case **nothing reaches stdout**, so a shell redirect cannot capture a half-right artifact. ### One transform (applied) `competencies[]` is folded into `skills[]` as one comma-delimited category, and the source array is emptied. `build-cv-html.mjs` renders competencies as bare tag spans into a container whose separation is entirely visual (`.competencies-grid { gap: 8px }`) — there is no delimiter *character* between one competency and the next, so the text layer runs adjacent items together into a single token. This is the class `modes/pdf.md` § "ATS Rules (clean parsing)" describes as outside a template lint's reach — nothing about the *rendered text layer* is visible in the HTML, so a clean lint and a clean score are both compatible with it — and it was reproduced with `pypdf` against a rendered PDF in #3251. The block also ships under `Core Competencies`, which parsers do not recognise as a section name, so it is frequently dropped whole rather than merely mangled. `Skills` **is** recognised, and already renders as `Category: a, b, c`. The same facts, expressed two ways, survive or don't depending on which array they sit in. Pure data movement: nothing invented, reversible, and **idempotent** — a second run is a no-op, and re-added competencies merge into the existing category rather than duplicating it. A payload carrying a localized `sections.competencies` keeps its own label as the category name. ### Three lints (reported, never applied) Each needs a decision only the user can make, which is where the no-fabrication rule in `AGENTS.md` draws the line. **Surface them; never act on them yourself.** | Code | What it sees | Why the agent must not fix it | |------|--------------|-------------------------------| | `employer-in-role` | An employer name inside `experience[].role` — the trap for consulting and agency work, where a client's name reads naturally as part of the title. Yields a phantom employer in the parsed record | Deciding "this substring is an employer" is not something a script *or an agent* should be confident about, and silently rewriting a job title is worse than the mangling it prevents | | `parenthetical-in-company` | `Globex (Cloud Platform Division)` — kept verbatim as part of the employer name, so the record matches no search for the employer | Stripping it is easy; deciding where that detail *goes* instead (role, location, a bullet) is authoring | | `multiple-date-ranges` | Two date ranges in one `experience[].dates` — two stints at one employer parse as one | Splitting them needs someone to decide which bullets belong to which stint. The tool can see the second range; it cannot allocate the bullets | A finding is a question for the user, not a task for you. Relay it, and if the user decides on a rewrite, the edit goes to the user-layer file it belongs in (`cv.md` / `config/profile.yml`) through the normal confirm-before-write path — never silently into the payload. ### Refused payload shapes The payload is shape-checked before anything is transformed or reported, and a shape that would make the run quietly wrong is refused rather than accommodated: a non-array `skills` (folding into it would discard the value), a non-array `experience` (the lints would report nothing, reading exactly like a clean payload), a `competencies` member with no scalar value, or an `items` on the merged-into category that is neither a string nor an array. If a run exits 1, relay the message — it names the field and why. ## Usage (HTML stage) ```bash node verify-ats.mjs output/cv-jane-smith-acme.html node verify-ats.mjs output/cv-jane-smith-acme.html --keywords "python,kubernetes,rag" node verify-ats.mjs output/cv-jane-smith-acme.html --role "Senior Backend Engineer" node verify-ats.mjs output/cv-jane-smith-acme.html --min-score 80 --json ``` Flags: - `--keywords "a,b,c"` — comma-separated target keywords; reports coverage vs the CV text. - `--role "..."` — a role title added to the keyword set as a single phrase (split only on commas, slashes, and the word "and"), matched verbatim against the CV text; it is not tokenized into individual words. - `--min-score N` — pass threshold (default `70`, range 0-100). - `--json` — machine-readable result on stdout (printed on both pass and fail). - `--self-test` — run the built-in regression suite. - `--help` — usage. **Exit code:** `0` when the structural score is at or above `--min-score` **and** no `critical` issue is present; `1` otherwise — the same 0/1 contract as the other verifiers. ## What it checks (structural score, sums to 100) Each check contributes a fixed weight; every deduction attaches a `critical`, `warning`, or `info` issue explaining what to fix. | Weight | Check | Why it matters | |--------|-------|----------------| | 15 | Real, selectable text present (>= 300 chars) | An image-only / rasterized CV has no text layer for the ATS to read. | | 20 | Standard section headings (Experience, Education, Skills required; Summary/Projects/Certifications bonus) | ATS parsers key off recognizable headings to segment the CV. | | 15 | Contact email reachable in the body (phone presence checked, but not its placement) | ATS routinely drop semantic `
`/`