Skip to content

feat(pdf): professional upload filename, per-job tailor, ATS parse order - #1

Draft
mbrow190 wants to merge 2629 commits into
mainfrom
cursor/pdf-upload-filename-1add
Draft

mbrow190 wants to merge 2629 commits into
mainfrom
cursor/pdf-upload-filename-1add

Conversation

@mbrow190

Copy link
Copy Markdown
Owner

What does this PR do?

Employer-facing CVs no longer use internal names like cv-michael-brown-acme-ats.pdf. generate-pdf.mjs still writes and indexes that file, then copies a professional upload name (default First-Last-Resume.pdf) into output/upload/. Per-job headline, summary, and skill order go through the existing JSON payload plus a small cv-tailor.mjs merge. ATS templates stack cert, award, and education fields so pdftotext reads name, then issuer, then year, and PDF <title> metadata uses a hyphen instead of an em dash.

Related issue

No issue. This is a fork-local fix from real use.

Type of change

  • Bug fix
  • New feature
  • Documentation / translation
  • Refactor (no behavior change)

Base note

This branch starts from the current HQ tip (fix/inbox-colon-cells, which is upstream main plus the inbox colon-cell fix). Fork main is an older snapshot without --ats / pdf-index. The PR therefore also fast-forwards the fork onto current HQ, then adds this feature. It is opened against this fork's main, not upstream. Nothing was pushed to career-ops-hq/career-ops.

Design

Two files, one index row. The tracker PDF column and data/pdf-index.tsv stay pointed at the internal path. A second copy is written for the employer. No seventh pdf-index column, because existing parsers are positional.

Workspace safety stays. --upload-dir and the default output/upload/ must pass isWorkspaceOutputPath. Outside destinations are refused. Agents should render into the tracker workspace (or a workspace-relative --upload-dir) instead of writing elsewhere and losing the PDF flag.

Per-job tailor extends the payload. candidate.title, summary, and skills[] already exist. cv-tailor.mjs reorders and rewrites those fields only. skill_order that names something not in the payload is a hard error. Facts still come from cv.md.

ATS layout. Skills stay last (the section-order guard). Category labels are omitted in the ATS skills partial so the fold does not repeat competency names. Cert/award/edu rows are stacked blocks. Empty issuer/year spans are omitted. School is a sibling of the degree, not nested inside it. Title tags in the shipped templates use - not an em dash. Body date dashes were already normalized by generate-pdf.mjs.

How to use

Name the upload copy in config/profile.yml:

cv:
  upload_filename: "{{First}}-{{Last}}-Resume.pdf"
  upload_dir: output/upload

Or on the renderer:

node generate-pdf.mjs output/cv-alex-acme.html output/cv-alex-acme-ats.pdf --report=12 --upload-name=Alex-Rivera-Resume.pdf

--upload-dir must stay inside the workspace. --no-upload-copy skips the employer file. Cover letters are not copied.

pdf mode: Step 21 writes both paths. Tell the user the internal indexed PDF and the output/upload/ file to attach.

Per-job customization after the payload is built (still grounded in cv.md):

node cv-tailor.mjs /tmp/cv-alex-acme.json --tailor=output/tailor/acme.yml
# or
node cv-tailor.mjs /tmp/cv-alex-acme.json --headline="Senior Backend Engineer" --summary="..." --skill-order=Languages,Python

Overlay keys: headline / title, summary, skill_order (category or existing item). Whole-section order is still cv.sections in the profile.

apply mode: Record output/upload/First-Last-Resume.pdf (or cv.upload_filename), not the internal ats slug. Pass it as uploadFile or files[].upload to application-answers.mjs.

Verification

Fictional sample: Alex Rivera / Northwind Labs.

  • pdftotext -layout reads certs and education as name, then issuer, then year
  • PDF <title> is Alex Rivera - CV (no em dash)
  • Internal cv-alex-rivera-northwind-ats.pdf is written and indexed
  • Employer copy output/upload/Alex-Rivera-Resume.pdf is written beside it

Suites run: tests/pdf-upload-ats-verify.test.mjs, tests/upload-copy.test.mjs, tests/cv-tailor.test.mjs, tests/pdf-ats-variant.test.mjs, tests/cv-title.test.mjs, tests/cv-partial-empty-fallbacks.test.mjs, generate-pdf batch/page-budget/workspace/experience-order, application-answers, user-layer and gitignore guards.

Follow-up (not in this PR)

Tracker merge already accepts a blank report cell. applications.md still has no URL column, so company+role dedup stays fuzzy. A URL column plus --backfill-urls (already sketched in merge-tracker.mjs) is the right next step, not a drive-by schema change here.

Checklist

  • I have read CONTRIBUTING.md
  • If this is a new feature or architecture change, I opened an issue first (bug fixes, providers, docs and translations are exempt)
  • My PR does not include personal data (CV, email, real names, scan results, or pipeline data)
  • Targeted generate-pdf, ATS, tailor, and application-answers tests pass (full node test-all.mjs not run in this environment)
  • My changes respect the Data Contract (no user-layer files)
  • My changes align with the project roadmap
Open in Web Open in Cursor 

sundarshankar and others added 30 commits October 3, 2026 20:57
`style:` can recolor the accents, but the competency-tag palette stayed three
hardcoded literals in every template that draws tags: the tag text
hsl(187 74% 28%), the tag and project-tech fill hsl(187 40% 95%), and the tag
border hsl(187 40% 88%).

So a profile that sets accent_color and secondary_color to a monochrome palette
gets monochrome headings and company lines with teal-tinted tags, and the only
way to finish the job is to edit templates/ — which theme-style.mjs's own header
explains is the wrong place, because templates/ is system-owned and every
`update-system.mjs apply` reverts it. The customization has to be re-made after
every release, or abandoned.

Adds --tag-color, --tag-bg and --tag-border with defaults byte-identical to the
literals they replace, and the matching tag_color / tag_bg / tag_border style
keys. A profile with no style: block renders exactly as before.

Applied to all three templates carrying those literals — cv-template.html,
cv-template.zh-minimal.html and resume-template.html — because a token only the
default template reads is a style: key that silently does nothing on the others.

Tests assert, per template, that each :root default equals the exact color it
replaced, that the template actually reads every token (an unreferenced var makes
an override inert), that no token resolves to itself, and that no palette literal
is left behind. Full suite: 8690 passed, 0 failed.

Co-authored-by: Santiago <santiago@me.com>
* fix: expire stale session activity claims

