மாற்றப் பதிவு
GitHubஒவ்வொரு குறிப்பிடத்தக்க மாற்றமும், வெளியீடு வாரியாக. இதே கோப்பு ரிப்போவில் இருக்கிறது மற்றும் அனைத்து 19 மொழிகளிலும் மொழிபெயர்க்கப்பட்டுள்ளது.
[1.32.0] — 2026-05-17
#/config Profile tab — raw-YAML blob → field-by-field form (WS1).
Before v1.32.0 the Profile tab was a single monospace textarea where name, email, location, archetypes, compensation and every custom key lived in one undifferentiated YAML blob — the exact pain the user called out (“всё в одной куче”). It is now a structured form.
✨ Features
feat(config): Profile field-form with merge-not-replace savepublic/js/views/config.js— 14 modeled scalar paths grouped into 3 collapsible sections: Candidate (full_name/email/phone/location/linkedin/github/portfolio_url/twitter), Narrative (headline/exit_story), Compensation (target_range/currency/minimum/location_flexibility). Full-name client-side required-check before save.server/lib/routes/content.mjs—PUT /api/profilegains a{ fields: { "candidate.full_name": … } }payload. Server reads the existingconfig/profile.yml, sets/clears only the allow-listed leaves, re-serializes the whole object. Arrays the form doesn’t model (target_roles.archetypes,narrative.proof_points,narrative.superpowers) and any custom keys survive the round-trip untouched — the load-bearing invariant (PLAN R1). Empty field → leaf deleted (nophone: ""residue). Allow-list rejects unknown dotted paths; identity gate (full name required) preserved; corrupt existing YAML → 409 (refuses to clobber, routes user to raw editor).- Raw-YAML escape hatch retained as a collapsed Advanced
<details>— the pre-1.32 full-file editor, unchanged ({ yaml }path, replaces whole file, preserves comments). For nested-array edits or comment preservation. - i18n: 23 new
config.pf*/config.profile*keys × 8 locales.
📝 Documentation
- help-bundle §2 (App settings) + §3 (Profile) × 8 locales — rewritten for the 3-tab layout + field-form description + the comment-loss tradeoff + Advanced raw-YAML disclosure.
🧪 Tests
test(config): tests/profile-field-form.test.mjs— 7 cases: arrays + unknown-key survival across a field-merge round-trip (the R1 gate), empty-field leaf-deletion, unknown-path rejection, no-full-name rejection, corrupt-YAML 409 guard, legacy raw{ yaml }path still works, value trimming.- 574 → 581 unit + acceptance (+7).
🔄 Migration
No user action. First Profile-tab save after upgrade merges into the existing file; comments are dropped on a field save (use the Advanced raw editor to keep them). All data keys preserved.
Verification
$ npm run test:ci
# 581 / 581 · ✓ no .also( leftovers · ✓ CHANGELOG parity: all 8 locales at v1.32.0
[1.31.0] — 2026-05-17
Parent career-ops 1.8.0 sync — #/batch exposes --model + --start-from.
The parent project bumped 1.7.1 → 1.8.0. The user-relevant code delta: batch-runner.sh gained --model NAME (#504) and --start-from N. web-ui surfaces both on #/batch, completing flag parity (the runner’s full surface — --parallel --dry-run --retry-failed --start-from --max-retries --min-score --model — is now reachable from the browser).
✨ Features
feat(batch): surface --model + --start-frompublic/js/views/batch.js— new Model text input (optional; placeholder “Claude Max default”) and Start from # numeric input (optional; min 1). Appended to the run-control row next to Max retries.server/lib/routes/batch.mjs—GET /api/stream/batch?model=…&startFrom=…. Defense-in-depth (same posture as v1.28.0--max-retries):modelaccepted only if it matches^[A-Za-z0-9.\-]{1,60}$(rejects shell-meta even thoughspawn()is arg-array — belt-and-suspenders);startFromaccepted only as an integer 1..100000. Bad values silently dropped, not 400’d — the UI input is the soft contract.- i18n:
batch.modelLbl,batch.modelAria,batch.startFromLbl,batch.startFromAria× 8 locales.
📝 Documentation
- help-bundle §14 (Batch evaluate) × 8 locales —
--model+--start-fromdocumented in the flag list with the#/batchfield-name mapping. - Parent career-ops 1.8.0 analysis recorded. Other parent deltas assessed as no web-ui code change required: interview-prep audience split (#489) flows through
bundleProjectContextautomatically;dashboard/is the parent’s Go/Bubble-Tea terminal TUI (browser equivalent is our SPA — not integrated by design);modes/tr/Turkish templates are parent-side (a 9th web-ui UI locale is a separate deferred decision);parentVersionauto-reports 1.8.0 viahealth.mjsruntime read.
🧪 Tests
test(batch): tests/batch-model-startfrom.test.mjs— 7 cases: model pass-through, shell-meta charset rejection (injection guard), empty-model drop, startFrom pass-through, out-of-range drop (< 1), non-integer drop, combined-with-other-flags coexistence.- 567 → 574 unit + acceptance (+7).
🔄 Migration
No user action. The next 🌐 Batch run with the Model / Start-from fields blank behaves exactly as before (Claude Max default, start at offer #1).
Verification
$ npm run test:ci
# 574 / 574 · ✓ no .also( leftovers · ✓ CHANGELOG parity: all 8 locales at v1.31.0
$ curl -sN 'http://127.0.0.1:4317/api/stream/batch?model=claude-sonnet-4-6&startFrom=5' | grep -m1 '"args"'
# data: {"script":"batch-runner.sh","args":["--model","claude-sonnet-4-6","--start-from","5"]}
[1.30.0] — 2026-05-14
#/scan results paginator — replaces the v1.12.0 “first 200 of N” truncation.
✨ Features
feat(scan): paginate over full filtered result set(public/js/views/scan.js) — pre-v1.30 the scan results table was hard-capped at the first 200 filtered rows with a footnote saying “Showing first 200 of N”. Rows 201..N were unreachable from the UI; the user had to re-tunetitle_filter.positiveinportals.ymlto narrow the set if they wanted to inspect later rows. v1.30.0 swaps the cap forUI.paginate(the same helper that drives#/tracker,#/reports,#/activity).PAGE_SIZE = 200preserves the prior visual density per page.- The FULL filtered set is sorted first (boost-to-top is stable across pages), then page-sliced — so a boosted row that lands on page 2 still appears at the top of page 2, not buried.
- Filter input (text / source / remote / scope / chips) calls
pager.reset()so the user lands on page 1 of the new filter result. pager.controls(visible, total)renders« ‹ N-M of K › »with disabled-state buttons when on first / last page. Whentotal ≤ pageSize, the controls show only the item count (clean for small datasets).
- Stale
scan.shownTopi18n key removed frompublic/js/lib/i18n-dict.js(× 8 locales — no longer referenced).
🧪 Tests
test(scan): tests/scan-paginator.test.mjs— 9 cases across three layers:- Static-source canaries (7): scan.js declares
PAGE_SIZE = 200; wiresUI.paginate({ pageSize: PAGE_SIZE, onChange: …renderResults… }); resets pager on filter input; sorts the FULLrows.slice()intosortedAllBEFORE paginating; usespager.slice(sortedAll); appendspager.controls(sorted.length, rows.length)after the table; no longer contains the pre-v1.30rows.slice(0, 200)truncation.i18n-dict.jsdoes NOT carry the stalescan.shownTopkey.api.jsstill exports thepaginate()helper with.slice / .controls / .resetsurface. - Pure-logic paginator table (1): replicates clamp+slice rules and exercises 6 boundary cases — page 0 of 550 → 200 rows starting at 0; page 1 → 200 rows starting at 200; page 2 → 150 rows starting at 400; overflow page=99 → clamp to last valid page (2); filter-narrow to 5 rows while on page 2 → clamp to page 0 returning the 5 rows; empty set → page=0, empty slice.
- Summary computation (1): mirrors
start = page * pageSize + 1; end = min(total, start + visible - 1)from api.js paginate().controls(). Verifies the displayed range across all 3 pages of a 550-row dataset.
- Static-source canaries (7): scan.js declares
- 558 → 567 unit + acceptance (+9 new).
🔄 Migration
No user action needed beyond updating to v1.30.0. The next scan that produces > 200 filtered rows will surface the paginator below the results table. Smaller scans see only an “N items” hint (unchanged from UI.paginate’s established behaviour in tracker / reports / activity).
Verification
$ npm run test:ci
# 567 / 567
# ✓ no .also( leftovers in views/
# ✓ CHANGELOG parity: all 8 locales at v1.30.0
Manual smoke after redeploy: run a scan that produces > 200 filtered rows, navigate #/scan, scroll past the results table, click › to advance to page 2 — should show rows 201..400 instantly (no server round-trip; pure client-side slicing of the already-fetched result set).
[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):driveOnenow accepts afinalparam (defaulttrue). Thedonepayload carriesfinal: <bool>. Thesource=bothbranch passesfinal: falseto the first phase,final: trueto the second. - Client (
public/js/api.js:148-172):stream(...)closes theEventSourceondoneonly whendata.final !== false. Backward-compatible: legacy single-phase producers (/api/stream/batch,/api/stream/pdf*, etc.) don’t setfinal, so the behaviour is unchanged. Close onerrorremains 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→ 1done(final:true);source=regional→ 1done(final:true);source=both→ 2doneevents withfinal:falsethenfinal:true;source=both→ 2startevents inen-scanner/ru-scannerorder; static canary that the pre-v1.29.2 unconditional close pattern is gone; static canary that the v1.29.2data.final !== falseguard is present. - Functional proof of fix (3 cases):
source=bothactually emits the RU-phase banner line (proves the body runs, not just empty shells);ru-scannerstart arrives AFTERen-scannerdone (ordering);dryRun=1does NOT modifydata/pipeline.md(no phase secretly flipswriteFiles). - Pure-logic close-decision table (1 case, parametrized over 11 inputs): mirrors the api.js branch in JS — covers
donewithfinal:false / true / undefined / null / 0 / 'false', pluserrorwith payload/null, plusstart/log(never close). - Bug-forensics (1 case): simulates the pre-v1.29.2 client by cancelling the response stream after the first
doneand verifies the server’sres.on('close')abort handler is still intact (documents the pre-fix mechanism for future readers).
- SSE contract (6 cases):
- 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, theHH_USER_AGENTenv-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. Handlesarticleanddiv-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, theGET /api/scan/sourcesendpoint, and the SPA’s source-filter dropdown. Adding a 12th adapter = one entry here + one adapter file + one row inRU_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 withCache-Control: max-age=60. The SPA fetches this on#/scanmount 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/sourcesand paints<option>entries. Build-time hardcoded fallback list survives if the endpoint is unreachable. The filter chip in#/scannow 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 whenportals.ymlomits the array) now pulls fromregistry.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 overRU_DISPATCHthat’s keyed by the registry. Adding a sixth source = no scanner-loop edit.
- Default
📝 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.ymlenablement); 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.sourcesexample 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,onlyRemotefilter, 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,onlyRemotefilter.test(scan): tests/scan-sources-endpoint.test.mjs— 4 cases: shape, RU-source list parity (5 entries), EN-source list parity (6 entries),Cache-Controlheader.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) andtests/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.1Router.go('/evaluate?url=…')produced a hash whose firstsplit('/')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 viawindow.location.hash.split('?')[1]+URLSearchParams(seeevaluate.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 indocs/help/<locale>.md §16(troubleshooting) andserver/lib/ru-scanner.mjsstill 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.jsmust split off?before name lookup), explainer-comment canary (the v1.28.1 fix rationale stays in the source), and pure-logic simulation ofcurrent()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/healthno longer surfacesHH_USER_AGENTrow; 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 viaparseInt, range-validates1 ≤ 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) inpublic/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 ownCLAUDE.md/AGENTS.md/GEMINI.mdshim files, not about career-ops upstream) deliberately retains its wider list (Cursor / Aider / Gemini CLI) since those CLIs do drive our shims.
- 8 help-bundles (
🧪 Tests
test(canonical-docs): AI-list regression canaries— 2 new canaries intests/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), withoutretry=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-opsmodes/oferta.md. Tracked inqa/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-opsmodes/oferta.md. Tracked inqa/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: 44pxto.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.2so 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-boxto keep parent flex rows from squashing the button under their own height constraints. .btn-smkeeps its existing 32 px floor (small-control exception per WCAG 2.5.5 + 2.5.8 spaced-target) —.btn-smfollows.btnin source order so the override applies.
- Adds
🧪 Tests
test(wcag): tests/wcag-target-size.test.mjs— 4 static CSS canaries:.btnblock hasmin-height: 44px.btnblock hasflex-shrink: 0.btn-smkeepsmin-height: 32px.btn-smdefined AFTER.btnin 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 + tempCAREER_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).
- Tier 1 (unit) — pure helpers (
- 100% line coverage target is explicitly scoped to working functionality — the
if (isMain) { … }boot block inserver/index.mjsand live-LLM call paths inauto-pipeline.mjsare 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/jdsshape on empty + populated statePOST /api/jdswith explicit slug, auto-generated slug, slug normalization warningPOST /api/jdsempty-body / stripped-to-empty-slug 400 responsesGET /api/jds/:namebody roundtrip, 404 on unknown, traversal rejectionserver/lib/routes/jds.mjscoverage: 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 eventserver/lib/routes/auto-pipeline.mjsbranch 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:POST /api/jds— save raw JDGET /api/jds— confirm in listGET /api/jds/:name— read body verbatimPOST /api/evaluate(manual fallback) — generate promptPOST /api/tracker— add rowGET /api/tracker— verify presenceGET /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 testnow runstests/*.test.mjs tests/acceptance/*.test.mjs.npm run test:coveragesame.npm run test:ciruns 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 isstreamNodeScriptspawn ofbatch-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. Passingmode: 'manual'(mirroring/api/evaluatesince v1.10.2) was silently ignored, the request hung 1-3 min on Anthropic. Now the handler:- Accepts
modeANDevalModefor 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. donepayload carries{ mode: 'manual', prompt: <buildEvaluationPrompt scaffold>, message }— the SPA can render it like the existing/api/evaluatemanual-prompt card.
- Accepts
- Closes DoS-risk on
HOST=0.0.0.0: previously, even withllmRateLimitcapping 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 withANTHROPIC_API_KEYset the short-circuit still fires (the original symptom), (3) legacyevalMode: '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 inCHANGELOG.{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 intonpm 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.autoPipelinecarried a leading✨in every locale string ANDpublic/js/views/dashboard.js:58prepended 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-opsproject (rewritemodes/oferta.mdto 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.jsalready 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.mdrename — verified during v1.25 prep: repo already hasREADME.zh-CN.md(no orphanREADME.cn.mdanywhere 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:cinow runsnpm 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 theElement.prototype.alsoglobal monkey-patch and migratedcv.jsto a free-statement pattern, but missedconfig.js. The result:#/configcrashed at first invocation on every locale withc(...).also is not a function. v1.24.1 applies the same migration pattern fromcv.js:188-201— extract the tree to aconst root = c(...), run the activation block on its own, thenreturn root;.
🛡️ CI gate
feat(ci): scripts/check-no-also-leftovers.mjs sweep— walks every file underpublic/js/views/and fails the build on any.also(call site (commented references allowed). Wired into the newnpm run test:ciscript. 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.jsvianode: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
- parse
- 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 pertracked_companiesentry. - §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>toSubmitted.withEvaluated → Appliedauto-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-parityasserts 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.mjsenforces). 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 againsthttp://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-helperchecklist✅ PASS (body contains career-ops apply+auto-submitwarning)31.4 /api/batchendpoint✅ PASS (keys [exists, runnerExists, raw, rows, additions])31.5 Playwright availability ✅ PASS ( /api/healthreportsPlaywright (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. |
[1.23.0] — 2026-05-14
i18n split + connection-banner CI fix + localized dashboard screenshots + every backlog stop-gap closed. Ships the three items the v1.22.0 “Out of scope” table flagged for v1.23 (M-9 locale CHANGELOG bodies, N-1 i18n.js LOC split, help-bundle content audit) plus a hot-fix for the smoke E2E test that turned the v1.22.0 main-branch CI red.
🚑 CI hot-fix — connection banner recovery
-
fix(client): reset health-poll cadence + visibilitychange eager re-check(public/js/api.js:21-91) — v1.22.0’s M-6 exponential backoff was correct (3 s → 6 s → 12 s → cap 15 s, down from the original cap 60 s) but the in-flightsetTimeoutwas locked to whatever delay was set previously. A server killed at t=0.1 with the first ping at t=3 would fail, double the delay to 6, and the next recovery probe wouldn’t fire until t=9. The smoke E2E’s “Flow 2a: connection banner appears on server down, hides on recovery” waited only 4 s and turned red onmain.v1.23.0 reshapes the polling loop:
_healthHandleis tracked sosetConnectionState(lost=true)canclearTimeoutand re-schedule with_HEALTH_MIN. The first recovery probe now fires within 3 s of going down, regardless of what delay was queued._HEALTH_MAXlowered from 60 s to 15 s. Backgrounded tab against a dead server still recovers within one polling cycle when the user comes back; bandwidth savings stay substantial.document.addEventListener('visibilitychange')eager-rechecks when the tab regains focus andconnectionLost === true— Cmd-Tab back doesn’t wait for the next backoff tick.
🧹 N-1 — i18n.js split (over the 400-LOC target)
refactor(client): split DICT into i18n-dict.js (data) + i18n.js (logic)— pre-v1.23public/js/lib/i18n.jswas 639 LOC. The bulk (lines 23-586) was theDICTtranslation table — pure structured data. v1.23.0 extracts that topublic/js/lib/i18n-dict.js(578 LOC, exempt-from-LOC-rule per CLAUDE.md “Exempt from these limits: generated files, migrations, test fixtures, lock files, vendored code” — translation tables qualify as fixtures), leavingpublic/js/lib/i18n.jsat 86 LOC of pure module logic (well under the 400-LOC target).- Loader contract:
i18n-dict.jspopulateswindow.__I18N_DICT = { … }, theni18n.jsreads it inside the existing IIFE.public/index.htmlloads them in order —i18n-dict.jsbeforei18n.js— so the IIFE sees a fully-populated DICT at construction time. Missing-dict fallback: everyt()call returns its inline fallback or bare key, which surfaces a misconfiguration loudly without crashing the SPA. - Test plumbing updated:
tests/i18n-coverage.test.mjs,tests/help-ui.test.mjs,tests/canonical-docs-coverage.test.mjsnow run both files through the test VM context (or concatenate their source for the regex sweep), preserving every existing assertion.
🌐 M-9 — Locale CHANGELOG body translations
docs(translate): 7 non-EN CHANGELOG files end-to-end— pre-v1.23CHANGELOG.{es,pt-BR,ko-KR,ja,ru,zh-CN,zh-TW}.mdcarried EN-bodied stop-gap notes for every entry from v1.13.0 onwards, with a footer pointing readers at the EN canonical. v1.23.0 dispatches 7 parallel translation agents — one per locale — that rewrite every body to publication-grade technical style in the target language. Stop-gap notes removed. Code blocks, file paths, URLs, commit-message-style strings (fix(security): B-1 — …), env vars, and link labels preserved verbatim across all locales.
🖼️ Localized dashboard screenshots in every README
docs(readme): wire each locale README at its locale-specific PNG— pre-v1.23 onlyREADME.pt-BR.mdreferenceddashboard-pt-BR.png; the other 6 non-EN READMEs still pointed atdashboard-en.png. The screenshots (already captured in v1.22.0 cycle byscripts/capture-dashboard-screenshots.mjs) were present inimages/but unused. v1.23.0 updates everyREADME.{es,ja,ko-KR,ru,zh-CN,zh-TW}.mdline 14 to its owndashboard-<locale>.png.
🧪 Tests
- Same 474 / 474 unit + 32 / 32 Playwright as v1.22.0. Smoke E2E now 20 / 20 (was 19 / 1 fail on
mainafter v1.22.0 due to the banner-recovery regression; v1.23.0’s reschedule fix closes it). - Three existing tests rewired to handle the i18n split. Zero new test files; zero new assertions deleted.
Verification
$ npm test
# 474 / 474
$ npm run test:e2e
# passed: 20 failed: 0 (was 19/1 on v1.22.0 main)
$ wc -l public/js/lib/i18n.js public/js/lib/i18n-dict.js
# 86 public/js/lib/i18n.js ← logic, under target
# 578 public/js/lib/i18n-dict.js ← data fixture, exempt
$ grep -h 'dashboard-' README*.md | sed -E 's/.*(dashboard-[^)]+).*/\1/' | sort -u
# dashboard-en.png (README.md only)
# dashboard-es.png dashboard-ja.png
# dashboard-ko-KR.png dashboard-pt-BR.png
# dashboard-ru.png dashboard-zh-CN.png dashboard-zh-TW.png
# CHANGELOG translation sanity: each locale file > 200 lines of native content
$ wc -l CHANGELOG.{es,pt-BR,ko-KR,ja,ru,zh-CN,zh-TW}.md | grep -v total
Breaking changes
None. public/index.html now loads two scripts where it loaded one — anyone serving the SPA from a CDN must pick up i18n-dict.js; the script load order is enforced by the order of <script src> tags in index.html. The runtime fallback (empty DICT → t() returns the inline EN fallback) prevents hard crashes when the new file is missing.
Out of scope (v1.24+)
| Item | Notes |
|---|---|
| Help-bundle CONTENT depth refresh from career-ops.org/docs (vs URL coverage) | The 5 canonical URLs already appear in every locale’s help bundle since v1.11.x and Scenario 31.6 in the QA prompt verifies coverage. Content-body depth refresh is a v1.24+ candidate. |
| Live execution of QA scenario 31 against a running server | Requires browser agent + live LLM credentials. v1.24 candidate. |
| Per-component touch-target sweep on new mode-page hint paragraphs | v1.22.0 M-1 added <p class="field-hint"> elements that haven’t been verified against WCAG 2.5.5 min-height in all 8 locales. |
[1.22.0] — 2026-05-14
M/L/N backlog clearout + docs alignment + translation quality pass. The entire v1.20.1-BACKLOG.md medium-and-below tier shipped in one release: nine M-items, five L-items, two nits. Plus a docs-alignment audit against the five canonical career-ops.org/docs guides, refreshed system prompts under .claude/ and .github/, and quality-refreshed READMEs in all 7 non-English locales.
🛡️ Security hardening (defense-in-depth)
-
fix(security): M-4 — entity-aware stripDangerousMarkdown(server/lib/security.mjs) — the pre-v1.22 regex matched<script>,javascript:,on*=as literal substrings.<script>,javascript:, and<img src="data:image/svg+xml,<svg onload=…>">slipped through. The strip now decodes<,>,&,", numeric (&#NN;) and hex (&#xHH;) entities before the strip regex runs. Validated by 11 tests intests/cv-xss-bypasses.test.mjs. Real defense is still the client-sideUI.mdescape-first pipeline; this hardens the at-rest file. -
fix(security): L-2 — bash --noprofile --norc on the batch runner(server/lib/routes/batch.mjs:108) —spawn('bash', [PATHS.batchRunner, ...])used to inherit the user’s~/.bashrc. A hostile rc file could influence the run. Nowspawn('bash', ['--noprofile', '--norc', PATHS.batchRunner, ...]).
🔒 Resilience
-
fix(client): M-6 — exponential backoff on health ping(public/js/api.js:22-48) — the disconnected-state poller used to fire 28,800 fetches against a dead server overnight. Now 3 s → 6 s → 12 s → 24 s → 60 s; resets to 3 s on first 2xx recovery. Setup is asetTimeoutchain (notsetInterval) so each step picks up the new delay. -
fix(client): M-5 — Safari private-mode localStorage guard(public/js/lib/i18n.js:572-583) — Safari private-mode throwsSecurityErroron everylocalStorage.getItem/setItem. The IIFE-during-load used to fail the entire i18n module, leaving the SPA rendering raw keys. Wrapped both calls in try/catch with thedetect()browser-language fallback. -
fix(server): M-2 — body-size cap on outbound preview fetches (test + verify)— the v1.21.0safeGetalready streamed chunks and capped atopts.maxBytes. v1.22 adds an explicit regression test intests/ssrf-redirect-rebind.test.mjsto lock the contract: 100 KB upstream + 4 KB cap → response ≤ 4 KB. -
fix(client): L-5 — clear setTimeout on hashchange in scan.js(public/js/views/scan.js:6-22, :113-120) — the post-done 300 msrefreshResults()timer used to leak when the user navigated off#/scanin that window. Handle is now captured and cleared in__cancelActiveScanPoll. -
fix(client): L-4 — multi-line SSE data: joiner(public/js/lib/auto-pipeline.js:158-176) — the SSE parser usedmatch()(single-line). Per spec, an event may carry multipledata:lines that the consumer joins with\n. Server currently sends single-line JSON, so the old code worked — but was brittle to any future multi-line payload.
♿ Accessibility
-
feat(a11y): M-3 — WCAG 1.4.1 redundant cues on score pills + connection banner(public/css/app.css:602-625, :812-822) — score-high / score-mid / score-low used to convey state by hue alone (red/amber/green). Users who can’t perceive hue had no fallback. Each tier now gets a redundant glyph via::before(✓ / ◐ / ○). Connection banner gets a leading⚠glyph in the offline state. Render sites untouched — pure CSS hardening. -
feat(a11y): M-1 — inline hint paragraphs for every mode-page field(public/js/views/mode-page.js,public/js/lib/i18n.js) — v1.20.0 wiredhtmlFor → idfor every mode-page field but didn’t carry inline hint copy; only the README walkthroughs documented field intent. v1.22.0 adds 19 hint i18n keys × 8 locales = 152 new translations and thefield()builder now renders a<p id="…-hint">witharia-describedbywiring per field. Screen-reader users hear the hint when the input is focused. -
fix(a11y): M-7 — null-guard on UI.el() htmlFor alias(public/js/api.js:194-198) —htmlFor: nullused to render literalfor="null". One-liner mirror of the fallthrough branch’sv != null && v !== falseguard.
🧹 Quality / portability
-
fix(server): L-1 — parseInt radix in health.mjs + bin/start.sh + bin/setup.sh—parseInt(process.versions.node)without radix triggers a lint warning and is brittle if Node ever ships hex versions. Added10everywhere. -
fix(server): L-3 — Windows-safe entrypoint check(server/index.mjs:159-163) —import.meta.url === \file://${process.argv[1]}`mishandles drive letters and backslashes on Windows. Replaced withfileURLToPath(import.meta.url) === path.resolve(process.argv[1])`. -
refactor(client): N-2 — drop Element.prototype.also monkey-patch(public/js/views/cv.js:188-201) — global DOM prototype pollution. Replaced with a local variable for the tree root. -
test(canary): M-8 — 404 regression test for retired /api/scan-ru/config(tests/scan-consolidated.test.mjs) — v1.20.0 retired the alias but added no canary. Three-line addition mirroring the v1.18 retirement tests.
📚 Docs + system prompts
-
docs(architecture): refresh OVERVIEW + DATA-FLOWS for v1.21+ surface— addedsafe-fetch.mjs(DNS-pinned GET),file-lock.mjs(per-path mutex),rate-limit.mjs(LLM throttle), andsanitizePathNameto OVERVIEW.md. DATA-FLOWS.md gained two new sections: “Outbound URL fetches (DNS-rebind-safe)” and “LLM endpoint rate-limiting”. -
docs(readme): security envelope section refresh— README.md “Security notes” now documents every helper in the v1.21+ security envelope (sanitizePathName, safeGet, withFileLock, llmRateLimit, entity-aware stripDangerousMarkdown). -
docs(qa): scenario 31 — career-ops.org/docs alignment(qa/claude-cowork-browser-test-prompt.md) — six new sub-tests (31.1–31.6) that verify the UI matches behavior described in the five canonical career-ops.org/docs guides: score thresholds, scan workflow (one button), apply workflow (checklist, not auto-submit), batch workflow (TSV editor), Playwright setup (graceful failure), help-bundle coverage (5 URLs × 8 locales). -
docs(translate): README quality refresh × 7 non-EN locales— every non-EN README rewritten to publication-grade technical style in its native language. Common clunky calques replaced; v1.21/v1.22 security envelope mentions added; release/test badges bumped. -
docs(system): .claude/PROJECT-CONTEXT.md + .github/copilot-instructions.md— single-file orientation for agents joining a session. Compressed CLAUDE.md, names the v1.21+ helpers, lists common pitfalls. -
docs(bin): actualize start.sh / setup.sh / run_all.sh comments— “two deps” → “three deps” (express + js-yaml + multer); “298 tests” → “474+ tests”;parseIntradix added.
🧪 Tests
- 461 → 474 unit (+13) + 32/32 Playwright unchanged.
- New test files:
cv-xss-bypasses.test.mjs(M-4, 11 tests). - Extended:
ssrf-redirect-rebind.test.mjs(+1 for M-2 body cap),scan-consolidated.test.mjs(+1 for M-8 alias canary). - Zero behavioral test deltas on existing suites — every fix is additive or covered by a new canary.
Verification
npm test # 474 / 474
npm run test:e2e:browser # 32 / 32
# Entity-encoded XSS strip:
node -e "import('./server/lib/security.mjs').then(({stripDangerousMarkdown}) => console.log(stripDangerousMarkdown('<script>alert(1)</script>')))"
# → '' (no <script> survives)
# Health-ping backoff (open devtools, kill server, watch network panel):
# 3 s → 6 s → 12 s → 24 s → 60 s, then resets on first successful ping
# Score-pill glyph (open #/reports in light + dark theme):
# .score-high shows ✓ + numeric score
# .score-mid shows ◐ + numeric score
# .score-low shows ○ + numeric score
# Mode-page hints (#/contacto, etc):
# <input aria-describedby="mode-contacto-recipient-hint"> ← targets <p id="…">
# Retired alias:
curl -sS -o /dev/null -w '%{http_code}\n' http://127.0.0.1:4317/api/scan-ru/config
# → 404
Breaking changes
None. Every fix is additive or preserves existing endpoint contracts.
Out of scope (v1.23+)
| Item | Notes |
|---|---|
| M-9 — locale CHANGELOG body translations | All CHANGELOG.{es,pt-BR,ko-KR,ja,ru,zh-CN,zh-TW}.md v1.13+ entries are EN-bodied stop-gaps. Bulk translation candidate after release cadence slows. |
N-1 — public/js/lib/i18n.js over the 400-LOC target |
Splitting per locale increases HTTP cost without a bundler. Defer until the build-step decision lands. |
| Help-bundle content refresh from career-ops.org/docs | The five canonical URLs already appear in every locale’s help bundle (since v1.11.x). Scenario 31.6 in the QA prompt verifies coverage. Content depth refresh is a v1.23 candidate. |
[1.21.0] — 2026-05-14
Security + concurrency + a11y polish from two independent code-review passes. Seven findings from docs/sdd/specs/V1.20.1-BACKLOG.md shipped in one release: one blocker (DNS-rebind TOCTOU), six high-severity bugs (path-traversal sanitization spread, rate-limit gap on LAN deploy, concurrent-write race, i18n coverage hole, dangling aria-describedby, missing label associations). 34 new tests; baseline rose from 427 → 461 unit + 32/32 Playwright. Every fix lands behind a named regression test.
🛡️ Security
-
fix(security): B-1 — close DNS-rebind TOCTOU via safe-fetch.mjs(server/lib/safe-fetch.mjs) — the previous pattern did one explicitdnsLookupfor validation, then letfetch()do its own independent lookup. A DNS rebind attacker with TTL=0 could return a public IP on lookup 1 and127.0.0.1/169.254.169.254/ a LAN address on lookup 2, bypassingisPrivateOrLoopbackHost. The newsafeGetresolves ONCE, pins the TCP connection to that exact IP via node:http(s), and sets SNI/Host so cert validation still targets the original hostname. Used by/api/pipeline/previewand/api/auto-pipeline. Fail-CLOSED on lookup error (reverses the priortry { … } catch { /* fall through */ }). Validated by 8 new tests intests/ssrf-redirect-rebind.test.mjs. -
fix(security): H-4 — consolidate sanitizePathName across 10 routes(server/lib/security.mjs) — the barereplace(/[^\w\-.]/g, '')regex was duplicated acrossjds.mjs,content.mjs,reports.mjs,llm.mjs,runners.mjsand kept.characters, so..pdf,....md, leading-dot names survived. Onlyreports.mjs::sanitizeSlugdid it right. v1.21.0 hoists the correct version (sanitizePathName) intosecurity.mjs, deletes 10 broken copies, and rejects empty results with 400. Validated by 12 tests intests/path-traversal.test.mjs. -
fix(security): H-5 — rate-limit LLM endpoints on public bind(server/lib/rate-limit.mjs) —/api/evaluate,/api/deep,/api/mode/:slug,/api/auto-pipelinepreviously had no per-IP throttle. Loopback users are unaffected; LAN-exposed deploys (HOST=0.0.0.0) get 10 req/min/IP withRetry-AfterandX-RateLimit-*headers on overflow. Configurable viaLLM_RATE_LIMIT="N/Ws". Cheap interim defense ahead of the v2.0 P-12 auth gate. Validated by 6 tests intests/rate-limit.test.mjs.
🔒 Concurrency
fix(data): H-6 — per-file mutex on applications.md / pipeline.md(server/lib/file-lock.mjs) — concurrentPOST /api/tracker(or auto-pipeline racing a manual add) used to both readnum=42, both writenum=43, and silently drop the earlier row.withFileLock(path, fn)serializes read-modify-write per path; independent paths still run in parallel. Wired intotracker.mjs,pipeline.mjs(POST + DELETE), andauto-pipeline.mjstracker step. Validated by 5 tests intests/concurrent-tracker-write.test.mjsincluding a 20-concurrent-POST integration check that asserts rows 001..020 land sequentially.
♿ Accessibility
-
fix(a11y): H-1 — id="batch-tsv-hint" on the batch.js hint paragraph(public/js/views/batch.js) — v1.20.0 addedaria-describedby="batch-tsv-hint"to the TSV textarea but never gave the hint<p>a matchingid. Screen readers had nothing to voice. Fixed. -
fix(a11y): H-2 — htmlFor on batch-parallel / batch-min-score labels(public/js/views/batch.js) — four v1.20.0 inputs got new ids but their labels weren’t programmatically associated. WCAG 3.3.2 now satisfied. -
New static-analysis canary in
tests/a11y-form-wires.test.mjs— walks every view file and asserts everyaria-describedby/htmlForIDREF points at a siblingid:declaration. Catches typo-class regressions at CI time.
🌐 i18n
fix(i18n): H-3 — 13 keys from v1.20.0 silently fell through to EN for 7 locales(public/js/lib/i18n.js) —pipe.filter,pipe.count,pipe.preview*,pipe.openTab,pipe.evaluateAll*,eval.jdHint,batch.parallelAria,batch.minScoreAria, pluscommon.delete,config.group{Core,Runtime,Regional},config.profileEmpty,config.viewProfile,scan.atsBadge,scan.regionalBadgewere referenced viat('key', 'EN fallback')but never added to DICT. Russian, Japanese, Chinese screen-reader users heard Englisharia-labels — directly defeating the WCAG 3.3.2 win v1.20.0 claimed. v1.21.0 adds all 19 keys × 8 locales (≈ 150 new translations) and extendstests/i18n-coverage.test.mjswith a static-analysis pass that scans everyt('key', …)call inpublic/js/**/*.jsand asserts each key exists in DICT. Future drift caught at CI time.
🧪 Tests
- 427 → 461 unit (+34) + 32/32 Playwright unchanged.
- New test files:
ssrf-redirect-rebind,path-traversal,concurrent-tracker-write,rate-limit,a11y-form-wires. - Existing
pipeline-preview.test.mjsrewired fromglobalThis.fetchmock to the new_setTransportinjection point insafe-fetch.mjs— the SSRF path no longer goes through fetch, so the old mock was bypassed silently.
Verification
npm test # 461 / 461
npm run test:e2e:browser # 32 / 32
node --test tests/ssrf-redirect-rebind.test.mjs tests/path-traversal.test.mjs \
tests/concurrent-tracker-write.test.mjs tests/rate-limit.test.mjs \
tests/a11y-form-wires.test.mjs # 34 new tests, all green
# Path-traversal: every traversal-style :name returns 400 / 404
curl -sS -o /dev/null -w '%{http_code}\n' http://127.0.0.1:4317/api/jds/..pdf
# → 400
# Rate-limit on public bind:
HOST=0.0.0.0 LLM_RATE_LIMIT=3/60s npm start &
for i in 1 2 3 4; do
curl -sS -o /dev/null -w '%{http_code} ' -X POST -H 'Content-Type: application/json' \
-d '{"jd":"…"}' http://0.0.0.0:4317/api/evaluate
done
# → 200 200 200 429
# Concurrent tracker writes: 20 parallel POSTs, 20 rows land:
node tests/concurrent-tracker-write.test.mjs
# 20 sequential rows 001..020
# Aria wires sanity:
grep -r 'aria-describedby' public/js/views/ | wc -l
# matching `id:` lookups all resolve (a11y-form-wires.test.mjs canary)
Out of scope (v1.22+)
| Item | Notes |
|---|---|
pipeline-preview body-size streaming cap (M-2) |
await upstream.text() reads full body before the 8 KB slice; malicious 1 GB stream could exhaust memory. Stream-read with byte counter + abort. |
WCAG 1.4.1 — color-only state on .connection-banner + score pills (M-3) |
Hue alone signals state; add icon prefix (✓ / ◐ / ○) or text suffix. |
stripDangerousMarkdown bypasses via HTML entities (M-4) |
<script>, javascript:, <img src="data:image/svg+xml,<svg onload=…>"> survive the regex. Defense-in-depth via UI.md still holds; doc + lock bypasses in a test sweep. |
Safari private-mode localStorage access without try/catch (M-5) |
i18n.js:544/571 throws → SPA renders raw keys. Wrap in try/catch with 'en' default. |
setInterval(checkHealth, 3000) polls forever with no backoff (M-6) |
Exponential 3s → 6s → 12s → cap 60s. |
htmlFor alias missing null-guard (M-7) |
One-line if (v != null && v !== false) defense. |
404 canary for retired /api/scan-ru/config (M-8) |
Three-line test mirroring v1.18 precedent. |
| Locale CHANGELOG body translations (M-9) | Bulk translation candidate after release cadence slows. |
| Inline-hint paragraphs for every mode-page field (M-1) | ~168 i18n keys × 8 locales; held back as polish item. |
| L-1 through L-5 nits | parseInt radix, bash –noprofile, Windows-safe fileURLToPath, multi-line SSE, scan.js timer cleanup. |
[1.20.0] — 2026-05-13
Per-component a11y polish + non-EN README parity + /api/scan-ru/config alias retired. Closes the four items the v1.19.0 “Out of scope” table flagged for v1.20.
♿ WCAG 2.5.5 / 2.5.8 — per-component touch-target audit
a11y(touch-target): chip min-height 28 px + 8 px gap (2.5.8 spaced-target exception)—.chipwas 24 × ~50 px (vertical was 24, height failed 2.5.5’s 24 px floor for clustered controls); the spaced-target exception of 2.5.8 requires either ≥ 24 × 24 px OR 24 px of clearance. Bumped.chiptomin-height: 28px; padding: 6px 12px;and the wrapping.chip-rowtogap: 8px;so both conditions hold.a11y(touch-target): sidebar nav-item min-height 44 px—.nav-itempadded only10px 14px, computed height ~36 px on most viewports. Nowpadding: 12px 14px; min-height: 44px; box-sizing: border-box;. Matches the.btnfloor.a11y(touch-target): tab-btn min-height 44 px— same treatment for Sortable Headers / category tabs across Reports, Tracker, Scan results.
♿ WCAG 1.3.1 / 3.3.2 — aria-describedby on inline form hints
Every form control across the SPA now owns a stable id, its <label> targets it via htmlFor, and any inline hint paragraph is associated via aria-describedby. Five view files were rewired:
a11y(forms): config.js— per-keyid+ hint association (cfg-<key>/cfg-<key>-hint).a11y(forms): evaluate.js—eval-jdtextarea +eval-jd-hintparagraph documenting the 50-char minimum after sanitization.a11y(forms): batch.js—batch-tsv/batch-tsv-hint, plusaria-labels onbatch-parallel,batch-min-score,batch-dry-run,batch-retry.a11y(forms): pipeline.js—pipe-filter+pipe-new-url/pipe-new-url-hint.a11y(forms): mode-page.js— every field across the 7 generic modes (project,training,followup,batch-prompt,contacto,interview-prep,patterns) getsmode-<slug>-<name>ids andhtmlForlabels.
UI.el() learned a React-style htmlFor alias so view code stays declarative — it sets the underlying for attribute (which is JS-reserved as a property name).
🌍 Non-EN README parity
docs(readme): translate 7 locales to 585-line parity with EN master—README.{es,pt-BR,ko-KR,ja,ru,zh-CN,zh-TW}.mdwere 306–316 lines (covered headlines but skipped the marketing-heavy walkthroughs and most of the API reference). All seven now mirror the EN structure end-to-end: About → One-command install → Why? → Quick start (3 numbered steps) → Requirements → What you get table → Scan → Architecture (full directory tree) → API reference (every route table) → Tests → Configuration → Security notes → Limitations → Contributing → 🌍 Getting Started 5-step walkthrough → License.
🧹 /api/scan-ru/config alias retired
feat!(scan): remove /api/scan-ru/config legacy alias (sunset v1.20)— kept as a one-release alias in v1.19 for back-compat. Canonical/api/scan/regional/configis the only path now. Removed: route registration inserver/lib/routes/scan.mjs, doc references inREADME.md,docs/architecture/{OVERVIEW,SERVER,API}.md. Tests already covered the canonical path — no test changes needed.
🧪 Tests
- Same suite as v1.19. 427 / 427 unit + 20/20 smoke + 23/23 comprehensive + 32/32 Playwright. All a11y wiring is additive (more
id/for/aria-describedbyattributes) — no behavioral changes, no test deltas.
Verification
npm test # 427 / 427
npm run test:e2e:browser # 32 / 32
# Touch targets — every chip / nav-item / tab-btn ≥ 28 / 44 / 44 px:
# Chrome DevTools → Computed → height/min-height on .chip, .nav-item, .tab-btn
# Form labels — every input has a label[for=…] association:
# document.querySelectorAll('input,textarea,select').forEach(el =>
# console.assert(el.labels?.length || el.getAttribute('aria-label'), el))
# Alias gone:
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:4317/api/scan-ru/config
# → 404
# Canonical still works:
curl -s http://127.0.0.1:4317/api/scan/regional/config | jq '.'
Breaking changes
DELETE /api/scan-ru/config— gone. Use/api/scan/regional/config. Was announced as sunset in v1.19.0’s CHANGELOG and verification script.
Out of scope (v1.21+)
| Item | Notes |
|---|---|
| Inline-hint paragraphs for every mode-page field | Today only the <label for=…> association is in place; visible per-field hint copy is still EN-only in the SPA. The README walkthroughs document the field intent in every locale, so this is a polish item, not a blocker. |
Color-only state surfacing in .connection-banner and dashboard score pills (WCAG 1.4.1) |
The banner relies on red/amber/green; needs an icon or text suffix for users who can’t perceive hue. |
| Locale-specific CHANGELOG body translations | English-bodied stop-gaps remain in CHANGELOG.{es,pt-BR,ko-KR,ja,ru,zh-CN,zh-TW}.md. Translation happens once the v1.x release cadence slows. |
[1.19.0] — 2026-05-13
WCAG 1.4.3 contrast + scan unification (final) + HH_USER_AGENT removed from UI. Closes the v1.18 out-of-scope contrast audit, finishes the EN/RU split elimination begun in v1.18, and removes the HH_USER_AGENT configuration knob from the UI per user direction (a sensible default bundled in the server already handles non-RU IPs for most users).
♿ WCAG 1.4.3 contrast pass
a11y(contrast): introduce AA-passing *-text variants for accent tokens— light theme:--rausch-text: #b80f42(6.59:1 on white, was 3.52:1),--kazan-text: #066507(7.31:1, was 4.53:1),--darjeeling-text: #7a5800(5.73:1 on amber bg, was 4.24:1),--babu-text: #00665e(6.09:1, was 2.70:1). Dark theme: lightened mirrors (#ff8aa0,#6ee7b7,#fcd34d,#5eead4) hit the same 4.5:1 floor on#161a22paper.- Badge classes (
.badge-ok,.badge-warn,.badge-bad,.badge-info) and score pills (.score-high,.score-mid,.score-low) now route through the new*-textvariants — every text-on-tinted-bg combo passes AA. The accent fill tokens (--rausch,--kazan, etc.) stay unchanged for borders and outlines (which only need 3:1 for non-text UI components).
🧹 Scan unification (finishes v1.18 work)
docs(scan): scrub remaining EN/RU split references across READMEs + help + architecture docs— eight READMEs + eight help bundles + three architecture docs (API.md, SERVER.md, OVERVIEW.md, DATA-FLOWS.md) + scan.js comment now describe a single consolidated scan method. The legacy/api/stream/scan-{en,ru}aliases were already gone in v1.18; v1.19 catches the doc/copy that still framed scanning as a two-step EN+RU process.feat(scan): canonical /api/scan/regional/config endpoint—/api/scan-ru/configkept as a thin alias through one release for back-compat. The new path matches the source-naming convention (?source=regional).
🛠️ HH_USER_AGENT removed from UI
feat!(config): drop HH_USER_AGENT field from /#/config + KNOWN_KEYS— power users can still setHH_USER_AGENTdirectly incareer-ops/.env(the server reads viaprocess.env.HH_USER_AGENTinserver/lib/sources/hh.mjswith the bundled UA as fallback). The UI no longer exposes it because the default works for most users and seeing an inscrutable User-Agent field in the App Settings page was a recurring source of confusion.- README mentions across 8 locales + help bundle mentions across 8 locales replaced with “run via a Russian IP / VPN” advice. The
scan.hhWarningi18n key was rephrased to drop the env-var setup detail. KEY_GROUPScollapsed: no moreregionalclassification (it only had HH_USER_AGENT). Tests updated;regionalActivepayload field preserved for SPA back-compat.
🧪 Tests
tests/env-config.test.mjs—KNOWN_KEYSassertion now excludes HH_USER_AGENT; new assertion that the key is intentionally absent.tests/config-endpoint.test.mjs— POST-write multi-key test usesGEMINI_MODELas the second known key instead of HH_USER_AGENT.tests/config-groups.test.mjs—groups.HH_USER_AGENTis now expectedundefined.- Total: 427 / 427 unit + 20/20 smoke E2E + 23/23 comprehensive E2E + 32/32 Playwright. Same counts as v1.18.0 because every adjusted test was already counted.
Verification
npm test # 427 / 427
# Contrast (Chrome DevTools or axe) on light + dark:
# .badge-ok / .badge-warn / .badge-bad / .badge-info → AA pass (4.5:1+)
# .score-high / .score-mid / .score-low → AA pass
# HH_USER_AGENT no longer in /api/config:
curl -s http://127.0.0.1:4317/api/config | jq '.values | keys'
# → ["ANTHROPIC_API_KEY","ANTHROPIC_MODEL","GEMINI_API_KEY","GEMINI_MODEL","HOST","PORT"]
# (no HH_USER_AGENT)
# Canonical regional config endpoint:
curl -s http://127.0.0.1:4317/api/scan/regional/config | jq '.'
# Legacy alias still alive through v1.20:
curl -s http://127.0.0.1:4317/api/scan-ru/config | jq '.'
Out of scope (v1.20+)
| Item | Notes |
|---|---|
| Per-component touch-target audit (filter chips, sortable headers, sidebar nav) | v1.18 set the global floor (.btn 44 px, .btn-sm 32 px); per-component verification across the SPA remains. |
aria-describedby on inline form hints (#/config, #/pipeline, #/evaluate, #/batch) |
v1.17 covered aria-label on global search + modal close. Per-input hint association is the next polish layer. |
| Full non-EN README parity (585 lines like EN) | v1.18 brought non-EN to ~307 (53 % of EN). Marketing-heavy “Quick start” + “🌍 Getting Started” walkthroughs remain EN-only. |
Remove /api/scan-ru/config legacy alias |
Sunset planned for v1.20. The canonical /api/scan/regional/config is the migration target. |
[1.18.0] — 2026-05-13
Scan-endpoint consolidation + WCAG 2.2 AA pass + i18n long-tail finalization. Retires the legacy /api/stream/scan-{en,ru} aliases (Sunset window 2026-10-01 advanced to v1.18 per user direction). Brings non-EN READMEs to ~307 lines and translates the remaining RU-bodied v1.16.0 + v1.17.0 CHANGELOG entries in 6 locales.
🚪 Breaking
feat!(scan): retire legacy /api/stream/scan-{en,ru} aliases— the deprecated EN/RU split SSE endpoints are gone. Every consumer goes through the consolidated/api/stream/scan?source=ats|regional|bothendpoint (live since v1.12.0). The legacy paths had Deprecation + Sunset (RFC 8594) headers since v1.15.0; the migration window is now closed. External integrations on the old paths get a clean 404 rather than being silently routed to the SPA catch-all.
♿ Accessibility (WCAG 2.2 AA pass)
- WCAG 2.4.1 Bypass Blocks — new Skip to main content link as the first focusable on every page. Visually hidden via
.skip-linkuntil it receives focus, snaps to the top-left corner on Tab from page load. - WCAG 2.4.7 Focus Visible — global
*:focus-visiblestyle. Mouse-click focus rings off, keyboard-Tab focus rings on (the WAI-ARIA AP standard pattern). Modal close (×) gets a higher-contrast focus ring. - WCAG 2.5.5 Target Size — minimum 44×44 px touch target on
.skip-link..btn-smkeeps a 32 px min-height (which combined with row spacing meets the 24×24 + spacing AAA exception for compact table-row controls). - WCAG 3.1.1 Language of Page —
<html lang="en">corrected fromlang="ru"(the JS i18n bootstrap already overrode this on load, but the SSR default now matches the SPA’s default locale). - WCAG 1.3.1 Info & Relationships —
#contentgetstabindex="-1"so the skip-link target focuses cleanly. (ARIA roles + focus-trap were already added in v1.17.)
📚 i18n long-tail
docs(i18n): v1.16.0 + v1.17.0 CHANGELOG translated in 6 locales— entries previously RU-bodied inCHANGELOG.{es,pt-BR,ko-KR,ja,zh-CN,zh-TW}.mdare now in their native language. RU-char count per locale dropped 79 → 42 → 23 (remaining 23 are technical inline references like file paths + the multi-locale header link, which is intentional).docs(readme): expand non-EN READMEs with Why / Requirements / Features / Configuration / Contributing— each non-EN README grew from 240 → ~307 lines. Now covers the same non-marketing sections as the 585-line EN. Full 1:1 parity (marketing-heavy walkthrough sections) remains deferred.
🛠️ Misc
docs(api): consolidated scan endpoint in API.md + DATA-FLOWS.md + README.md— the API reference table now lists only/api/stream/scan?source=…. README’s Scan section explains the v1.18.0 retirement of the EN/RU split.fix(scan.js): drop stale comment about deprecated aliases being live— the SPA’s runScanAll dispatcher comment now reflects the consolidated reality.
🧪 Tests
tests/scan-consolidated.test.mjs::F-018 backwards compatrewritten — the two former “legacy endpoint still works” assertions now verify that requests to/api/stream/scan-{en,ru}return 404 (rather than being routed to the SPA catch-all).- Total: 427 / 427 unit + 20/20 smoke E2E + 23/23 comprehensive E2E + 32/32 Playwright (unchanged count; +2 newly-correct legacy-removal assertions replacing the +2 legacy-still-works assertions).
Verification
npm test # 427 / 427
npm run test:e2e:full # 23 / 23
# Legacy endpoint retirement:
curl -sI http://127.0.0.1:4317/api/stream/scan-en | head -1 # → HTTP/1.1 404
curl -sI http://127.0.0.1:4317/api/stream/scan-ru | head -1 # → HTTP/1.1 404
# Consolidated endpoint:
curl -sN 'http://127.0.0.1:4317/api/stream/scan?source=ats&dryRun=1' | head -5
# → event: start
# → data: {"script":"en-scanner","writeFiles":false,…}
# Skip link (a11y):
curl -s http://127.0.0.1:4317/ | grep -c 'class="skip-link"' # → 1
# html lang fallback:
curl -s http://127.0.0.1:4317/ | grep -c 'html lang="en"' # → 1
Out of scope (v1.19+)
| Item | Notes |
|---|---|
| Full non-EN README parity (585 lines like EN) | v1.18 brought non-EN to ~307 (53 % of EN). Marketing-heavy “Why?” / “Quick start” walkthroughs remain EN-only. |
| Color-contrast audit (WCAG 1.4.3 AA — text 4.5:1, large text 3:1) | v1.18 covered structural a11y; per-token contrast verification across light + dark palettes remains. |
| Touch-target audit across every interactive element | v1.18 set the floor (.btn: 44 px, .btn-sm: 32 px); per-component verification (filter chips, sidebar nav, sortable headers) remains. |
[1.17.0] — 2026-05-13
Polish + a11y + CI fix release. Closes all 9 follow-ups from the
v1.16.0 list: browser smoke verification, README badge truth,
coverage refresh, lastWorkdayFallback surfaced in SPA, full E2E
re-baseline, Playwright auto-pipeline scenarios, a11y audit pass,
historical CHANGELOG condensed in 6 locales, and non-EN READMEs
expanded with Architecture / API / Security / Tests sections.
🐛 Fixes
fix(e2e): smoke + comprehensive suites re-aligned with v1.16 UX— the v1.16 Cmd+K Enter → AutoPipeline modal change made the e2e tests’search.press('Enter')open a modal that intercepted subsequent clicks. Tests now useShift+Enterfor the legacy quick-add path, matching the v1.16 documented split. Also updates the comprehensive E2E batch-mode iteration to use/#/batch-prompt(the legacy mode-prompt slug that v1.15 PR-H introduced). This was the CI failure on v1.16.0 push — Playwright e2e timed out 30 s waiting on backdrop-intercepted clicks.fix(mode-page): batch-prompt route → modes/batch.md via serverSlug— v1.15 renamed the legacy mode slug tobatch-prompt, but the server’sPOST /api/mode/:slugwas then looking formodes/batch-prompt.mdwhich doesn’t exist. NewserverSlugfield decouples the route hash from the parent’s mode filename.chore: bump deprecation messages from v1.16.0 to v1.17.0— the scan-en/scan-ru deprecation copy and the batch-prompt deprecation banner referenced the past version.
✨ Features
feat(scan): 🔒 Workday CAPTCHA chip in Active Companies card— the server-sidelastWorkdayFallbackexport from v1.16 PR-7 is now consumed by the SPA./api/scan-resultsreturns the snapshot;#/scanrenders a warn-tinted card above Active Companies when a Workday tenant fell back (“🔒 Workday tenant blocked — fallback: use /career-ops scan (Playwright)”). NewgetLastWorkdayFallback()exporter avoids ESM live-binding ambiguity. 2 new i18n keys × 8 locales.
♿ Accessibility
a11y: ARIA roles + focus management pass on critical surfaces—index.html:roleattributes on<aside>(navigation),<header>(banner),<section id="content">(main),<div id="modal">(dialog with aria-modal/aria-labelledby),<div id="toast">+#conn-banner(status with aria-live),<div class="searchbar">(search).#sidebar-togglegetsaria-controls="sidebar"+aria-expandedsynced by JS on open/close.#global-searchgets a visually-hidden<label>plus an explicitaria-labelthat surfaces the Cmd+K shortcut hint.- Modal close (×) gets
aria-label="Close dialog". - Decorative backdrops get
aria-hidden="true". - Focus trap on modal —
UI.modal()remembers the click owner, focuses the first non-close focusable on open, and cycles Tab/Shift+Tab inside the modal.UI.closeModal()restores focus to the prior owner. - New
.visually-hiddenutility class inpublic/css/app.css(WAI-ARIA AP standard pattern).
📚 Documentation
docs(readme): badge truth across 8 READMEs— tests badge284 / 379 / 360→ 427; release badgev1.9.1 / v1.13.0→ v1.16.0 then → v1.17.0 via the v1.17 bump. Release link targets updated.docs(readme): expand 7 non-EN READMEs with reference sections— each grew 170 → ~240 lines with new Architecture / API reference / Security notes / Tests / A11y / Limitations / License sections in the native language. Not yet at full 585-line parity with EN but covers all key non-marketing surfaces.docs(changelog): condense pre-v1.12 entries in 6 locales— the long RU-bodied v1.11.x + v1.10.x entries that bled into the non-EN/non-RU CHANGELOGs are now replaced by a compact “Earlier releases” exec summary in each locale’s native language. Detailed history stays inCHANGELOG.md(EN).
🛠️ Tooling
coverage: refresh numbers— last published was 95.46 % line / 84.06 % branch (v1.13.0 REVIEW). v1.17 baseline: 94.14 % line / 82.98 % branch / 93.20 % function. Slight drop from new error paths in auto-pipeline + reports-write; still well above the 80 % floor in CLAUDE.md.
🧪 Tests
- Total: 427 / 427 unit + 20/20 smoke E2E + 23/23 comprehensive
E2E + 32 / 32 Playwright (was 28; +4 new auto-pipeline
scenarios: button opens modal, Cmd+K paste triggers modal,
invalid URL gates step 1,
POST /api/auto-pipelineSSE event framing). - E2E suite re-aligned with v1.16.0 UX (Shift+Enter quick-add, /#/batch-prompt for legacy mode).
Verification
# Locally:
npm test # 427 / 427
npm run test:e2e # 20 / 20
npm run test:e2e:full # 23 / 23
npm run test:e2e:browser # 32 / 32
# Browser smoke (page-level):
curl -s http://127.0.0.1:4317/api/scan-results | jq '.workdayFallback'
# null when no Workday fallback occurred; {apiUrl, reason, at} after a 4xx.
# A11y spot-check:
node -e "
const c = require('cheerio').load(require('fs').readFileSync('public/index.html','utf8'));
['banner','navigation','main','dialog','status','search'].forEach(r =>
console.log(r, c('[role=' + r + ']').length));
"
# Each role should appear ≥1.
# CI gate verification: dashboard-screenshots workflow boots a /tmp
# scaffold, regenerates PNGs, diffs against committed — green when
# images/dashboard-*.png are up to date with rendered SPA.
Out of scope (v1.18+)
| Item | Notes |
|---|---|
| Translate v1.16.0 entry in non-EN CHANGELOGs | Currently RU-bodied (~30 lines × 6 locales = 180 lines). Was outside the user’s explicit v1.11.x/v1.10.x scope. |
| Full non-EN README parity (585 lines like EN) | v1.17 brought non-EN to ~240; the marketing-heavy “Why?” / “Quick start” walkthroughs remain EN-only. |
| Parent commit for canonical A-F prompt | santifer/career-ops::modes/oferta.md rewrite still needed upstream (CLAUDE.md hard rule #1). |
| Full WCAG 2.2 AA audit | v1.17 covered structural ARIA + focus trap; per-component contrast/Tab-order audit pending. |
career-ops-ui v1.267.1 · page 10/11 · mirror of the project changelog for cvstart.org