Changelog
GitHubHver væsentlig ændring, udgivelse for udgivelse. Samme fil findes i repository'et og er oversat til alle 19 sprog.
[1.57.0] — 2026-05-19
feat(provider): OpenRouter as a 5th headless live-eval provider + fix(config): “validation failed” when saving any API key.
🐛 Fixes
/#/configno longer rejects valid API keys with “validation failed”. Pasted keys routinely arrive with a trailing newline or surrounding spaces (OS clipboard, the “copy” buttons on provider consoles). Pre-1.57 that tripped the newline guard for every provider, and the$-anchoredANTHROPIC_API_KEYcharset regex (/^sk-ant-[A-Za-z0-9_-]{20,}$/) false-rejected genuine Anthropic keys whose base64url tail / future prefix didn’t fit the class.validateConfignow normalizes (trims) every value before validating, the route persists the trimmed value (so it authenticates at runtime — no stray-newline.envquoting), and the Anthropic check is a resilientsk-ant-prefix + length sanity check (the sharedisUsableKey()20-char floor remains the real “is it real?” gate). Internal newlines are still rejected — that.env-injection guard is intact.
✨ Features
- OpenRouter is now a first-class provider. Add
OPENROUTER_API_KEYon/#/configand one key fronts 300+ models (Anthropic, OpenAI, Google, Meta, Qwen, DeepSeek …). It’s the last entry of theautoorder (Anthropic → Gemini → OpenAI → Qwen → OpenRouter), so an existing setup is never silently re-routed;LLM_PROVIDER=openrouterpins it. Wired into the same_tailProvider()path as OpenAI/Qwen across/api/evaluate,/api/deep,/api/mode/:slug, and surfaced by/api/status/providers+ the Health dashboard. OpenAI-compatible client (zero new deps — directfetch,AbortControllertimeout, key never logged) with the recommendedHTTP-Referer/X-Titleattribution headers. - The OpenRouter model dropdown is live.
OPENROUTER_MODELis a dynamic select fed byGET /api/openrouter/models— a server-side proxy of OpenRouter’s public catalogue (keeps the CSPconnect-src 'self'envelope intact; no browser→third-party fetch). It degrades to a curated namespaced fallback list when the catalogue is unreachable (offline / rate-limited / 5xx), so the dropdown is never empty, and a 10-minute in-memory cache keeps repeat/#/configvisits from re-hitting OpenRouter. New i18n keys (config.openrouter*) across all 8 locales;config.llmProviderHintupdated.
🧪 Tests
- New CI-isolated suites:
tests/openrouter-route.test.mjs(proxy answers 200 with a non-empty namespaced list even offline; leaks no secret) andtests/openrouter-model-selector.test.mjs(env-config contract + config.js wiring + 8-locale i18n parity). Extendedenv-config,openai, andprovider-selectorsuites: trim/relax validation,runOpenRouter/hasOpenRouterKey/fetchOpenRouterModels, the appendedautotail, and the OpenRouter gate. Two previously-weakvalidateConfigcases (used the no-longer-knownHH_USER_AGENT, so they passed for the wrong reason) now assert against a real key. 831 → 855.
[1.56.4] — 2026-05-19
feat(ui): UX-N2 — visible, platform-aware ⌘K / Ctrl K hint on the global search input.
✨ Features
- The Cmd/Ctrl+K “focus search” shortcut is now discoverable on screen. It lived only in the input’s
aria-label/ source, so sighted users never found it and the app felt slower than it is. A muted<kbd class="kbd-shortcut">now sits at the end of the search pill, filled at boot fromdata-mac/data-otherby a platform check (navigator.platform/userAgent): ⌘K on macOS/iOS, Ctrl K elsewhere. It isaria-hidden="true"(the existingaria-labelalready announces the shortcut to screen readers — the badge must not double-announce) andpointer-events:none(decorative). The existing Cmd/Ctrl+K keybinding is unchanged. No new i18n keys (the glyphs are universal); the badge is a flex child of the existing.searchbar(no wrapper / absolute positioning — the input is alreadyflex:1).
🧪 Tests
- New CI-isolated source-static suite
tests/cmdk-hint-visible.test.mjs(5): the.kbd-shortcut<kbd>lives inside the.searchbarpill; it isaria-hidden="true"with bothdata-mac/data-othervariants;app.jsfills it via anavigatorplatform check from the dataset; the existing(e.ctrlKey||e.metaKey)&&e.key==='k'→search.focus()keybinding is intact (regression guard);app.cssstyles.kbd-shortcutand neverdisplay:none. index.html/app.js/app.css are browser-only → asserted statically. 826 → 831.
[1.56.3] — 2026-05-19
fix(reliability): provider key detection rejects placeholder / too-short values, not only the empty string.
🐛 Fixes
- A placeholder
GEMINI_API_KEYin a parent.envwas reported “✓ set” AND mis-selected as the active provider over a validANTHROPIC_API_KEY.effectiveEnv()only rejectedundefined/'', so a 10-char placeholder counted as a real key: the onboarding banner showed GEMINI ✓ set,GET /api/status/providersreturnedactiveProvider: "gemini", and every live ⚡ eval would have silently failed against a dead key while a working 108-char Anthropic key was ignored. New pureisUsableKey()(env-config.mjs) treats a secret as configured only when it is ≥ 20 chars (no supported provider’s key is shorter — GeminiAIza…≈ 39, Anthropicsk-ant-…≈ 100+, OpenAI ≥ 40, Qwen ≈ 35 — so a real key is never false-negatived) and not a known placeholder (your_*_here,changeme,placeholder,<…>, all-one-char, …). Applied uniformly tohasAnthropicKey()/hasGeminiKey()(anthropic.mjs),hasOpenAIKey()/hasQwenKey()(openai.mjs), and theGEMINI_API_KEY/ANTHROPIC_API_KEYrows ofGET /api/health— which also moved off rawprocess.envonto the same effective+plausible view, so the health page, the providers endpoint, and the OR-router now always agree.selectActiveProvider()is unchanged; it just receives a correctkeysConfigured.
🧪 Tests
- New CI-isolated suite
tests/key-detection-rejects-placeholder.test.mjs(5):isUsableKeyunit cases (empty/non-string, too-short incl. the 10-char repro, long-but-placeholder, realistic keys accepted) + an in-processcreateApp()reproduction of the exact reported scenario (10-charGEMINI_API_KEY+ realANTHROPIC_API_KEYin a temp parent.env, the 4 keys stripped fromprocess.env) assertinggeminiis NOT inkeysConfigured,activeProvider === "anthropic", and the/api/healthrows agree. Four existinganthropic/openaieffective-env layering tests had trivially-short stub keys (sk-x,AIzaTEST,sk-o, …) lengthened to realistic values — the layering contract they assert is unchanged; only the stubs had to clear the new floor. 821 → 826.
[1.56.2] — 2026-05-19
feat(a11y): UX-N1 — per-route, locale-aware document.title (multi-tab orientation + screen-reader page-change announcement).
✨ Features
- Every SPA route now sets a distinct, localized
document.title. Pre-fix all 24 routes kept the staticindex.html<title>(“career-ops — command center”), so multi-tab browsers showed identical tab labels, bookmarks were generic, and the screen-reader “page changed” announcement read the same on every navigation.public/js/router.jsfocusNewView()now derives the title from the view’s own localized<h1 class="page-title">—"<View> — career-ops"— so titles are automatically translated (no new i18n keys) and distinct per route. Set before the first-paint guard so the initial tab is titled too (the same ordering rule the v1.56.0 UX-12tabindexset follows). Falls back tocareer-ops — command centerwhen a view has no heading.
🧪 Tests
- New CI-isolated source-static suite
tests/document-title-per-route.test.mjs(4):focusNewViewassignsdocument.title; the title is derived from the view<h1>textContent (per-route + localized, not one hardcoded literal); the assignment precedes the!firstPaintDoneearly-return; a sane product default is present. router.js is browser-only → asserted statically (same approach asdashboard-initial-focus.test.mjs). 817 → 821.
[1.56.1] — 2026-05-19
fix(a11y): suppress the spurious brand focus ring on router-managed tabindex="-1" heading focus.
🐛 Fixes
- A red box framed every view’s
<h1>(regression surfaced by v1.56.0 UX-12, rooted in the v1.41.0 SPA focus-management pattern).public/js/router.jsgives each destination view’s headingtabindex="-1"and.focus()’s it on every client-side navigation so screen readers announce the new page. Atabindex="-1"element is never keyboard-reachable, yet Chromium’s:focus-visiblemodality heuristic still painted the global brand ring (*:focus-visible { outline: 2px solid var(--rausch) }) around it — a red rectangle around the page heading (e.g. “Command Center” on#/dashboard) on every navigation, which had also been baked into theimages/dashboard-*.pnghero screenshots. The fix is one scoped rule —[tabindex="-1"]:focus, [tabindex="-1"]:focus-visible { outline: none }— the WAI-ARIA APG managed-focus pattern. Genuine keyboard focus on real interactive controls keeps the global*:focus-visiblering (WCAG 2.4.7 intact); the skip-link’s ring is unaffected (it is an<a>, nottabindex="-1", with a higher-specificity rule). - All 8
images/dashboard-*.pngregenerated against the fixed UI — the README hero screenshots no longer show the red box.
🧪 Tests
- New CI-isolated source-static suite
tests/managed-focus-no-ring.test.mjs(4): the global*:focus-visiblebrand ring is still defined (WCAG 2.4.7 not regressed);[tabindex="-1"]:focus, :focus-visibleresolves tooutline: none; the suppression rule follows the global rule (cascade safety); the fix stays scoped (no blanket*:focus { outline: none }). Pairs with the existingtests/dashboard-initial-focus.test.mjs(router still sets tabindex + focuses on navigation). 813 → 817.
[1.56.0] — 2026-05-19
feat(ux): LOW-priority polish bundle — UX-9 / UX-10 / UX-11 / UX-12 (one grouped minor release).
✨ Features
- UX-9 —
#/cvbreadcrumb (visual hierarchy): the page title is demoted to a quiet uppercase.cv-breadcrumbchip and the loud page-subtitle paragraph moves to the<h1>titletooltip, so the user’s CV (their name, rendered in the preview) owns the visual space. The F-V54-A invariant is intact — still exactly one<h1>, still.page-title(the router focus target); only its weight changed. - UX-10 — honest cost ballpark (trust/feedback): a new shared
UI.providerCostHint(t)helper renders next to ⚡ Run live on#/auto,#/evaluate,#/deepand every#/<mode>. It reusesGET /api/status/providers(v1.55.3): with a key it shows “Estimated cost: OpenAI gpt-5-codex · ~$0.04/eval” (order-of-magnitude, deliberately “~”); with no key it states plainly that ⚡ Run live copies a manual prompt at no API cost. Fail-soft (hidden when offline). - UX-11 —
#/helpTOC auto-jump (aesthetic/flow): when the TOC filter narrows to exactly one section, the page smooth-scrolls there after a 300ms idle (debounced — mid-typing keystrokes never yank the page; never fires for 0 or >1 matches), reusing the same scroll+focus path as a TOC click. - UX-12 —
#/dashboardfirst-paint a11y (accessibility): on the very first SPA paint the landing view’s<h1>is now made programmatically focusable (tabindex="-1") so screen-reader / heading navigation lands on it, and#contentstaysaria-live="polite"so the heading is announced on boot — without stealing focus (that would fight the skip-link, the deliberate v1.41.0 behaviour). Only subsequent route changes move focus. (Note: UX-12 as originally specified — “call focusNewView() on boot” — conflicted with the v1.41.0 skip-link decision; resolved conservatively to satisfy the a11y intent without the regression.) - New i18n keys
cost.estimate,cost.manual×8 locales; new token-based.cv-breadcrumb/.cost-hintCSS.
🧪 Tests
- 4 new CI-isolated source-static suites —
tests/cv-breadcrumb.test.mjs(3),tests/run-cost-line.test.mjs(4),tests/help-toc-autoscroll.test.mjs(4),tests/dashboard-initial-focus.test.mjs(3): single-h1 + breadcrumb class; shared helper exported + fail-soft + present in all 4 views +cost.*×8; debounced exactly-1-match jump gated; tabindex-before-guard ordering + focus-after-guard +#contentaria-live. The pre-existingcv-single-h1andhelp-nav-a11ylocks were updated for the new (invariant-preserving) code. 800 → 813. Live Playwright probe of all four (no-key fixture): single h1 13px breadcrumb, cost hint manual note, TOC→1 narrow, h1 tabindex=-1 + aria-live polite, 0 console errors.
[1.55.8] — 2026-05-19
feat(tracker): server-side pagination + clickable funnel chips (UX-8).
✨ Features
- Server:
GET /api/trackergains optional?page/?pageSize/?statusquery params. With none, the response is byte-for-byte the legacy{ rows: [...] }(every existing caller/test untouched). With them, it returns{ rows: slice, total, page, pageSize, funnel }—pageSizeclamped to[1,500],pageclamped to≥1,statusfiltersrows+total, andfunnelis the whole-history status→count breakdown (independent of the page or status filter, so the UI chips are always accurate). #/tracker: a new clickable funnel summary chip bar at the top — “all statuses · N · Applied · N · Interview · N · Offer · N · Rejected · N …” (ordered Applied → Responded → Interview → Offer → Rejected → Discarded → Evaluated → SKIP). Clicking a chip sets the Status filter (clicking the active chip clears it); the active chip isaria-pressedand visually highlighted. The pipeline state is now legible at a glance instead of buried behind a dropdown. Feedback/forms lens.- New i18n key
track.funnelAria×8 locales; new token-based.tracker-funnel/.tracker-chip/.tracker-chip--activeCSS.
🧪 Tests
test: tests/tracker-server-paged.test.mjs(new, 7 cases, CI-isolated, in-process Express on an ephemeral port + tempCAREER_OPS_ROOTapplications.md fixture — CLAUDE.md #2/#8): back-compat (no params ⇒ exactly{rows}, no envelope leak);?page&pageSizeslice +total+page+pageSize+fullfunnelsumming to N; last partial page with no overlap; out-of-range page ⇒ empty rows + valid total;?status=filters total/rows while funnel stays whole-history;pageSizecap; plus a source-static lock on the.tracker-funnelchip bar (toggle-on-reclick,aria-pressed,track.funnelAria×8, CSS). 793 → 800.
[1.55.7] — 2026-05-19
feat(pipeline): vanilla-JS row virtualization at >1000 rows (UX-7).
✨ Features
#/pipelinerendered every row (filtered.forEach(list.appendChild(urlRow))) — a real scan fills the queue with thousands of URLs, so thousands of row nodes (each a flex div +<a>+ two buttons) were built synchronously on every filter keystroke, flooding the DOM and the accessibility tree. New vanilla-JS virtualization (a react-window equivalent, no deps): aboveVIRTUALIZE_THRESHOLD = 1000,#/pipelinebecomes a fixed-height (70vh) scroll viewport with a non-shrinkable spacer (flex:0 0 auto,height = rows × 56px) that preserves the real scrollbar for the full list, and an rAF-throttled scroll listener renders only the viewport ± a 5-row buffer (~16–19 nodes at a time instead of N). At/below the threshold the original simple full render is kept byte-for-byte, so typical pipelines and all existing pipeline tests/e2e are unaffected. Each virtualized row keeps its URL-disambiguated ▶/✕aria-label(F-V54-B regression-locked). Window math is a purecomputeWindow()helper.
🧪 Tests
test: tests/pipeline-virtualize.test.mjs(new, 5 cases, CI-isolated, source-static): a numeric ~1000 threshold gates the path; the ≤threshold branch keeps the simpleforEach→appendChild; the >threshold branch rendersslice(start,end)with a rAF-throttled scroll listener + a scrollbar-preserving spacer;computeWindow()clamps[0,total]with a ± buffer; rows keep the disambiguated ▶/✕ aria-labels. 788 → 793. Live Playwright probe (1200-URL fixture):scrollHeight≈67248(full range), only ~16–19 row nodes in the DOM, window tracks scroll end-to-end (row 0 → 595 → 1199), 0 console errors.
[1.55.6] — 2026-05-19
feat(scan): tuck secondary filters behind an “Advanced filters” disclosure (UX-4).
✨ Features
#/scanstacked every filter — free-text, remote/hybrid/onsite, scope, source, and the post-scan stack/level/dynamic facet chips — at equal weight, a wall of controls. Now the everyday filters stay visible (free-text + Remote/Hybrid/Onsite; the 🌐 Scan button is already separate in the controls card) and the secondary ones collapse behind a<details class="scan-advanced"><summary>Advanced filters</summary>: the Scope + Source selects, and — separately — the stack/level/dynamic facet-chip cluster (which now leads the fresh result set with the table, not a wall of chips, and only renders when at least one chip row exists). Cognitive-load lens.- New i18n key
scan.advancedFiltersacross all 8 locales; new token-based.scan-advancedsummary styling (quiet ⚙ affordance, marker-less, bold when open).
🧪 Tests
test: tests/scan-advanced-disclosure.test.mjs(new, 6 cases, CI-isolated, source-static): an Advanced-filters<details>/<summary>exists with the.scan-advancedhook andscan.advancedFilterslabel; free-text + remote stay in the always-visible group; scope + source live inside the disclosure;chipsContaineris a<details>;.scan-advanced summaryis styled;scan.advancedFilters×8 locales. 782 → 788.
[1.55.5] — 2026-05-19
feat(dashboard): hero-promote the 2 P0 CTAs + a focal recent-activity hint (UX-3).
✨ Features
#/dashboardopened with ~30 equal-weight nodes — no clear “what next”. A new.dash-heroblock now sits directly under the page header: the two P0 journeys — ✨ Auto-pipeline a URL and 🌐 Scan now — are promoted to large.btn-herobuttons, and a single focal recent-activity hint (“Last evaluation:<score>—<title>”, linked to the report; a guiding empty-state on cold start viadash.heroNoEval) tells a returning user where they left off and a new user the one action that matters. The two primary buttons were removed from the header (only the secondary “📋 Open pipeline” stays there) so the action isn’t duplicated.- The application-status buckets were demoted from prominent
.badges to quiet.dash-chippills so they no longer compete with the hero (Information-scent / cognitive-load lens). - New i18n keys
dash.lastEval,dash.heroNoEvalacross all 8 locales; new token-based.dash-hero/.btn-hero/.dash-chipCSS.
🧪 Tests
test: tests/dashboard-hero.test.mjs(new, 5 cases, CI-isolated, source-static): a.dash-heroblock exists and precedes the Quick-actions grid; both P0 CTAs are.btn-herowith the/auto+/scanroutes; a focaldash.lastEvalhint +dash.heroNoEvalempty-state; status buckets use.dash-chip;.dash-hero/.btn-hero/.dash-chipCSS exists;dash.lastEval+dash.heroNoEval×8 locales. 777 → 782.
[1.55.4] — 2026-05-19
feat(ux): honest auto-pipeline ETA next to Run + prominent Stop during a scan (UX-6).
✨ Features
#/auto: a new.auto-etahint — “⏱ ~1–2 min” (keyauto.eta,titleviaauto.etaTitle) — now sits directly next to the Run button, so the one-click promise is honest about duration before the user commits. The wording matches career-ops.org/docs (“paste a URL → full report in 1–2 minutes”). Feedback & system-status lens.#/scan: while the multi-minute crawl is running (aria-busy), the Stop control is promoted from a low-contrast ghost button to a prominent destructive button (new.btn-danger— filled, high-contrast white-on-coral, weight 600).setScanRunning(running)flipsscan-stop-btnbetweenbtn-danger(running) andbtn-ghost(idle, when it is hidden anyway), so the user can find and trust Stop under load. Error-recovery lens.- New i18n keys
auto.eta,auto.etaTitleacross all 8 locales; new token-based.btn-danger/.auto-etaCSS.
🧪 Tests
test: tests/auto-eta-stop.test.mjs(new, 4 cases, CI-isolated, source-static):#/autorenderst('auto.eta')with the.auto-etaclass adjacent torunBtn;auto.eta×8 locales;setScanRunning(running)promotes Stop tobtn-danger;.btn-dangerexists with high-contrast white text. 773 → 777.
[1.55.3] — 2026-05-19
feat(onboarding): on-screen 4-provider OR status — cold-start banner + active-provider chip (UX-2, HIGH).
✨ Features
- New read-only endpoint
GET /api/status/providers→{ activeProvider, activeModel, keysConfigured }.keysConfigureduses the same effective-env view as thellm.mjsgate sites (process.env ∨ parent.env, viahasAnthropicKey/hasGeminiKey/hasOpenAIKey/hasQwenKey);activeProvideris what the OR-router would actually pick —selectActiveProvider(), a new pure helper inenv-config.mjsthat walksproviderOrder()(so anLLM_PROVIDERpin with no matching key correctly yieldsnull). No secrets are returned — only provider names + the model id. - The SPA shell now renders a global onboarding region (
#onboarding-banner, populated byapp.jsfrom that endpoint, CSP-safe DOM only): 0 keys → a red banner “No LLM key set — ‘⚡ Run live’ is in manual-prompt mode…” with a CTA deep-linking to#/config?tab=api-keys; ≥1 key → a subtle chip naming the active provider + model (e.g. Live eval: OpenAI (gpt-5-codex)). It re-evaluates on locale change and when the user navigates away from the config tab (keys may have just been saved). This makes the product’s headline differentiator — “one of Anthropic / Gemini / OpenAI / Qwen works, auto-ordered” — discoverable on screen instead of learned by trial. - New i18n keys
onboarding.noKey.title,onboarding.noKey.cta,onboarding.activeProvideracross all 8 locales; new.onboarding-warn/.onboarding-okCSS (token-based, mirrors.conn-banner).
🧪 Tests
test: tests/onboarding-key-banner.test.mjs(new, 9 cases, CI-isolated):selectActiveProviderauto-order / none /LLM_PROVIDER-pin semantics;GET /api/status/providersin-process (ephemeral port + tempCAREER_OPS_ROOT.envso the real parent key is never read — CLAUDE.md #2/#8) for 0-key, 1-key+model, and Anthropic-over-Gemini auto order; static SPA wiring (banner host, endpoint fetch,#/config?tab=api-keysCTA) +onboarding.*×8 locale coverage. 764 → 773.
[1.55.2] — 2026-05-18
fix(cv): give the #/cv markdown editor a descriptive, self-contained accessible name (F-V55-H / UX-5).
🐛 Bug Fixes
- The
#/cvprimary editor<textarea id="cv-editor">now carries a descriptivearia-labelvia the newcv.editorAriakey — “CV markdown editor — your professional resume in markdown format” — instead of the terse name it inherited from the visible “Markdown” section heading. Note: contrary to the F-V55-H symptom (which only inspectedaria-label/labels), the field was not nameless — v1.47.0 (WS2 #16) had already bound it viaaria-labelledby→ the<h3 id="cv-md-heading">Markdown</h3>, so a screen reader announced “Markdown, edit, multiline”. v1.55.2 upgrades that terse “Markdown” to a self-contained label so a screen-reader user lands and immediately knows what the field is. The redundantaria-labelledbyis removed (a leftover would be dead markup —aria-labelwins per ARIA precedence); the visible<h3>Markdown</h3>stays on screen for sighted users. WCAG 1.3.1 + 4.1.2; parallels the v1.54.5 batch-tsv fix (F-V54-C).
🧪 Tests
test: tests/cv-editor-a11y.test.mjs(new, 3 cases, CI-isolated, source-static likeauto-stepper-prerender.test.mjs):#cv-editornames itself viat('cv.editorAria', …)with a non-empty fallback;cv.editorAriais present and non-empty in all 8 locales; no redundantaria-labelledbyremains on the element. 761 → 764.
[1.55.1] — 2026-05-18
fix(auto): pre-render the 5-stage pipeline stepper on #/auto mount (F-V55-E / UX-1, senior obs S-4 reopened).
🐛 Bug Fixes
#/autonow shows the documented five-stage outline — validate → fetch → evaluate → save report → add tracker — the moment the screen mounts, instead of staying blank until the first SSE event. Previously<ol class="auto-stepper">was createddisplay:noneandrenderStepper()was only reached fromsetStep()/run(), so a cold-start user never saw the pipeline the docs promise before clicking Run. The stepper is now visible on mount with all five steps in thependingstate, and carries anaria-label(auto.stepperAria) so assistive tech announces the region. Closes F-V55-E (a11y/static-guarantee lens) and UX-1 (promise-fidelity lens) — same fix, both lenses.
🧪 Tests
test: tests/auto-stepper-prerender.test.mjs(new, 4 cases, CI-isolated, source-static likerouter.test.mjs): theSTEPSarray is exactly the 5 canonical stages in order;stepperElis notdisplay:noneon mount and carriesauto.stepperAria; a mount-scoperenderStepper()call precedesfunction setStep(;auto.stepperAriais present in all 8 locales. 757 → 761.
[1.55.0] — 2026-05-18
feat(llm): headless live-eval runs via “OR” — Anthropic | Gemini | OpenAI | Qwen, auto-selected by whichever key is set.
✨ Features
- Per user request, the web-ui ⚡ live eval now works with whichever API key is set, not just Anthropic/Gemini.
LLM_PROVIDERgainsopenaiandqwen;auto(default) uses the first provider whose key is present, preferring Anthropic → Gemini → OpenAI → Qwen. An explicit value pins one; a forced provider with no key still falls through to the manual-prompt path. - New
server/lib/openai.mjs— a zero-dependency OpenAI-compatible Chat Completions client (same secure direct-HTTPS pattern asanthropic.mjs:AbortControllertimeout, key never logged,effectiveEnv()key resolution so a parent-.envkey works without a restart). One core (runOpenAICompatible) backsrunOpenAI(api.openai.com) andrunQwen(Alibaba DashScope OpenAI-compatible mode; override the endpoint withQWEN_BASE_URLin the raw.envfor the mainland-CN host). No SDKs, no arbitrary CLI execution — the parent project stays CLI-agnostic (Claude Code · Codex · Gemini · OpenCode · Qwen · Copilot · Kimi); this only extends the headless API-key path. - The OpenAI/Qwen tail is wired into all eval surfaces:
/api/evaluate,/api/deep,/api/mode/:slug, and the/api/auto-pipelineSSE — consulted after the existing Anthropic (inline) + Gemini (subprocess) branches so the auto preference is preserved, with the same bundled-context inlining Anthropic uses. env-config.mjs:QWEN_API_KEY(secret) +QWEN_MODEL(not secret) added toKNOWN_KEYS/KEY_GROUPS.core;LLM_PROVIDERSandproviderOrder()extended;OPENAI_API_KEYis now a first-class headless provider key (was stored-only).#/configAPI-keys tab:LLM_PROVIDERselect gainsopenai/qwen; newQWEN_API_KEY+QWEN_MODELfields (curatedqwen-max/qwen-plus/qwen-turbo/qwen2.5-*list); a new top-of-tab note explains the CLI-agnostic parent vs the headless web-ui eval and the OR order. Updated OpenAI/provider hint copy. New i18n keys (config.providerModelNote,config.qwen*) + 3 updated hints, across all 8 locales.
🧪 Tests
test: tests/openai.test.mjs(new, 9 cases, CI-isolated): OpenAI/Qwen success + block-array content, Bearer auth, default +QWEN_BASE_URL-overridden endpoints, 4xx/5xx/malformed,max_tokensclamp, timeout,effectiveEnvkey detection, no-key-leak canary.tests/provider-selector.test.mjsupdated for the v1.55.0providerOrder/LLM_PROVIDERS/SECRET surface + the OpenAI/Qwen tail wiring. 748 → 757.
[1.54.10] — 2026-05-18
fix(auto-pipeline): SSE client-disconnect hygiene — kill the flaky Playwright e2e job.
🐛 Fixes
- The Playwright e2e job intermittently went red (32/32 individual tests pass, but
not ok 2 - tests/playwright-smoke.mjs): closing a page while the#/autoSSE stream was mid-flight made the server’s nextres.write()reject withEPIPE/"aborted", and — with no'error'listener on the response — Node escalated it to an uncaughtException that node:test reported as “asynchronous activity after the test ended”.openSse()inauto-pipeline.mjsnow registers a no-opres.on('error')and guardssend()onres.writableEnded || res.destroyed(wrapped in try/catch) — a vanished client is expected, not exceptional. This is correct production SSE hygiene, not just a test fix. tests/playwright-smoke.mjs: the Cmd+K test used a real outbound URL (https://example.com/jobs/123) but only waited for the modal to appear, soclosePage()aborted the server’s in-flightsafeGet()after the test ended. It now waits for the pipeline to reach a terminal state (so the fetch resolves normally before close). A sharedclosePage()helper (window.stop()then close) and theafter-hookserver.closeAllConnections()remain as defence-in-depth. Verified: 8/8 consecutive green runs (6×node --test+ 2× browser-smoke), previously ~1-in-2 red.
🧪 Tests
test: tests/auto-pipeline.test.mjs+1 static case locking theopenSsedisconnect-hygiene contract (res.on('error')listener +writableEnded||destroyedguard + try-wrapped writes). 747 → 748.
[1.54.9] — 2026-05-18
fix(llm): honour the parent .env LLM keys at request time — stop mis-routing to a stale/invalid provider.
🐛 Fixes
- Live evaluation could fail with “Gemini API error: API key not valid” even when
ANTHROPIC_API_KEYwas the configured provider. Root cause:hasAnthropicKey()/hasGeminiKey()(andrunAnthropic’s key/model lookup) read only the boot-timeprocess.envsnapshot. If the Anthropic key was added to the parent.envafter the server started, the running process never saw it → Anthropic detection was false, and evaluation fell through to whatever stale key was inprocess.env(often an old, invalidGEMINI_API_KEY). The Gemini exec path (a parent Node subprocess) already read the live parent.env, so the two providers resolved keys inconsistently. - New
effectiveEnv(key, envFilePath)inenv-config.mjs: a non-emptyprocess.envvalue wins (covers shell exports and the live-apply inPOST /api/config); otherwise the current parent.envfile is consulted.anthropic.mjsnow resolvesANTHROPIC_API_KEY,ANTHROPIC_MODEL, and the Gemini-key check through it, so a key set in the parent.envis honoured without a server restart and key DETECTION always matches the key the request actually SENDS. Provider order is unchanged (auto→ Anthropic-then-Gemini); this only fixes detection. Keys are never logged or returned (the REVIEW-B4 no-leak test still passes).
🧪 Tests
test: tests/anthropic.test.mjsrewritten to be CI-isolated (tempCAREER_OPS_ROOT, dynamic import) with 2 new cases reproducing the exact bug (key only in parent.env→ detected;runAnthropicsends the parent-.envkey + model whenprocess.envis unset).test: tests/env-config.test.mjs+3effectiveEnvcases (process.env precedence,.envfallback incl. empty-string-as-unset, missing-file / absent-key / no-path → undefined) — 100% of the new branch. 742 → 747.
[1.54.8] — 2026-05-18
feat(config): Modes field-form always renders the canonical schema (even on an empty/stub file) with career-ops.org field guidance.
✨ Features
- The v1.54.3 Modes field-form only rendered fields for
##sections that already existed — so on a fresh, empty, or non-schemamodes/_profile.md(e.g. the common 1-line stub) it fell back to “No ## sections found — use the raw editor below.” and the user never got fields. Per user request (“разбей по полям … описание полей возьми из career-ops.org/docs”), the form now always renders the 5 canonical fields in documented order (Target Roles, Adaptive Framing, Exit Narrative, Comp Targets, Location Policy), pre-filled from the file when present and empty-but-editable when not — so a brand-new profile can be filled in entirely through the form. - Each field shows a description sourced from the canonical career-ops.org Quick Start §Step-5 (what to put in Target Roles / Adaptive Framing / Exit Narrative / Comp Targets / Location Policy), wired via
aria-describedbyfor screen readers. - Heading-variant tolerant: the template’s
## Your Target Roles(etc.) maps to the same canonical field as## Target Roles, so neither the template nor the server-scaffold convention breaks the form. collect()is now a tagged payload: a non-destructive{ sections }merge when the rendered headings exactly match the file’s existing ones (preamble + untouched + custom sections survive byte-stable), or a{ markdown }full-file rebuild that bootstraps/normalises a schema-conformant document when the file lacked the schema. The rebuild path is confirm-gated inconfig.js(it replaces the parent file — WS2 #4 destructive-save invariant), preserves the existing preamble (or a documented default), and keeps non-canonical sections verbatim.- 6 new i18n keys (
config.modesDescTargetRoles…config.modesDescLocationPolicy+config.modesFormRebuildBody) across all 8 locales.
🧪 Tests
test: tests/modes-form.test.mjs— rewritten for the v1.54.8 contract: schema + canonical order, config.js payload/confirm wiring, every field’s doc-sourced description present in all 8 locales,canonicalKey“Your X” tolerance, list round-trip stability, the bootstrap-always-renders guarantee, and the tagged sections-vs-markdown collect() with data-safety. Verified live against the real parent stub file (5 fields + descriptions appear, 0 console errors) and an isolated stub fixture (fill → confirm-gated save → all 5 canonical sections persisted). 742 unchanged (7 cases, replaced).
[1.54.7] — 2026-05-18
fix: W-001 — code/style assets + SPA shell served Cache-Control: no-store (deploy-hygiene).
🐛 Fixes
- The SPA loads
api.js/router.js/ every view via plain<script src>with no version query string, and there is no build step (no content hashing), so after a deploy a browser could keep serving a cached old bundle for hours → stale-cache 404s on query-string routes (observed live during the v1.29.2 regression; regression run W-001).server/index.mjsnow setsCache-Control: no-storeon.js/.mjs/.css/.htmlvia theexpress.staticsetHeadershook, and explicitly on the SPA-shell catch-all (which usessendFileand bypassessetHeaders), so the browser always revalidates the code that drives routing. Non-code static assets keepexpress.static’s default caching. Security headers (CSP / nosniff / frame-deny / referrer-policy) are unchanged — verified by the existingsecurity-headerssuite (8 cases) running green alongside the new test.
🧪 Tests
test: tests/asset-cache-control.test.mjs— 4 cases (JS assetsno-store, CSSno-store, staticindex.htmlno-store, SPA catch-all deep-route shellno-store), booting the real app against an isolatedCAREER_OPS_ROOT. Plus a flaky-teardown fix intests/playwright-smoke.mjs(separatetest(e2e)commit): the auto-pipeline SSE smoke test now cancels the reader + aborts the fetch in afinallyand theafterhook force-closes lingering sockets, eliminating the post-teardown “Error: aborted” that reddened the v1.54.6 Playwright e2e job. 738 → 742.
[1.54.6] — 2026-05-18
fix(a11y): S-7 — #/help back-to-top button carries the canonical back-to-top selector class.
🐛 Fixes
- The
#/helpfloating back-to-top button worked correctly (verified live) but its class list (btn btn-primary help-back-top) sat outside the.back-to-topselector convention the spec §2 #28 test targets — a tightened selector would have flaked (regression run S-7, “easy win”). The button now also carries the canonicalback-to-topclass. Purely additive and a CSS-no-op:help-back-top(the existing CSS hook) is unchanged andback-to-tophas no CSS rule — it’s a stable test/automation handle only. Verified live:document.querySelector('.back-to-top')resolves the button,aria-labelintact, 0 console errors.
🧪 Tests
test: tests/help-nav-a11y.test.mjs— extended the existing #12 case with an assertion that the back-to-top button’s class list includes the canonicalback-to-topselector (no new file; 738 unchanged).
[1.54.5] — 2026-05-18
fix(a11y): F-V54-C — #/batch TSV editor has an accessible name.
🐛 Fixes
- The
#/batchTSV textarea had a hint wired viaaria-describedbybut no accessible name — no<label htmlFor>, noaria-label/aria-labelledby(regression run F-V54-C; WCAG 1.3.1 Info & Relationships / 4.1.2 Name, Role, Value).aria-describedbysupplies a description, not a name, so a screen reader announced an unlabelled “edit text”. The textarea now carries anaria-labelvia the new i18n keybatch.tsvAria, consistent with the sibling run-control inputs that already use*Ariakeys; the existing describedby hint is preserved. Verified live:aria-labelpresent + localized,aria-describedbyintact, 0 console errors. - New i18n key
batch.tsvAriaacross all 8 locales.
🧪 Tests
test: tests/batch-tsv-accessible-name.test.mjs— 2 cases (thebatch-tsvblock has anaria-labelviat(batch.tsvAria)while keeping its describedby hint;batch.tsvAriadefined in all 8 locales). 736 → 738.
[1.54.4] — 2026-05-18
fix(a11y): F-V54-B — #/pipeline row-action buttons have accessible names.
🐛 Fixes
- The per-row
▶(evaluate) and✕(delete) buttons on#/pipelinewere icon-only with only atitleattribute (regression run F-V54-B; WCAG 4.1.2 Name, Role, Value).titleis not a reliable accessible name, so a screen-reader user heard a long run of indistinct “button”s and could not tell which row a delete would hit. Both buttons now carry an explicitaria-labeldisambiguated by a compact URL via a newshortUrl()helper (host+…/+ last 2 path segments; trailing-slice fallback for unparseable input), so the a11y tree reads e.g. “Delete: hh.ru/…/vacancy/12345”. No new i18n keys — reusescommon.delete/pipe.evaluateBtn+ the URL. Verified live: 1385 rows, each button name unique per row, 0 console errors.
🧪 Tests
test: tests/pipeline-row-action-names.test.mjs— 4 cases (both buttons wired withshortUrl(url)+ exactly two such labels,shortUrldeclared before use, same-host different-job URLs don’t collapse, bare-host / unparseable / empty fallbacks). 732 → 736.
[1.54.3] — 2026-05-18
feat(config): structured field-form for the #/config “Modes” tab (no more raw markdown).
✨ Features
- The “Modes” tab edited
modes/_profile.mdas one raw<textarea>per##section (v1.36.0 section-level granularity). Per user request — “собери данные по полям, разбей из документации, определи набор полей и реализуй поля именно, а не сырой” — it now renders a structured field-form derived from the documented schema (career-ops.org Quick Start §Step-5):Target Roles/Adaptive Framing/Comp Targets→ repeatable add/remove labelled line-inputs (one role/angle/comp line per field,+ Add line/ per-row✕witharia-label).Exit Narrative/Location Policy→ single labelled prose<textarea>.- Each field is a real
<label htmlFor>-bound control with an i18n section name.
- New
public/js/lib/modes-form.js(window.ModesForm) owns the parse → render →collect()logic; it feeds the existingPUT /api/modes/_profile { sections }merge path, so the preamble, ordering, and any section the form doesn’t touch survive byte-stable (merge-not-replace, server-enforced). - Data-safety: a canonical list section whose body isn’t a pure bullet list (user put prose there) and any non-canonical
##section fall back to a labelled verbatim<textarea>with an explanatory note — arbitrary content round-trips untouched, never silently reshaped or lost. Round-trip stability proven:serialise(parse(body))re-parses identically. - The raw full-file markdown editor remains as the confirm-gated Advanced disclosure for add/remove-section and preamble edits (WS2 #4 destructive-save gate unchanged).
- 10 new i18n keys (
config.modesTargetRoles…config.modesUnknownNote) across all 8 locales.
🧪 Tests
test: tests/modes-form.test.mjs— 7 cases (documented schema present, config.js wiresModesForm.build/collect+ drops the stalemodesSectionInputsmap, list classification incl. scaffold/prose/mixed, list & prose round-trip stability, empty-list → empty section, custom-section verbatim data-safety). 725 → 732. Verified live against an isolatedCAREER_OPS_ROOTfixture: 5 canonical sections rendered as fields + 1 custom section as a labelled fallback, edit-and-save round-trip preserved the preamble + custom section, 0 console errors.
[1.54.2] — 2026-05-18
feat(config): OpenAI / Codex model selector in #/config.
✨ Features
#/confighad no way to pick the OpenAI / Codex model — onlyANTHROPIC_MODELandGEMINI_MODELhad dropdowns, even thoughOPENAI_API_KEYwas already exposed for the parent multi-CLI (Codex / OpenCode) flow.OPENAI_MODELis now a first-class env key: added toenv-config.mjsKNOWN_KEYS(ordered right afterOPENAI_API_KEY) and thecorekey group, and deliberately not inSECRET_KEYS— it’s a model id, not a credential, so it’s never masked.config.jsgains a curatedOPENAI_MODELSlist (gpt-5-codexdefault, thengpt-5/gpt-5-mini/gpt-4.1/o4-mini/o3) and anOPENAI_MODEL<select>field rendered immediately after the OpenAI key, mirroring the Anthropic/Gemini model fields exactly (samekind: 'select',aria-describedbyhint wiring, label = env-var name). Read by the parent Codex / OpenCode CLI flow — web-ui live-eval still uses Anthropic|Gemini. Verified live:#/config→OPENAI_MODELselect with 6 options, defaultgpt-5-codex, label-bound, 0 console errors.- New i18n keys
config.openaiModel+config.openaiModelHintacross all 8 locales.
🧪 Tests
test: tests/openai-model-selector.test.mjs— 4 cases (env-config core/non-secret contract + key ordering,OPENAI_MODELSlist withgpt-5-codexdefault, theOPENAI_MODELselect field wired afterOPENAI_API_KEY, both i18n keys across all 8 locales). 721 → 725.
[1.54.1] — 2026-05-18
fix(a11y): F-V54-A — #/cv single <h1>.
🐛 Fixes
- The CV markdown’s own
# Namerendered as a second top-level<h1>next to the page-title<h1>CV</h1>(regression run F-V54-A; WCAG 1.3.1 Info & Relationships / 2.4.6 Headings).cv.jsnow feeds every preview-injection point (initial render, file-import refresh, live editor sync) through a scopedcvMd()that shifts headings down one level (h1→h2 … h5→h6, h6→role="heading" aria-level="7"), so the page keeps exactly one<h1>. Scoped tocv.json purpose —UI.mdis shared by help/reports/deep/evaluate, which manage headings their own way (help strips article h1s + builds its TOC from h2). Verified live:#/cv→ 1 page<h1>(“CV”), the user’s# Nameis now<h2>, 0 console errors.
🧪 Tests
test: tests/cv-single-h1.test.mjs— 4 cases (cvMd shift chain, every preview site uses cvMd not raw UI.md, single page-title h1, the transform re-derived + proven to map every level down). 717 → 721.
Verification
$ npm run test:ci
# 721 / 721 · ✓ no .also( leftovers · ✓ CHANGELOG parity: all 8 locales at v1.54.1
# Playwright #/cv: 1 <h1> ("CV"), preview h1=0/h2=1, 0 console errors
[1.54.0] — 2026-05-18
WS10 — canonical-docs re-validation + help-bundle H3 parity (final convergence release).
The absolute-last workstream: re-validate the SPA against the 5
canonical career-ops.org/docs guides + the parent project, close the
last structural doc-divergence, and refresh the SDD spec — shipped as
its own release per the WS10 mandate.
🐛 Fixes
fix(docs): help-bundle H3 parity — en=70 vs locales=68— the CHANGELOG/structure CI gate only checked H2, sodocs/help/en.mdhad silently drifted to 70###subsections while all 7 localized bundles stayed at 68. The gap was §17 (“How to add a new job-portal source”): the “Reference adapters” table + “Common pitfalls” list had been added to en only. Both are now translated into es/ja/ko-KR/pt-BR/ru/zh-CN/zh-TW (adapter filenames, relative links,registry.mjs,r.source === fs,fetchImpl/signal,tracked_companieskept byte-identical). All 8 bundles: 17 H2 / 70 H3.
🧪 Tests
test(help): H3-parity gate—help-ru-config-section.test.mjsnow also asserts every bundle (en + 7) has an identical H3 count (70), not just H2. An en-only###addition can no longer silently diverge the localized bundles. 716 → 717.
📝 Documentation
- Canonical re-validation:
tests/canonical-docs-coverage.test.mjs(7/7) confirms the help bundles still mirror all 5 canonical guides — what-is-career-ops, scan-job-portals, apply-for-a-job, batch-evaluate-offers, set-up-playwright — and the WS2 UX-audit (40 findings, every screen Playwright-verified across v1.41→v1.52) validated every screen against documented behaviour. No divergence found. docs/sdd/CONVENTIONS.mdrefreshed to v1.54.0 reality: test totals (716node --test+ 4 E2E + shell-surface tier), the new H3-parity gate, updated file-size outliers (scan/config grew with WS2), and a new Accessibility section codifying the WS2 patterns (focus-trappedUI.confirm, WAI-ARIA tabs, SSE live-regions, label binding, sortable-tablearia-sort, async-relabel announce) as standing conventions.
Verification
$ npm run test:ci
# 717 / 717 · ✓ no .also( leftovers · ✓ CHANGELOG parity: all 8 locales at v1.54.0
# help bundles: all 8 → 17 H2 / 70 H3 · canonical-docs-coverage 7/7
WS0–WS10 complete. Only WS11 (qa/ actualization + final QA prompt) remains.
[1.53.0] — 2026-05-18
WS9 — shell-surface test pyramid (the last untested layer).
🧪 Tests
test: tests/sh-files.test.mjs— the 4bin/*.shscripts and the.githooks/pre-commithook had zero coverage. 10 cases now lock:bash -n/sh -nsyntax, shebang + executable bit, and the behavioural contracts other workstreams depend on:career-ops-ui.sh—helpexits 0 + prints usage with no shell-source leak (v1.40.0 regression guard), unknown verb exits 2 with usage on stderr,usage()is a heredoc (not the fragilesed-scrape), all dispatcher case-labels present.start.sh—NO_OPENhonored, Node ≥ 18 gate, browser-raise delegated toscripts/open-dashboard.mjs(v1.43.0 guard), no bareopen "$URL".setup.sh— strict mode,SKIP_START, clones both repos,need git.run_all.sh—--quick/--no-e2eparsing, runs all 4 suites viarun_suite..githooks/pre-commit— execs the WS7 reviewer; no shell file invokesgit --no-verify(CLAUDE.md hard rule #7 guard);install-hooks.mjswirescore.hooksPathand is the npmpreparestep.
- 706 → 716.
📝 Documentation
docs/architecture/TESTING.md— added the shell-surface base layer to the pyramid diagram + a v1.53.0 totals note (716node --testcases / 90 files + 4 E2E surfaces); documents thatscripts/*.mjslogic is covered bycli-doctor/open-dashboard/ai-precommit-review/provider-selector+ the CI-gate scripts.
Verification
$ npm run test:ci
# 716 / 716 · ✓ no .also( leftovers · ✓ CHANGELOG parity: all 8 locales at v1.53.0
[1.52.0] — 2026-05-18
WS2 LOWs #33–#40 — batched polish sweep (closes the UX-audit queue).
🐛 Fixes
fix(a11y/i18n): WS2 LOW batch— eight low-severity findings:- #33
#/dashboard— the 3 header CTAs were inconsistent (only 2 had a leading icon); “Open Pipeline” now gets📋so all three match. - #34
#/profile— archetypefit/levelrendered as two bare ambiguous chips; now prefixed (Fit: …/Level: …) with matchingaria-label. - #35
#/health— Run-doctor / verify toasts showed rawdoctor.mjs/verify-pipeline.mjsstrings; now i18n-keyed (health.runningDoctor/Verify). - #36
#/health— the check results were a flat run of<div>s with no programmatic name↔status link. Now arole="list"<ul>/<li>and the status badge carriesaria-label="<check>: <status>". - #37
#/reports— report cards were mouse-only<div onClick>; nowrole="link"+tabindex="0"+ Enter/Space handler +aria-label. - #38
#/activity— the paginator comment said “200” while the code requested 500; reconciled to aCAPconstant and arole="note"notice now surfaces when the 500-cap truncates older history (activity.truncated). - #39
#/batch— prose placeholders were hardcoded English while theiraria-labels were localized; the four (minScore/maxRetries/model/startFrom) are now i18n-keyed (the TSV-template placeholder stays — it’s a data-format example). - #40 mode pages — the async health probe relabelled/reordered the primary button silently; a visually-hidden
role="status"region now announces it (mode.liveReadyAnnounce).
- #33
🌐 i18n
- 10 new keys × 8 locales (
set.fit/level,health.runningDoctor/Verify,activity.truncated({n}preserved),batch.minScorePh/maxRetriesPh/modelPh/startFromPh,mode.liveReadyAnnounce).i18n-coveragegate green.
🧪 Tests
test: tests/low-sweep.test.mjs— 9 cases (one per finding + the 10-key ×8 i18n check). 697 → 706. Playwright-verified: healthul[role=list](17li, badge aria “Node version: OK”), report card role=link/tabindex/aria, dashboard CTAs all-iconed, 0 console errors.
This closes the WS2 UX-audit queue (#1–#40 across v1.41→v1.52). Next: WS9 (test pyramid) → WS10 (canonical re-validation + separate final release) → WS11 (qa/ finalization).
Verification
$ npm run test:ci
# 706 / 706 · ✓ no .also( leftovers · ✓ CHANGELOG parity: all 8 locales at v1.52.0
[1.51.0] — 2026-05-18
WS2 #13 + #14 + #18 + #19 + #20 — feedback / i18n sweep (auto + evaluate).
🐛 Fixes
fix(a11y/ux): pending feedback, actionable errors, real clipboard, status regions:- #13
#/autoRun button was disabled with an unchanged label → no pending cue for the multi-second pipeline. It now shows a busy state (is-loading+aria-busy+ “⏳ Running…”), restored infinally. - #14 the HTTP-failure branch put a bare “HTTP 500” on step 1 with no toast and an empty live region. Now an actionable, i18n’d message (
auto.httpFail,{n}-substituted) on the step and a toast. - #18 the manual-mode “Copy prompt” used deprecated
document.execCommand('copy')and always toasted “Copied” even when it silently no-op’d. Now prefers the async Clipboard API, falls back toexecCommand, and toasts a real failure (auto.copyFail) instead of a false success. - #19
#/evaluate#eval-outhad no live role — the long LLM call was silent for screen readers. Nowrole="status" aria-live="polite"so “Evaluating…”, the verdict, and errors are announced. - #20 the Evaluate button was a plain
onClick: run(enabled during the call → duplicate submissions). NowUI.withSpinner-wrapped (disables + busy state).
- #13
🌐 i18n
- 3 new keys × 8 locales —
auto.running,auto.httpFail({n}preserved),auto.copyFail.i18n-coveragegate green.
🧪 Tests
test: tests/feedback-i18n-sweep.test.mjs— 6 cases (busy state + restore, actionable+toasted HTTP fail, async-clipboard fallback, eval status region, eval spinner-wrap, 3 i18n keys ×8). 691 → 697.fix(test): e2e pipeline-delete teardown(commit 7f8e250) —e2e.mjs/e2e-comprehensive.mjsdeleted the test row via the pre-v1.48 native-confirm path; v1.48.0’s focus-trappedUI.confirm()left the modal open and its backdrop blocked later flows (CI Playwright-e2e red). Teardown is now an API DELETE. Not a product regression — tests predated the confirm gate.
Verification
$ npm run test:ci
# 697 / 697 · ✓ no .also( leftovers · ✓ CHANGELOG parity: all 8 locales at v1.51.0
$ node tests/e2e.mjs # 20/0
$ node tests/e2e-comprehensive.mjs # 23/23
[1.50.0] — 2026-05-18
WS2 #12 + #27 + #28 — help navigation accessibility.
🐛 Fixes
fix(a11y): help — single h1, labelled+filterable TOC, focus-on-anchor, back-to-top— three#/helpfindings on a 17-section / 90+-heading guide:- #28 the doc markdown opened with its own
# Title, producing a SECOND<h1>on a page whose header already supplies the canonical h1 (and a h1→h3 jump in some locales). Every article<h1>is now stripped, so there is exactly one h1 and the hierarchy starts cleanly at the<h2>sections. - #27 the TOC
<nav>was an unnamed landmark (two unlabeled<nav>s on the page); it now hasaria-label(help.toc). Clicking a TOC entry no longer just scrolls the viewport — focus moves to the section heading (tabindex=-1+focus()), so keyboard/SR users land in the section. - #12 no way to find anything in a long doc. A
type="search"filter above the TOC narrows entries by heading text live; a floating,aria-labelled Back to top button appears after scrolling, returns to top and moves focus back to the page<h1>. Its scroll listener is removed onhashchangeaway from#/help(no leak).
- #28 the doc markdown opened with its own
🌐 i18n
- 2 new keys × 8 locales —
help.tocFilter,help.backToTop.i18n-coveragegate green.
🧪 Tests
test: tests/help-nav-a11y.test.mjs— 6 cases (h1 strip, labelled nav, focus-on-anchor, filter narrows, back-to-top + listener cleanup, 2 i18n keys ×8). 685 → 691. Playwright-verified: 1 h1, TOCaria-label, filter narrows 17→1 on “scan”, 0 console errors.
Verification
$ npm run test:ci
# 691 / 691 · ✓ no .also( leftovers · ✓ CHANGELOG parity: all 8 locales at v1.50.0
[1.49.0] — 2026-05-18
WS2 #10 + #11 + #25 + #26 — tracker table accessibility & sort.
🐛 Fixes
fix(a11y): tracker headers, sortable table, localized fix labels, empty state— four#/trackerfindings:- #10 the action column header was an empty string and the per-row Report button had no context. Every
<th>now hasscope="col"; the action header ist('track.col.actions');Score/PDFheaders are i18n-keyed (were hardcoded English); the Report button gains anaria-label(<report> — <company>). - #11 a job tracker with no way to sort. Date / Score / Status headers are now keyboard-operable sort buttons inside the
<th>witharia-sort(none/ascending/descending); asorted()comparator (numeric for score, locale-compare for date/status) runs before pagination; clicking toggles direction and resets the pager. - #25
track.normalize/dedup/mergewere identical English in all 8 locales despite being the highest-stakes destructive controls — now properly localized, plus atitletooltip (Rewrites data/applications.md in place). - #26 a zero-row first run showed the same “no match” message as an over-filtered list.
rows.length === 0now renders a distinct empty state (title + body + “Open pipeline” CTA); the filter-excluded-everything case keepstrack.noMatch.
- #10 the action column header was an empty string and the per-row Report button had no context. Every
🌐 i18n
- 7 new keys × 8 locales (
track.col.score/pdf/actions,track.fixHint,track.emptyTitle/Body/Cta) + 3 re-localized (track.normalize/dedup/merge).i18n-coveragegate green.
🧪 Tests
test: tests/tracker-a11y-sort.test.mjs— 6 cases (scope + i18n headers, Report aria-label, sortable th/aria-sort/comparator, localized destructive labels + title, distinct empty state, 7 i18n keys ×8). 677 → 683. Playwright-verified: 9 th all scope=col, 3 sortable, action header localized, aria-sort none→ascending on click, 0 console errors.
Verification
$ npm run test:ci
# 683 / 683 · ✓ no .also( leftovers · ✓ CHANGELOG parity: all 8 locales at v1.49.0
[1.48.0] — 2026-05-18
WS2 #8 + #22 — pipeline: focus-trapped confirm + preview a11y.
🐛 Fixes
fix(a11y): pipeline UI.confirm() + live preview region—- #8 all three
#/pipelineactions used nativeconfirm()(auto-dismissed in embeds, not focus-trapped): the preview-pane Delete, the per-row✕delete, and “Evaluate first”. All now route through the focus-trappedUI.confirm()(v1.44.0 infra) — the two deletesdanger:true(Cancel-default), “Evaluate first”danger:false. - #22
previewPanehad no live role and a fetch failure was stuffed intopreviewBodyso it rendered as a misleading<pre>“preview”. It’s nowrole="region" aria-live="polite"with anaria-label; failures set a separatepreviewErrorand render a distinctrole="alert"block.previewErroris cleared on (re)select and when the active row is deleted.
- #8 all three
- No native
confirm()remains inpipeline.js.
🌐 i18n
- 4 new keys × 8 locales —
pipe.confirmDelTitle,pipe.previewError,pipe.evaluateAllTitle,pipe.previewRegion.i18n-coveragegate green.
🧪 Tests
test: tests/pipeline-confirm-preview.test.mjs— 5 cases (no native confirm, ≥3 UI.confirm with correct danger flags, labelled live region, distinct alert + state-clear, 4 i18n keys ×8). 672 → 677. Playwright-verified: preview role=region/aria-live, row-delete opens a focus-trapped modal (focus on Cancel), 0 console errors.
Verification
$ npm run test:ci
# 677 / 677 · ✓ no .also( leftovers · ✓ CHANGELOG parity: all 8 locales at v1.48.0
[1.47.0] — 2026-05-18
WS2 #7 + #30 + #31 + #16 — unbound-label accessibility sweep.
🐛 Fixes
fix(a11y): bind every swept form control to an accessible name— four UX-audit findings where inputs had no programmatic label (WCAG 1.3.1 / 3.3.2 / 4.1.2):- #7
scan.js— thedry-runcheckbox andcompany-selectdropdown had labels with nofor; addedhtmlFor(matching the existingids). - #30
deep.js—company/roleinputs had unbound labels; addedid+htmlFor(deep-company,deep-role). - #31
apply.js—url/jdhad unbound labels; addedid+htmlFor(apply-url,apply-jd). - #16
cv.js— the primary markdown<textarea>had no accessible name; bound it viaaria-labelledbyto the visible “Markdown”<h3>(id="cv-md-heading") — SR name == on-screen heading, no new i18n key.
- #7
- Uses the explicit
label[for]↔control[id]pattern already standard inbatch.js/mode-page.js. No new i18n keys; zero behaviour change.
🧪 Tests
test: tests/unbound-label-sweep.test.mjs— 5 cases incl. a binding-integrity check that every newhtmlFor/aria-labelledbyhas a matchingidin-file. 667 → 672. Playwright-verified: on #/scan, #/deep, #/apply everylabel[for]resolves to a control; #/cvaria-labelledbyresolves to the heading.
Verification
$ npm run test:ci
# 672 / 672 · ✓ no .also( leftovers · ✓ CHANGELOG parity: all 8 locales at v1.47.0
# Playwright: scan/deep/apply 2 bound label[for] each (all resolve) · cv aria-labelledby resolves · 0 errors
[1.46.0] — 2026-05-18
WS2 #5 + #6 + #21 + #24 — scan SSE accessibility.
🐛 Fixes
fix(a11y): scan SSE — live-log region, Stop, run-state, error banner— four UX-audit findings on#/scan:- #5 the streaming console (
<pre id="scan-console">) was a bare element; screen readers got no announcement of scanned lines. Nowrole="log" aria-live="polite" aria-relevant="additions"+ anaria-label+tabindex="0"(keyboard-scrollable). A separate visually-hiddenrole="status" aria-live="assertive"region announces terminal events (complete / failed / stopped). - #6 an in-flight
EventSourcescan had no abort path. The handle is now captured (activeES) and a Stop button closes it (es.close()), cancels the result poll, and resets state. Stop is shown only while a scan runs. - #21 the Scan button stayed enabled with no busy cue during a multi-minute crawl.
setScanRunning()now disables it + setsaria-busyand toggles the Stop button across both stream paths (single-phasestreamToand multi-phaserunScanAll— the latter only ends the run on the terminaldone,final !== false). - #24 an SSE failure was a 3.5 s toast only. A persistent
role="alert"banner now shows the error with a Retry scan action (re-invokes the last run fn); cleared on the next run.
- #5 the streaming console (
- All side-effecting closures keep the existing
__cancelActiveScanPoll()hashchange cleanup.
🌐 i18n
- 8 new keys × 8 locales —
scan.consoleLabel/stop/stopped/statusDone/statusFailed/statusStopped/errBannerTitle/errRetry.i18n-coveragegate green.
🧪 Tests
test: tests/scan-sse-a11y.test.mjs— 7 cases (log-region roles, assertive status, Stop closes EventSource on both paths, run-state aria-busy, persistent alert + Retry, terminal-done gating, 8 i18n keys ×8). 660 → 667.
Verification
$ npm run test:ci
# 667 / 667 · ✓ no .also( leftovers · ✓ CHANGELOG parity: all 8 locales at v1.46.0
# Playwright #/scan: console role=log aria-live=polite tabindex=0 · status role=status · error role=alert(hidden) · Stop hidden · 0 errors
[1.45.0] — 2026-05-18
WS2 #3 — config tabs: full WAI-ARIA Tabs pattern.
🐛 Fixes
fix(a11y): config.js tabs implement role=tablist/tab/tabpanel— the three #/config tabs (API keys / Profile / Modes) were plain<button class="tab-btn">with click-only activation: norole, noaria-selected, no keyboard model (UX-audit HIGH #3, WCAG 4.1.2 / 2.1.1). Now: arole="tablist"container with anaria-label; each tabrole="tab"+id+aria-controls+aria-selected(synced inactivate()) + rovingtabindex(0 active / -1 rest); the panelrole="tabpanel"+tabindex="0"+aria-labelledbytracking the active tab. Full keyboard nav: ←/→/↑/↓ (wrapping) + Home/End move focus AND activate. The legacy.tab-btn.is-activeCSS hook is preserved. Verified live: ArrowRight API→Profile syncs aria-selected + panel labelledby; End→Modes; 0 console errors.
🌐 i18n
- 1 new key × 8 locales —
config.tablistLabel(“Settings sections”).i18n-coveragegate green.
🧪 Tests
test: tests/config-tabs-aria.test.mjs— 7 cases (tablist/tab/tabpanel roles, aria-controls + roving tabindex, activate() aria-selected sync, keyboard map incl. wrap + preventDefault + focus move, legacy textContent-toggle removed, i18n key ×8). Total 653 → 660.fix(test): retarget 2 stale auto-pipeline smoke tests(commit 5d253ba) — the pre-v1.34 Playwright-e2e smoke tests asserted a transient modal the dashboard “Auto-pipeline” button stopped opening in v1.34.0 (→Router.go('/auto')); they had been red on the separate Playwright-e2e CI job for 10 releases. Retargeted to the #/auto screen. Local smoke 16/16; CI green.
Verification
$ npm run test:ci
# 660 / 660 · ✓ no .also( leftovers · ✓ CHANGELOG parity: all 8 locales at v1.45.0
# Playwright: #/config ArrowRight/End move + activate tabs, aria-selected synced, 0 errors
[1.44.0] — 2026-05-18
WS2 #4 + #9 — focus-trapped confirmation for destructive parent writes.
🐛 Fixes
fix(a11y/safety): UI.confirm() gate before whole-file parent overwrites— two UX-audit HIGHs, both data-loss: (#4)config.jssaveProfileRaw/saveModesRawreplaced the ENTIRE parentconfig/profile.yml/modes/_profile.mdwith no confirmation; (#9)tracker.jsNormalize/Dedup/Merge rewrote parentdata/applications.mdin place with no confirmation. AddedUI.confirm(title, message, opts)topublic/js/api.js— a focus-trapped dialog reusing the existing WAI-ARIA modal infra (focus-return, Tab-trap), returningPromise<boolean>. A new_onClosehook fires fromcloseModal()so EVERY dismissal path (Esc / backdrop / × / Cancel) resolvesfalse; only the explicit confirm button resolvestrue. Focus defaults to Cancel (safe choice for a destructive op). NOT nativeconfirm()(auto-dismissed in embeds, not focus-trapped). All three call sites are now gated before theAPI.put/API.post. Verified live: Normalize → focus-trapped “Переписать applications.md?” modal, focus on Cancel, Cancel dismisses with no POST, 0 console errors.
🌐 i18n
- 8 new keys × 8 locales —
common.confirm,config.rawConfirmTitle/Ok,config.profileRawConfirmBody,config.modesRawConfirmBody,track.fixConfirmTitle/Body/Ok. The{op}placeholder intrack.fixConfirmBodyis preserved verbatim across all locales (runtime-substituted).i18n-coveragegate green.
🧪 Tests
test: tests/confirm-gate.test.mjs— 8 cases:UI.confirmexported,_onCloseresolves false on every dismissal path, Cancel-default focus,modal()back-compat onClose arg, all 3 destructive sites gated before their write, no nativeconfirm()left, 8 i18n keys present with{op}intact. 644 → 652.
Verification
$ npm run test:ci
# 652 / 652 · ✓ no .also( leftovers · ✓ CHANGELOG parity: all 8 locales at v1.44.0
# Playwright: #/tracker Normalize → focus-trapped modal, focus=Cancel, Cancel→no POST, 0 errors
[1.43.0] — 2026-05-18
User-requested — career-ops-ui open + autostart browser-raise.
✨ Features
feat(cli): career-ops-ui open — open AND raise the dashboard tab— aftersetup/run, bareopen/xdg-openleft the dashboard tab in the background when the browser was already running, so the user had to hunt for it. Newscripts/open-dashboard.mjsbuilds the URL from HOST/PORT (rewriting a0.0.0.0bind to loopback), optionally waits for/api/health, opens the default browser, then force-raises it — macOSosascriptactivating whichever of Chrome/Brave/Edge/Safari/Arc/Firefox is running, Linuxxdg-open+wmctrl, Windowsstart. Exposed as thecareer-ops-ui openverb (aliasesdash,focus).bin/start.shautostart now delegates to it so the tab is raised automatically;NO_OPEN=1disables auto-open for headless/CI starts. Verified live:career-ops-ui open→ URL printed, browser raised, exit 0.
🧪 Tests
test: tests/open-dashboard.test.mjs— 8 cases:dashboardUrl(defaults / PORT /0.0.0.0→loopback / explicit HOST),openAndRaiseplatform routing (darwin/win32/linux, no real browser),waitForHealthbounded-timeout against a dead port, and static guarantees that the dispatcher routesopen|dash|focusandstart.shdelegates + honorsNO_OPEN(old bare-openpath gone). 636 → 644.
📝 Documentation
- README ×8 + help-bundle §1 ×8 — the
openverb added to the launch block + a note thatsetup/runnow raises the tab automatically andNO_OPEN=1disables it. H2-section parity preserved (17).
Verification
$ npm run test:ci
# 644 / 644 · ✓ no .also( leftovers · ✓ CHANGELOG parity: all 8 locales at v1.43.0
$ career-ops-ui open --no-wait # URL printed, browser raised, exit 0
[1.42.0] — 2026-05-18
WS2 fix #2 — #/portals dead-route → config deep-link.
🐛 Fixes
fix(router): #/portals 404 → alias to config + Regional-sources deep-link—#/portalswas an unregistered route that rendered the 404 view, even though it is a plausible bookmarked/typed URL for portal-source management (UX-audit HIGH #2). Addedportals: 'config'torouter.jsALIASES(same bookmark-stability pattern assettings→profile), so it now resolves to the config view with the config nav item active. When a Regional-sources group exists, the view (config.js) detects the#/portalshash, force-opens that<details>group, scrolls it into view, and moves focus to its summary (overriding the default h1 focus) so the user lands exactly on the portal-source controls. Never renders an empty regional group from the alias alone. Verified live:#/portals→ config view,is404:false, active nav = config, 0 console errors.
🧪 Tests
test(router): portals→config alias guarantee—router.test.mjsstatic assertion guarding the new ALIASES entry against future-refactor regression. 635 → 636.
📝 Documentation
- help-bundle §5 × 8 — a “Shortcut” blockquote noting
#/portalsnow resolves to App settings / Regional sources instead of 404ing. H2-section parity preserved (17 each).
Verification
$ npm test
# 636 / 636
# Playwright: #/portals → {h1:"App settings", is404:false, activeNav:"config"} · 0 console errors
[1.41.0] — 2026-05-18
WS2 — senior UX/usability audit + cross-cutting focus-management fix.
A 10+ yr heuristic audit (Nielsen × WCAG 2.2 AA × project conventions) of
all 17 routes produced a 40-finding, severity-ranked queue
(.planning/.../UX-AUDIT.md); HIGH→MEDIUM→LOW are now shipped one fix per
release. This release lands the #1 cross-cutting HIGH.
🐛 Fixes
fix(a11y): move focus to the new view on every route change—router.js render()replaced#contenton each hashchange but never moved focus, so keyboard / screen-reader users stayed on the destroyed node and lost their place (WCAG 2.4.3 Focus Order / 4.1.3 Status Messages — cross-cutting, affected all 17 screens). NewfocusNewView(content)focuses the new view’s firsth1/.page-title(concise SR announcement + correct focus order), making the heading focusable (tabindex=-1) if needed and falling back to#content. The very first paint is skipped so it never fights the skip-link. Wired on both the success and error render paths. Verified live: after nav,document.activeElementis the new view’sH1.page-title.
🧪 Tests
test(router): focus-management static guarantees— 4 cases inrouter.test.mjs(helper defined, heading-target + content fallback, first-paint skip guard, ≥2 call sites). 631 → 635.
📝 Documentation
.planning/.../UX-AUDIT.md— full 40-finding audit + prioritized fix queue + per-release ship grouping (v1.42 → v1.51). Drives the remaining WS2 iterations.
Verification
$ npm run test:ci
# 635 / 635 · ✓ no .also( leftovers · ✓ CHANGELOG parity: all 8 locales at v1.41.0
# Playwright: #/dashboard → #/config → #/tracker · activeElement = new H1.page-title · 0 console errors
[1.40.0] — 2026-05-18
WS8.3 — docs-actualization sweep + career-ops-ui help fix + askSecret hardening.
🐛 Fixes
fix(cli): career-ops-ui help no longer leaks shell source— the dispatcher printed its header comment withsed -n '2,12p', but line 12 (set -euo pipefail) is code, not a comment, socareer-ops-ui help(and the unknown-verb usage text) ended with a strayset -euo pipefailline. Narrowed to2,11p(the comment block) in both thehelpand*)cases.helpexits 0, unknown verb exits 2 — verified.fix(cli): scripts/init.mjs key entry never echoes— the v1.39.0 follow-up replaced the cosmetic readline-overwrite mask with a real raw-mode reader:setRawMode(true)+ a buffered line so typed/pasted key bytes never reach the terminal at all (no scrollback / tmux / screen-share leak). A full VT escape FSM consumes every CSI/SS3/OSC/DCS/SOS/PM/APC sequence so arrow & function keys can’t corrupt the secret;stdinis dependency-injected so the non-TTY fallback is unit-tested without poking the global. Iterated to a clean AI-review LGTM.
📝 Documentation
- README ×8 — the old “one-command install” section is replaced by a prominent “Launch & initialize in one command” section: the curl one-liner plus the explicit
career-ops-uiCLI chain (clone →npm link→setup→init→doctor→run→help), the provider-wizard explanation, the CI--provider --anthropic-key --yesform, and theLLM_PROVIDERnote. All 8 README badges actualized from the stale v1.22–v1.24 / tests-461–474 to v1.40.0 / tests-631 (e2e badge made non-numeric to avoid an invented count). - help-bundle ×8 §1 — a “One-command launch & init” blockquote callout added at the top of the Quick-start playbook (before “A. Setup”) in all 8 locales. H2-section parity preserved (17 each — CI gate green).
🧪 Tests
test(init): non-TTY askSecret fallback—provider-selector.test.mjsgains a DI-stdin case assertingaskSecretdelegates to plainask()(trim-parity) off a TTY without mutating the shared global. 629 → 631.
Verification
$ npm run test:ci
# 631 / 631 · ✓ no .also( leftovers · ✓ CHANGELOG parity: all 8 locales at v1.40.0
$ career-ops-ui help # clean, exit 0 (no `set -euo pipefail` leak)
$ career-ops-ui doctor # all required green, exit 0
[1.39.0] — 2026-05-18
WS8.2 — LLM provider selector + OpenAI/Codex key + interactive init wizard.
✨ Features
feat(config): LLM_PROVIDER selector + OPENAI_API_KEY—server/lib/env-config.mjsaddsLLM_PROVIDER(auto|claude|gemini) +OPENAI_API_KEYtoKNOWN_KEYS/KEY_GROUPS;OPENAI_API_KEYis secret-masked. NewproviderOrder(env)helper:auto→[anthropic,gemini](legacy),claude→[anthropic],gemini→[gemini]. All 6 provider-gate sites inserver/lib/routes/llm.mjs(evaluate/deep/mode × Anthropic/Gemini) consult it via_provGate()— a forced provider with no key falls through to the manual-prompt path exactly like the pre-v1.39 no-key behaviour (zero behaviour change forauto/default).#/configAPI-keys tab gains anLLM_PROVIDERselect +OPENAI_API_KEYinput. Provider-key set matches what santifer/career-ops actually implements (Gemini = parent gemini-eval, Anthropic = web-ui SDK + Claude Code, OpenAI = Codex/OpenCode CLI side); Mistral/Qwen are model names with no parent env key — not invented.feat(cli): interactive career-ops-ui init—scripts/init.mjsis now a real wizard (was a WS8.1 stub): pick provider 1-4, enter key(s), writes parent.envvia the validatedenv-config.updateEnvFilepath (explicit user action, same as POST /api/config). Flag-driven too:--provider --anthropic-key --gemini-key --openai-key --yes.
📝 Documentation
- help-bundle §2 × 8 +
docs/sdd/CONVENTIONS.md— provider selector + the 3 parent-implemented provider keys. (Full README ×8 + canonical-docs fold = WS8.3/WS10, the user-mandated final steps.)
🧪 Tests
test: tests/provider-selector.test.mjs— 7 cases:providerOrder(auto/unknown/whitespace → legacy, claude/gemini forced), env-config surface (KNOWN/SECRET/LLM_PROVIDERS),initparseArgs+buildUpdates(clamp, non-empty-only), and a static canary that all 6 llm.mjs gate-sites use_provGate(). 622 → 629.
Verification
$ npm run test:ci
# 629 / 629 · ✓ no .also( leftovers · ✓ CHANGELOG parity: all 8 locales at v1.39.0
$ career-ops-ui init --provider gemini --gemini-key … --yes # writes parent .env
$ career-ops-ui doctor # verifies
[1.38.0] — 2026-05-17
WS8.1 — unified CLI dispatcher + doctor verb.
AutoResearchClaw-style one-command workflow. bin/career-ops-ui.sh dispatches setup / run / doctor / init / help; package.json bin.career-ops-ui points at it.
✨ Features
feat(cli): bin/career-ops-ui.sh dispatcher—setup→bin/setup.sh(existing one-command bootstrap),run→bin/start.sh,doctor→scripts/doctor.mjs,init→scripts/init.mjs(WS8.1 stub; interactive provider wizard = WS8.2),help. Backward-compatcareer-ops-ui-startbin alias kept.feat(cli): scripts/doctor.mjs— standalone health check that reuses the exact/api/healthengine (spinscreateApp()in-process on an ephemeral port → renders the JSON to a colorized terminal report). Single source of truth — doctor can never drift from the Health page. Exit 0 iff every REQUIRED check is green, exit 1 otherwise, sosetup/ CI can gate on it. No new deps; read-only.
📝 Documentation
docs/sdd/CONVENTIONS.md— “CLI dispatcher” section. help-bundle §1 × 8 — CLI quickstart note. (Full README ×8 quickstart block lands in WS8.3, the user-requested final verification step.)
🧪 Tests
test(cli): tests/cli-doctor.test.mjs— 6 cases:formatReportpure logic (all-pass / required-fail / optional-only-fail / empty-tolerant), dispatcher verb-routing canary,package.jsonbin wiring. 616 → 622.
Verification
$ npm run test:ci
# 622 / 622 · ✓ no .also( leftovers · ✓ CHANGELOG parity: all 8 locales at v1.38.0
$ node scripts/doctor.mjs # ✓ all required checks pass · exit 0
[1.37.0] — 2026-05-17
WS7 — pre-commit AI review in the git workflow.
Per user request: an AI review before every commit. Two layers — a fast deterministic floor that fails-HARD, and an advisory AI pass that fails-SOFT.
✨ Features
feat(workflow): pre-commit AI reviewscripts/ai-precommit-review.mjs— deterministic floor (fail-HARD): rejects a commit that stages a.env/secret-bearing file, contains a high-confidence secret pattern in the added diff lines (.env.exampleplaceholders exempt), leaves.also(in a staged view (mirrors the CI gate), or stages a.mjs/.jsthat failsnode --check. AI layer (fail-SOFT): runsclaude -pover the staged diff when the CLI is on PATH andAI_REVIEW !== 'off'; missing CLI / offline / timeout → notice, never blocks..githooks/pre-commit+scripts/install-hooks.mjs—npm installwirescore.hooksPath=.githooksvia the newpreparescript. Idempotent; no-op outside a git checkout.AI_REVIEW=off git commit …skips only the AI layer. Never--no-verify(CLAUDE.md hard rule #7); CI runs the full gate regardless.
📝 Documentation
docs/sdd/CONVENTIONS.md— new “Pre-commit AI review” section (floor vs AI layer, env switch, never-bypass rule).
🧪 Tests
test(workflow): tests/ai-precommit-review.test.mjs— 6 cases over the exported pure floor functions: blocked-path detection (.env.exampleexempt), secret-pattern hits (added-lines only, placeholders ignored),.also(view leftover, aggregate floor, clean-diff pass. 610 → 616.
Verification
$ npm run test:ci
# 616 / 616 · ✓ no .also( leftovers · ✓ CHANGELOG parity: all 8 locales at v1.37.0
# This very commit was gated by the new hook (live dogfood).
[1.36.0] — 2026-05-17
WS6.3 — #/config Modes tab: raw blob → per-section editor. WS6 complete.
modes/_profile.md is a prompt-engineering doc (markdown tables + prose), not key→value settings — so section-level editing is the right granularity (not field decomposition). This finishes WS6: every settings surface is now structured.
✨ Features
feat(config): per-section _profile.md editorserver/lib/routes/content.mjs— byte-exactsplitProfileSections(preamble + per-##{ heading, headingLine, body }via string slices, not line-join — round-trip is byte-identical) +joinProfileSections.GET /api/modes/_profilenow also returns{ preamble, sections }.PUTaccepts{ sections: { "<heading>": "<body>" } }: replaces only named sections, preamble + unknown sections + ordering survive byte-for-byte (merge-not-replace). Unknown heading → 400. ExistingstripDangerousMarkdownsanitization retained. Legacy{ markdown }raw path unchanged.public/js/views/config.js— one collapsible textarea per##section (label =## heading); Save sends{ sections }. Collapsed Advanced: raw markdown disclosure retained for add/remove-section + preamble edits.- i18n: 5 keys × 8 (
config.modesSectionHint/modesNoSections/modesRawToggle/modesRawHint/modesRawSave).
📝 Documentation
- help-bundle §2 × 8 locales — Modes tab documented as per-section editor (merge-by-section, byte-exact preservation, Advanced raw disclosure).
🧪 Tests
test(config): tests/modes-section-form.test.mjs— 6 cases: GET exposes preamble+sections, section-merge preserves preamble/others byte-exact, unknown-heading 400, ordering preserved, legacy raw path, sanitization. 604 → 610.
WS6 outcome
Every settings surface is now field/section-structured: API-keys (already field-based — WS6.2 audit confirmed KNOWN_KEYS ≡ FIELDS, no gap), Profile scalars (WS1 v1.32.0), Profile arrays (WS6.4 v1.35.0), Modes sections (WS6.3 v1.36.0). The raw editors remain as documented escape hatches.
Verification
$ npm run test:ci
# 610 / 610 · ✓ no .also( leftovers · ✓ CHANGELOG parity: all 8 locales at v1.36.0
[1.35.0] — 2026-05-17
WS6.4 — #/config Profile structured array editors + WS6.2 API-keys audit.
WS1 (v1.32.0) gave the 14 scalar profile fields a form. WS6.4 finishes the job: the list-shaped fields get add/remove-row editors, so the raw-YAML hatch is now truly last-resort.
✨ Features
feat(config): profile array editorsserver/lib/routes/content.mjs—PUT /api/profilenow also accepts an{ arrays: { … } }payload (alongside / combinable with{ fields }). Allow-listed paths:target_roles.primary+narrative.superpowers(string lists),target_roles.archetypes(name/level/fit),narrative.proof_points(name/url/hero_metric). Object rows keep ONLY allow-listed sub-keys (injected keys dropped); empty rows dropped; an emptied list deletes the leaf. Same merge-not-replace invariant — scalars, unknown keys, and untouched arrays survive.public/js/views/config.js— 4 collapsible add/remove-row editors (string-list rows; object rows with per-sub-key inputs). Save sends{ fields, arrays }in one request.- i18n: 6 new keys × 8 (
config.pfPrimaryRoles/Superpowers/Archetypes/ProofPoints/AddRow/RemoveRow).
audit(config): WS6.2 API-keys tab— verified serverKNOWN_KEYS(ANTHROPIC_API_KEY/MODEL, GEMINI_API_KEY/MODEL, PORT, HOST) ≡ clientFIELDS. Every recognized.envkey already has its own labeled input. No gap — no code change.
📝 Documentation
- help-bundle §2 × 8 locales — Profile-tab section documents the array editors (add/remove rows, drop-empty, merge-not-replace).
🧪 Tests
test(config): tests/profile-array-editors.test.mjs— 7 cases: string-array merge preserves scalars+unknown-keys, object-array allow-list+drop-empty, empty→leaf-removed, proof_points round-trip, unknown-array-path 400, combined fields+arrays, arrays-only request. 597 → 604.
Verification
$ npm run test:ci
# 604 / 604 · ✓ no .also( leftovers · ✓ CHANGELOG parity: all 8 locales at v1.35.0
[1.34.0] — 2026-05-17
WS5 — one-click Auto-pipeline screen (#/auto).
The v1.15 dashboard auto-pipeline was a transient modal. Promoted to a dedicated, linkable page with senior-UX scaffolding.
✨ Features
feat(auto): dedicated #/auto screen(public/js/views/auto.js) — paste one job URL, one click runs the full chain (validate → fetch → evaluate → save report → append tracker) via the existingPOST /api/auto-pipelineSSE contract. Senior-UX:- Single primary CTA; Enter in the URL field also runs it.
- Live vertical stepper as an ordered list with
aria-current="step"on the running row + a politerole="status"live-region announcing every transition (comprehensible without sight). - On success the result card deep-links to the saved report (
#/reports/:slug · N/5) and the tracker — next action one click away. - Failed step marked red with message; CTA re-enables for fix-and-retry without reload.
- No API key → manual mode: steps collapse to a copy-the-prompt card (no spend).
- Linkable:
#/auto?url=<encoded>&go=1opens + auto-starts. - Sidebar entry (✨ Auto-pipeline, after Pipeline); dashboard ✨ button now routes here (single coherent flow; the
window.AutoPipelinemodal helper stays for backward-compat). - i18n: 14 new keys × 8 locales.
📝 Documentation
- help-bundle §1 × 8 locales — new “One-click Auto-pipeline (
#/auto) — the 21-step shortcut” subsection (full step list + a11y + manual-mode + deep-link behaviour). H2 count unchanged (17) — added as a###subsection, no parity-gate churn. - README × 8 — Auto-pipeline headline feature bullet.
🧪 Tests
test(auto): tests/auto-screen.test.mjs— 8 cases: route registration, POST+SSE-drain transport, a11y scaffolding (aria-live + aria-current), manual-mode card, success deep-links, index.html script+nav wiring, dashboard→#/auto routing, 14 i18n keys × 8 locales parity.- 589 → 597 unit + acceptance (+8).
Verification
$ npm run test:ci
# 597 / 597 · ✓ no .also( leftovers · ✓ CHANGELOG parity: all 8 locales at v1.34.0
[1.33.0] — 2026-05-17
WS4 — full parent career-ops 1.8.0 feature-parity audit + location_filter.
Audited every user-facing parent commit v1.7.0→v1.8.0 (see .planning/.../PARENT-PARITY.md). One real GAP found and closed; everything else is FLOW (parent script we shell out to / inline via bundleProjectContext), CLI-ONLY (parent Go TUI), or N/A.
✨ Features
feat(scan): portals.yml location_filter parity (parent #570)— parent’sscan.mjsgained an optionallocation_filterblock; web-ui runs its OWN in-processen-scanner/ru-scanner(they do NOT shell out to parentscan.mjs), so it did not flow through. Newserver/lib/location-filter.mjsmirrors the parentbuildLocationFiltersemantics verbatim (no key → pass-all; empty location → pass;blockprecedence overallow;allowempty → pass;allownon-empty → match ≥ 1; case-insensitive substring). Wired into both scanners’ post-fetch filter step (after title/negative, before dedup/persist). Config-driven viaportals.ymltop-levellocation_filter:— no UI needed.
📝 Documentation
- help-bundle §5 (Portals) × 8 locales — new
location_filtersubsection with the worked allow/block example + exact semantics + the “top-level key, sibling of title_filter” note. .planning/.../PARENT-PARITY.md— full classification table of the 20+ parent commits (GAP / FLOW / CLI-ONLY / DOCS / N/A) with rationale per row.
🧪 Tests
test(scan): tests/location-filter.test.mjs— 8 cases: no-filter pass-all, empty-location pass, block precedence, allow-empty pass, allow-match-required, case-insensitivity, malformed-config safe-pass, exact parity with parent’sportals.example.ymlworked example. Scanner-integration regression: 51 en/ru/scan tests green (wiring didn’t regress).- 581 → 589 unit + acceptance (+8).
Parity outcome
career-ops v1.8.0 feature-parity complete as of web-ui v1.33.0. Deferred (PLAN R4 scope-guard): #341 Turkish as a 9th UI locale — parent added TR mode templates (template-side); a 9th web-ui locale is a dedicated future phase. Optional/LOW backlog: #602 explicit Greenhouse hostname allowlist (already covered by web-ui’s safe-fetch + isValidJobUrl envelope; slug-based URL construction, not raw hostname).
Verification
$ npm run test:ci
# 589 / 589 · ✓ no .also( leftovers · ✓ CHANGELOG parity: all 8 locales at v1.33.0
[1.32.0] — 2026-05-17
#/config Profile tab — raw-YAML blob → field-by-field form (WS1).
Before v1.32.0 the Profile tab was a single monospace textarea where name, email, location, archetypes, compensation and every custom key lived in one undifferentiated YAML blob — the exact pain the user called out (“всё в одной куче”). It is now a structured form.
✨ Features
feat(config): Profile field-form with merge-not-replace savepublic/js/views/config.js— 14 modeled scalar paths grouped into 3 collapsible sections: Candidate (full_name/email/phone/location/linkedin/github/portfolio_url/twitter), Narrative (headline/exit_story), Compensation (target_range/currency/minimum/location_flexibility). Full-name client-side required-check before save.server/lib/routes/content.mjs—PUT /api/profilegains a{ fields: { "candidate.full_name": … } }payload. Server reads the existingconfig/profile.yml, sets/clears only the allow-listed leaves, re-serializes the whole object. Arrays the form doesn’t model (target_roles.archetypes,narrative.proof_points,narrative.superpowers) and any custom keys survive the round-trip untouched — the load-bearing invariant (PLAN R1). Empty field → leaf deleted (nophone: ""residue). Allow-list rejects unknown dotted paths; identity gate (full name required) preserved; corrupt existing YAML → 409 (refuses to clobber, routes user to raw editor).- Raw-YAML escape hatch retained as a collapsed Advanced
<details>— the pre-1.32 full-file editor, unchanged ({ yaml }path, replaces whole file, preserves comments). For nested-array edits or comment preservation. - i18n: 23 new
config.pf*/config.profile*keys × 8 locales.
📝 Documentation
- help-bundle §2 (App settings) + §3 (Profile) × 8 locales — rewritten for the 3-tab layout + field-form description + the comment-loss tradeoff + Advanced raw-YAML disclosure.
🧪 Tests
test(config): tests/profile-field-form.test.mjs— 7 cases: arrays + unknown-key survival across a field-merge round-trip (the R1 gate), empty-field leaf-deletion, unknown-path rejection, no-full-name rejection, corrupt-YAML 409 guard, legacy raw{ yaml }path still works, value trimming.- 574 → 581 unit + acceptance (+7).
🔄 Migration
No user action. First Profile-tab save after upgrade merges into the existing file; comments are dropped on a field save (use the Advanced raw editor to keep them). All data keys preserved.
Verification
$ npm run test:ci
# 581 / 581 · ✓ no .also( leftovers · ✓ CHANGELOG parity: all 8 locales at v1.32.0
[1.31.0] — 2026-05-17
Parent career-ops 1.8.0 sync — #/batch exposes --model + --start-from.
The parent project bumped 1.7.1 → 1.8.0. The user-relevant code delta: batch-runner.sh gained --model NAME (#504) and --start-from N. web-ui surfaces both on #/batch, completing flag parity (the runner’s full surface — --parallel --dry-run --retry-failed --start-from --max-retries --min-score --model — is now reachable from the browser).
✨ Features
feat(batch): surface --model + --start-frompublic/js/views/batch.js— new Model text input (optional; placeholder “Claude Max default”) and Start from # numeric input (optional; min 1). Appended to the run-control row next to Max retries.server/lib/routes/batch.mjs—GET /api/stream/batch?model=…&startFrom=…. Defense-in-depth (same posture as v1.28.0--max-retries):modelaccepted only if it matches^[A-Za-z0-9.\-]{1,60}$(rejects shell-meta even thoughspawn()is arg-array — belt-and-suspenders);startFromaccepted only as an integer 1..100000. Bad values silently dropped, not 400’d — the UI input is the soft contract.- i18n:
batch.modelLbl,batch.modelAria,batch.startFromLbl,batch.startFromAria× 8 locales.
📝 Documentation
- help-bundle §14 (Batch evaluate) × 8 locales —
--model+--start-fromdocumented in the flag list with the#/batchfield-name mapping. - Parent career-ops 1.8.0 analysis recorded. Other parent deltas assessed as no web-ui code change required: interview-prep audience split (#489) flows through
bundleProjectContextautomatically;dashboard/is the parent’s Go/Bubble-Tea terminal TUI (browser equivalent is our SPA — not integrated by design);modes/tr/Turkish templates are parent-side (a 9th web-ui UI locale is a separate deferred decision);parentVersionauto-reports 1.8.0 viahealth.mjsruntime read.
🧪 Tests
test(batch): tests/batch-model-startfrom.test.mjs— 7 cases: model pass-through, shell-meta charset rejection (injection guard), empty-model drop, startFrom pass-through, out-of-range drop (< 1), non-integer drop, combined-with-other-flags coexistence.- 567 → 574 unit + acceptance (+7).
🔄 Migration
No user action. The next 🌐 Batch run with the Model / Start-from fields blank behaves exactly as before (Claude Max default, start at offer #1).
Verification
$ npm run test:ci
# 574 / 574 · ✓ no .also( leftovers · ✓ CHANGELOG parity: all 8 locales at v1.31.0
$ curl -sN 'http://127.0.0.1:4317/api/stream/batch?model=claude-sonnet-4-6&startFrom=5' | grep -m1 '"args"'
# data: {"script":"batch-runner.sh","args":["--model","claude-sonnet-4-6","--start-from","5"]}
[1.30.0] — 2026-05-14
#/scan results paginator — replaces the v1.12.0 “first 200 of N” truncation.
✨ Features
feat(scan): paginate over full filtered result set(public/js/views/scan.js) — pre-v1.30 the scan results table was hard-capped at the first 200 filtered rows with a footnote saying “Showing first 200 of N”. Rows 201..N were unreachable from the UI; the user had to re-tunetitle_filter.positiveinportals.ymlto narrow the set if they wanted to inspect later rows. v1.30.0 swaps the cap forUI.paginate(the same helper that drives#/tracker,#/reports,#/activity).PAGE_SIZE = 200preserves the prior visual density per page.- The FULL filtered set is sorted first (boost-to-top is stable across pages), then page-sliced — so a boosted row that lands on page 2 still appears at the top of page 2, not buried.
- Filter input (text / source / remote / scope / chips) calls
pager.reset()so the user lands on page 1 of the new filter result. pager.controls(visible, total)renders« ‹ N-M of K › »with disabled-state buttons when on first / last page. Whentotal ≤ pageSize, the controls show only the item count (clean for small datasets).
- Stale
scan.shownTopi18n key removed frompublic/js/lib/i18n-dict.js(× 8 locales — no longer referenced).
🧪 Tests
test(scan): tests/scan-paginator.test.mjs— 9 cases across three layers:- Static-source canaries (7): scan.js declares
PAGE_SIZE = 200; wiresUI.paginate({ pageSize: PAGE_SIZE, onChange: …renderResults… }); resets pager on filter input; sorts the FULLrows.slice()intosortedAllBEFORE paginating; usespager.slice(sortedAll); appendspager.controls(sorted.length, rows.length)after the table; no longer contains the pre-v1.30rows.slice(0, 200)truncation.i18n-dict.jsdoes NOT carry the stalescan.shownTopkey.api.jsstill exports thepaginate()helper with.slice / .controls / .resetsurface. - Pure-logic paginator table (1): replicates clamp+slice rules and exercises 6 boundary cases — page 0 of 550 → 200 rows starting at 0; page 1 → 200 rows starting at 200; page 2 → 150 rows starting at 400; overflow page=99 → clamp to last valid page (2); filter-narrow to 5 rows while on page 2 → clamp to page 0 returning the 5 rows; empty set → page=0, empty slice.
- Summary computation (1): mirrors
start = page * pageSize + 1; end = min(total, start + visible - 1)from api.js paginate().controls(). Verifies the displayed range across all 3 pages of a 550-row dataset.
- Static-source canaries (7): scan.js declares
- 558 → 567 unit + acceptance (+9 new).
🔄 Migration
No user action needed beyond updating to v1.30.0. The next scan that produces > 200 filtered rows will surface the paginator below the results table. Smaller scans see only an “N items” hint (unchanged from UI.paginate’s established behaviour in tracker / reports / activity).
Verification
$ npm run test:ci
# 567 / 567
# ✓ no .also( leftovers in views/
# ✓ CHANGELOG parity: all 8 locales at v1.30.0
Manual smoke after redeploy: run a scan that produces > 200 filtered rows, navigate #/scan, scroll past the results table, click › to advance to page 2 — should show rows 201..400 instantly (no server round-trip; pure client-side slicing of the already-fetched result set).
career-ops-ui v1.267.1 · page 7/9 · mirror of the project changelog for cvstart.org