* test: harden session activity cleanup regressions
* docs(i18n): fix German interview prep link (career-ops-hq#4596)

* docs(i18n): fix Chinese interview prep link (career-ops-hq#4596)

---------

Co-authored-by: Santiago <santiago@me.com>
…areer-ops-hq#4745)

* docs(faq): note where the Windows skill repair lands from a worktree

Since career-ops-hq#4738, `update-system.mjs apply` run from a linked Git worktree
installs in the checkout that has `main`, so the skill-entrypoint
materialization lands there too. Tell readers to `git merge main` in the
worktree to pick it up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(faq): cover the CAREER_OPS_UPDATE_IN_WORKTREE opt-out

With the opt-out set, apply repairs the worktree's own branch and main
stays unrepaired. Addresses review on career-ops-hq#4745.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…ops-hq#4746)

* feat(web): derive robust run cost estimates

* refactor(web): share shortlist cost estimation

* fix(web): confirm paid assistant fan-out
…jobs posting blanks the first score-sorted page (career-ops-hq#4714)

* fix(providers): sort deutschebahn by publication date and throw on a results-less first page

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(providers): fetch deutschebahn in 1000-hit pages so tie-order drift can't truncate the walk

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(providers): deutschebahn warns at the max_pages cap and keeps partials when a later page fails

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(providers): state where db.jobs renders the results-less shell in the deutschebahn header

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(providers): name the unrenderable db.jobs posting as the cause of the deutschebahn results shell

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(providers): deutschebahn warns when a posting link has no hit anchor beside parsed hits

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(web): update vulnerable dependencies

* fix(web): complete cross-platform lock entries
… across CLI tools (career-ops-hq#4087) (career-ops-hq#4088)

* fix(tracker): resolve tracker paths via resolveTrackerPath(DATA_ROOT) across CLI tools

* test(tracker): expand mutation-sensitive coverage across CLI tools and satisfy test-all guard

---------

Co-authored-by: Santiago <santiago@me.com>
career-ops-hq#4535)

* feat(oferta): surface fixed-term contract disclosure

* fix(oferta): address fixed-term review gaps
…r-ops-hq#4747)

* test(updater): build the updater fixtures under hermeticGitEnv

makeUpdaterRepo pinned its git settings with `git config` and ran git
through the updater's own gitIn, which inherits the environment. An ambient
GIT_CONFIG_COUNT pair is applied after every config file, so it overrode
the core.excludesFile pin, the seed file was never staged, and the base
commit died before the first assertion (career-ops-hq#3801).

- tests/helpers.mjs: hermeticGitRunner(dir), the gitIn contract with
  hermeticGitEnv as its environment for the life of the runner.
  makeUpdaterRepo uses it and drops its gitIn parameter. The file-layer
  pins stay.
- updater-rollback-behavior: its own makeRepo failed the same way under
  the same injection and takes the same runner.
- hermetic-git-env: three assertions on what git saw through the fixture,
  built in a child process under all three ambient channels.

No production code changes.

* test(updater): the fixture runner drops inherited repository-location variables

Review finding on this PR, confirmed by measurement. With an ambient
GIT_DIR, cwd no longer decides which repository git acts on: the fixture's
`git config user.name Test` rewrote user.name in the repository GIT_DIR
pointed at. Same behaviour on main, where the fixture ran through gitIn.

hermeticGitRunner now deletes the rest of `git rev-parse --local-env-vars`
(the three config variables are already handled by hermeticGitEnv). One
new assertion: a bystander repository named by GIT_DIR, GIT_WORK_TREE and
GIT_INDEX_FILE keeps its config byte for byte while the fixture builds.
…eer-ops-hq#3438) (career-ops-hq#4346)

* feat(scan): let a target declare which field its whitelist reads (career-ops-hq#3438)

A title whitelist cannot express "this posting is in an occupation I
want" on a board that publishes an occupation code: one title maps to
several occupations, one occupation to unboundedly many titles. Those
matches were dropped silently — the title never matched, so nothing was
ever counted as rejected for the reason that actually applied.

A target now declares WHICH FIELD its whitelist reads:

  field_filters:
    noc:
      positive: ["stem:22", "stem:13"]
  job_boards:
    - name: Job Bank — help desk
      filter_on: noc        # string, or array (AND), default ["title"]

No filter_on, or filter_on: title, is byte-identical to today: the same
titleFilter(job.title) call against the same compiled object. Every
field_filters block is compiled by the same buildTitleFilter() as
title_filter, so word:/stem:/substring and the career-ops-hq#3103 word-boundary rules
stay identical across fields — no new matching semantics anywhere.

Silent failure is the thing this fixes, so it is not traded for another:

- filter_on naming a field with no field_filters block exits at startup,
  and validate-portals.mjs rejects it earlier still.
- A declared field absent from a posting PASSES and is counted, never
  dropped.
- A declared field absent from EVERY posting of a target prints an
  end-of-run warning: the whitelist is effectively off for it.
- Rejections by a non-title field get their own summary counter.

providers/local-parser.mjs: normalizeParserJob() built a closed object
literal, discarding every key beyond title/url/company/location. That
made a non-title filter_on dead on arrival for every local-parser board,
which is the transport Job Bank targets use. Extra keys now pass
through; today no parser emits any, so the object is unchanged.

This is the generic mechanism only. Populating job.noc from Job Bank,
and the same resolver in scan-ats-full.mjs, are separate changes.

* fix(scan): address review on career-ops-hq#3438 — per-field accounting, and a field_filters key list of its own

Five findings from the review, all verified against the code. Three of them
were one design flaw, so the accounting is restructured once rather than
patched three times.

Per-(target, field) absence counters. They were keyed by target alone, which
could not distinguish "noc is never published here" from "company was missing
on some other job", and reported the second as the first. A target declaring
`filter_on: [title, noc]` was excluded from the dead-declaration check
outright, so an always-missing noc produced no warning at all — the exact
silent failure this feature exists to surface. Keys are now JSON [target,
field] pairs, the warning names one field per line, and a mixed declaration is
covered. Both cases are asserted end to end.

The gate no longer short-circuits. `.every()` stopped at the first failing
field, leaving the per-field absence counts incomplete — and those counts are
what the warning reads. Every declared field is evaluated; the verdict is
derived afterwards from the first field that failed.

Rejections are attributed to the field that failed. With
`filter_on: [title, noc]`, a matching title and a rejected noc incremented
totalFilteredTitle. It now increments the field counter.

totalFilteredDeclaredField joins the JSON receipt's `filtered` total, which
was undercounting: found/filtered/added arithmetic did not close for a machine
reader.

field_filters gets FIELD_FILTER_FIELDS = [positive, negative] rather than
reusing TITLE_FILTER_FIELDS. buildTitleFilter reads those two and nothing else,
so a block containing only seniority_boost validated clean and compiled to an
empty positive list — which reads as "no positive constraint" and matches every
posting. A pass-all whitelist that looks configured, i.e. precisely what that
validation is there to catch. The list is written out rather than derived, so a
fourth title-level field added later does not silently become legal here.

New end-to-end test drives scan.mjs itself against local-parser fixture boards
that publish an occupation code, replacing a unit test that re-implemented the
filter loop and would have stayed green if scan.mjs stopped applying filter_on.
It covers the local-parser passthrough in the same pass: without that, job.noc
never reaches the filter.

* fix(scan): close the three holes found in the second review round (career-ops-hq#3438)

All three were in code this PR added, and all three are asserted by tests that
fail against the previous commit.

field_filters block shape is now checked at scan startup. scan.mjs never calls
validatePortalsConfig — that is a separate CLI — so the validation added in the
last commit only ran if the user remembered to run it. A block whose only key
is a typo (`positve`) leaves `positive` undefined, buildTitleFilter reads an
empty positive list as "no positive constraint", and the declared whitelist
passed every posting while the summary reported it as configured. Reproduced
before fixing: all three fixture postings reached the pipeline, including the
one the whitelist should have rejected.

Scoped to field_filters on purpose. title_filter has exactly the same property
today — a typo there is equally silent — but making scan.mjs validate it would
exit on configs that currently work, which is a call for the maintainer and
not part of this key.

Absence counters are keyed by the target's index, not its name. A duplicate
enabled name is only a validate-portals WARNING, so two distinct targets can
share one; keying by name merged their counters, and a field supplied by one
board suppressed the all-absent warning for the other.

Presence accounting moved above the blacklist skip. It answers "does this
provider publish this field at all", which the user's own do-not-apply
decision cannot bear on. With the counters inside the gate, blacklisting the
code-bearing postings left only the code-less one counted, and the run
reported "noc absent on all 1 job" — a claim about the provider manufactured
from the blacklist. The verdict, the absent-passes rule and the filtering all
stay below the skip.

tests/scan-field-filters-guards.test.mjs drives scan.mjs on bad input only:
the typo, an empty block, two same-named targets, and a mixed blacklisted
board. Each case was run against the previous commit first and fails there.

Two of those cases did not discriminate in their first form and are worth
recording: blacklisting EVERY posting leaves the warning silent before and
after, since the seen count stays zero, so the fixture had to mix blacklisted
and open companies; and data/blacklist.md is parsed as a markdown table, so a
bullet-list fixture blacklisted nobody and the test passed vacuously.

* fix(scan): reject field_filters.title at startup (career-ops-hq#3438)

`title` routes to the top-level title_filter by definition, so a block under
that key is never read. validate-portals.mjs already rejected it; scan.mjs's
startup check did not — and scan.mjs is the path that actually runs.

That gap is the pass-all trap in its most convincing form. Reproduced: with
`field_filters.title.positive: ["Help Desk"]` and no top-level title_filter,
all three fixture postings reached the pipeline. None of their titles is
"Help Desk". buildTitleFilter compiled the absent title_filter into an empty
positive list — "no positive constraint" — while the user was reading a
whitelist they believed was in force.

Same shape as the previous round: the check existed in the tool nobody calls.
The error names where the keywords belong rather than only refusing the key.

Guard test added to the bad-input file; it queues 3 postings and reports no
error against the previous commit.

* fix(scan): close two more startup-check gaps found by cross-checking the validator (career-ops-hq#3438)

Not from review. After the same class of defect appeared twice — a check that
lived only in validate-portals.mjs, which scan.mjs never calls — I listed both
rule sets side by side and ran every divergence through a real scan. Three
turned up; two are mine.

A keyword list can be a well-formed array and still compile to nothing.
buildTitleFilter drops every non-string and blank entry, so
`positive: [123, null]` passes an Array.isArray check, compiles to an empty
positive list — "no positive constraint" — and the whitelist matches every
posting. Reproduced: all three fixture postings queued, no error. The startup
check now counts the entries that survive compilation instead of inspecting
the type.

An empty `filter_on` silently became ["title"]. normalizeFilterOn defaults an
empty or all-unusable list to title, which quietly gates on something the user
did not ask for — and with no title_filter present, gates on nothing at all.
The validator errored on this; scan.mjs did not.

Third divergence left alone and reported on the PR instead: a keyword list
written as a bare string (`negative: "intern"`) is dropped by buildTitleFilter
and accepted by BOTH tools. That behaviour is shared with title_filter and
predates this key, so changing it belongs to whoever owns that decision.

Two guard tests added; both fail against the previous commit. One existing
guard asserted the old error wording and had to be updated — it was checking a
phrase rather than the behaviour, which is its own small version of the same
mistake.

* fix(scan): close the last scan/validator gaps, report ungated postings (career-ops-hq#3438)

The last open review thread: scan.mjs accepted `filter_on: [noc, 123]`
because one entry was usable, and normalizeFilterOn then dropped the 123 —
the AND gate silently lost a field the user wrote. It now rejects an empty
list and every non-string or blank entry, as validate-portals.mjs already did.

That was the third time a rule lived in one tool and not the other, so the
two rule sets are now pinned by tests/scan-field-filters-parity.test.mjs:
13 bad configs go through both tools and each must be rejected by both, for a
field_filters/filter_on reason; two valid ones must pass both. Against the
previous commit it fails 7 of the 13 — six divergences between the tools, in
both directions, and one gap they shared:

- scan.mjs accepted `filter_on: [noc, 123]` and `[noc, ""]`
- scan.mjs accepted `positive: ["stem:22", 123]`, dropping the 123
- the validator accepted `noc: {}`, empty positive + negative lists, and a
  bare-string `positive: "stem:22"`, all of which scan.mjs refuses
- neither tool rejected a bare-string `negative: "stem:65"` next to a valid
  positive list, so the negative list was silently ignored

field_filters now requires real lists of non-empty strings in both tools.
title_filter keeps its leniency for existing configs; field_filters is new and
has none to preserve. scan.mjs also rejects a field_filters that is not a
mapping, and compiles the blocks only after checking them.

The absent-field counter was incremented and never printed; the summary now
shows `Passed, field absent: N ungated`. Printing it exposed a miscount: a
posting rejected by title with its noc absent was booked both as a title
rejection and as passed. It is now counted only when the posting clears the
gate. With presence counted above the blacklist skip, the gate can stop at
the first failing field.

isFieldAbsent() is the single definition of "absent" for presence accounting
and the gate, and it treats a whitespace-only value as absent: judged as
present, it would be rejected by every positive list.

Also: an end-to-end case for a matching title with a rejected noc (booked to
the field), normalizeFilterOn's JSDoc back on its function, a stray re-indent
in validate-portals.mjs reverted, and comments that described intermediate
states of this PR rewritten to state the invariant instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(portals): document field_filters and filter_on (career-ops-hq#3438)

A commented section in templates/portals.example.yml next to
title_filter_overrides, a paragraph in the Portals section of
docs/CUSTOMIZATION.md, and the new checks in the validate:portals entry of
docs/SCRIPTS.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(scan): count a repeated filter_on field once; own keys only in the validator (career-ops-hq#3438)

Both findings from the review of 9ba80af, reproduced before fixing.

normalizeFilterOn() now returns unique fields in declared order. Presence
is counted once per field per job, so `filter_on: [noc, noc]` counted every
posting twice and the warning read "absent on all 4 job(s)" for two postings.

validate-portals.mjs looked the block up with `config.field_filters?.[name]`,
so `filter_on: __proto__` found Object.prototype and validated clean, while
scan.mjs rejects it. The lookup is now own-key only, and the case joins the
parity test, which now runs 14 bad shapes through both tools.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(scan): drop tests the parity test already covers (career-ops-hq#3438)

Split per triage: the local-parser passthrough moves to career-ops-hq#4697, and this PR
drops the tests that repeat what tests/scan-field-filters-parity.test.mjs
asserts through both scan.mjs and validate-portals.mjs.

- tests/validate-portals-field-filters.test.mjs: every rejection it checked
  is a parity case; seniority_boost is added there.
- tests/scan-field-filters-guards.test.mjs: the five bad-config cases are
  parity cases; it keeps the two that test accounting, not config shape
  (two targets sharing a name, blacklisted code-bearing postings).
- Fixture and test comments condensed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(scan): own properties only for declared fields; template example fixes (career-ops-hq#3438)

- test-all's template check (new on main): the local-parser example now
  points at the gitignored local/ path, not scripts/parsers/.
- The commented example no longer opens a second tracked_companies: root key,
  which would make portals.yml fail to load once uncommented (CodeRabbit).
- Declared fields are read through declaredFieldValue(), own properties only:
  with `filter_on: constructor` and a matching block, a job without the field
  was judged against Object's constructor and never counted as absent
  (CodeRabbit). Unit-tested.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(scan): a non-scalar declared field counts as absent, not a crash (career-ops-hq#3438)

The local-parser passthrough lets a parser emit any value, and String() on an
object with no usable toString throws: one such value stopped every later job
of its target (CodeRabbit). declaredFieldValue() now returns only string,
number and boolean values; anything else reads as absent, so the posting
passes, is counted as "Passed, field absent", and a target whose every value
is unusable gets the all-absent warning — the same unjudgeable-means-pass
rule as a missing field, rather than a silent drop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: ronanime-arch <282372760+ronanime-arch@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…4701)

* feat(web): edit application status inline in Pipeline

* test(web): include inline status browser suite in required gate
* fix(web): bind the dashboard to loopback by default

`next dev` / `next start` with no -H bind every network interface. The
dashboard is a local-first tool with no login, so the interface it listens on
is what decides who can reach it — it should default to this machine only.

The bind is derived from CAREER_OPS_WEB_ALLOWED_HOSTS, the variable the origin
guard already reads, through one function in bind-host.mjs: unset binds
127.0.0.1, and naming a host that loopback cannot already serve binds 0.0.0.0
with the guard still answering for loopback and the hosts named. Naming a
loopback host is not an opt-in, since it would widen the socket while granting
nothing the guard did not already admit.

npm scripts cannot branch on an env var portably, so a small launcher does it,
spawning next through this Node rather than the .bin shim that is a .cmd on
Windows. It prints the interface it is about to listen on before resolving
next, so a broken install still reports what the run would have exposed.

A caller's own -H is forwarded, and commander keeps the last value, so that
flag — in every spelling it accepts — decides the real bind. The startup notice
is therefore computed from the effective host rather than the resolved one, and
an override whose allow-list names no host beyond loopback is called out
separately, since the guard then protects nobody on that interface.

Also documents CAREER_OPS_WEB_ALLOWED_HOSTS, which shipped unmentioned, and
corrects the guard's header: its Host check answers for loopback or an opted-in
host, and is defence in depth behind the bind rather than a substitute for it.

* chore: rerun CI

* test(web): use platform-native termination assertion

* fix(web): run the origin guard on every route but build assets

The matcher was scoped to /api/:path*, so no page route was filtered on
any bind. A page that re-resolves its own domain to 127.0.0.1 fetches
/pipeline same-origin from the user's own browser, and force-dynamic
pages read the tracker off disk per request — a loopback bind does not
close that, because the browser making the request is already on the
machine. The rebound request carries the attacker's domain in Host, so
the guard refuses it once it actually runs.

Build assets stay out: they are the same for every user, and a caller
who cannot read a page has no use for the chunk that renders it.

The matcher is extracted and exercised in the guard's own suite, since a
guard that never runs is indistinguishable from one that always allows.

* fix(web): validate the allow-list and stop parsing next's own flags

Three ways the launcher could widen the socket or misreport it.

Any token the loopback predicate rejected counted as an opt-in, so
CAREER_OPS_WEB_ALLOWED_HOSTS=off bound every interface for someone
turning exposure off — every such word is a syntactically valid
hostname, so only an explicit check catches it. Values are now
validated as hostnames or IP literals, with switch-like words and
URLs refused by name rather than resolved to a host.

Naming one address bound 0.0.0.0, publishing the dashboard on every
other interface the machine happens to have — a VPN tunnel, a phone
hotspot — none of which was asked for. A single IP literal now binds
that address. One socket carries one address, so loopback stops
answering; the startup line says so, and naming a loopback host
alongside is the documented way back to 0.0.0.0.

A caller's own -H was appended after an injected one, which made
commander's last-wins resolution load-bearing while being asserted only
against a stub next. That bet was already lost once: a missed spelling
of the attached short form had the launcher announce loopback while
every interface was open. The bind is now injected only when no
hostname flag was supplied, so no duplicate exists to resolve; when one
was supplied, next owns the decision and the notice says that instead
of naming an address it would have to guess.

Presence detection is deliberately conservative — an -H that narrows to
loopback is also reported as next's to choose. A warning that is never
false beats one that is precise until the parser drifts.

* fix(web): refuse wildcard and link-local addresses in the allow-list

Three defects in the single-address bind added in 6097f86.

CAREER_OPS_WEB_ALLOWED_HOSTS=0.0.0.0 was accepted and produced the worst
state the module can reach: the socket opened on every interface while
the guard went on matching Host literally, so an honest client on the
network got 403 and only one spelling `Host: 0.0.0.0` was let in. The
notice that flags exactly this shape stayed silent, because
grantsNoNewHost reads false for a list that does name a non-loopback
host. Wildcards are bind targets, not host identities, and are now
refused in every spelling — 0.0.0.0, ::, 0::0, 0:0:0:0:0:0:0:0.

loopbackUnreachable compared the bind against the 0.0.0.0 literal, so a
bind of :: — the IPv6 wildcard, which serves loopback as much as 0.0.0.0
does — would have announced that localhost stops answering while it
answered fine. That is the launcher stating something false about its
own bind, the failure this module exists to prevent. It now asks whether
the address is a wildcard.

An unscoped link-local address (fe80::/10) was accepted as a bindable
literal, but is ambiguous across interfaces and fails at listen without
a zone index. The scoped spelling was already refused, since % is not a
host character. A test asserted fe80::1 was a valid bind target, so it
encoded the bug; it now uses a routable address.

isWildcardAddress is exported and tested directly. Validation makes the
loopbackUnreachable guard unreachable through planNextRun, so testing it
only through the plan would leave it unfalsifiable — decoration rather
than a guard.

README: a named address must be one this machine holds, or listen fails
with EADDRNOTAVAIL; and the container path names a hostname now that
0.0.0.0 is refused.

Note: CAREER_OPS_WEB_ALLOWED_HOSTS=0.0.0.0 now fails at startup rather
than starting a server. That config admitted only spoofed clients, so
refusing it is the correct outcome, but it is a breaking change for
anyone who had set that value.

* fix(web): parse addresses with node:net instead of counting colons

isIPv6Literal counted hex groups and `::` runs, which accepted
incomplete addresses: `2001:db8:1` and `1:2:3` passed validation, were
treated as single-address binds, and were handed to `next -H`, where
listen() refused to start the server. `192.168.1` went the same way.

Leniency in this predicate cannot fail safe, because whatever it admits
as a literal becomes the bind target. node:net's isIP is the parser to
use, and it also covers the leading-zero case the previous IPv4 check
handled by hand.

The module keeps its no-process-state, no-I/O property, which is what
made it testable on values; it now imports node:net for one pure
predicate. Only server.mjs and the tests import this module — proxy.ts
imports origin-guard.mjs, which stays free of node imports for the edge
runtime — so the dependency costs nothing here. The header said "no node
imports"; that claim is updated rather than quietly falsified.

Net -13 lines: the stdlib parser replaces both hand-rolled ones.

* fix(web): reject multicast bind targets
…areer-ops-hq#4749)

* fix(merge-tracker): warn when a report link points at a missing file (career-ops-hq#4748)

merge-tracker copied the report cell into the tracker without checking the
linked file exists, so dead links only surfaced later in verify-pipeline.
Check at merge time (reusing resolveReportPath, which resolves against the
data root) and print a warning naming the TSV, row and link, plus a summary
count. The row is still merged unchanged; no-report cells (—, N/A, empty)
are not flagged. Works under --dry-run.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* fix(merge-tracker): check report links from the same bases as verify-pipeline (career-ops-hq#4748)

CodeRabbit finding on PR career-ops-hq#4749: resolveReportPath() strips leading ../ before
resolving, so a link such as ../../stray.md could pass the merge-time check
when a file exists at the data root, while verify-pipeline (which resolves
from the tracker directory and the data root) still flags it. The warning now
uses the same two bases, so a link warned about at merge is exactly one
verify-pipeline would report later. Also covers an empty report cell in the
no-report sentinel test and adds a regression test for the ../../ case.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* fix(merge-tracker,verify-pipeline): a directory is not a report file (career-ops-hq#4748)

CodeRabbit finding on PR career-ops-hq#4749: existsSync accepts a directory, so a report
link such as `reports/` produced no warning even though it does not resolve
to a report. Both the merge-time warning and verify-pipeline's Check 3 now
require a regular file, so the two stay in agreement. Adds a test that a
directory link warns at merge time and is flagged by verify-pipeline.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
…areer-ops-hq#4651)

* feat(registry): check pins against published releases on a schedule

plugins-registry/<id>.json pins the exact commit plugin-install.mjs
clones, so the pin is the code users actually get. Nothing re-read that
pin after the day it was written.

plugin-registry-validate.yml runs on pull_request with
paths: plugins-registry/**, and the event that makes a pin stale is a
plugin cutting a release in its own repo, which changes nothing in this
repo. No PR fires, so a pin is only ever validated at the one moment it
is guaranteed to be correct. Wiring this check into that workflow would
have produced a gate that never runs, so the new workflow is scheduled.

Two choices that decide whether the check is worth having:

- Compare against the release commit, not the default branch. HEAD
  normally carries unreleased commits (dependency bumps, CI edits) that
  nobody has installed, so comparing to HEAD reports permanent false
  drift. Resolving via /commits/<tag> also yields the commit an
  annotated tag wraps rather than the tag object's own SHA.
- Four statuses rather than two. "No release published" is a legitimate
  resting state that two entries occupy today, and a failed lookup
  reports error instead of collapsing into fresh. An unreachable repo
  and a current pin produce the same empty diff, and calling that green
  is what let this go unnoticed.

Against the shipped registry the check finds docx and markdown three
patch releases behind their own latest release, and the other seven
correct. The default (offline) validator path is unchanged and still
makes no network call.

Scheduled and manual triggers only, no pull_request. The job reports the
state of the registry, not the correctness of the code, and the registry
is allowed to be dirty: a pin goes stale when a third party publishes a
release, through no fault of the PR under review. Gating merges on it
would block unrelated PRs, and would deadlock the pin bumps themselves,
since the registry CI permits only one changed plugins-registry/*.json
per PR. The code's own correctness is covered by the unit suite, which
test-all discovers and runs on every PR.

Refs career-ops-hq#4573

* fix(registry): a 404 on the releases endpoint is not proof of no release

GitHub answers 404 on /releases/latest both for a repo that has published
nothing and for a repo that is deleted or private. Measured:

  has releases      releases/latest=200  repos/{slug}=200
  no releases       releases/latest=404  repos/{slug}=200
  does not exist    releases/latest=404  repos/{slug}=404

fetchLatestRelease took the first reading at face value and returned null
for both, so the caller reported a vanished plugin repo as `no-release` —
the benign resting state that does not fail the run. That is the exact
collapse this module's own comments forbid, and it is the scenario in
career-ops-hq#4186, which is the case the check most needs to catch.

Disambiguate with the repo endpoint, on the 404 path only. Only a
SUCCESSFUL probe proves the absence is real; a 403, a 5xx or anything else
means "could not tell", which is an error rather than an absence. A repo
that has a release never pays the extra request, and a test pins that.

Two narrower corrections found while writing the tests:

- Only a genuine 404 now takes the ambiguity branch. A 200 whose body
  carries no tag_name is malformed rather than absent, and previously ran
  a reachability probe it would always pass, so the anomaly disappeared
  into `no-release`.
- The error text claimed to cover renamed repos. It does not: a rename
  answers with a 301 that fetch follows, so it never reaches this branch.
  One registry entry reaches its plugin that way today. Reworded, and the
  comment now records what the branch really covers.

Tests: the suite previously injected fetchRelease everywhere, so the real
resolver and the default binding were executed by nothing. Adds isolated
coverage through an injected transport (lightweight tag, annotated tag
resolving to the wrapped commit, failed commit resolution, rate-limited
probe, malformed 200, happy-path request count) plus one end-to-end test
of checkRegistryFreshness with no injection at all. Verified by mutation,
not just by passing: reverting the disambiguation fails 3 of them, and
rewiring the default binding to a stub fails the end-to-end one, which
previously stayed green while every entry reported healthy.

16 tests, 10590 passing overall. Live behaviour unchanged for the real
registry: the two genuinely release-less entries still report no-release.

Refs career-ops-hq#4573
* feat(providers): add startup.jobs RSS provider

Adds a new zero-token job_boards provider for startup.jobs' public,
no-auth RSS feed (startup.jobs/feeds/jobs) — a global startup/scale-up
job aggregator not previously covered by any existing provider.

The site's JSON API (api.startup.jobs) requires a bearer-token account
and is deliberately not used; only the public RSS feed is read, per the
public-no-auth-sources-only requirement.

Notes from live verification against the real feed:
- Fixed ~50-item snapshot per request, no cursor/pagination.
- `role` (slug) and `workplace` (free-text) query params both confirmed
  to change the result set.
- The JSON API's documented `country` filter is a confirmed no-op on
  this RSS endpoint (byte-identical output with/without it, including
  for a nonsense code) — deliberately not exposed in config; NL/location
  eligibility is left entirely to the scanner's own location_filter.
- No structured company/location fields in the feed, so both are parsed
  heuristically: company from the title's trailing "... at {Company}",
  location from the description's last non-empty line with a trailing
  " · {comp range}" stripped.

Includes full provider-contract tests (17 cases) and the required
SUPPORTED_JOB_BOARDS.md + portals.example.yml doc updates.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(providers): carry the feed description on each startup.jobs job

parseStartupJobsFeed() read each item's <description> only to pull the
location from its last line, then dropped it, so scan.mjs's
content_filter and visa_filter (which read job.description) had nothing
to match on for this provider. The text ships in the same RSS payload,
so it is free under the zero-token rule: carry it as job.description,
flattened by the shared htmlToText() helper (markup stripped, whitespace
collapsed, capped at DESCRIPTION_CAP) the same way pythonorg.mjs does,
and only when non-empty. location still comes from the raw lines.

Tests: the sample item's text arrives as description, markup is
stripped, and items with no or an empty <description> are kept without
the field.

Maintainer follow-up for career-ops-hq#4704 (lote 2026-10-02-3).

* fix(providers): address CodeRabbit review on startup-jobs

- Skip a row when its title has no usable " at " segment instead of
  attributing it to the board name — an unidentifiable employer should
  not read as a real, employer-attributed posting.
- Validate the <rss><channel> envelope before parsing; a malformed or
  non-string response now throws descriptively instead of silently
  returning [], so an upstream break surfaces instead of reading as
  "0 jobs" forever. A genuinely empty <channel> (zero items) still
  returns [] — that's a valid empty board, not a malformed envelope.
- Update docs/SUPPORTED_JOB_BOARDS.md to describe both behaviors.
- Test suite: 22/22 passing (was 17), full suite run before/after this
  diff shows identical 14 pre-existing failures (environment/baseline
  issues unrelated to this file — no npm install in this sandbox, plus
  a few flaky/platform suites), confirming no regressions.

Leaves the feed's ~50-item-snapshot completeness concern as a
documented, known tradeoff (same single-fetch shape as larajobs.mjs)
rather than removing the provider — there is no pagination on this
public endpoint to add (confirmed live during development).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(providers): reject a startup-jobs feed truncated mid-item

The outer <rss><channel> envelope check from the previous commit
doesn't catch a body truncated after an opening <item> but before its
closing tag (with a stray </channel></rss> tail from buffering/retry
behavior) — the non-greedy item regex simply wouldn't match the
dangling <item>, so that case read as a genuinely empty board instead
of the broken fetch it actually is.

Count <item>/</item> tags within the channel body before parsing: a
mismatch is unambiguous truncation evidence and now throws
descriptively, per CodeRabbit's second review pass on commit 3d2ce19.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(providers): exclude CDATA text from startup-jobs truncation count

The <item>/</item> tag-count check from the previous commit searched
the whole channel body as raw text, so a job description's CDATA
section literally mentioning "<item>" or "</item>" (e.g. a posting
about XML/RSS tooling) would false-positive and throw on an otherwise
valid feed. Strip CDATA content before counting — structural tags
never live inside it, only a description's raw passthrough text does.

Adds a CDATA fixture covering an item-like string in a description,
per CodeRabbit's third review pass on commit 9218bce.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(providers): mask CDATA for startup-jobs item-boundary matching

A real bug, not just a false-positive: the item-boundary regex matched
against the raw XML, so a CDATA description literally containing
"</item>" (e.g. a posting mentioning XML/RSS tooling) would stop that
item's block early — title/company (parsed before the truncation
point) still came through fine, silently masking the loss of that
item's own description and location.

Mask every CDATA section with same-length filler before locating item
boundaries (reusing the helper for the open/close tag-count check
too), then slice each matched block from the REAL xml at the same
position — positions stay aligned since the filler preserves length,
but it never matches a tag itself.

Test now asserts description and location survive intact on the
CDATA-with-embedded-tag-text fixture, not just company (the earlier
assertion that masked this bug), per CodeRabbit's fourth review pass
on commit 77e98ea.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(providers): also mask XML comments in startup-jobs feed parsing

maskCdata only masked CDATA sections — a feed-generator XML comment
literally containing tag-like text (e.g. <!-- example <item> -->)
would still false-positive the open/close tag-count check on an
otherwise genuinely empty, valid channel. Renamed to
maskCdataAndComments and extended the mask regex to also cover
<!-- ... --> blocks with the same same-length-filler approach.

Per CodeRabbit's fifth review pass on commit c86e833, which supplied
a verified one-line diff; applied as suggested plus a regression test.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Santiago <santiago@me.com>
* feat(modes): add Singapore market mode (modes/sg)

English-language market mode for Singapore-based roles: EP/S Pass/ONE
Pass/Tech.Pass sponsorship vocabulary, MOM/COMPASS/FCF norms, CPF exclusion
for pass holders, SGD monthly-base comp handling, MyCareersFuture legitimacy
cross-checks. offer/apply/pipeline are delta layers over the canonical
English modes (Singapore JDs are in English), adding only SG overrides.

* fix(modes/sg): address maintainer review on career-ops-hq#4687

- apply.md: read sg.work_status from config/profile.yml instead of assuming
  foreign; default to foreign only when the profile is silent
- apply.md: adopt modes/apply.md output format (Responses, Based on,
  per-answer Length) with Singapore header line; add Application Answers
  persistence section
- offer.md: convert AWS/bonus stated in months to SGD (monthly base x months)
  before annualizing, in formula and report header
- offer.md: CPF guidance follows profile work status (pass holder excluded,
  citizen/PR included)
- update-system.mjs: register modes/sg/ in SYSTEM_PATHS

* fix(modes/sg): sponsorship answer follows the profile work status

The sponsorship field answered Yes for everyone, including citizens and PRs.
It now reads sg.work_status like the work-authorization field above it.
Post-apply defers to modes/apply.md Step 9 instead of restating it, and
profile.example.yml documents the optional sg.work_status key.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* docs(agents): list the Singapore market mode in the market table

tests/market-modes-documented.test.mjs requires every shipped market mode
set (a modes/ subdirectory with _shared.md) to appear in the AGENTS.md
market table, which is what lets the agent route to it. modes/sg/ was
missing, so the suite failed on this branch.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Santiago <santiago@me.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…-hq#4757)

career-ops-hq#4363 added contact-extract-tests.mjs at the repo root, registered by hand in
test-all.mjs's `scripts` list, after the convention that new suites go in
tests/ was written down (career-ops-hq#3306, and the header of test-all.mjs itself). It is
the same shape as the suites career-ops-hq#3765 moves — a private pass/fail harness and a
trailing process.exit — and nothing pins it to the root: no flag-driven
invocation, no assertion on its own filename, no per-script timeout.

Moved to tests/contact-extract.test.mjs and put on the shared helpers
contract: private counters and check() now delegate to pass/fail from
tests/helpers.mjs, ROOT comes from helpers rather than import.meta.url, and the
trailing summary and process.exit go — test-all.mjs refuses to import a
discovered suite that calls either. `fileURLToPath` is dropped (it fed only the
ROOT line); `dirname` stays, since it is also used to locate the data root.

Checked before moving it in-process: discovery imports a suite inside
test-all's own process, and this suite imports contact-extract.mjs. That
module's only process.exit sits inside its isMainModule guard, and neither it
nor contacts.mjs reads process.env at module scope, so importing it cannot
exit the run or leak state into later suites (the career-ops-hq#3411 shape).

Same assertions before and after, measured rather than assumed:

  main's root suite, standalone          84 passed, 0 failed
  tests/contact-extract.test.mjs (--only) 84 passed, 0 failed

Upgrade path: the old path STAYS in SYSTEM_PATHS, annotated exactly like the
existing lib/context-budget.test.mjs retirement, and is listed in
ALLOWED_MISSING_ENTRIES beside it. SYSTEM_PATHS is what apply() prunes
against — drop the entry in the same change that moves the file and an
upgrading install keeps the old copy forever, where
root-tests-registration.test.mjs then reports it as an unregistered suite.
Probed with the production function:

  staleSystemFiles(local has it, remote does not) -> ["contact-extract-tests.mjs"]

No doc change needed: ARCHITECTURE.md says eight *-tests.mjs suites remain at
the root, seven registered by name. That went stale when career-ops-hq#4363 made it nine,
and this move makes it true again.

Verified: root-tests-registration 5/0 (8 root suites, all reachable),
no-root-suites 2/0, updater-system-paths-shadowing 3/0, SYSTEM_PATHS coverage
1813/1813. updater-migration-tests reports 5 failures locally, all the
updater's own refusal to apply while this machine's main checkout has
uncommitted edits — none unexplained, and none touched by this change.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…eedback (career-ops-hq#3472)

* feat(pdf): add CV structure check; land as a warning per maintainer feedback

Closes career-ops-hq#3413. verify-cv-structure.mjs checks a tailored CV's JSON payload
against cv.md's own structure (entry order, company descriptors) before
HTML/PDF rendering — a class of silent loss/reordering that
verify-cv-facts.mjs's fabrication check can't see, since the surviving
content is still 100% true. Wired into modes/pdf.md step 17b and
registered in update-system.mjs's SYSTEM_PATHS so it ships on update.

Per maintainer review on the issue: lands as a non-blocking warning, not a
hard gate — the one cv.md header convention it understands
(`### Company — Location[ · descriptor]`) is a personal habit, not a
documented system-wide spec, so blocking the pipeline over it would punish
anyone whose cv.md looks different. A cv.md that doesn't use this
convention reports UNVERIFIED (not a false pass) rather than either
silently no-opping or fabricating a violation against content it never
parsed. Promoting this to a hard gate is future work, contingent on first
documenting the descriptor convention as part of the actual cv.md
contract. Violation messages name both the descriptor expected and the
entry checked, per review request.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DJ7nxnT2mPwDELdzta7Dah

* fix(verify-cv-structure): address CodeRabbit findings

Four review findings, all self-contained within this new file:

- Scope header parsing to the ## Experience section (up to the next
  level-2 heading). Previously any level-3 "### X — Y" header anywhere in
  cv.md (e.g. a degree entry under ## Education) parsed as a phantom
  experience entry, capable of triggering a false order/descriptor warning.
- Prefer an exact (normalized) company match before falling back to the
  existing fuzzy/substring match. Without this, a payload's "Acme Corp"
  could resolve to cv.md's unrelated "Acme" entry via prefix matching,
  letting a genuinely swapped "Acme"/"Acme Corp" pair pass the order check.
- Reject a non-object (e.g. null) JSON payload with a clear error instead
  of letting `payload.experience` throw an uncaught TypeError.
- Wrap the --source read in try/catch so a directory (or otherwise
  unreadable path) reports a controlled error instead of an uncaught
  EISDIR stack trace.

Left one CodeRabbit finding unaddressed: unslugified {company} in
modes/pdf.md's payload filename pattern (`/tmp/cv-{candidate}-{company}.json`).
That pattern is pre-existing (step 17, which this PR's step 17b only
repeats) and used unslugified across 6+ steps in the file (steps 17, 18,
21, the ATS check, the preview command, Canva export) that this PR didn't
touch — fixing it well means a system-wide filename-convention decision,
out of scope for a PR specifically about landing the structure-check
warning shape per maintainer request.

Self-test: 9 -> 15 assertions (new coverage for the Education-section
decoy, the Acme/Acme Corp prefix collision, and both new CLI guards
exercised in-process against real temp files).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DJ7nxnT2mPwDELdzta7Dah

* fix(verify-cv-structure): reject a non-object entry in payload.experience

A null (or array/primitive) entry in payload.experience reached
checkExperienceOrder's e.company access uncaught, throwing a TypeError
instead of a controlled CLI error. Validate each entry alongside the
existing payload-root check, and add a CLI self-test for a null entry.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DJ7nxnT2mPwDELdzta7Dah

* fix(verify-cv-structure): reject a present non-array payload.experience

verifyStructure() silently coerced a present-but-non-array experience
value (null, an object, a string) to [], which checkExperienceOrder and
checkLocationDescriptors then read as "nothing to compare" — a false
'pass' against a recognized cv.md, without ever validating the payload.

Fixed in two places: runCli() now rejects it before ever calling
verifyStructure() (clear CLI error, exit 1), and verifyStructure() itself
now returns 'unverified' rather than a false 'pass' for the same input,
so any other caller of the exported function gets the same protection.
Added CLI and library-level self-test coverage (16 -> 18 assertions).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DJ7nxnT2mPwDELdzta7Dah

* fix(verify-cv-structure): harden exported verifyStructure against malformed input

verifyStructure() is an exported function reachable by any caller, not
only runCli()'s pre-validated CLI path — but it had no guard of its own:
verifyStructure(null, cvMd) and a null entry inside payload.experience
both threw uncaught TypeErrors when reached directly.

Added the same validation runCli() already does (non-object payload
root, non-array experience, non-object experience entries) inside
verifyStructure() itself, so it degrades to 'unverified' rather than
throwing regardless of how it's called. Added library-level self-test
coverage for both cases (18 -> 20 assertions).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DJ7nxnT2mPwDELdzta7Dah

* fix(verify-cv-structure): correct the exit-code contract in usage text and pdf.md

CodeRabbit review on career-ops-hq#3472: the usage text and modes/pdf.md both claimed
runCli() "always exits 0," which is false and could lead automation to
treat a genuine CLI/input failure as a non-blocking structural warning.
runCli() returns 1 for CLI usage errors (--source missing its argument, an
extra positional argument, no target argument), a missing/unreadable
payload or source file, invalid JSON, and a malformed payload shape (a
non-object payload, or a non-array/non-object payload.experience) — only
the actual structural verdicts (pass, warn, unverified) exit 0.

Corrects both the CLI usage text (verify-cv-structure.mjs:248-250) and the
pdf.md pipeline step (modes/pdf.md:45) to state the real contract: a
structural result always exits 0 and never blocks, but a CLI/input error
exits 1 and means the check itself failed to run.

Verified: self-test still 20/20, --help output matches the corrected text,
full suite green (551/552, 1 pre-existing unrelated skip).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rny91cphDESS1f363H6xc9

* fix(verify-cv-structure): address maintainer review

* fix: resolve CV source from data root

* fix(verify-cv-structure): confine source paths

* feat(verify-cv-structure): warn on omitted employers

* fix(cv): recognize sick leave headings

* Revert "fix(cv): recognize sick leave headings"

This reverts commit 693c511.

* Revert "feat(verify-cv-structure): warn on omitted employers"

This reverts commit a601ec3.

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Mikel Cármenes Cavia <905453+euskode@users.noreply.github.com>
Co-authored-by: Santiago <santiago@me.com>
… sample (career-ops-hq#4756)

The first test of batch-runner-backoff drew 180 samples and started about
three processes for each: the $(...) the test captured the result with, the
$(...) inside rate_limit_delay, and the awk that one runs. On Windows a
process start is the expensive operation, and the loop alone outlasted the
fixture timeout that career-ops-hq#4733 raised from 10 to 30 seconds.

- The test calls rate_limit_delay in place and lets it print. Same 180
  lines, same assertions.
- rate_limit_delay skips awk when the log exists and is empty: there is no
  header to find and awk prints nothing for it. A missing log still reaches
  awk. The runner calls this only after is_rate_limit_log matched, so a real
  batch never passes an empty log.

Old and new function agree byte for byte over 150 seeded cases.
…ops-hq#4754)

* docs: update CONTRIBUTORS.md

* docs: update .all-contributorsrc

---------

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
career-ops-hq#4755)

* fix(skill-extract): read the other spellings of AI/ML skills a CV uses

The Data / ML / AI tokens each matched one spelling. A CV that writes
"HuggingFace", "sklearn", "fine tuning", "large language models",
"retrieval-augmented generation" or "natural language processing"
extracted none of those skills, so jd-skill-gap and upskill reported
each one as a gap against a JD that spelled it the canonical way. The
shipped example CV examples/dual-track-engineer-instructor/cv.md lists
"HuggingFace Transformers" under Skills and got Hugging Face back as a
gap.

Each spelling is now a token aliased in CANONICAL to the display name
its sibling already resolves to, the way the certification and
marketing blocks list both spellings of one skill. The spelled-out NLP
and LLM forms are the pairs keyword-match.mjs's SYNONYMS already treats
as one skill. Verb forms stay out: "fine-tune the funnel" is ordinary
prose in GTM postings.

tests/skill-extract.test.mjs checks that each spelling extracts and
canonicalizes, that none fires on a verb form, on "small language
models" or on a bare "natural language", and that jd-skill-gap reads
the example CV's Skills line as knowing Hugging Face.

* fix(skill-extract): leave the spaced "fine tuning" out of the vocabulary

From review: "fine tuning" is ordinary prose in GTM and ops writing, so
the alias read "Fine tuning the funnel" in a CV as knowing Fine-tuning,
and jd-skill-gap reported a JD's Fine-tuning requirement as supported
by the resume. On main that prose correctly stays a gap.

Unlike the other spellings this PR adds, it cannot be recognized
without context, so it joins the verb forms in the left-out note, and
the funnel sentence becomes a negative in tests/skill-extract.test.mjs.
The hyphenated "fine-tuning" already matches the same prose on main;
gating either form on model context is a separate change.
…ng view (career-ops-hq#2739)

* fix(agent-inbox): number resolve against file position, not the pending view

`resolve N` indexed into the *pending* subset, so every resolve shifted all
higher numbers down by one. Reading `list` once and firing a batch of resolves
off that snapshot stamped results onto the wrong items — silently, with no
error.

Number against the full item list instead. `add` only ever appends, so a file
position never changes meaning once printed; `list` now shows gaps as items
resolve and prints a footer so the gaps don't read as a display bug.

Two guards on the write path: re-resolving a done item aborts instead of
overwriting its result (that is where a stale number most often lands), and
--expect "<substring>" aborts unless the target contains that text. A valueless
--expect fails rather than silently disabling the guard.

Tests 8-10: batch-of-resolves regression against one snapshot, re-resolve
refusal, and --expect mismatch/valueless/case-insensitive-match.

Rebased onto main after career-ops-hq#2614 merged; the atomic-append fix is preserved and
this change touches only parseItems/list/resolve.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FxADPBJCghNfm5jPZ2Phhh

* fix(agent-inbox): replace the queue file atomically on resolve

resolve rewrote data/agent-inbox.md in place with writeFileSync, which
truncates before it writes. The lock from career-ops-hq#3479 serialises writers, but
`list` and anyone reading the file by hand do not take it, so a reader in
that window — or a crash inside it — saw a queue with items missing.

Write through tracker-utils' writeFileAtomic (same-directory temp file,
rename with the Windows contention retry) inside the existing lock. No
second mechanism, and the critical section is unchanged.

Test 4c asserts the file is replaced rather than rewritten in place and
that no temp file is left behind; it fails against the in-place write.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LpaAhR5zSCuVCvsta6dUfM

* fix(agent-inbox): reject every --expect given without a value

opt('expect') reads only the first occurrence, so
`resolve 2 --expect "valid" --expect --result "..."` resolved: the bare
--expect behind a valid one was never looked at, and the guard the caller
meant to set was silently skipped.

Collect every occurrence (optAll) and fail if any is valueless. Each one
given must also match, so a second --expect is a second guard rather than
an ignored argument. A single --expect behaves as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LpaAhR5zSCuVCvsta6dUfM

* fix(agent-inbox): keep symlinks and permissions across the atomic resolve

Replacing the queue file by rename dropped two things the in-place write
gave for free:

- A symlinked inbox was swapped for a regular file, leaving the link's
  target stale. Resolve the real path first and replace that.
- The replacement took the process umask, so a 0600 inbox came back 0644.
  writeFileAtomic gains an optional `mode`, applied to the temporary file
  before the rename so the destination is never observable with wider
  permissions. Callers that pass nothing behave exactly as before.

Test 4c covers both on POSIX; each fails against the previous commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LpaAhR5zSCuVCvsta6dUfM

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…three lints) (career-ops-hq#3422)

* feat(cv): ats-payload.mjs — one safe fold, three lints (career-ops-hq#3251)

An ATS variant is not a template swap (career-ops-hq#3202): a template owns the DOM, not
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.

THE TRANSFORM (applied). 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 extracting the text layer runs adjacent
competencies into a single token. 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`. Same facts, two expressions, and
only one survives — so the fold moves them into the array that survives. Pure
data movement: nothing invented, reversible, and idempotent (a second run is a
no-op; re-added competencies merge into the existing category rather than
duplicating it — the edge asked for in review).

THE THREE LINTS (reported, never applied). employer-in-role,
parenthetical-in-company and multiple-date-ranges each need a decision only
the author can make, which is where AGENTS.md's no-fabrication rule draws the
line. A tool that reports four findings and applies one is trustworthy; one
that applied four would guess three times per run.

Standalone rather than a flag on build-cv-html.mjs: the transformed payload is
useful to anything consuming a payload, and it keeps ATS-specific knowledge out
of the generic builder. Zero-LLM, deterministic, offline, read-only with
respect to user-layer files — same family as jd-skill-gap.mjs and
verify-cv-facts.mjs. stdout carries the payload and only the payload, so
`> cv-ats.json` is safe and the script composes into a pipe; every human-facing
word goes to stderr (JSON by default, formatted with --summary). Findings are
advisory, so a run with findings still exits 0; exit 1 is for an unreadable or
malformed input.

Tests: 31 self-test assertions plus tests/ats-payload.test.mjs (15 CLI cases),
including the stdout/stderr split, two-pass byte-identity, and an end-to-end
render through build-cv-html.mjs proving no competency-tag spans survive and
the facts arrive comma-delimited under Skills.

Closes career-ops-hq#3251

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(cv): refuse a payload shape that would make the run quietly wrong (career-ops-hq#3422)

CodeRabbit caught a real one on ats-payload.mjs. `foldCompetencies()` started
from `Array.isArray(source.skills) ? source.skills : []`, so a payload carrying
`"skills": "Python"` had that value DISCARDED and was handed back a
well-formed-looking artifact with the skills gone. A transform whose entire
claim is "pure data movement, nothing invented, fully reversible" must not be
the thing that loses data — and must especially not lose it into output that
looks correct.

The same defect class sits one field over: a non-array `experience` makes
lintPayload() iterate `[]` and report nothing, which prints as "✅ No lint
findings" and reads exactly like a clean payload. That is the shape
jd-skill-gap.mjs added diagnoseExtraction() for — an empty result is
indistinguishable from a clean bill of health, and the caller acts on the wrong
one. Fixed here too rather than left for the next report.

validatePayloadShape() checks the three fields this script reads and runs at the
CLI boundary, before anything is transformed or reported, so nothing reaches
stdout and a shell redirect cannot capture a half-right artifact. Refusing
rather than coercing is the point: there is no honest place to put a string
`skills` value inside `skills[]`, and inventing the structure to hold it is
exactly the authoring this script refuses to do everywhere else. A
`competencies` string is still accepted, mirroring the string-or-array `items`
the builder's joinItems() already takes; absent and null still mean "not
supplied".

Covers the input named in review, {"competencies":["AWS"],"skills":"Python"},
in both the self-test and the CLI suite, plus the preserved-valid-array and
absent-field cases so the guard cannot be over-tightened later.

Self-test 31 → 40 assertions; CLI suite 15 → 19 tests. Full suite: 6560 passed,
0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(cv): reject competency members that would be dropped, not coerced (career-ops-hq#3422)

Second CodeRabbit pass, and the finding is real: the previous commit validated
the array CONTAINER but not its members, so `["AWS", {"name":"Kubernetes"}]`
passed every check while toItemList() dropped the object on the way through.
The fold emitted `items: "AWS"` and the second competency was gone from a
payload that looked fine — the same silent-loss shape as the string `skills`
case, one layer down.

Narrower than the fix proposed in review, on purpose. The suggested check was
`typeof item !== 'string'`, which would also reject a numeric member — and a
numeric member is not lost, it is coerced by String() with its value intact.
That is a deliberate, tested position here rather than an accident:
build-cv-html.mjs's escapeHtml() used to `return ''` for anything non-string
and silently dropped numeric years and dates from shipped CVs, which is what
tests/cv-numeric-scalars.test.mjs pins and why buildCompetencies() renders
`escapeHtml(String(tag))`. Rejecting numbers would make this script stricter
than the builder it feeds and refuse payloads that render correctly today. So
the line is drawn at what is LOST — objects, arrays, null, undefined — which is
exactly the defect reported.

Also fixes the same loss in the field the fold WRITES to: when a competencies
category already exists, the merge re-joins its items through toItemList(), and
a non-scalar member there disappears identically. Guarded on a fold actually
happening and on the one category it would touch, so a payload this script
would otherwise pass through untouched is never rejected for a field it was
never going to rewrite.

Self-test 40 → 51 assertions; CLI suite 19 → 23 tests, covering the reported
mixed-type array, the numeric member that must still be accepted, the
merged-into category, and the untouched category that must not be policed.
Full suite: 6560 passed, 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(cv): refuse a merge target whose items is neither string nor array (career-ops-hq#3422)

Third CodeRabbit pass, valid again and the last corner of the same defect. The
merge-target check only looked at the ARRAY form, so a matching category with
`items: 2024` (or `true`, or an object) skipped validation entirely:
toItemList() returns [] for it, the merge rejoins from nothing, and the supplied
value is REPLACED by the folded competencies. Silently overwritten, in the one
field this transform writes.

The asymmetry with the competency members validated in 84f74e5 is deliberate
and worth stating, since the two rules now disagree about a number. A competency
MEMBER is coerced by String() on both sides — here in toItemList() and in the
builder's `escapeHtml(String(tag))` — so it carries its value and is accepted.
An `items` CONTAINER has no such path: build-cv-html.mjs's joinItems() returns
`''` for anything that is neither string nor array, so a scalar there renders as
nothing in the builder and is discarded by the merge here. With no way to carry
the value and no honest way to invent one, refusing is the only non-destructive
answer.

Unchanged, and tested so it stays that way: a string or array `items` still
merges rather than being replaced, an absent `items` is still fine, and a
category the fold never touches is still never policed — its scalar `items`
passes through byte-identical.

CLI coverage asserts stdout stays empty for all three refused shapes, as asked.
Self-test 51 → 61 assertions; CLI suite 23 → 26 tests. Full suite: 6560 passed,
0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…ill (career-ops-hq#4761)

* fix(skill-extract): count "fine-tuning" only where it names the ML skill

"fine-tuning" is the ML skill and an everyday verb. Matched anywhere, as
'Fine-?tuning' was in SKILL_TOKENS, it let a backend CV that is
"fine-tuning PostgreSQL queries" hold the skill an ML posting asks for,
so upskill filed that requirement as known, and in jd-skill-gap a
marketing CV "fine-tuning the funnel" read as support for it. Follow-up
to the review of career-ops-hq#4755, which left the spaced spelling out for this
reason.

The noun now goes the way of Go and SAFe: out of the case-insensitive
list and into a pass of its own. A mention counts as an entry of its own
in a list or table row, which keeps Skills sections, report Gap tables
and the bare name jd-skill-gap looks up, or in model context: "model"
right beside it ("model fine-tuning", "fine-tuning models"), or a model
skill on the same line (LLMs, NLP, PyTorch, Hugging Face and the rest of
the AI block). The block's data tools are not model skills:
"fine-tuning Spark jobs" is performance work. Behind that check the
spaced "fine tuning" is as safe as the other two spellings, so it is
matched too.

jd-skill-gap needed one more change for the fix to reach it. A skill
that skill-extract knows fell through to the word-boundary search
whenever the canonical sets missed it, and that search matched the JD's
spelling in the very prose the extractor declines to count. The comment
above it already says the canonical lookup is authoritative for known
skills; the fallback now runs for unknown tokens only, which also closes
the same hole for Go ("go the extra mile") and SAFe ("a safe pair of
hands").

* fix(skill-extract): a label before a colon is not a fine-tuning entry

From review: splitting on ':' turned "Fine-tuning: dbt models to cut
warehouse spend" into a standalone "Fine-tuning" entry, so a label over
analytics prose read as the ML skill.

The separator is now captured, so split() keeps each one after its
piece, and a piece closed by a colon is skipped as a label. A colon
still opens the entries after it: "Techniques: Fine-tuning,
distillation" lists the skill. Both lines join the tables in
tests/skill-extract.test.mjs.

* fix(jd-skill-gap): a token is known only when it extracts as itself

From review: "known" counted any token in which skill-extract found a
skill, and "React.js" extracts as React. The canonical sets never hold
"React.js", and with the word-boundary fallback now reserved for
unknown tokens, a CV listing "React.js" left a JD's "React.js" as a gap
where main matched it.

Known now means extractSkills(skill) holds the token's own canonical
form, so React.js is a free token again and takes the word-boundary
path, while Fine-tuning, Go and SAFe stay on the canonical sets. The
self-test runs a JD bullet through the tokenizer and checks the CV's
Skills entry satisfies it.
…Opus 5.5 (career-ops-hq#4703)

spend_tier_to_model() still mapped standard to claude-sonnet-5 and premium
to claude-opus-5, one release behind the current Sonnet and Opus. Point
them at claude-sonnet-5-5 and claude-opus-5-5, update the tier table in
modes/_shared.md to match, and tighten the routing tests: the old
assertions used includes('claude-sonnet-5'), which also matched
claude-sonnet-5-5 and so could not catch a regression in either direction.

Economy stays on claude-haiku-4-5, still the newest Haiku.


Claude-Session: https://claude.ai/code/session_018eKNeHZQazjgSmKkS9rX27

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…areer-ops-hq#4771)

Hard expired patterns name a closure without telling whether it has
happened, and they are checked before the apply control. A live posting
that says how it will end matched them: "applications may be accepted
until the position has been filled", "you will receive final
notification via email when this vacancy has been filled", "once
applications have closed", "will be closed on 15 December". scan
--verify then recorded the live posting as skipped_expired, and every
later scan skipped it.

An occurrence now counts only when no time or condition word (until,
once, when, if, ...) opens its clause: none within ten words of the end
of the match, with no clause punctuation in between. Every occurrence is
checked, so a real banner on the same page still closes the posting. The
two dated "closed on" patterns skip "be closed on", the future and modal
forms.
…l/404/paywall) (career-ops-hq#4528)

* fix(archive-posting): refuse to save a non-content capture (login wall/404/paywall)

check-jd-archive.mjs already has detectNonContentMarker() — logic
that recognizes a saved page is actually a login wall, a 404 shell,
a paywall interstitial, or a "please enable JavaScript" page rather
than a real posting — but that only ever ran at AUDIT time, well
after a bad capture was already sitting in jds/. archive-posting.mjs
had no equivalent check: a live fetch landing on a login/CAPTCHA/
paywall page was saved and reported as success like any other
capture.

Reuses the same detectNonContentMarker() against the rendered page's
own extracted body text, checked right after the page's title/h1 are
read and before anything is written to jds/. A match throws (refusing
to archive), same as the existing restricted-destination guards in
this function, rather than silently saving garbage.

Closes career-ops-hq#4526.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(archive-posting): narrow non-content check, add bot-challenge marker

Addresses CodeRabbit findings on PR career-ops-hq#4528:

1. detectNonContentMarker() was applied to the whole rendered page's
   body text, which could false-positive on a real, long posting that
   incidentally carries a small sign-in/paywall nudge somewhere on the
   page (a sidebar "sign in to save this job" prompt next to a full
   JD, say). Gated the check on the extracted text being short overall
   (<= 600 chars) — a genuine login-wall/404/paywall/challenge page IS
   essentially just that message; a real posting merely mentioning one
   nearby stays in the thousands of characters and is unaffected.

2. Added a 'bot-challenge' marker category to check-jd-archive.mjs's
   shared NON_CONTENT_MARKERS (Cloudflare-style "checking your browser"
   / "verify you are human" / challenge Ray ID phrasing), matched on
   established challenge phrases rather than the bare word "captcha" —
   a real posting can legitimately mention CAPTCHA as a technology
   (a security-engineering role's requirements, say), and that word
   alone isn't distinctive enough to tell the two apart.

3. tests/archive-posting-non-content.test.mjs: the fake page's
   evaluate() can now simulate a genuine extraction failure (rejects)
   rather than only ever returning an empty string, so the
   extraction-failure test actually exercises archiveUrl()'s real
   `.catch(() => '')` fallback path. Added a mixed-content case (long
   real JD + incidental "sign in" line) proving it archives normally,
   and a bot-challenge case proving the new marker is refused.

check-jd-archive.mjs's own --self-test (66 cases) still passes
unchanged with the new marker added.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(check-jd-archive): recognize "Verify that you are human" bot-challenge phrasing

CodeRabbit flagged (PR career-ops-hq#4528) that the bot-challenge regex's
verify(?:ing)? you are (?:a )?human branch didn't allow an optional
"that" between "verify" and "you are human", missing the common
Cloudflare-style phrasing "Verify that you are human". Confirmed via
direct regex testing before the change (matched "Verify you are human"
but not "Verify that you are human" or "Verifying that you are human").

Added (?:that )? to the branch, narrow enough to still reject unrelated
JD prose like "Ability to verify data integrity" or "Please verify your
employment history". Added a new regression test case to
tests/archive-posting-non-content.test.mjs covering the exact fixture
CodeRabbit suggested.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(archive-posting): fail closed on extraction failure, detect CAPTCHA phrasing

Addresses two more CodeRabbit findings on PR career-ops-hq#4528:

1. archiveUrl()'s body-text extraction used `.catch(() => '')`, which
   coerced a genuine page.evaluate() failure (detached frame, context
   destroyed, etc.) into "empty page" — indistinguishable from a page
   that was actually inspected and found clean. A page whose content
   genuinely couldn't be checked would silently reach page.pdf()
   without ever being screened, which is exactly the capture-time gap
   this whole feature exists to close. Removed the catch so the error
   propagates and archiving is refused, not silently allowed through
   blind.

2. Added "captcha verification required to continue" as a specific
   bot-challenge phrase in check-jd-archive.mjs's shared
   NON_CONTENT_MARKERS — matched on the exact phrase, not the bare
   word "captcha", so a real posting mentioning CAPTCHA as a
   technology topic (a security-engineering role's requirements, say)
   is still archived normally. Added both a positive case and a
   false-positive guard proving that distinction holds.

check-jd-archive.mjs's own --self-test (66 cases) still passes
unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* test(archive-posting): shorten the CAPTCHA false-positive fixture

Addresses CodeRabbit finding on PR career-ops-hq#4528: the false-positive guard's
fixture was >600 chars, so it passed only because archiveUrl()'s
length gate (NON_CONTENT_PAGE_MAX_CHARS) skips the marker check
entirely for long pages — proving nothing about
detectNonContentMarker()'s own regex specificity. Shortened it to stay
under the gate, so the test now actually exercises the regex
distinguishing "CAPTCHA verification required to continue" from an
unrelated mention of CAPTCHA as a technology. The separate
mixed-content test already covers the length-gate behavior on its own.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* test(archive-posting): update extraction failure comment

* fix(archive-posting): reject empty pages and HTTP 404

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
mbrow190 and others added 17 commits October 12, 2026 08:55
…eer-ops-hq#4104)

* fix(web): keep tailored PDFs tied to their resolved report

* test(web): verify application CV lookup through the PDF manifest

* test(web): isolate PDF fixture from core-only dependencies

---------

Co-authored-by: Santiago <santiago@me.com>
…hq#4818)

* chore(skills): retire redundant .cursor skill entrypoint (career-ops-hq#4794)

Cursor discovers project skills from .agents/skills/ and .claude/skills/,
both of which this repo already ships, so the .cursor/skills/career-ops
symlink only pointed at the canonical skill.

- delete the .cursor/skills/career-ops/SKILL.md symlink
- drop Cursor from SKILL_ENTRYPOINTS, BOOTSTRAP_PATHS and SYSTEM_PATHS
- remove the DATA_CONTRACT row, labeler matcher and test expectations
- point README (all translations) and SUPPORTED_CLIS at .agents/skills/

Existing installs keep a harmless leftover symlink: the updater only
prunes files that match an active SYSTEM_PATHS entry, and no migration
entry is kept. Cursor remains a supported client.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* test(doctor): use the opencode entrypoint as the second stub

main's multi-stub case planted a stub at .cursor/skills, which this PR
retires from SKILL_ENTRYPOINTS, so doctor no longer scans it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: Santiago <santiago@me.com>
Co-authored-by: Santiago <hi@career-ops.org>
…one URL (career-ops-hq#4789)

* feat(providers): Personio language versions and language-aware URL dedup

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(providers): retry Personio's /xml and job-link fallback fetches

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(providers): cap Personio's language fan-out, warn on a changed page build

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(providers): fetch Personio's careers_url language ahead of the language cap

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(providers): move the Personio provider change to its own PR

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(scan): keep the language-version exception through --verify's role check

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(providers): state that each language version carries Job.language

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…and broke the screen silently on reload (career-ops-hq#4939)

* fix(dashboard): report a tracker read failure instead of returning nil

ParseApplications folded every read failure into a nil slice, so an
applications.md that exists and cannot be opened was indistinguishable
from one that is not there yet. Measured on main, with a directory where
the file belongs:

  broken tracker -> 0 applications (nil=true)
  absent  tracker -> 0 applications

Absent is a first run and empty is the truth. Unreadable is the user's
whole pipeline being inaccessible, and reporting it as "no applications"
loses the pipeline and makes ComputeProgressMetrics report the search as
failing — every rate falls to 0%, which progress.go renders through
rateColor, whose default branch is Red.

The error wraps the underlying one, so callers can branch on
errors.Is(err, fs.ErrNotExist) and keep the first-run wording for the
file that genuinely is not there. internal/data is an internal package
with three callers, all in main.go, so there is no external surface.

Same rule the rest of the project follows for user-layer reads: ENOENT
is the only failure that legitimately means empty.

Refs career-ops-hq#4937

* fix(dashboard): tell an absent tracker from an unreadable one

Three call sites, three different right answers.

STARTUP said "could not find applications.md in ..." for every failure,
which sent a user with a permission problem looking for a file sitting
in front of them. fs.ErrNotExist keeps that wording; anything else now
says what actually happened.

RELOAD said nothing at all. It rebuilt the pipeline from nil, so a
tracker that broke while the dashboard was open emptied the screen and
zeroed every rate with no error anywhere. It now retains every derived
value and surfaces the failure, which is exactly what the status-ledger
read on the next line already does — its comment states the principle
("retain historical metrics and surface the failed history read"), and
the tracker is the more consequential of the two files.

PipelineTrackerFailedMsg is the sibling of PipelineHistoryFailedMsg and
flashes in the same help bar.

The POST-WRITE re-read now does what its own comment already promised —
"retaining the old one if it cannot be read" — because a failed read
leaves the match count at zero instead of ranging over nil.

Refs career-ops-hq#4937

* test(dashboard): pin absent vs unreadable vs empty

Four assertions over the distinction the fix introduces, including the
one that is easy to break while fixing the others: a tracker with only a
header — what onboarding tells a new user to create — must stay a
SUCCESS returning a non-nil empty slice, because main() exits when apps
is nil. I checked that case against main before filing and it already
worked; the test is there so the error path does not take it down.

EISDIR (a directory where the file belongs) rather than a chmod denial,
so it reproduces as a non-root user on every platform CI runs on.

These do not compile against main, since the signature is what changed —
the behavioural evidence is in the commit that changed it, measured.

The 25 existing call sites go through mustParseApplications rather than
`apps, _ :=`. The discarded form would let a test that stopped being
able to read its own fixture keep running against a nil slice, pass
every "no unexpected rows" assertion, and report as coverage.

Refs career-ops-hq#4937

* fix(dashboard): resolveTrackerPath falls back only when the tracker is absent

The same mix-up as the rest of this PR, one step earlier. The fallback
to the legacy ./applications.md fired on ANY stat failure:

  if _, err := os.Stat(dataPath); err == nil { return dataPath }
  return filepath.Clean(filepath.Join(careerOpsPath, "applications.md"))

So when data/applications.md existed but could not be examined — a
permission problem on data/, say — the reader was sent to a legacy path
that usually does not exist, got ENOENT from there, and startup printed
"could not find applications.md" about a file sitting exactly where it
belongs.

Only fs.ErrNotExist falls back now. Any other stat error returns the
canonical path, so the caller's own read reports the real error against
the real file rather than this function inventing a second error channel
for a path it only computes — which also keeps the signature, and its
three callers, unchanged.

The new test denies traversal of data/ so stat fails with something that
is not ErrNotExist while the file is demonstrably there, and asserts all
three consequences: the resolved path, that the error does not unwrap to
ErrNotExist, and that the message names data/applications.md. All three
go red against the previous behaviour, reporting "no such file or
directory" for the legacy path.

Skipped on Windows and as root, where directory bits do not deny access.

Raised by CodeRabbit on main.go:417 and by review.

Refs career-ops-hq#4937
…, not Write/Edit (career-ops-hq#5062)

The Tools table in modes/_shared.md, loaded by every evaluation, listed
`Write → applications.md` and `Edit → Update tracker`, eleven lines below
rule 9 ("NEVER edit applications.md directly") and against AGENTS.md's
Pipeline Integrity rules. Fourteen localized copies carried the same two
rows. A direct Write or Edit skips the tracker lock the writers share, and
a hand-edited status never reaches data/status-log.tsv.

- Tools table, English + ar/da/de/es/fr/hi/id/it/ja/ko/pl/ru/sg/ua: Write
  names the TSV additions directory, Edit names merge-tracker.mjs for new
  rows and set-status.mjs for status and notes. nl/pt/tr, already half
  corrected, get the set-status pointer (tr's Edit row still said to edit
  existing rows in place).
- ru/ua rule 9 no longer says direct edits of existing rows are fine.
- oferta.md "Record in tracker": write the TSV and run merge-tracker
  instead of recording in applications.md; run --migrate-via before
  merging a confirmed agency into a tracker with no Via column (the merge
  otherwise drops it); take the salary line's tracker number after the
  merge, since the merge reassigns a taken number.
- auto-pipeline, followup and pdf name the writer for their tracker step
  (merge-tracker, set-status --note, mark-pdf-ready).
- Localized evaluation/apply modes that repeated "record in
  applications.md" or allowed in-place status edits point at the same
  writers.

Not changed: the "role title changed" step in apply (and its localized
copies) and the `?` → employer reveal in tracker.md. Both rename a cell
that no writer script can change yet, so they need a writer first.

Fixes career-ops-hq#5059

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
… ban, not a dataflow judgement (career-ops-hq#3818) (career-ops-hq#3908)

* refactor(lib): one shared walkTree() so the nested-checkout gate is a ban, not a dataflow judgement (career-ops-hq#3818)

Eleven distinct bypasses were found and closed in `guardsEveryDescent()` over
four review rounds on career-ops-hq#3792. Each was real. The pattern is the finding: it asks
a DATAFLOW question ("does this branch prevent that descent?") with TEXT
MATCHING, and an approximation of a dataflow question has no fixed point.

Eight of the eleven lived in that judgement half — which argument, which branch,
which position. Those are now unaskable, because the recursions they lived
inside no longer exist:

  lib/walk-tree.mjs is the only hand-rolled directory recursion in the repo.
  The nested-checkout guard runs on every descent and is not a caller's
  responsibility. One place to get it wrong, one behavioural test that proves
  it is not got wrong.

The remaining three were shapes the FINDER could not see (method syntax,
brace-less arrow, mutual recursion, wrapper reads). Those still need a call
graph — a ban is only decidable if "is this a recursion?" is — but both of the
finder's questions are now the same reachability question over one graph, so
bypass career-ops-hq#11 (a read-position rule disagreeing with a transitive read resolver)
is not expressible either. Net: ~430 lines of source analysis to ~130.

Migrated to walkTree()/listTree() (11 walkers, 9 files):

  lib/mjs-files.mjs                       collectMjsFiles
  test-all.mjs                            discoverTests, copyDirSync,
                                          web-parity walk, walkMjs,
                                          modes/ recursive readdir
  tests/helpers.mjs                       walkFiles
  tests/local-today-gates.test.mjs        sourceFiles
  tests/main-guard-convention.test.mjs    walk
  tests/mojibake-canary.test.mjs          walkAndCheck
  seed-fixture.mjs                        walk
  plugin-audit.mjs                        listMjs
  validate-untrusted-content-coverage.mjs globSync calls

The exemption moved to the call site. `allowNestedCheckouts: true` now sits in
test-all.mjs's plugin deny-list scan and plugin-audit.mjs next to the reason it
exists — a plugin directory that could opt out of a deny-list scan by planting
a `.git` marker is a place to hide a child_process import. That reason used to
live in an EXEMPT map in a test file, far from the code it described.

Two walks stay hand-rolled, each with its reason in EXEMPT:

  plugins/_lock.mjs  must THROW on a non-regular file; walkTree silently
                     ignores one, and an un-hashed entry is the hole the
                     integrity hash exists to close.
  intake.mjs         two mutually-constraining realpath passes with alias
                     resolution — a different algorithm, not a walk with
                     options.

Also fixes seed-fixture.mjs's listStates(), which the refactor structurally
cannot: those entries are not walked, each becomes a walk ROOT, and walkTree
deliberately never tests a root. A checkout parked among the fixture states
would otherwise become an allowlisted --state pointing at a second repository.

tests/walk-tree.test.mjs: 16 tests. Ten behavioural — markers planted at depths
1/2/3 in a real temp tree, both marker types, root exemption, the flag, symlink
modes, ENOENT asymmetry, onDir, per-level sort. Six for the ban, including the
detector's own fixture suite pinning every walker shape that cost a review round
on career-ops-hq#3792.

stripNonCode's regex-literal handling is not cosmetic: without it a `/^["'{]/`
literal reads as a string opener and swallows the rest of the file. salary-gap.mjs
and openrouter-runner.mjs each came back as one 19KB function that appeared to
call itself — four false positives, found while building this.

Verified: node test-all.mjs → 8571 passed, 0 failed, 16 warnings (all
pre-existing). npm run lint clean at 686 files. plugin-audit.mjs output diffed
byte-identical against origin/main on all five bundled plugins.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(update-system): register lib/walk-tree.mjs in SYSTEM_PATHS

The coverage guard compares TRACKED files against SYSTEM_PATHS/USER_PATHS, so
it could not see the new module until the first commit made it tracked — which
is why the pre-commit runs were green and CI was not. Unregistered, the updater
would ship every caller of walkTree() without shipping walkTree() itself,
breaking `update-system.mjs apply` on an import that resolves to nothing.

tests/walk-tree.test.mjs needs no entry; the directory is already covered.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(walk-tree): terminate on an ancestor symlink loop, and close two detector holes

Review findings on career-ops-hq#3908.

1. `links: 'follow'` had no cycle guard (Major). A link pointing at one of its
   own ancestors (docs/current -> docs/) passes statSync as an ordinary
   directory, so the walk re-entered it until the path length gave out. Both
   callers that follow links walk trees a user can shape: test-all.mjs's repo
   copy and seed-fixture.mjs's fixture manifest. Pre-existing — the old
   hand-rolled recursions had the same hole — but there is one walker now, so
   one guard closes it everywhere.

   Scoped to the ANCESTOR CHAIN, not a global visited set: two sibling links
   onto the same real directory are not a cycle, and collapsing them would
   silently drop the second copy from a tree copy meant to mirror what is there.
   Costs nothing under the default `links: 'skip'`, where re-entry is impossible.

   The reviewer's committable suggestion used a set that was never unwound,
   which is the global-visited behaviour its own prose warned against, so the
   ancestor-chain form is implemented instead. Pinned by a test that produces 62
   paths from a 3-file tree with the guard removed.

2. DEFS could not see three walker shapes, so the ban was bypassable by writing
   one as `obj.walk = (d) => {…}`, `{ walk: (d) => {…} }`, or `#walk(d) {…}`.
   Confirmed against the committed detector: all three returned []. The `#` sits
   outside the capture group so a private method is recorded under the name
   `this.#walk(...)` presents to the call scan. Property forms require a visible
   `=>` or `function` rather than just a `(`, so an ordinary parenthesized value
   (`total: (a + b)`) is not read as a definition — pinned both ways.

3. The recursive-readdir ban is now call-scoped. `[^)]*` stopped at the first
   `)`, so `readdirSync(join(d, 'x'), { recursive: true })` read as clean. Not
   fixed as two file-wide regexes ANDed, which was suggested and would be worse
   in the other direction: `mkdirSync(x, { recursive: true })` appears dozens of
   times in this repo and would flag every file holding one near any readdir.
   New `callArgs()` balances parens; both directions are pinned.

Verified: --quick (CI's invocation) and full both green, 0 failed. Lint clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(walk-tree): mkdtemp the two end-to-end fixtures, drop a relocation leftover

Both carried over from career-ops-hq#3792 with their original fixed paths, and both cleared
that path with a recursive rm BEFORE creating it — a delete of whatever a
developer happened to have at tests/nested-checkout-fixture-3762 or
test-fixtures/upgrade/state-nested-checkout-probe. mkdtemp under the same parent
instead, removing only the directory it returns.

Each fixture still has to live under the real tests/ and the real fixture-state
directory, because the point is that the real discovery walks them. The
generated basename is now what `--only` filters on and what the listStates()
assertion looks for, so both discovery contracts are unchanged. Re-verified the
runner test still has teeth in this form: passing allowNestedCheckouts: true to
discoverTests reddens it.

Also drops an unused `import { execFileSync }` left in tests/mjs-files.test.mjs
when those two tests moved out of it (Scott-Emberson).

Verified: --quick 8628 passed / 0 failed; full 8629 passed / 0 failed; lint
clean at 687 files.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…budget (career-ops-hq#4187)

* feat(providers): allow CAREER_OPS_HTTP_TIMEOUT_MS to raise the fetch budget

The 10s default aborts any board whose single response outlasts it, and the
abort surfaces as an unreachable portal rather than as a timeout — so the board
reads as broken instead of merely slow.

Measured against Anduril's Greenhouse board (slug `andurilindustries`):

    2026-08-06   2.28 MB   18.2 s
    2026-09-14   2.42 MB   26.6 s

Both exceed 10s, so it failed deterministically on every run; Foundry and Epic
Games fail the same way. The board is also growing, so any fixed constant is a
guess with an expiry date — 26.6s already sits at 89% of a 30s budget.

Today the only fix is to edit the constant, which makes the file diverge from
upstream permanently. That has a cost beyond the edit: update-system.mjs
compares committed HEAD against upstream across SYSTEM_PATHS with no exempt
mechanism, so one patched system file pins `system-files-changed` on forever
and the signal stops distinguishing "my local patch" from "upstream shipped a
fix I have not taken". Reading the budget from the environment keeps the file
byte-identical to upstream and leaves that signal meaningful.

Default behavior is unchanged: unset, empty, whitespace, non-numeric, zero,
negative, Infinity and NaN all fall back to 10_000.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(providers): reject timeout values setTimeout would rewrite to 1ms

resolveDefaultTimeoutMs accepted any finite value above zero, but setTimeout
honors neither end of that range: a delay below 1ms or above 2_147_483_647ms is
silently rewritten to 1ms (the overflow case warns, the sub-millisecond case
does not). CAREER_OPS_HTTP_TIMEOUT_MS=0.5 or =3000000000 therefore aborted
every request almost immediately — the exact failure this variable exists to
prevent, reached by a typo instead of a slow board, and presenting the same way:
every portal reads as unreachable rather than slow.

Verified on node v22:

    setTimeout(fn, 0.5)          fired after 4ms
    setTimeout(fn, 2147483648)   fired after 4ms
    TimeoutOverflowWarning: 2147483648 does not fit into a 32-bit signed
    integer. Timeout duration was set to 1.

Both now fall back to 10_000 alongside the shapes that already did. The bounds
are inclusive, and tests pin both edges (1 and 2_147_483_647 are kept as
written) as well as the two rejected values — the fallback path is quiet by
design, so a bounds check that landed one off would silently discard valid
configuration with nothing to catch it.

Reported by CodeRabbit on career-ops-hq#4187.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(providers): pin CAREER_OPS_HTTP_TIMEOUT_MS to the real request budget

The existing assertions covered resolveDefaultTimeoutMs directly but never the
wiring from the environment into DEFAULT_TIMEOUT_MS and on to the AbortSignal.
Replacing the env read with a hardcoded 10_000, which deletes the feature,
left the suite green.

The new case sets CAREER_OPS_HTTP_TIMEOUT_MS=300, imports a fresh _http.mjs
instance (the default is read at module load), and stalls a request that
passes no timeoutMs. It aborts in ~300ms on the real code. With the env read
removed it waits out the 10s default, trips the 4s hard timeout, and fails:

    real code:  aborted a request with no timeoutMs in 303ms
    mutant:     took 4002ms to abort (env budget not applied)

Drives the abort path rather than exporting DEFAULT_TIMEOUT_MS, so the
module's public surface is unchanged. The env var is restored afterwards.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…reer-ops-hq#5044)

* feat(providers): Personio language versions from the careers page

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(scripts): state Personio's five-language cap under dedup_include_language

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…oth suites (career-ops-hq#5055)

* fix(test-harness): link the dependency tree a worktree inherits, in both suites

A git worktree usually has no node_modules of its own: test-all.mjs and
the scripts under test resolve their packages through the main
checkout's tree by Node's upward walk. Two fixtures still insisted on a
literal <checkout>/node_modules, so in a worktree:

- linkNodeModules() (tests/helpers.mjs) reported "not installed".
  reply-proposals throws on that reason and failed; the suites that skip
  on it (verifiers-plugin-providers, merge-tracker-states-missing,
  writer-scripts-data-root, the test-all e2e block) went quiet instead.
- web/tests/lib/cv-template.test.mjs symlinked <core>/node_modules into
  its throwaway checkout. The link dangled, the copied cv-templates.mjs
  could not load js-yaml, and four of five cases failed on the base
  template.

linkRepoPackage got this treatment in career-ops-hq#4781; these are the two sites it
did not cover.

linkNodeModules now falls back, only when the root has no tree of its
own, to the nearest node_modules that holds one of the dependencies the
root's package.json declares. Keyed on the declared dependencies so a
stray node_modules above a fresh clone is not mistaken for the repo's;
an own tree that exists and is unusable is still reported as itself.

The web suite links the one package the copied module imports, from
wherever Node resolves it for the core, through a small helper under
web/tests/helpers (web suites do not import from the root suite).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(test-harness): link inherited packages one by one, and carry dependencies on the copy path

Review feedback on career-ops-hq#5055.

Dependencies in two ancestor trees. linkNodeModules() linked the one
nearest node_modules that held a declared dependency. Node resolves each
specifier on its own, so js-yaml can come from a nearer ancestor and
undici from a farther one; the sandbox then loaded the first and not the
second while the worktree loaded both. In the inherited case the
sandbox's node_modules is now a real directory with one link per declared
dependency, each taken from wherever findInstalledPackage() resolves it.
A checkout with its own node_modules is linked whole, as before.

Copy fallback. Where no link can be made, the package was copied alone.
A copy resolves from the sandbox, so a package with a separately
installed dependency (js-yaml and argparse) could not load. The copy now
brings the dependency closure with it, each dependency resolved the way
Node resolves it for the original and placed in the copy's own
node_modules; a cycle and a dependency installed nowhere are both
handled. Both helpers take an injectable link primitive so a test can
force this path. linkRepoPackage() had the same package-only copy and now
shares the helper.

Unreadable manifest. A package.json that exists but cannot be read or
parsed is reported as that, not as "node_modules is not installed -- run
npm ci". It is returned as a reason rather than thrown: this function's
contract is that a caller skips on a reason, and a throw lands in the
call site's catch, which is the misattribution it exists to prevent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(test-harness): the copy fallback brings installed peer dependencies too

Review feedback on career-ops-hq#5055. When no link can be made, the package is copied with the packages it depends on, but the traversal followed only dependencies and optionalDependencies. A package imports a peer like any other dependency, and npm installs the peer beside it, so the original resolved it and the copy did not.

Both helpers (tests/helpers.mjs and web/tests/helpers/core-package.mjs) now follow peerDependencies as well. A peer that is installed nowhere is skipped like any other, which covers optional peers. Each gets a forced-copy test in which the package imports its peer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…-hq#5021)

* test(cv): pin that an empty summary leaves no bare header (career-ops-hq#5019)

An empty, whitespace-only or absent summary still renders the Professional
Summary heading over an empty container. These cases fail until the summary
joins the optional sections in cv-sections-core.mjs.

* fix(cv): drop the summary block when the summary is empty (career-ops-hq#5019)

An empty, whitespace-only or absent summary rendered the Professional
Summary heading over an empty container. The summary now joins
OPTIONAL_SECTIONS, so build-cv-html.mjs and the rendered-HTML pass in
generate-pdf.mjs both remove it, heading and container.

The summary is text, so isEmptySection() gains a text branch. Blank,
whitespace-only, falsy and structural values are empty. A number still
renders, so it stays content. Lists keep answering by length, which is
how stripEmptyRenderedSections() passes its verdicts.

The summary pattern uses the marker-only lookahead Skills uses. It is the
first section in every shipped HTML template, so a template with no
marker after it keeps the bare header instead of losing the CV below it.

modes/pdf.md, templates/ats-rules.yml and templates/README.md now list
the summary among the optional sections.

* fix(cv): stop an empty summary at a section marker and keep a summary of 0 (career-ops-hq#5019)

The empty-summary strip stopped at any all-caps marker, including
<!-- END -->. A custom template with a summary marker, an unmarked
{{EXPERIENCE}} and a later <!-- END --> lost its experience block along
with the summary. The summary now stops at a following section marker
only, so that shape is left alone.

isEmptySection() read a summary of 0 as empty through a truthiness check,
and build-cv-html.mjs printed it as '' through `|| ''`. escapeHtml() prints
0, so both now treat only absent, structural and blank values as empty.
…er-ops-hq#5073)

On NixOS, the browsers Playwright downloads can't start, because they
link against library paths that don't exist there. The flake dev shell
already points Playwright at nixpkgs' patched browsers, but a fresh
clone still didn't reach a working setup:

- `npm install` hung for about 20 minutes. The postinstall script runs
  `playwright install chromium`, which can't create its lock file in the
  read-only Nix store, and retries silently for about 10 minutes per
  attempt. postinstall now skips the install when
  PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD is truthy, which the dev shell sets
  and which is Playwright's own "don't download browsers" switch.
- Playwright MCP couldn't launch a browser, because `@playwright/mcp`
  bundles its own Playwright. The dev shell now includes nixpkgs'
  playwright-mcp, built against the same browsers, and doctor.mjs
  recognizes a config that names its `mcp-server-playwright` binary.
- doctor.mjs told NixOS users to run `npx playwright install chromium`.
  On NixOS it now points them to the dev shell.
- The shell's playwright version pin hid its errors. It now reports a
  failed pin and skips the project's install scripts.
- The shell set PLAYWRIGHT_SKIP_VALIDATE_HOST_REQUIREMENTS, which the
  patched browsers don't need. With it set, every launch printed a
  notice to stderr, and doctor-dependency-resolution.test.mjs failed
  inside the shell.

The shell also gains poppler-utils for reading PDF CVs. docs/NIXOS.md
covers setup, the re-entry needed after `npm install`, and the MCP
config. The README and docs/SETUP.md link to it.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…the first (career-ops-hq#4905)

* fix(extract): read the content root with the most text, not the first

readDom() took the first main/[role=main]/article as the JD root. A board
that leads with a short <article> (a share widget) ahead of the one holding
the posting gave a root of a few characters, so jd mode emitted no JD text
and failed with empty_text.

Compare every candidate on its text after nav/header/footer are stripped
and keep the longest, so a <main> that is mostly menu cannot outrank the
posting either. A page with no candidate still reads <body>; a page whose
candidates are all stubs still yields the stub, so jd mode keeps failing
empty_text there and the caller's fallback runs rather than unrelated body
text (a cookie banner) passing as the JD.

readDom is exported so the new test can drive it against fixture HTML in
headless Chromium; the test warns and skips where Chromium is not installed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(extract): compare rendered text of live candidates, not detached clones

Address review: innerText on a detached clone degrades to textContent, so
CSS-hidden text counted toward a candidate's length and could outrank the
visible posting. readDom now reads innerText from the live element, hides
nav/header/footer only for the read (restoring their style attributes),
and skips candidates that are not rendered. Adds fixtures for a hidden
candidate, a hidden panel inside a candidate, and style restoration.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(extract): hide chrome via serialized cssText; cover display:contents

Address review: the hide string now prefixes the browser's serialized
cssText instead of the raw style attribute, so an unterminated token in
the original (an open /* comment) cannot swallow display:none. The raw
attribute is still restored verbatim. Adds fixtures for that case and for
a display:contents candidate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* ci: retrigger tests (unrelated Windows flake in scheduled-jobs-runner race test)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
## Why

parseInbox treated every word followed by a colon as a label. A location such as Hybrid: London, and a pay cell such as OTE: 180k, never reached the row. scan.mjs only writes posted, trust, note, and rank. Those four stay labels.

## What changed

- parseInbox keeps any other colon cell as location or pay.

## Scope

This covers the web inbox reader. It leaves the scanner writer alone. A single shared allow-list for both is still open.

## Tradeoffs

Copying the four names into the web reader can drift from scan.mjs. Importing the scanner into the web package is a larger cut than this bug.

## Blast Radius

The pipeline inbox. A cell that was dropped now shows. The four real labels still do not become a city.

## Verification

- node --test tests/lib/pipeline-labels.test.mjs failed on Hybrid: London, then passed.
- npm test in web/ passed 1194, failed 0, skipped 16.
- npx tsc --noEmit passed.
Keep the internal cv-*-ats.pdf in pdf-index. Write a professional
copy to output/upload/. Ground per-job headline, summary, and skill
order in the existing payload via cv-tailor.mjs. Stack cert, award,
and education fields so pdftotext reads name then issuer then year.
Replace the em dash in PDF title metadata with a hyphen.

Co-authored-by: mbrow190 <mbrow190@users.noreply.github.com>
@github-actions

Copy link
Copy Markdown

Welcome to career-ops, @mbrow190! Thanks for your first PR.

A few things to know:

  • Tests will run automatically — check the status below
  • Make sure you've linked a related issue (required for features)
  • Read CONTRIBUTING.md if you haven't

We'll review your PR soon. Join our Discord if you have questions.

@github-actions

Copy link
Copy Markdown

Almost there — Only SIGNATURES.md may change in a signature PR (changed: .agents/skills/career-ops/SKILL.md.

Add exactly ONE line at the very END of SIGNATURES.md: one sentence about your search (or what you want hiring to become), or just a dash:

-

Your username, the date and the id are added automatically when your signature lands. No other file or line should change.

Comment thread keyword-match.mjs
.replace(/<!--[\s\S]*?-->/g, ' ')
// Match the full opening tag (incl. attributes) before the lazy body, and
// tolerate whitespace in the closing tag — a stricter filter CodeQL accepts.
.replace(/<script\b[^>]*>[\s\S]*?<\/script\s*>/gi, ' ')
// Matched by name, but the posting lives on another board: say so, and point the fix at that board
const moved = diagnose({ url: 'https://jobs.ashbyhq.com/coinbase/9', company: 'Coinbase', title: 'Senior Rust Engineer', location: '' }, ctx());
check(moved.evidence.includes('posting is on ashby:coinbase, entry reads greenhouse:coinbase'), 'name-only match: evidence names both boards');
check(moved.gap === 'tracked-not-seen' && moved.fix.includes('https://jobs.ashbyhq.com/coinbase'), 'name-only match: the fix points at the posting\'s board');
Comment thread tests/helpers.mjs
: opts;
opts = isolatedOpts;
try {
return execFileSync(exe, args, { cwd: ROOT, encoding: 'utf-8', timeout: 30000, ...opts }).trim();

function visibleText(html) {
return html
.replace(/<script[\s\S]*?<\/script>/gi, ' ')
// wins over the computed schedule (it even revives a cold application) until a
// follow-up is logged on/after the set-date, which resumes the normal cadence.

const pinRe = (appNum: number) => new RegExp(`^-\\s+next\\s+#${appNum}\\s`, "i");
Comment thread scaffolder/bin/cli.mjs
let installed = true;
try {
const npm = npmCommand(["install"]);
execFileSync(npm.file, npm.args, { cwd: target, stdio: "inherit" });
Comment thread tests/helpers.mjs
: opts;
opts = isolatedOpts;
try {
return execFileSync(exe, args, { cwd: ROOT, encoding: 'utf-8', timeout: 30000, ...opts }).trim();
if (process.platform === 'win32') {
const runs = (label, cmd) => {
try {
const out = execFileSync(cmd.file, cmd.args, { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 60_000 }).trim();
// alone; importing the full core here would require its separate packages.
// The application, report and manifest lookups still use the real web code.
writeFileSync(join(root, 'tracker-utils.mjs'), [
`export const resolveTrackerPath = () => ${JSON.stringify(join(root, 'data', 'applications.md'))};`,
// The application, report and manifest lookups still use the real web code.
writeFileSync(join(root, 'tracker-utils.mjs'), [
`export const resolveTrackerPath = () => ${JSON.stringify(join(root, 'data', 'applications.md'))};`,
`export const resolvePdfIndexPath = () => ${JSON.stringify(join(root, 'data', 'pdf-index.tsv'))};`,
santifer pushed a commit that referenced this pull request Oct 27, 2026
…eer-ops-hq#5678)

* fix(web): keep profile.yml comments on profile and cadence saves (career-ops-hq#5662)

POST /api/profile and POST /api/followups/cadence wrote config/profile.yml
through yaml.dump, which drops every comment, including the commented-out
opt-in keys profile.example.yml documents. Both routes only set known keys
one level under a top-level section, so they now edit those lines as text
(patchProfileText in web/src/lib/profile-yaml-edit.mjs) and leave every
other line byte for byte.

The edited text must parse to exactly the document the route's merge
produced; when it does not, or the file uses a shape the line edit does not
place, the route falls back to the previous yaml.dump write.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(web): keep quoted # in flow lists and replaced items' comments

trailingComment now tracks quotes across the whole value, so a `#` inside
a quoted flow-list value (`["Role #1"] # note`) is no longer read as the
start of the trailing comment. A quote opens only at a token start, so a
plain `O'Brien` is not taken for one.

Replacing a block list keeps each old item line's trailing comment as a
comment line ahead of the new items, as standalone comments already were.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(web): open a quoted scalar only where a scalar starts

trailingComment treated any quote after whitespace as opening a quoted
scalar, so `full_name: John "Jack # keep` lost its comment on a rename.
A quote now opens one only at the start of the value, or after `[ { , :`
inside a flow collection.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(web): only a mapping colon starts a scalar in a flow value

Every `:` in a flow value let the next quote open a quoted scalar, so
`[a:"b] # keep` lost its comment. A colon now counts only when a space,
a flow indicator or the end follows it, or right after a quoted key.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
santifer pushed a commit that referenced this pull request Oct 27, 2026
…name (career-ops-hq#5861)

* fix(outcome): never record an outcome on a row the selector does not name

After the exact company tier, outcome.mjs fell back to a raw substring
test in both directions. normalizeCompany('?') is '' (the career-ops-hq#1596
blind-employer convention) and `key.includes('')` is always true, so
every blind `?` row matched every selector:

  node outcome.mjs "Acme Corp" rejected   # Acme was never tracked
  ✅ Recorded outcome "rejected" for #1 ? (Rejected)

The agency row went from Applied to Rejected, got "Application
rejected" appended to its Notes, and got an outcome journal. With
--clean-output that row's PDFs would also be removed. The same tier
selected "Metabase" for "Meta", and while any blind row exists every
fuzzy lookup comes back ambiguous ("Acme" for "Acme Inc."). set-status
cannot catch any of this, because outcome.mjs passes it the chosen
row's own number and role together with --force.

- The fallback is now companiesMatchIgnoringCorporateForm(), the rule
  dedup-tracker and merge-tracker use. "Acme" still finds "Acme Inc."
  and "Acme Corp"; a blind row (empty stem) and a longer, different name
  never match.
- An empty selector key is refused with a usage error, as set-status
  refuses it, pointing at the row # instead.
- When nothing matches, substring near misses are listed in the error
  ("did you mean career-ops-hq#2 Metabase?") and never selected.

* test(outcome): pin company-selector resolution against blind rows

Runs the real CLI on a tracker holding a blind `?` row, Metabase and
Acme Inc.: an untracked company fails not-found and leaves the tracker
and outcomes untouched; "Meta" is offered career-ops-hq#2, not selected; "?" is
refused; "Acme" still finds Acme Inc.; an exact name and a row number
still work.

Without the fix four of the five fail. Restoring the substring tier,
dropping the empty-key refusal, and dropping the hint each fail a test.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.