سجل التغييرات

GitHub

كل تغيير جدير بالذكر، إصدارًا بعد إصدار. الملف نفسه يأتي مع المستودع ومترجم إلى جميع اللغات الـ19.

[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.md as 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 ✕ with aria-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 existing PUT /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 wires ModesForm.build/collect + drops the stale modesSectionInputs map, 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 isolated CAREER_OPS_ROOT fixture: 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

  • #/config had no way to pick the OpenAI / Codex model — only ANTHROPIC_MODEL and GEMINI_MODEL had dropdowns, even though OPENAI_API_KEY was already exposed for the parent multi-CLI (Codex / OpenCode) flow. OPENAI_MODEL is now a first-class env key: added to env-config.mjs KNOWN_KEYS (ordered right after OPENAI_API_KEY) and the core key group, and deliberately not in SECRET_KEYS — it’s a model id, not a credential, so it’s never masked. config.js gains a curated OPENAI_MODELS list (gpt-5-codex default, then gpt-5 / gpt-5-mini / gpt-4.1 / o4-mini / o3) and an OPENAI_MODEL <select> field rendered immediately after the OpenAI key, mirroring the Anthropic/Gemini model fields exactly (same kind: 'select', aria-describedby hint 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_MODEL select with 6 options, default gpt-5-codex, label-bound, 0 console errors.
  • New i18n keys config.openaiModel + config.openaiModelHint across all 8 locales.

🧪 Tests

  • test: tests/openai-model-selector.test.mjs — 4 cases (env-config core/non-secret contract + key ordering, OPENAI_MODELS list with gpt-5-codex default, the OPENAI_MODEL select field wired after OPENAI_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 # Name rendered 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.js now feeds every preview-injection point (initial render, file-import refresh, live editor sync) through a scoped cvMd() that shifts headings down one level (h1→h2 … h5→h6, h6→role="heading" aria-level="7"), so the page keeps exactly one <h1>. Scoped to cv.js on purpose — UI.md is 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 # Name is 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, so docs/help/en.md had 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_companies kept byte-identical). All 8 bundles: 17 H2 / 70 H3.

🧪 Tests

  • test(help): H3-parity gate — help-ru-config-section.test.mjs now 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.md refreshed to v1.54.0 reality: test totals (716 node --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-trapped UI.confirm, WAI-ARIA tabs, SSE live-regions, label binding, sortable-table aria-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 4 bin/*.sh scripts and the .githooks/pre-commit hook had zero coverage. 10 cases now lock: bash -n/sh -n syntax, shebang + executable bit, and the behavioural contracts other workstreams depend on:
    • career-ops-ui.sh — help exits 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 fragile sed-scrape), all dispatcher case-labels present.
    • start.sh — NO_OPEN honored, Node ≥ 18 gate, browser-raise delegated to scripts/open-dashboard.mjs (v1.43.0 guard), no bare open "$URL".
    • setup.sh — strict mode, SKIP_START, clones both repos, need git.
    • run_all.sh — --quick/--no-e2e parsing, runs all 4 suites via run_suite.
    • .githooks/pre-commit — execs the WS7 reviewer; no shell file invokes git --no-verify (CLAUDE.md hard rule #7 guard); install-hooks.mjs wires core.hooksPath and is the npm prepare step.
  • 706 → 716.

📝 Documentation

  • docs/architecture/TESTING.md — added the shell-surface base layer to the pyramid diagram + a v1.53.0 totals note (716 node --test cases / 90 files + 4 E2E surfaces); documents that scripts/*.mjs logic is covered by cli-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 — archetype fit/level rendered as two bare ambiguous chips; now prefixed (Fit: … / Level: …) with matching aria-label.
    • #35 #/health — Run-doctor / verify toasts showed raw doctor.mjs / verify-pipeline.mjs strings; 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 a role="list" <ul>/<li> and the status badge carries aria-label="<check>: <status>".
    • #37 #/reports — report cards were mouse-only <div onClick>; now role="link" + tabindex="0" + Enter/Space handler + aria-label.
    • #38 #/activity — the paginator comment said “200” while the code requested 500; reconciled to a CAP constant and a role="note" notice now surfaces when the 500-cap truncates older history (activity.truncated).
    • #39 #/batch — prose placeholders were hardcoded English while their aria-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).

🌐 i18n

  • 10 new keys × 8 locales (set.fit/level, health.runningDoctor/Verify, activity.truncated ({n} preserved), batch.minScorePh/maxRetriesPh/modelPh/startFromPh, mode.liveReadyAnnounce). i18n-coverage gate green.

🧪 Tests

  • test: tests/low-sweep.test.mjs — 9 cases (one per finding + the 10-key ×8 i18n check). 697 → 706. Playwright-verified: health ul[role=list] (17 li, 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 #/auto Run 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 in finally.
    • #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 to execCommand, and toasts a real failure (auto.copyFail) instead of a false success.
    • #19 #/evaluate #eval-out had no live role — the long LLM call was silent for screen readers. Now role="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). Now UI.withSpinner-wrapped (disables + busy state).

🌐 i18n

  • 3 new keys × 8 locales — auto.running, auto.httpFail ({n} preserved), auto.copyFail. i18n-coverage gate 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.mjs deleted the test row via the pre-v1.48 native-confirm path; v1.48.0’s focus-trapped UI.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 #/help findings 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 has aria-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 on hashchange away from #/help (no leak).

🌐 i18n

  • 2 new keys × 8 locales — help.tocFilter, help.backToTop. i18n-coverage gate 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, TOC aria-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 #/tracker findings:
    • #10 the action column header was an empty string and the per-row Report button had no context. Every <th> now has scope="col"; the action header is t('track.col.actions'); Score/PDF headers are i18n-keyed (were hardcoded English); the Report button gains an aria-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> with aria-sort (none/ascending/descending); a sorted() comparator (numeric for score, locale-compare for date/status) runs before pagination; clicking toggles direction and resets the pager.
    • #25 track.normalize/dedup/merge were identical English in all 8 locales despite being the highest-stakes destructive controls — now properly localized, plus a title tooltip (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 === 0 now renders a distinct empty state (title + body + “Open pipeline” CTA); the filter-excluded-everything case keeps track.noMatch.

🌐 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-coverage gate 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 #/pipeline actions used native confirm() (auto-dismissed in embeds, not focus-trapped): the preview-pane Delete, the per-row ✕ delete, and “Evaluate first”. All now route through the focus-trapped UI.confirm() (v1.44.0 infra) — the two deletes danger:true (Cancel-default), “Evaluate first” danger:false.
    • #22 previewPane had no live role and a fetch failure was stuffed into previewBody so it rendered as a misleading <pre> “preview”. It’s now role="region" aria-live="polite" with an aria-label; failures set a separate previewError and render a distinct role="alert" block. previewError is cleared on (re)select and when the active row is deleted.
  • No native confirm() remains in pipeline.js.

🌐 i18n

  • 4 new keys × 8 locales — pipe.confirmDelTitle, pipe.previewError, pipe.evaluateAllTitle, pipe.previewRegion. i18n-coverage gate 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 — the dry-run checkbox and company-select dropdown had labels with no for; added htmlFor (matching the existing ids).
    • #30 deep.js — company / role inputs had unbound labels; added id + htmlFor (deep-company, deep-role).
    • #31 apply.js — url / jd had unbound labels; added id + htmlFor (apply-url, apply-jd).
    • #16 cv.js — the primary markdown <textarea> had no accessible name; bound it via aria-labelledby to the visible “Markdown” <h3> (id="cv-md-heading") — SR name == on-screen heading, no new i18n key.
  • Uses the explicit label[for]↔control[id] pattern already standard in batch.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 new htmlFor/aria-labelledby has a matching id in-file. 667 → 672. Playwright-verified: on #/scan, #/deep, #/apply every label[for] resolves to a control; #/cv aria-labelledby resolves 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. Now role="log" aria-live="polite" aria-relevant="additions" + an aria-label + tabindex="0" (keyboard-scrollable). A separate visually-hidden role="status" aria-live="assertive" region announces terminal events (complete / failed / stopped).
    • #6 an in-flight EventSource scan 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 + sets aria-busy and toggles the Stop button across both stream paths (single-phase streamTo and multi-phase runScanAll — the latter only ends the run on the terminal done, 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.
  • 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-coverage gate 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: no role, no aria-selected, no keyboard model (UX-audit HIGH #3, WCAG 4.1.2 / 2.1.1). Now: a role="tablist" container with an aria-label; each tab role="tab" + id + aria-controls + aria-selected (synced in activate()) + roving tabindex (0 active / -1 rest); the panel role="tabpanel" + tabindex="0" + aria-labelledby tracking the active tab. Full keyboard nav: ←/→/↑/↓ (wrapping) + Home/End move focus AND activate. The legacy .tab-btn.is-active CSS 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-coverage gate 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.js saveProfileRaw/saveModesRaw replaced the ENTIRE parent config/profile.yml / modes/_profile.md with no confirmation; (#9) tracker.js Normalize/Dedup/Merge rewrote parent data/applications.md in place with no confirmation. Added UI.confirm(title, message, opts) to public/js/api.js — a focus-trapped dialog reusing the existing WAI-ARIA modal infra (focus-return, Tab-trap), returning Promise<boolean>. A new _onClose hook fires from closeModal() so EVERY dismissal path (Esc / backdrop / × / Cancel) resolves false; only the explicit confirm button resolves true. Focus defaults to Cancel (safe choice for a destructive op). NOT native confirm() (auto-dismissed in embeds, not focus-trapped). All three call sites are now gated before the API.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 in track.fixConfirmBody is preserved verbatim across all locales (runtime-substituted). i18n-coverage gate green.

🧪 Tests

  • test: tests/confirm-gate.test.mjs — 8 cases: UI.confirm exported, _onClose resolves false on every dismissal path, Cancel-default focus, modal() back-compat onClose arg, all 3 destructive sites gated before their write, no native confirm() 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 — after setup/run, bare open/xdg-open left the dashboard tab in the background when the browser was already running, so the user had to hunt for it. New scripts/open-dashboard.mjs builds the URL from HOST/PORT (rewriting a 0.0.0.0 bind to loopback), optionally waits for /api/health, opens the default browser, then force-raises it — macOS osascript activating whichever of Chrome/Brave/Edge/Safari/Arc/Firefox is running, Linux xdg-open+wmctrl, Windows start. Exposed as the career-ops-ui open verb (aliases dash, focus). bin/start.sh autostart now delegates to it so the tab is raised automatically; NO_OPEN=1 disables 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), openAndRaise platform routing (darwin/win32/linux, no real browser), waitForHealth bounded-timeout against a dead port, and static guarantees that the dispatcher routes open|dash|focus and start.sh delegates + honors NO_OPEN (old bare-open path gone). 636 → 644.

📝 Documentation

  • README ×8 + help-bundle §1 ×8 — the open verb added to the launch block + a note that setup/run now raises the tab automatically and NO_OPEN=1 disables 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 — #/portals was 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). Added portals: 'config' to router.js ALIASES (same bookmark-stability pattern as settings→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 #/portals hash, 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.mjs static assertion guarding the new ALIASES entry against future-refactor regression. 635 → 636.

📝 Documentation

  • help-bundle §5 × 8 — a “Shortcut” blockquote noting #/portals now 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 #content on 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). New focusNewView(content) focuses the new view’s first h1/.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.activeElement is the new view’s H1.page-title.

🧪 Tests

  • test(router): focus-management static guarantees — 4 cases in router.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 with sed -n '2,12p', but line 12 (set -euo pipefail) is code, not a comment, so career-ops-ui help (and the unknown-verb usage text) ended with a stray set -euo pipefail line. Narrowed to 2,11p (the comment block) in both the help and *) cases. help exits 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; stdin is 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-ui CLI chain (clone → npm link → setup → init → doctor → run → help), the provider-wizard explanation, the CI --provider --anthropic-key --yes form, and the LLM_PROVIDER note. 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.mjs gains a DI-stdin case asserting askSecret delegates to plain ask() (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.mjs adds LLM_PROVIDER (auto|claude|gemini) + OPENAI_API_KEY to KNOWN_KEYS/KEY_GROUPS; OPENAI_API_KEY is secret-masked. New providerOrder(env) helper: auto→[anthropic,gemini] (legacy), claude→[anthropic], gemini→[gemini]. All 6 provider-gate sites in server/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 for auto/default). #/config API-keys tab gains an LLM_PROVIDER select + OPENAI_API_KEY input. 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.mjs is now a real wizard (was a WS8.1 stub): pick provider 1-4, enter key(s), writes parent .env via the validated env-config.updateEnvFile path (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), init parseArgs+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-compat career-ops-ui-start bin alias kept.
  • feat(cli): scripts/doctor.mjs — standalone health check that reuses the exact /api/health engine (spins createApp() 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, so setup / 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: formatReport pure logic (all-pass / required-fail / optional-only-fail / empty-tolerant), dispatcher verb-routing canary, package.json bin 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 review
    • scripts/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.example placeholders exempt), leaves .also( in a staged view (mirrors the CI gate), or stages a .mjs/.js that fails node --check. AI layer (fail-SOFT): runs claude -p over the staged diff when the CLI is on PATH and AI_REVIEW !== 'off'; missing CLI / offline / timeout → notice, never blocks.
    • .githooks/pre-commit + scripts/install-hooks.mjs — npm install wires core.hooksPath=.githooks via the new prepare script. 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.example exempt), 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 editor
    • server/lib/routes/content.mjs — byte-exact splitProfileSections (preamble + per-## { heading, headingLine, body } via string slices, not line-join — round-trip is byte-identical) + joinProfileSections. GET /api/modes/_profile now also returns { preamble, sections }. PUT accepts { sections: { "<heading>": "<body>" } }: replaces only named sections, preamble + unknown sections + ordering survive byte-for-byte (merge-not-replace). Unknown heading → 400. Existing stripDangerousMarkdown sanitization 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 editors
    • server/lib/routes/content.mjs — PUT /api/profile now 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 server KNOWN_KEYS (ANTHROPIC_API_KEY/MODEL, GEMINI_API_KEY/MODEL, PORT, HOST) ≡ client FIELDS. Every recognized .env key 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 existing POST /api/auto-pipeline SSE 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 polite role="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=1 opens + auto-starts.
    • Sidebar entry (✨ Auto-pipeline, after Pipeline); dashboard ✨ button now routes here (single coherent flow; the window.AutoPipeline modal 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’s scan.mjs gained an optional location_filter block; web-ui runs its OWN in-process en-scanner/ru-scanner (they do NOT shell out to parent scan.mjs), so it did not flow through. New server/lib/location-filter.mjs mirrors the parent buildLocationFilter semantics verbatim (no key → pass-all; empty location → pass; block precedence over allow; allow empty → pass; allow non-empty → match ≥ 1; case-insensitive substring). Wired into both scanners’ post-fetch filter step (after title/negative, before dedup/persist). Config-driven via portals.yml top-level location_filter: — no UI needed.

📝 Documentation

  • help-bundle §5 (Portals) × 8 locales — new location_filter subsection 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’s portals.example.yml worked 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 save
    • public/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/profile gains a { fields: { "candidate.full_name": … } } payload. Server reads the existing config/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 (no phone: "" 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-from
    • public/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): model accepted only if it matches ^[A-Za-z0-9.\-]{1,60}$ (rejects shell-meta even though spawn() is arg-array — belt-and-suspenders); startFrom accepted 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-from documented in the flag list with the #/batch field-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 bundleProjectContext automatically; 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); parentVersion auto-reports 1.8.0 via health.mjs runtime 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-tune title_filter.positive in portals.yml to narrow the set if they wanted to inspect later rows. v1.30.0 swaps the cap for UI.paginate (the same helper that drives #/tracker, #/reports, #/activity).
    • PAGE_SIZE = 200 preserves 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. When total ≤ pageSize, the controls show only the item count (clean for small datasets).
  • Stale scan.shownTop i18n key removed from public/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; wires UI.paginate({ pageSize: PAGE_SIZE, onChange: …renderResults… }); resets pager on filter input; sorts the FULL rows.slice() into sortedAll BEFORE paginating; uses pager.slice(sortedAll); appends pager.controls(sorted.length, rows.length) after the table; no longer contains the pre-v1.30 rows.slice(0, 200) truncation. i18n-dict.js does NOT carry the stale scan.shownTop key. api.js still exports the paginate() helper with .slice / .controls / .reset surface.
    • 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.
  • 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).


[1.29.2] — 2026-05-14

Hot-fix: 🌐 Scan with source=both only ran the EN phase. RU phase was silently dropped.

🚑 Critical hot-fix

User-reported symptom: clicking the unified 🌐 Scan button (which calls runScanAll() → GET /api/stream/scan?source=both) returned only the EN ATS phase. The console showed ▶ ATS scan (Greenhouse + Ashby + Lever) … ✓ ATS done · NEW=0 and then stopped. No RU phase output ever appeared, even with all 5 RU adapters listed in russian_portals.sources.

Root cause — public/js/api.js:156 closed the EventSource on the first done event:

if (ev === 'done' || ev === 'error') es.close();

But server/lib/routes/scan.mjs::driveOne emits done once per phase, and the source=both branch drives two phases sequentially. The client closed after the EN done, the server detected res.on('close') → AbortController.abort() → the RU phase started but was immediately cancelled.

Fix — multi-phase SSE contract:

  • Server (server/lib/routes/scan.mjs): driveOne now accepts a final param (default true). The done payload carries final: <bool>. The source=both branch passes final: false to the first phase, final: true to the second.
  • Client (public/js/api.js:148-172): stream(...) closes the EventSource on done only when data.final !== false. Backward-compatible: legacy single-phase producers (/api/stream/batch, /api/stream/pdf*, etc.) don’t set final, so the behaviour is unchanged. Close on error remains unconditional.

🧪 Tests

  • test(scan): tests/scan-stream-multi-phase.test.mjs — 11 cases covering both server-emitted SSE contract and client decision logic:
    • SSE contract (6 cases): source=ats → 1 done (final:true); source=regional → 1 done (final:true); source=both → 2 done events with final:false then final:true; source=both → 2 start events in en-scanner/ru-scanner order; static canary that the pre-v1.29.2 unconditional close pattern is gone; static canary that the v1.29.2 data.final !== false guard is present.
    • Functional proof of fix (3 cases): source=both actually emits the RU-phase banner line (proves the body runs, not just empty shells); ru-scanner start arrives AFTER en-scanner done (ordering); dryRun=1 does NOT modify data/pipeline.md (no phase secretly flips writeFiles).
    • Pure-logic close-decision table (1 case, parametrized over 11 inputs): mirrors the api.js branch in JS — covers done with final:false / true / undefined / null / 0 / 'false', plus error with payload/null, plus start / log (never close).
    • Bug-forensics (1 case): simulates the pre-v1.29.2 client by cancelling the response stream after the first done and verifies the server’s res.on('close') abort handler is still intact (documents the pre-fix mechanism for future readers).
  • 547 → 558 unit + acceptance (+11).

🔄 Migration

No user action needed beyond updating to v1.29.2. The next 🌐 Scan will run both phases.

Verification

$ npm run test:ci
# 553 / 553
# ✓ no .also( leftovers in views/
# ✓ CHANGELOG parity: all 8 locales at v1.29.2

# Manual smoke — both phases should now emit:
$ curl -sN "http://127.0.0.1:4317/api/stream/scan?source=both&dryRun=1" \
    | grep -E '^event:|"final":'
# event: start                                        ← en-scanner
# ...
# event: done                                         ← phase 1 of 2
# data: {"code":0,"counts":{...},"errors":0,"final":false}
# event: start                                        ← ru-scanner
# ...
# event: done                                         ← phase 2 of 2 (final)
# data: {"code":0,"counts":{...},"errors":0,"final":true}

[1.29.1] — 2026-05-14

Detailed user-facing config guide for the 5 RU portals in help-bundle §5, all 8 locales.

📝 Documentation

  • docs(help): §5 "Configuring Russian portals — detailed setup guide" (docs/help/<locale>.md): new ### subsection within Portals & sources covers the end-user config flow:
    • 5-row source-inventory table with auth + geo restrictions per adapter.
    • Step 1 — locate portals.yml + bootstrap from template.
    • Step 2 — full 5-source russian_portals: YAML example.
    • Step 3 — tuning queries, area, per_page, only_remote.
    • Common pitfalls — negative-list collision (with worked example showing the fix).
    • How to disable a single source without losing data.
    • How to verify via 🌐 Scan + the per-source SSE log line shape.
  • Universal YAML/code blocks shared across locales; prose translated for ES / PT-BR / KO / JA / RU / ZH-CN / ZH-TW.
  • §17 (“How to add a new portal”) was the developer flow shipped in v1.29.0; §5 is the user flow shipped in v1.29.1.

🧪 Tests

  • test(help): tests/help-ru-config-section.test.mjs — 7 cases asserting every locale’s §5 contains the 5-source YAML, the negative-list collision fix, the disable-one-source example, the 5 adapter labels in the verify block, the 5-row inventory table, the HH_USER_AGENT env-var reference, and the 17-H2 parity contract held after the edit.
  • 540 → 547 unit + acceptance (+7).

Verification

$ npm run test:ci
# 547 / 547
# ✓ no .also( leftovers in views/
# ✓ CHANGELOG parity: all 8 locales at v1.29.1

# Manual smoke (after redeploy):
$ for lc in en es pt-BR ko ja ru zh-CN zh-TW; do
    curl -fsS "http://127.0.0.1:4317/api/help/$lc" \
      | python3 -c "import sys,json; print('$lc:', 'YES' if 'trudvsem' in json.load(sys.stdin).get('markdown','') else 'NO')"
  done
# every line ends in "YES" — the §5 expansion mentions trudvsem in every locale.

[1.29.0] — 2026-05-14

Russian-portal scanner expanded from 2 to 5 sources. Source registry + dynamic dropdown. New help-section explaining how to add a 12th.

✨ Features

  • feat(scan): 3 new RU portal adapters — Trudvsem, GetMatch, GeekJob (server/lib/sources/):

    • trudvsem.mjs — Russian government open-data API (opendata.trudvsem.ru/api/v1/vacancies). No auth, no IP gate. Normalizes the documented v1 JSON shape.
    • getmatch.mjs — tech-focused RU HTML board. Defensive regex parser, returns [] on parse miss (never throws on healthy 200).
    • geekjob.mjs — same pattern as GetMatch. Handles article and div-wrapped card variants.
  • feat(scan): source registry — single source of truth for every adapter (server/lib/sources/registry.mjs): one array of { value, label, region, configKey } records, consumed by the scanner dispatcher, the GET /api/scan/sources endpoint, and the SPA’s source-filter dropdown. Adding a 12th adapter = one entry here + one adapter file + one row in RU_DISPATCH. The pre-v1.29 three-place drift (hardcoded dropdown / hardcoded if-chain / hardcoded default) is gone.

  • feat(api): GET /api/scan/sources (server/lib/routes/scan.mjs): returns the canonical source list with Cache-Control: max-age=60. The SPA fetches this on #/scan mount and rebuilds the source-filter dropdown dynamically.

  • feat(scan-ui): dynamic source-filter dropdown (public/js/views/scan.js): on view mount, fetches /api/scan/sources and paints <option> entries. Build-time hardcoded fallback list survives if the endpoint is unreachable. The filter chip in #/scan now lists 11 sources (6 EN ATS + 5 RU).

  • feat(ru-scanner): default = 5 sources, dispatcher loop generalized (server/lib/ru-scanner.mjs):

    • Default russian_portals.sources (the value used when portals.yml omits the array) now pulls from registry.mjs::RU_CONFIG_KEYS — 5 sources, not 2.
    • Pre-v1.29 the dispatcher had two hand-written if (cfg.sources.includes('hh')) / if (cfg.sources.includes('habr')) blocks. v1.29 replaces them with a single loop over RU_DISPATCH that’s keyed by the registry. Adding a sixth source = no scanner-loop edit.

📝 Documentation

  • docs(help): new §17 "How to add a new job-portal source" × 8 locales (docs/help/<locale>.md) — full English step-by-step (adapter template for API + HTML patterns, registry entry, dispatcher wiring, mocked unit test, portals.yml enablement); 7 locale versions with localized prose + universal code blocks + cross-link to the EN canonical text for the full pitfalls table.
  • docs(help): §5 + §7 updated for 5 RU sources × 8 locales — russian_portals.sources example now reads ["hh", "habr", "trudvsem", "getmatch", "geekjob"]; the Source-dropdown description names all 5.
  • Help-bundle section count: 16 → 17 (CI parity contract bumped accordingly).

🧪 Tests

  • test(sources): tests/sources-trudvsem.test.mjs — 6 cases: normalization, удалённо→remote inference, onlyRemote filter, 5xx propagation, empty results = no throw, null-record safety.
  • test(sources): tests/sources-getmatch-geekjob.test.mjs — 11 cases across both HTML scrapers: fixture-driven card extraction, nav-anchor skip, empty/null safety, 5xx propagation, onlyRemote filter.
  • test(scan): tests/scan-sources-endpoint.test.mjs — 4 cases: shape, RU-source list parity (5 entries), EN-source list parity (6 entries), Cache-Control header.
  • test(ru-scanner): tests/ru-scanner.test.mjs — e2e dispatcher test extended to mock all 5 sources.
  • test(canonical-docs): 17-H2 parity contract (tests/canonical-docs-coverage.test.mjs) and tests/help-ui.test.mjs — both lifted 16 → 17.
  • 520 → 540 unit + acceptance (+ 20 new). Playwright 32/32 unchanged.

🔄 Migration

For the new RU adapters to fire on your stand, the parent project’s portals.yml must list them:

russian_portals:
  sources: ["hh", "habr", "trudvsem", "getmatch", "geekjob"]
  area: 113
  per_page: 50
  only_remote: false
  queries:
    - "Senior PHP"
    - "Senior Go"

If your portals.yml has NO russian_portals.sources: line at all, the v1.29.0 default kicks in and all 5 sources run automatically. If sources: IS present (as in the pre-v1.29 setup), it’s used verbatim and you must update it manually — the web-ui never edits parent-project files.

Also note: a global title_filter.negative: ["php"] will neutralize every Senior PHP query. The scanner emits a stderr warning at scan time (collision detector from v1.13). Adjust the negative list if you see “0 hits” but expected results.

Verification

$ npm run test:ci
# 540 / 540
# ✓ no .also( leftovers in views/
# ✓ CHANGELOG parity: all 8 locales at v1.29.0

$ curl -fsS http://127.0.0.1:4317/api/scan/sources | jq '.sources | length'
11
$ curl -fsS http://127.0.0.1:4317/api/scan/sources | jq '[.sources[] | select(.region=="ru") | .value]'
[ "geekjob", "getmatch", "habr-career", "hh.ru", "trudvsem" ]

[1.28.1] — 2026-05-14

Hot-fix: router 404 on hashes with ?query. HH_USER_AGENT row pruned from health.

🚑 Critical hot-fix

  • fix(router): strip ?query before route lookup (public/js/router.js) — pre-v1.28.1 Router.go('/evaluate?url=…') produced a hash whose first split('/') segment was the whole "evaluate?url=…" literal, which never matched a registered route → __not_found__ (404). Two reported failures had this single root cause:
    • #/pipeline → ▶ button (pipeline.js:145: Router.go('/evaluate?url=' + encodeURIComponent(url))).
    • “App settings → Modes” deep link (settings.js:80: href="#/config?tab=modes"). Fix is one line: hash.split('?')[0] before the route-name split. The view itself continues to parse query strings via window.location.hash.split('?')[1] + URLSearchParams (see evaluate.js, config.js).

🧹 Cleanup

  • fix(health): remove HH_USER_AGENT optional row (server/lib/routes/health.mjs:54) — the row surfaced "unset (hh.ru may 403 from non-RU IPs)" on every Health page render, including for users who never scan hh.ru. The hh.ru adapter falls back to a baked-in UA when the env var is unset; the 403-from-non-RU gate is still documented in docs/help/<locale>.md §16 (troubleshooting) and server/lib/ru-scanner.mjs still emits a stderr hint at scan time. Removing the row reduces dashboard noise without losing any diagnostic.

🧪 Tests

  • test(router): tests/router-query-string.test.mjs — 3 cases: static-source canary (router.js must split off ? before name lookup), explainer-comment canary (the v1.28.1 fix rationale stays in the source), and pure-logic simulation of current() over four representative hashes (#/evaluate?url=…, #/config?tab=modes, #/reports/abc-123, #/dashboard).
  • test(health): tests/health-no-hh-user-agent-row.test.mjs — 2 cases: regression guard that /api/health no longer surfaces HH_USER_AGENT row; sanity that adjacent optional rows survived the prune.
  • 515 → 520 unit + acceptance (+ 5 new). Playwright 32/32 unchanged.

Verification

$ npm run test:ci
# 520 / 520
# ✓ no .also( leftovers in views/
# ✓ CHANGELOG parity: all 8 locales at v1.28.1

# Manual smoke (after redeploy):
$ open "http://127.0.0.1:4317/#/evaluate?url=https%3A%2F%2Fexample.com%2Fjd"
# (should render the Evaluate view with the URL prefilled — no 404)
$ open "http://127.0.0.1:4317/#/config?tab=modes"
# (should land directly on the Modes tab of App settings — no 404)

[1.28.0] — 2026-05-14

Docs alignment + #/batch --max-retries N UI surface. Closes two open backlog items raised by qa/QA-PROMPT-docs-vs-app.md.

✨ Features

  • feat(batch): surface --max-retries N control on #/batch (Issue #2) — the canonical batch-evaluate-offers guide documents --max-retries N (default 2) but pre-v1.28 the SPA had no way to set it; users were stuck on the runner default.
    • public/js/views/batch.js — new numeric input (1..10), disabled by default, enables only when “Retry failed” is checked. Clears its value when retry is unchecked so an orphaned value can’t leak into the next run.
    • server/lib/routes/batch.mjs — GET /api/stream/batch?retry=1&maxRetries=N: parses via parseInt, range-validates 1 ≤ N ≤ 10, silently drops out-of-range/non-integer values (UI is the hard contract; server is defense-in-depth). No-op without --retry-failed.
    • i18n: 2 new keys × 8 locales (batch.maxRetriesLbl, batch.maxRetriesAria) in public/js/lib/i18n-dict.js.

📝 Documentation

  • docs: align AI-assistant list to career-ops.org/docs canonical (Issue #1) — the upstream Quick Start lists Claude Code / Codex / OpenCode / Qwen CLI. Pre-v1.28 we drifted to Claude Code / Codex / Cursor / Gemini CLI / GitHub Copilot CLI, identically across all 8 locales. Resolution: aligned downstream.
    • 8 help-bundles (docs/help/<locale>.md) — intro paragraph + comparison-table row both now match upstream canonical. One-liner appended: “other Claude-compatible CLIs work too via the same slash-command surface”, localized per locale.
    • 8 READMEs (README*.md) — intro paragraph aligned with the same one-liner. The “Multi-CLI” feature bullet (about web-ui’s own CLAUDE.md/AGENTS.md/GEMINI.md shim files, not about career-ops upstream) deliberately retains its wider list (Cursor / Aider / Gemini CLI) since those CLIs do drive our shims.

🧪 Tests

  • test(canonical-docs): AI-list regression canaries — 2 new canaries in tests/canonical-docs-coverage.test.mjs:
    • Every help-bundle + README must contain “OpenCode” and “Qwen CLI”.
    • No help-bundle or README may contain the pre-v1.28 stale phrase “Cursor, Gemini CLI, GitHub Copilot CLI” (Latin or CJK delimiter).
  • test(batch): tests/batch-max-retries.test.mjs — 7 cases covering: present (maxRetries=3 → flag appended), out-of-range upper (=11 → dropped), out-of-range lower (=0 → dropped), non-integer (=abc → dropped), without retry=1 (always dropped), no-param (runner default 2 wins), combined-with-all-other-flags.
  • 506 → 515 unit + acceptance tests (+ 7 max-retries + 2 AI-list canaries). Playwright 32/32 unchanged.

📒 Deferred (unchanged)

  • G-005 A-G → A-F report-block realignment — still requires a coordinated commit on the parent santifer/career-ops modes/oferta.md. Tracked in qa/REGRESSION-v1.27.md §11.

Verification

$ npm run test:ci
# 515 / 515
# ✓ no .also( leftovers in views/
# ✓ CHANGELOG parity: all 8 locales at v1.28.0

# Manual smoke:
$ curl -sS http://127.0.0.1:4317/api/health | jq '.version'
"1.28.0"

# AI-list canaries — every help-bundle + README mentions OpenCode + Qwen CLI:
$ for f in docs/help/*.md README*.md; do
    grep -L 'OpenCode' "$f" && echo "  FAIL: $f"
  done
# (silent — all 16 files green)

# max-retries flag pass-through:
$ curl -sS http://127.0.0.1:4317/api/stream/batch?retry=1&maxRetries=3 | head -3
# event: error                                      (runner missing in stand)
# data: {"message":"batch/batch-runner.sh not found..."}
# (the path is exercised by tests/batch-max-retries.test.mjs against a stub runner)

[1.27.0] — 2026-05-14

Cosmetic + a11y polish: deduplicate sidebar #/dashboard entry.

🩹 Cosmetic / a11y

  • fix(sidebar): dedupe #/dashboard entry (public/index.html:25-32) — the brand logo block (<a class="logo" href="#/dashboard">) and the first nav item (<a class="nav-item" href="#/dashboard">) both targeted the same route. Screen readers announced “Dashboard” twice in a row when entering the sidebar, and keyboard users had a useless extra tab stop on a control with no distinct purpose. The brand block now renders as a plain <div class="logo">. The Dashboard nav item remains the sole link.

📒 Deferred (parent-coordinated)

  • G-005 / PR-B (A-G → A-F report block realignment) — still requires a coordinated commit on the parent santifer/career-ops modes/oferta.md. Tracked in qa/REGRESSION-v1.27.md §Deferred.

🧪 Tests

  • 506 / 506 unit + 32 / 32 Playwright — unchanged. No new test required (cosmetic-only).
  • CHANGELOG parity gate: ✓ all 8 locales at v1.27.0.

Verification

$ npm run test:ci
# 506 / 506
# ✓ no .also( leftovers in views/
# ✓ CHANGELOG parity: all 8 locales at v1.27.0

# Manual smoke (after restart):
$ curl -sS http://127.0.0.1:4317/api/health | jq '.version'
"1.27.0"
$ curl -sS http://127.0.0.1:4317/ | grep -c 'href="#/dashboard"'
1   # was 2 on v1.26.1

[1.26.1] — 2026-05-14

Hot-fix: WCAG 2.5.5 — header .btn height restored to 44 px floor.

🚑 Critical hot-fix

  • fix(css): restore min-height: 44px + line-height + flex-shrink:0 on .btn (public/css/app.css:391-410) — live Playwright measurement on v1.26.0 found 5 header buttons rendering at 39-41 px (Doctor / Quick scan / Open Pipeline / 🌐 Scan now / ✨ Auto-pipeline a URL) — a WCAG 2.5.5 violation. The fix:
    • Adds min-height: 44px to .btn. A stale comment at line 427-430 still claimed this floor was in place, but the declaration itself had been lost between v1.18 and v1.26.
    • Adds line-height: 1.2 so the in-block text doesn’t compute the row taller than intended on browsers with looser default leading.
    • Adds flex-shrink: 0 + box-sizing: border-box to keep parent flex rows from squashing the button under their own height constraints.
    • .btn-sm keeps its existing 32 px floor (small-control exception per WCAG 2.5.5 + 2.5.8 spaced-target) — .btn-sm follows .btn in source order so the override applies.

🧪 Tests

  • test(wcag): tests/wcag-target-size.test.mjs — 4 static CSS canaries:
    • .btn block has min-height: 44px
    • .btn block has flex-shrink: 0
    • .btn-sm keeps min-height: 32px
    • .btn-sm defined AFTER .btn in source order (cascade)
  • 502 → 506 unit (+4) + 32/32 Playwright unchanged.
  • Live verification via headless Chromium across all 13 sidebar routes — every .btn:not(.btn-sm) measured ≥ 44 × 44 px after the fix.

Verification

$ npm run test:ci
# 506 / 506
# ✓ no .also( leftovers in views/
# ✓ CHANGELOG parity: all 8 locales at v1.26.1

# Live Playwright probe (run against server on :4317):
$ for route in /dashboard /scan /pipeline /evaluate /batch /reports /tracker \
                /activity /cv /profile /config /health /help; do
    # use the parent project's playwright to measure
    cd $CAREER_OPS_ROOT && node -e "
      const { chromium } = require('playwright');
      (async () => {
        const browser = await chromium.launch({ headless: true });
        const page = await (await browser.newContext()).newPage();
        await page.goto('http://127.0.0.1:4317/#$route', { waitUntil: 'networkidle' });
        const bad = await page.\$\$eval('.btn:not(.btn-sm)', els =>
          els.filter(b => b.getBoundingClientRect().height < 44).length);
        console.log('$route:', bad === 0 ? 'PASS' : 'FAIL ' + bad);
        await browser.close();
      })();
    "
  done
# Every route → PASS

Out of scope (v1.27.0)

Item Notes
G-005 — A-G → A-F report block realignment Still waiting on coordinated parent commit.
G-003 — README.cn.md rename Verified-already-closed: repo has README.zh-CN.md, no orphan.
Sidebar duplicate #/dashboard (brand logo + nav item) Trivial cosmetic, zero UX impact.

[1.26.0] — 2026-05-14

Test pyramid (unit → functional → acceptance → e2e) + coverage push to ≥ 93 % line / ≥ 83 % branch. Adopts the 4-tier structure mandated by the v1.25 backlog. Adds 22 new tests targeting the biggest coverage gaps from v1.25’s npm run test:coverage report; introduces the tests/acceptance/ directory for cross-endpoint user-journey tests.

📐 Test pyramid documentation

  • docs(architecture): TESTING.md describes the 4-tier pyramid (docs/architecture/TESTING.md) — single-source explanation of how the suite is structured and where new tests land:
    • Tier 1 (unit) — pure helpers (security, parsers, prompts, file-lock, rate-limit, safe-fetch, env-config). No port binding, no FS beyond stubs, no subprocess.
    • Tier 2 (functional) — per-endpoint contracts. createApp() against ephemeral port + temp CAREER_OPS_ROOT.
    • Tier 3 (acceptance) — multi-endpoint user journeys. NEW tests/acceptance/ directory.
    • Tier 4 (e2e) — Playwright headless Chromium (tests/playwright-{smoke,full-cycle}.mjs, tests/e2e{,-comprehensive}.mjs).
  • 100% line coverage target is explicitly scoped to working functionality — the if (isMain) { … } boot block in server/index.mjs and live-LLM call paths in auto-pipeline.mjs are documented exclusions.

🧪 Tier 2 — Functional gap fills

  • test(jds): jds-list-create-get.test.mjs — pre-v1.26 only the DELETE path was tested. New file adds 10 tests covering:
    • GET /api/jds shape on empty + populated state
    • POST /api/jds with explicit slug, auto-generated slug, slug normalization warning
    • POST /api/jds empty-body / stripped-to-empty-slug 400 responses
    • GET /api/jds/:name body roundtrip, 404 on unknown, traversal rejection
    • server/lib/routes/jds.mjs coverage: 61.64 % → 100 % line.
  • test(auto-pipeline): auto-pipeline-rejection-paths.test.mjs — 10 tests covering every URL rejection branch that doesn’t need a live LLM:
    • javascript: / file:// / malformed-string / empty / no-key-body
    • SSRF: loopback, RFC1918, link-local IMDS (169.254.169.254)
    • mode: 'manual' interaction with rejected URL — error precedes any done event
    • server/lib/routes/auto-pipeline.mjs branch coverage: 50.00 % → 59.38 %.

🧪 Tier 3 — Acceptance (NEW)

  • test(acceptance): jd-evaluate-tracker-flow.test.mjs — first cross-endpoint user journey. Threads 7 endpoints in the order the SPA invokes them:

    1. POST /api/jds — save raw JD
    2. GET /api/jds — confirm in list
    3. GET /api/jds/:name — read body verbatim
    4. POST /api/evaluate (manual fallback) — generate prompt
    5. POST /api/tracker — add row
    6. GET /api/tracker — verify presence
    7. GET /api/activity — confirm audit-log entry

    Second journey: pipeline-add → preview → tracker → delete cycle.

🧪 Test count

  • 480 → 502 unit + acceptance (+22). 32/32 Playwright unchanged.
  • npm test now runs tests/*.test.mjs tests/acceptance/*.test.mjs.
  • npm run test:coverage same. npm run test:ci runs all of the above plus the two CI gates from v1.24.1 (check-no-also-leftovers) and v1.25.0 (check-changelog-parity).

Coverage snapshot

all files                     | 93.66 line | 83.73 branch | 92.91 func
server/lib/security.mjs       | 99.30 line
server/lib/safe-fetch.mjs     | 95.81 line
server/lib/file-lock.mjs      | 93.15 line
server/lib/rate-limit.mjs     | 100.00 line
server/lib/parsers.mjs        | 99.57 line
server/lib/routes/jds.mjs     | 100.00 line  ← was 61.64 in v1.25
server/lib/routes/tracker.mjs | 100.00 line
server/lib/routes/reports.mjs | 100.00 line
server/lib/routes/activity.mjs| 100.00 line

Remaining < 95 % line modules are gated by live-LLM / spawn-mock complexity:

  • auto-pipeline.mjs (46 %) — uncovered region is the live Anthropic / Gemini evaluate path + report-save + tracker-write SSE flow. Out of scope for the 100 % target per TESTING.md “Goal: 100% line coverage of working functionality”.
  • batch.mjs (67 %) — uncovered region is streamNodeScript spawn of batch-runner.sh. Out of scope (subprocess mocking gap).
  • cv-import.mjs (77 %) — uncovered region is pandoc / pdftotext fallback paths when the system tools are missing. Out of scope (env-dependent).

Verification

$ npm run test:ci
# 502 / 502
# ✓ no .also( leftovers in views/
# ✓ CHANGELOG parity: all 8 locales at v1.26.0

$ npm run test:coverage 2>&1 | grep '^# all files'
# all files | 93.66 | 83.73 | 92.91

$ ls tests/acceptance/
# jd-evaluate-tracker-flow.test.mjs

Breaking changes

None.

Out of scope (v1.27+)

Item Notes
Live LLM-path coverage in auto-pipeline.mjs Needs SDK-adapter mock + withFileLock stub + report-save stub. Currently 46 % line; would push toward 95 % with the mock harness.
Subprocess-mocked coverage for batch.mjs::streamNodeScript Needs spawn-mock; would push 67 % → 90 %.
cv-import.mjs pandoc / pdftotext fallback path Needs env-injected which() stub.
G-005 (A-F report block realignment) Still waiting on coordinated parent commit.

[1.25.0] — 2026-05-14

Auto-pipeline manual short-circuit + dashboard cosmetic + CHANGELOG parity backfill. Closes G-014 (auto-pipeline ignored mode: 'manual'), G-012 (CHANGELOG parity drift — 6 locales were 2 releases behind), and the dashboard ✨ ✨ double-glyph cosmetic. G-003 (README.cn.md rename) was de-facto already closed — repo only has README.zh-CN.md. G-005 (A-G → A-F report block realignment) requires a coordinated parent-project commit and stays deferred.

🛡️ G-014 — Auto-pipeline mode: 'manual' short-circuit

  • fix(auto-pipeline): G-014 — honour mode:'manual' short-circuit (server/lib/routes/auto-pipeline.mjs:158-195) — pre-v1.25 the route always called an LLM. Passing mode: 'manual' (mirroring /api/evaluate since v1.10.2) was silently ignored, the request hung 1-3 min on Anthropic. Now the handler:
    • Accepts mode AND evalMode for back-compat. Either value of 'manual' triggers the short-circuit.
    • Emits all 5 SSE stages with status: 'done' / status: 'skipped'. No fetch. No LLM call. No $0.05 per request.
    • done payload carries { mode: 'manual', prompt: <buildEvaluationPrompt scaffold>, message } — the SPA can render it like the existing /api/evaluate manual-prompt card.
  • Closes DoS-risk on HOST=0.0.0.0: previously, even with llmRateLimit capping 10 req/60s/IP, 10 attackers × 10 reqs = $50/min in Anthropic burn. Short-circuit fires before the rate-limit decrement counts toward a real call.
  • Tests — tests/auto-pipeline-manual-mode.test.mjs: 3 tests confirm (1) mode: 'manual' returns < 2 s with all 5 step keys, (2) even with ANTHROPIC_API_KEY set the short-circuit still fires (the original symptom), (3) legacy evalMode: 'manual' callers keep working.

📝 G-012 — CHANGELOG parity backfill (6 locales × 2 missing releases)

  • docs(changelog): backfill v1.23.0, v1.24.0, v1.24.1, v1.25.0 in 6 lagging locales — pre-v1.25 only EN had v1.23-v1.24; RU was 1 release behind, the other 6 were 2 releases behind. v1.25 dispatches parallel translation agents (mirrors the v1.23 pattern) to land all four entries in CHANGELOG.{es,pt-BR,ko-KR,ja,zh-CN,zh-TW}.md. RU gets v1.24.0 + v1.24.1 + v1.25.0 (it already had v1.23.0 from the v1.23 cycle).
  • feat(ci): scripts/check-changelog-parity.mjs gate — fails the build if any locale CHANGELOG’s newest entry is older than the EN canonical. Wired into npm run test:ci. Pre-existing G-012 drift would have caught itself the moment it crossed the EN boundary.

✨ Cosmetic — dashboard double-glyph dedup

  • fix(dashboard): dedup ✨ glyph in auto-pipeline button label (public/js/lib/i18n-dict.js:219) — dash.autoPipeline carried a leading ✨ in every locale string AND public/js/views/dashboard.js:58 prepended another ✨ in the view. Result: button rendered ✨ ✨ Auto-pipeline …. v1.25 drops the leading glyph from every locale’s DICT entry; the view’s prefix is the single source. Same audit pass swept the rest of the i18n bundle — no other double-glyph patterns found.

🚫 Deferred to a future release

  • G-005 — Report block A-G → A-F per canonical career-ops.org/docs — requires a coordinated commit in the parent santifer/career-ops project (rewrite modes/oferta.md to emit A=Role, B=CV-match, C=Strategy, D=Comp, E=Personalization, F=STAR — drop C-Risks/G-Legitimacy as separate blocks). v1.25.0 ships the web-ui side ready for the new schema (reports.js already accepts arbitrary block letters since v1.13). Tracked for the next release window when parent + child can land together.
  • G-003 — README.cn.md → README.zh-CN.md rename — verified during v1.25 prep: repo already has README.zh-CN.md (no orphan README.cn.md anywhere under the worktree). The G-003 finding was stale.

🧪 Tests

  • 477 → 480 unit (+3 from PR-B auto-pipeline-manual-mode.test.mjs).
  • 32/32 Playwright unchanged.
  • npm run test:ci now runs npm test + check-no-also-leftovers.mjs + check-changelog-parity.mjs.

Verification

$ npm run test:ci
# 480 / 480
# ✓ no .also( leftovers in views/
# ✓ CHANGELOG parity: all 8 locales at v1.25.0

# G-014 — manual mode returns < 2 s even with ANTHROPIC_API_KEY set:
$ ANTHROPIC_API_KEY=sk-ant-test PORT=4317 npm start &
$ sleep 3
$ time curl -sS -X POST -H 'Content-Type: application/json' \
    -d '{"url":"https://job-boards.greenhouse.io/anthropic/jobs/x","mode":"manual"}' \
    http://127.0.0.1:4317/api/auto-pipeline | head -20
# real  0m0.1xx s  (was 1-3 min)
# event: start … event: step (×5) … event: done {"mode":"manual","prompt":"…"}

# G-012 — every locale CHANGELOG carries the v1.25.0 entry:
$ grep -c '^## \[1.25.0\]' CHANGELOG*.md
# 8 files, each → 1

# Cosmetic — dashboard glyph:
$ grep "dash.autoPipeline" public/js/lib/i18n-dict.js
# No leading ✨ in any locale value (view supplies the single glyph)

Breaking changes

None. mode: 'manual' is opt-in; legacy evalMode: 'manual' callers keep working unchanged.

Out of scope (v1.26+)

Item Notes
G-005 — A-F report block realignment Needs coordinated parent commit (santifer/career-ops rewrites modes/oferta.md).
Live execution of QA scenario 31 visual sub-tests Require browser-driven agent (Claude Cowork). Covered partially by Playwright smoke.
i18n-dict.js over 400-LOC target Translation fixture — exempt by policy. Split would add HTTP requests without a bundler.

[1.24.1] — 2026-05-14

Hot-fix: #/config crash on all 8 locales (G-015).

🚑 Critical hot-fix

  • fix(config): G-015 — replace removed Element.prototype.also call in config.js (public/js/views/config.js:371) — v1.22.0 N-2 dropped the Element.prototype.also global monkey-patch and migrated cv.js to a free-statement pattern, but missed config.js. The result: #/config crashed at first invocation on every locale with c(...).also is not a function. v1.24.1 applies the same migration pattern from cv.js:188-201 — extract the tree to a const root = c(...), run the activation block on its own, then return root;.

🛡️ CI gate

  • feat(ci): scripts/check-no-also-leftovers.mjs sweep — walks every file under public/js/views/ and fails the build on any .also( call site (commented references allowed). Wired into the new npm run test:ci script. Future revert of the monkey-patch removal can’t re-introduce the same regression silently.

🧪 Tests

  • test: tests/config-view-syntax.test.mjs — three guards:
    • parse config.js via node:vm.Script (catches syntax-level regressions without needing Playwright)
    • assert no .also( survives outside comments
    • assert the const root = c(...) / return root; migration anchors are present
  • 474 → 477 unit (+3) + 32/32 Playwright unchanged.

Verification

$ npm run test:ci
# 477 / 477
# ✓ no .also( leftovers in views/

# Browser smoke:
$ open http://127.0.0.1:4317/#/config
# → renders normally, no "is not a function" card. Every locale equivalent.

Out of scope (deferred to v1.25)

  • G-014, G-012, G-005, G-003 — see v1.25.0 entry below for the bundle.

[1.24.0] — 2026-05-14

Help-bundle content-depth refresh + live execution of QA scenario 31 + RU CHANGELOG end-to-end. Closes both items the v1.23.0 “Out of scope” table deferred to v1.24: the full content-depth refresh of all 8 help bundles from the 5 canonical career-ops.org/docs URLs (was URL-coverage-only since v1.11.x), and the live execution of QA scenario 31 against a running server (was “needs browser agent + LLM credentials” — turned out 6/6 sub-tests are reachable via curl + grep, only the visual sub-tests need a browser).

📖 Help-bundle content-depth refresh

  • docs(help): refresh en.md from 5 canonical career-ops.org/docs URLs (docs/help/en.md) — pre-v1.24 the EN bundle was 1113 lines and listed the 5 canonical URLs in the front-matter but didn’t expand on them in the body. v1.24 fetches all 5 URLs via WebFetch and deepens the matching H2 sections:
    • About career-ops (front-matter) — added principles (data sovereignty, AI-agnostic, human-controlled), “What career-ops is NOT” block, expanded concepts inventory from 6 to 10 rows (added Proof points, JD store, Interview-prep, Batch additions).
    • §5 Portals — added canonical bootstrap cp templates/portals.example.yml portals.yml, clarified required vs optional fields per tracked_companies entry.
    • §7 Scan — added “no AI tokens consumed” note for Option A, follow-up commands list (apply / contacto / deep / tracker).
    • §14 Apply checklist — split into SPA checklist mode vs Manual-vs-Playwright-assisted vs Full CLI flow (canonical 8 numbered steps from /career-ops apply <company> to Submitted. with Evaluated → Applied auto-transition); batch evaluate subsection now has TSV schema table + all 4 flags documented + merge-tracker.mjs --dry-run; Playwright Setup subsection lists install commands, MCP registration, alternative .claude/settings.local.json, headless-by-default note.
  • 16-H2 section parity preserved (CI test help-ui.test.mjs::section-parity asserts exactly 16 H2 sections across all 8 locales).
  • Each of the 5 canonical URLs appears ≥ 2 times in the bundle (CI test canonical-docs-coverage.test.mjs enforces). Per-URL count after v1.24: what-is-career-ops × 4, scan-job-portals × 5, apply-for-a-job × 3, batch-evaluate-offers × 5, set-up-playwright × 3.
  • docs(help): translate the v1.24 deepening to 7 non-EN locales — 7 parallel translation agents dispatched. Each target locale (es / pt-BR / ko-KR / ja / ru / zh-CN / zh-TW) gets a refreshed bundle that mirrors the EN structure section-for-section, preserves verbatim code blocks / URLs / file paths / button labels (📁 Upload CV / 🌐 Scan now / ▶ Evaluate / 📄 Generate PDF / 💾 Save) and English abbreviations (CSP, SSRF, TOCTOU, WCAG, ATS, JD, SSE, REST, API), and translates the deepening to publication-grade native technical style in the target language.

🧪 QA scenario 31 — live execution (6/6 PASS)

  • docs(qa): append last-verified live-execution log to qa/claude-cowork-browser-test-prompt.md — pre-v1.24 scenario 31 was documented but never run against a live server (deferred as “needs browser agent + LLM credentials”). v1.24 ran all 6 sub-tests against http://127.0.0.1:4317:

    Sub Description Status
    31.1 Score thresholds in help bundles ✅ PASS (4.5 × 3, 4.0 × 9, 3.5 × 6 mentions in docs/help/en.md)
    31.2 Scan workflow endpoints ✅ PASS (/api/stream/scan-{en,ru} + /api/scan-ru/config → 404; /api/scan/regional/config → 200)
    31.3 /api/apply-helper checklist ✅ PASS (body contains career-ops apply + auto-submit warning)
    31.4 /api/batch endpoint ✅ PASS (keys [exists, runnerExists, raw, rows, additions])
    31.5 Playwright availability ✅ PASS (/api/health reports Playwright (parent node_modules) ok: true, value: installed)
    31.6 Help-bundle URL coverage (5 URLs × 8 locales) ✅ PASS (40 / 40 ✓)

    Visual-only sub-tests (require browser) flagged separately in the QA prompt — they remain runnable via Claude Cowork or npm run test:e2e:browser.

🌐 RU CHANGELOG end-to-end (M-9 follow-up)

  • docs(translate): CHANGELOG.ru.md retry agent — full body translation (CHANGELOG.ru.md) — the v1.23.0 release shipped with the RU CHANGELOG retry agent still in flight (it had crashed once with a socket error and was re-dispatched). v1.24 picks up the agent’s 1542-line full translation: every entry v1.23.0 → v1.6.0 has a publication-grade Russian body, no more EN-bodied stop-gaps. Style discipline matches the v1.22.0 README quality refresh: “функциональность” / “возможности” / “поведение” replace clunky “функционал”; “через” / “с помощью” replace “при помощи”; active voice over passive; “эндпоинт”, “лимит запросов”, “состояние гонки”, “санитайзинг” as canonical terms; English abbreviations (TOCTOU, CSP, SSRF, WCAG, ATS, JD, SSE, REST, API) preserved.

🧪 Tests

  • 474 / 474 unit + 20 / 20 smoke E2E + 32 / 32 Playwright. Zero behavioral test deltas; every help-bundle CI assertion (16 H2 sections × 8 locales, 5 URLs × ≥ 2 mentions, content floor) still green.

Verification

$ npm test                            # 474 / 474

# Help-bundle deepening:
$ wc -l docs/help/en.md
# ~1270 lines (was 1113 — deepened, not bloated)

$ for url in what-is-career-ops scan-job-portals apply-for-a-job \
             batch-evaluate-offers set-up-playwright; do
    echo -n "$url: "
    grep -c "$url" docs/help/en.md
  done
# what-is-career-ops: 4
# scan-job-portals: 5
# apply-for-a-job: 3
# batch-evaluate-offers: 5
# set-up-playwright: 3

# Scenario 31.6 — 40/40 URL coverage:
$ for lang in en es pt-BR ko ja ru zh-CN zh-TW; do
    echo -n "$lang: "
    for url in what-is-career-ops scan-job-portals apply-for-a-job \
               batch-evaluate-offers set-up-playwright; do
      curl -sS "http://127.0.0.1:4317/api/help/$lang" \
        | python3 -c "import sys,json; print(json.load(sys.stdin).get('markdown',''))" \
        | grep -q "$url" && echo -n "✓ " || echo -n "✗ "
    done
    echo
  done

Breaking changes

None.

Out of scope (v1.25+)

Item Notes
Live execution of scenario 31 visual sub-tests Require browser-driven agent (Claude Cowork or npm run test:e2e:browser). Out of scope for curl-only execution; covered by existing Playwright smoke.
RU CHANGELOG body translation of older entries (v1.5.x and below) The retry agent only covered v1.6.0 onwards. Pre-v1.6 entries (v1.5.x, etc.) — if they ever existed — remain pre-existing-content.
Visual regression on dashboard screenshots after future SPA changes scripts/capture-dashboard-screenshots.mjs regenerates per-locale PNGs; no automated diff currently.


career-ops-ui v1.267.1 · page 9/11 · mirror of the project changelog for cvstart.org