Repository navigation
Conversation
`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>
…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>
… an Applied-or-later row is never removed (career-ops-hq#4986)
…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>
|
Welcome to career-ops, @mbrow190! Thanks for your first PR. A few things to know:
We'll review your PR soon. Join our Discord if you have questions. |
|
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. |
| .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'); |
| : 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"); |
| let installed = true; | ||
| try { | ||
| const npm = npmCommand(["install"]); | ||
| execFileSync(npm.file, npm.args, { cwd: target, stdio: "inherit" }); |
| : 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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What does this PR do?
Employer-facing CVs no longer use internal names like
cv-michael-brown-acme-ats.pdf.generate-pdf.mjsstill writes and indexes that file, then copies a professional upload name (defaultFirst-Last-Resume.pdf) intooutput/upload/. Per-job headline, summary, and skill order go through the existing JSON payload plus a smallcv-tailor.mjsmerge. ATS templates stack cert, award, and education fields sopdftotextreads 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
Base note
This branch starts from the current HQ tip (
fix/inbox-colon-cells, which is upstreammainplus the inbox colon-cell fix). Forkmainis 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'smain, not upstream. Nothing was pushed tocareer-ops-hq/career-ops.Design
Two files, one index row. The tracker PDF column and
data/pdf-index.tsvstay 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-dirand the defaultoutput/upload/must passisWorkspaceOutputPath. 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, andskills[]already exist.cv-tailor.mjsreorders and rewrites those fields only.skill_orderthat names something not in the payload is a hard error. Facts still come fromcv.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 bygenerate-pdf.mjs.How to use
Name the upload copy in
config/profile.yml:Or on the renderer:
--upload-dirmust stay inside the workspace.--no-upload-copyskips 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):Overlay keys:
headline/title,summary,skill_order(category or existing item). Whole-section order is stillcv.sectionsin the profile.apply mode: Record
output/upload/First-Last-Resume.pdf(orcv.upload_filename), not the internal ats slug. Pass it asuploadFileorfiles[].uploadtoapplication-answers.mjs.Verification
Fictional sample: Alex Rivera / Northwind Labs.
pdftotext -layoutreads certs and education as name, then issuer, then year<title>isAlex Rivera - CV(no em dash)cv-alex-rivera-northwind-ats.pdfis written and indexedoutput/upload/Alex-Rivera-Resume.pdfis written beside itSuites 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.mdstill has no URL column, so company+role dedup stays fuzzy. A URL column plus--backfill-urls(already sketched inmerge-tracker.mjs) is the right next step, not a drive-by schema change here.Checklist
node test-all.mjsnot run in this environment)