Changelog
GitHubEvery notable change, release by release. The same file ships in the repository and is translated into all 19 languages.
[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. |
[1.16.0] — 2026-05-13
Auto-pipeline finalization + adapter polish + i18n long-tail. Closes
all 11 follow-ups from the v1.15.0 REVIEW: server-side SSE auto-pipeline,
POST /api/reports primitive, Cmd+K shortcut, SmartRecruiters pagination,
Workday CAPTCHA-fallback, CI screenshot-drift gate, scan source filter UX,
historical CHANGELOG translation (v1.13.0/v1.12.0 × 6 locales), non-EN
README expansion, and a paste-ready trending-companies importer.
✨ Features
feat(auto-pipeline): server-side SSE orchestrator(#1, #2, #3, #8) — the v1.15 client-side chained-fetch orchestrator is gone.POST /api/auto-pipelineis now a curl-able SSE endpoint that chains validate → fetch JD → evaluate → save report → tracker server-side with real-time step events. The slow Anthropic call (30–90 s) now emits arunningevent instead of a generic spinner. Failures emiterrorwithstep+message. The orchestrator also persists the report markdown to parentreports/<slug>.md(was lost in v1.15).feat(reports): POST /api/reports primitive— new writer endpoint inserver/lib/routes/reports.mjs. Slug sanitization with path- traversal guard (strip leading dots, collapse internal...). 1 MB cap (413). 409 on existing file unlessoverwrite:true. Atomic write throughstripDangerousMarkdownXSS pass. Logs activity.reports.save. Tests: 9 cases.feat(app): Cmd+K paste URL → auto-pipeline— pasting a URL into the global search + Enter now opens the AutoPipeline modal withautoStart=true. Shift+Enter preserves the legacy “add to pipeline only” path. The canonical career-ops.org Quick Start §7 “paste URL → done” UX.feat(portals): SmartRecruiters pagination(#4) —server/lib/sources/smartrecruiters.mjswalks pages via?limit=100&offset=NuntiltotalFoundis reached OR an empty page is returned OR the 30-page / 3000-job safety cap fires. Strips caller-supplied limit/offset so the cursor is server-owned. Big boards (Procter & Gamble, Amazon-style) no longer lose their tail of 100+ postings. Tests: 6 cases.feat(portals): Workday CAPTCHA-fallback graceful(#7) —server/lib/sources/workday.mjsno longer throws on 4xx / non-JSON / network errors. Returns[]and annotates the new exportedlastWorkdayFallbacksnapshot. Scanner timeline continues with the next tenant. Caller can opt back into the v1.14 throw behaviour withstrict:true. Tests: 7 cases.
🛠️ Tooling + CI
ci(workflows): dashboard-screenshots drift gate(#5) — new.github/workflows/dashboard-screenshots.yml. On PRs touchingpublic/css/app.css/public/js/views/dashboard.js/public/js/lib/i18n.js/public/index.html, the workflow boots the web-ui server against a /tmp scaffold, regenerates the 8 hero PNGs via Playwright + chromium, and fails the build if the result drifts from what’s committed. Uploads the regenerated PNGs as a CI artifact on failure.feat(scripts): import-trending-companies.mjs(#11) — verifies the 13 trending companies indocs/portals-examples.mdvia their real boards-API and emits paste-ready YAML for the user’s parentportals.yml::tracked_companies.enabled: falseis stamped on any candidate whose slug 404s. Live probe of all 6 ATSes (Greenhouse / Ashby / Lever / Workable / SmartRecruiters / Workday). Run vianpm run import:trending.feat(scripts): npm run capture:dashboards— exposesscripts/capture-dashboard-screenshots.mjsas a top-level script (was only documented inimages/README.mdbefore).
🎨 UX
fix(scan): consolidated source-filter dropdown(#6) —#/scansource dropdown rebuilt from the v1.14 adapter registry: 6 ATSes + hh.ru + Habr Career, alphabetical, no geo-tag prefix.runEnScan/runRuScannow hit the consolidated/api/stream/scan?source={ats,regional}endpoint instead of the deprecated/api/stream/scan-{en,ru}aliases (Sunset headers stay live through v1.16).
📚 i18n long-tail
docs(i18n): translate v1.13.0 + v1.12.0 CHANGELOG in 6 locales(#9) — entries previously RU-bodied inCHANGELOG.{es,pt-BR,ko-KR,ja,zh-CN,zh-TW}.mdare now in their actual locale. Each non-EN/non-RU CHANGELOG also gets an i18n note explaining that pre-v1.12 entries remain RU per project convention (canonical text lives inCHANGELOG.md).docs: expand non-EN READMEs with v1.16.0 highlights section(#10) — 6 non-EN READMEs (es / pt-BR / ko-KR / ja / ru / zh-CN / zh-TW) get a new ~35-line section covering: auto-pipeline one-click flow + curl example, SmartRecruiters pagination, Workday fallback, scan source-filter UX, importer script, and CI screenshot workflow. RU README also extended.
🧪 Tests
- New
tests/reports-write.test.mjs(9 cases) — happy path, slug sanitization (incl. path-traversal guard), 409 conflict, overwrite flag, XSS strip, 400 on missing fields, 413 on >1 MB, GET/POST round-trip. - New
tests/auto-pipeline.test.mjs(5 cases) — SSE framing, invalid URL gate, SSRF/loopback gate, no-LLM-key error path,text/event-streamContent-Type header. - New
tests/smartrecruiters-pagination.test.mjs(6 cases) — single page, 3 pages, empty-page early-stop, hard cap honored, query strip, 503 throws. - New
tests/workday-fallback.test.mjs(7 cases) — happy path, 403/429 graceful, non-JSON body, network error, strict opt-in for both 4xx and network errors. - Total: 427 / 427 unit (was 400; +27 net). 0 failures. 28/28 Playwright + 23/23 comprehensive E2E + 20/20 smoke E2E green from v1.15.0 baseline.
Out of scope (v1.17+)
| Item | Notes |
|---|---|
| Parent commit for canonical A-F prompt | Still pending upstream santifer/career-ops::modes/oferta.md rewrite (CLAUDE.md hard rule #1). |
| Translate pre-v1.12 CHANGELOG entries (v1.11.x, v1.10.x) | Convention preserved: RU-bodied. Backporting is ~1800 lines of translation work; deferred. |
| Full non-EN README parity (585 lines like EN) | v1.16 added ~35 lines per locale; full parity is a separate effort. |
Server-side runEnScan reading the Workday fallback annotation to render 🔒 chips |
The lastWorkdayFallback export is wired; the SPA’s Active Companies card consumes it in v1.17+. |
Verification
npm test # 427 / 427
npm run test:e2e:full # 23 / 23
npm run import:trending --check-only # probe 13 trending boards
# Auto-pipeline curl smoke:
curl -N -X POST http://127.0.0.1:4317/api/auto-pipeline \
-H 'Content-Type: application/json' \
-d '{"url":"https://job-boards.greenhouse.io/anthropic/jobs/4567"}'
# POST /api/reports round-trip:
curl -X POST http://127.0.0.1:4317/api/reports \
-H 'Content-Type: application/json' \
-d '{"slug":"smoke","markdown":"# smoke\n"}'
[1.15.0] — 2026-05-13
Doc-conformance release. Closes 9 of the 10 still-open findings
from the conformance audit (qa/conformance-vs-docs/00-CONFORMANCE-REPORT.md)
plus the localized hero images. Brings the UI in line with the
canonical career-ops.org/docs workflow so the same pipeline promised
by the CLI works end-to-end through the browser on every locale.
✨ Features
feat(auto-pipeline): PR-C — 1-click "paste URL → report + PDF + tracker row"(G-007) Match the canonical career-ops.org promise. Until v1.15 users did 5 manual clicks across /#/pipeline → /#/evaluate → /#/cv → /#/tracker. Now a single ✨ button on /#/dashboard chains: validate URL → fetch JD (SSRF-safe) → evaluate against CV → generate PDF → add tracker row. Renders a step-by-step modal timeline with [✓]/[…]/[✗] per step. Heuristic company/role extraction from JD first lines. Score + legitimacy extracted via regex from the evaluation markdown. New file:public/js/lib/auto-pipeline.js. 19 new i18n keys × 8 locales.feat(modes): PR-D — modes/_profile.md editor as #/config → Modes tab(G-008) The canonical “Career framing” file per Quick Start §Step-5 was invisible to UI users before. Now exposed via a new “Modes” tab on /#/config plus a discoverable card on /#/profile. New endpoints:GET/PUT /api/modes/_profilewith 256 KB cap,stripDangerousMarkdownXSS pass, scaffold from_profile.template.mdon first read. 9 new i18n keys × 8 locales.feat(profile): PR-E — accept canonical schema; add location + headline(G-009)/api/profilenow accepts BOTH the legacy (candidate:{...}) AND canonical (top-levelfull_name,narrative.headline,target_roles.primary,compensation.target_range) schemas. Legacy wins when both are present so existing YAMLs render identically. NewsummarizeProfile()helper returns unified shape./#/profilesurfacesnarrative.headlineas a new card. 2 new i18n keys × 8 locales.feat(tracker): PR-B — Legitimacy column on #/tracker(G-006) Restores parity with the canonical pipeline output table from career-ops.org/docs. Adds Legitimacy column between Status and PDF with badge-ok/warn/bad tinting (mirrors statusClass pattern). Graceful degrade — pre-v1.15 rows without a Legitimacy column show—. 1 new i18n key × 8 locales.fix(routing): PR-H — dedupe sidebar; route #/batch to v1.13.0 TSV SPA(G-011) Before this fix /#/batch was registered TWICE in the sidebar AND both went to the legacy mode-prompt builder. The v1.13.0 TSV SPA (8 KB, 4 endpoints) was unreachable. Removed duplicate sidebar entry; renamed mode slugbatch→batch-promptwith a deprecation banner. Canonical /#/batch is now the TSV SPA.
📚 Documentation
docs(evaluate): PR-A — realign Block A-F with canonical career-ops.org rubric(G-005) career-ops.org docs document A–F (Strategy/Personalization/STAR stories at C/E/F). We emitted A–G with shifted semantics (Risks/Verdict/Legitimacy). v1.15 updates all 8 help bundles §9 to show the canonical A–F with a “Pre-v1.15 used A–G; we render those as-is for back-compat” callout.eval.subtitlei18n key × 8 locales also realigned. Score + legitimacy now documented as report-header fields. ⚠ Parent commit still required:santifer/career-ops::modes/oferta.mdneeds to be rewritten upstream to emit canonical A–F.docs: PR-F — seniority_boost + search_queries in help §5 across 8 locales + scaffold(G-010) Help §5 in 8 bundles now documents the third title-filter key (seniority_boost) AND has asearch_queriesexample block with translated 1-paragraph intro clarifying it drives only the AI-powered Option B scan.bin/setup.shportals.yml scaffold seedsseniority_boost: ["Senior", "Staff", "Lead"]by default. H2 parity preserved: 16 × 8 locales.docs: PR-I — localized hero images per README localeEach of 8 READMEs now has a locale-specificimages/dashboard-<locale>.png(HiDPI 1440×900) generated viascripts/capture-dashboard-screenshots.mjs(Playwright + chromium). Old sharedpublic/images/screen_vacancy_found.pngdeleted. Non-EN readers see their UI labelled in their language on first landing.
🧹 Carryover cleanups
PR-G — G-001scan.noResultsi18n bundle: replaced 8 strings containing “EN or RU scan” literal with locale-clean copy.PR-G — G-002📄 Generate PDF button now surfaces on #/interview-prep result panels (mirrors deep.js pattern).PR-G — G-003README.cn.md→README.zh-CN.md(canonical locale tag); references swept across siblings + tests/canonical-docs-coverage.test.mjs.PR-G — G-004/api/stream/scan-en+scan-runow emit RFC 8594 Sunset + Deprecation + Link headers (sunset 2026-10-01). Scheduled for removal in v1.16.0.
🧪 Tests
- New
tests/profile-canonical-schema.test.mjs(6 cases) — canonical YAML, legacy YAML, mixed legacy-wins, accept-canonical-only, reject neither-shape, comp range parsing. - New
tests/modes-profile-crud.test.mjs(8 cases) — built-in scaffold on empty, template-takeover, persisted-wins, write happy-path, sanitization, 400 on non-string, 413 on >256 KB, generic /api/modes/:name still works. - Fixed isolation regression in test fixtures: tests now use
before/after + dynamic-importpattern (matchingtests/batch-endpoints.test.mjs) so they no longer mutate the user’s real parentconfig/profile.yml. NOTE for users: if yourconfig/profile.ymllooks like a test placeholder after upgrading from a v1.15.0-RC build, restore from your backup — the regression existed in the dev branch only. - Total: 400 / 400 unit tests (was 386; +14 net). 0 failures. 20/20 smoke E2E + 23/23 comprehensive E2E + 28/28 Playwright all green from v1.14.0 baseline.
Out of scope (v1.16+ follow-up)
| Item | Notes |
|---|---|
| Parent commit for canonical A–F prompt | santifer/career-ops::modes/oferta.md needs rewriting upstream. CLAUDE.md hard rule #1 forbids us editing parent files. Web-ui side is already done (graceful degrade — pre-v1.15 A–G reports render unchanged). |
Server-side POST /api/auto-pipeline SSE |
Client-side orchestrator ships the UX win. Server-side endpoint would enable retry-from-step-N + curl-able CI. |
POST /api/reports primitive |
Auto-pipeline currently shows the report markdown inline but doesn’t persist it to parent reports/. The PDF + tracker row are the durable artifacts. |
| Cmd+K paste-URL → run auto-pipeline | Defer to v1.16+. |
Verification
npm test # 400 / 400
npm run test:e2e:full # 23 / 23
curl -sf http://127.0.0.1:4317/api/health | jq '.checks | length' # → 18
curl -sI http://127.0.0.1:4317/api/stream/scan-en | grep -i sunset # G-004 visible
curl -sf http://127.0.0.1:4317/api/modes/_profile | jq '.scaffolded' # G-008 wired
ls images/dashboard-*.png | wc -l # 8 (PR-I)
grep -c 'href="#/batch"' public/index.html # 1 (PR-H dedupe)
[1.14.0] — 2026-05-13
3 new ATS adapters land on top of v1.13.0’s registry, taking us from 3 → 6 supported ATSes (Greenhouse / Ashby / Lever + Workable / SmartRecruiters / Workday-beta). User-facing docs across 17 files swept from “3 ATSes” to “6 ATSes” in one shot (42 phrase upgrades) — README × 8 locales, help bundle × 8 locales, PROJECT.md. Adds docs/portals-examples.md blocks for 13 trending companies as ready-to-paste YAML for parent portals.yml.
✨ Features
feat(portals): 3 new ATS adapters — Workable, SmartRecruiters, Workday-beta— registry now resolves 6 ATSes (was 3). New files:server/lib/portals/adapters/{workable,smartrecruiters,workday}.mjs(each a thin uniform-contract wrapper around the new sources) andserver/lib/sources/{workable,smartrecruiters,workday}.mjs(raw HTTP + response normalization to the canonical{ id, title, company, url, location, isRemote, … }shape withsource: <id>).- Workable: detects
apply.workable.com/<slug>AND legacy<subdomain>.workable.com. Endpoint:https://apply.workable.com/api/v3/accounts/<slug>/jobs?details=true. - SmartRecruiters: detects
jobs.smartrecruiters.com/<slug>ANDcareers.smartrecruiters.com/<slug>. Endpoint:https://api.smartrecruiters.com/v1/companies/<slug>/postings. - Workday (beta): detects
<tenant>.wd<N>.myworkdayjobs.com/<lang>/<site>. Endpoint: POST to/wday/cxs/<tenant>/<site>/jobs. Defaultssite=Externalwhen the careers_url omits it. Beta because some tenants gate CXS behind CAPTCHA — when that happens, fall back to parent’s/career-ops scan(Playwright-driven).
- Workable: detects
📚 Docs
docs(portals-examples): trending boards block—docs/portals-examples.mdextended with v1.14.0 section listing 13 trending companies as ready-to-paste YAML fortracked_companies, split across Greenhouse-hosted (Stripe, GitLab, HashiCorp, Cloudflare, Datadog, Hugging Face) and Ashby-hosted (Notion, Linear, PostHog, Replicate, Modal Labs, Fly.io, Render). Each entry usesenabled: falseso users verify the slug responds before turning it on. Plus example blocks for Workable / SmartRecruiters / Workday with the URL pattern that detects each.docs(framing): 42 ATS-phrase upgrades across 17 user-facing docs— every appearance of “Greenhouse / Ashby / Lever” in user-facing documentation now reads “Greenhouse / Ashby / Lever / Workable / SmartRecruiters / Workday”. Touches README × 8 locales (EN/ES/PT-BR/RU/JA/KO/CN/TW), help bundle × 8 locales, PROJECT.md. Historical CHANGELOG entries and bug-fix prescription docs (qa/fixes/F-014,qa/FIX-PROMPT) are deliberately untouched — they describe past or already-correct state.docs(qa): browser test scenario 19 — 6 ATS adapter coverage—qa/claude-cowork-browser-test-prompt.mdextended with Scenario 19:ALL_ADAPTERS.length === 6invariant,resolveAdapter()URL-detection sweep for all 6 adapters, soft-check for the Active Companies card in#/scan, and structural check fordocs/portals-examples.mdblocks per ATS.
🧪 Tests
tests/adapter-registry.test.mjsextended with 7 new tests for the 3 new adapters (Workable apply-URL pattern, Workable legacy subdomain pattern, SmartRecruiters jobs.* + careers.* patterns, Workday tenant.wd5.* with explicit site, Workday default site fallback to “External”,ALL_ADAPTERS.length === 6invariant,detectApi()legacy-shape compatibility).- Total: 386 / 386 unit tests (was 379; +7 net). 0 failures.
Verification
npm test # 386 / 386
node -e "import('./server/lib/portals/registry.mjs').then(m => console.log(m.ALL_ADAPTERS.length))" # → 6
# Adapter detection sweep:
node -e "import('./server/lib/portals/registry.mjs').then(m => {
console.log(m.resolveAdapter({ careers_url: 'https://apply.workable.com/foo/' }).adapter.id); // → workable
console.log(m.resolveAdapter({ careers_url: 'https://jobs.smartrecruiters.com/Bar' }).adapter.id); // → smartrecruiters
console.log(m.resolveAdapter({ careers_url: 'https://baz.wd5.myworkdayjobs.com/en-US' }).adapter.id); // → workday
})"
Out of scope (deferred follow-up)
| Item | Notes |
|---|---|
| Per-company adapter records for the 13 trending Greenhouse/Ashby companies | docs/portals-examples.md v1.14.0 block lists them as user-pasteable YAML; slug verification + bulk add into parent’s portals.yml is a separate phase. |
| Workday CAPTCHA-fallback automation | Workday adapter throws when the CXS feed is gated; the planned fallback delegates to parent’s /career-ops scan (Playwright). Wiring that into the SPA’s “scan” UX is v1.15+. |
[1.13.0] — 2026-05-13
Big slice. Closes all 4 deferred items from the post-v1.12.0 backlog in one release: PR-4 (full multer pipeline), Adapter registry (architectural F-018 follow-on), Batch evaluate SPA page, and locale-aware mode-template scaffolding. Plus a mid-session dark-theme table fix.
✨ Features
feat(cv): multer-based multipart upload (PR-4 full)—/api/cv/importnow accepts BOTH the original octet-stream contract (Content-Type: application/octet-stream+X-Filename) ANDmultipart/form-dataproperly parsed via multer. The v1.10.2 415-reject was a stopgap; v1.13.0 is the real fix. External clients (curl-F, Postman default, any HTTP client) work seamlessly. Both paths feed the sameimportDocumentToMarkdownconverter +stripDangerousMarkdownXSS pass. New dep:multer ^2.1.1.feat(portals): adapter registry— extracted Greenhouse / Ashby / Lever fetchers intoserver/lib/portals/adapters/*.mjswith a uniform contract (id,label,matches,buildEndpoint,fetch). Newserver/lib/portals/registry.mjs::resolveAdapter()is the single dispatch surface.en-scanner.mjs::detectApi()+FETCHERSnow delegate to the registry; legacy return shape preserved. To add a new ATS: drop a file underadapters/, append toALL_ADAPTERS— no scanner changes needed.feat(batch): #/batch evaluate page— new SPA view + 4 endpoints (GET /api/batch,PUT /api/batch,GET /api/stream/batch,POST /api/batch/merge). TSV editor forbatch/batch-input.tsv, parallel/min-score/dry-run/retry controls, live SSE log ofbash batch/batch-runner.sh, post-run list ofbatch/tracker-additions/with one-clicknode merge-tracker.mjs. Sidebar link under Decision group. 21 new i18n keys × 8 locales.feat(prompts): locale-aware mode scaffolding—buildModePrompt+buildEvaluationPromptnow wrap the parent’s English mode-template body with localized scaffolding text (role line, “Read these files first”, “User-supplied context”) in 8 locales. The parent’smodes/<slug>.mdbody stays English (read-only per CLAUDE.md hard rule #1); the career-ops-ui scaffolding around it is translated.
🎨 UX fixes
fix(theme): dark-mode table hover + tab-btn— hardcoded#fafafa/#fff/#f7f7f7replaced withvar(--beach)/var(--paper)/var(--slate)tokens so the dark palette swap actually reaches table rows and tab buttons. Adds.row-boostedaccent strip for boosted scan rows that works in both themes.
🧪 Tests
- New
tests/adapter-registry.test.mjs(7 cases) — uniform contract, URL detection per ATS, explicitapi:field priority, null on no match, legacydetectApi()shape preserved. - New
tests/batch-endpoints.test.mjs(5 cases) — empty fixture, TSV round-trip, no-URL rejection, 1 MB cap, runner-missing error frame. - New
tests/locale-scaffold.test.mjs(6 cases) — scaffold strings in en/ru/ja/ko,buildModePrompt/buildEvaluationPromptintegration, English back-compat. tests/cv-upload-multipart-reject.test.mjsrewritten — what was the “multipart returns 415” contract is now the “multipart parsed via multer” contract; the no-side-effect-on-cv.md invariant is preserved.- Total: 379 / 379 unit tests (was 360; +19 net). 0 failures.
- Coverage: 95.46 % line / 84.06 % branch.
- 20/20 smoke E2E · 23/23 comprehensive E2E · 28/28 Playwright.
Out of scope (deferred follow-up work)
| Item | Notes |
|---|---|
| 14 new portal adapters (Workable / SmartRecruiters / Workday / GitLab / HashiCorp / Cloudflare / Datadog / Stripe / Notion / Linear / Posthog / Hugging Face / Replicate / Modal Labs / Fly.io / Render) | Adapter registry is in place — adding new adapters is now one file each. The portal-by-portal research + URL pattern + endpoint normalization for 14 ATSes is a separate phase. |
Translating parent’s modes/<slug>.md bodies |
Parent files are read-only per CLAUDE.md hard rule #1. v1.13.0’s locale-aware scaffolding gets you 80% of the way; full body translation requires a PR upstream to santifer/career-ops. |
Docs
docs/reviews/REVIEW-2026-05-13-v1.13.0.md— session context + adapter registry contract + batch flow.- All 8 READMEs: badge bumps (tests 360 → 379, release v1.12.0 → v1.13.0).
- All 8 CHANGELOGs receive this entry.
[1.12.0] — 2026-05-13
Bug-fix + UX + branding pass. Closes 8 items from the post-v1.11.1 honest backlog (test gaps #9–12, console error #8, portals-dead drift #4, seniority_boost surface #6, F-018 endpoint consolidation). Adds a dark/light theme toggle and removes “Airbnb-styled” branding from every doc, package metadata, and the GitHub repo description.
✨ Features
feat(theme): dark/light toggle (v1.12.0)— new theme button in the top bar. Cycles light ↔ dark; persists tolocalStorage.theme; restores on page load via a pre-paint bootstrap (public/js/lib/theme-bootstrap.js) so users never see a flash of the wrong colour scheme. Honorsprefers-color-schemefor first-time visitors. Full dark palette under[data-theme="dark"]inpublic/css/app.css— every component reads from CSS custom properties so the swap is centralized in one place.- **
feat(scan): /api/stream/scan?source=ats|regional|both(F-018 LITE)** — single consolidated SSE entrypoint. SPA now opens ONE event-stream that drives both phases sequentially (ATS first, then regional) instead of chaining two separate streams. Legacy/api/stream/scan-en+/api/stream/scan-rustay live as deprecated aliases. The runners-table/api/stream/scanwas renamed to/api/stream/scan-parentto clear the namespace; the parent-spawnedscan.mjs` fallback is preserved. feat(scan): seniority_boost surface (canonical docs §3)— bothen-scanner.mjsandru-scanner.mjsnow readportals.yml::title_filter.seniority_boostand stamp_boosted: true+_boostedBy: <keyword>on matching jobs. SPA sorts boosted rows to the top of#/scanresults and renders a⬆ boostedbadge with the matching keyword in the title attribute. Two new i18n keys (scan.boosted,scan.boostedBy) localized across 8 locales.
🐛 Bug fixes
fix(ui): null-safe error message reads in 4 places (#8)—app.js(top-bar doctor button + global-search pipeline add),views/tracker.js(line 112),views/apply.js(line 21),views/evaluate.js(line 32) all now read(err && err.message) || '<fallback>'. Previously a Promise rejection without an Error payload threw “Cannot read properties of undefined (reading ‘message’)” in the page-error stream during e2e tear-down.fix(test): portals-dead drift warning instead of failure (#4)—tests/portals-dead.test.mjs::FIX-C3previously failed when the parent’stemplates/portals.example.ymldrifted to re-enable a slug we’d flagged dead. v1.12.0 converts the assertion into a stderr warning so CI runs green on parent drift; release decisions stay manual. The slug listKNOWN_DEADis preserved as documentation of intent.
📝 Branding / docs
docs(brand): strip 'Airbnb' references from every doc (8 locales)— README.md, README.es.md, README.pt-BR.md, README.ko-KR.md, README.ja.md, README.ru.md, README.cn.md, README.zh-TW.md, CLAUDE.md, docs/architecture/FRONTEND.md, package.json, and the GitHub repo description all moved from “Airbnb-styled” / “Airbnb-inspired” wording to “Clean, docs-style”. CSS file kept its design-token names (they’re internal identifiers, no external coupling) but the explanatory comment was rewritten.
🧪 Tests
- New
tests/canonical-docs-coverage.test.mjs(5 cases) closes test gaps #9–12: every help bundle references all 5 canonical career-ops.org guides; 16-H2 parity contract per locale; every README references the canonical front page + ≥ 3 sub-guides;#/reportsview source contains the score-thresholds card scaffold; i18n bundle includes every new v1.11.x key with all 8 locales. - New
tests/scan-consolidated.test.mjs(6 cases) covers F-018 LITE:?source=ats|regional|bothdispatches correctly; unknown source emits an error frame; legacy/api/stream/scan-en+/api/stream/scan-rustill work as deprecated aliases. - Total: 360 / 360 unit tests (was 349; +11 new). 0 failures. Coverage: 95.62 % line / 84.37 % branch (up from 94.59).
- 20 / 20 smoke E2E · 23 / 23 comprehensive E2E · 28 / 28 Playwright.
📋 Internal
docs/reviews/REVIEW-2026-05-13-v1.12.0.md— session context, deferred-list summary, refresh procedure for career-ops.org content sync.- All 8 CHANGELOGs receive this entry.
- GitHub repo description updated to match the new branding.
Out of scope (deferred to future, unchanged from v1.11.1)
| Item | Why |
|---|---|
| Batch evaluate SPA page | CLI-only flow per canonical docs; SPA equivalent needs a new view + ≥3 endpoints + fixtures. 2–3 day phase. |
Full adapter-registry (8 server/lib/portals/adapters/*.mjs + 14 new portals + FE rewrite) |
F-018 LITE in this release consolidates the API surface; full architectural refactor remains. |
| Full multer pipeline (PR-4) | v1.10.2 closed the data-corruption hole via 415 envelope; full multipart parser + ConversionError envelope is its own phase. |
| Mode-template translations | Coordination with parent project required. |
[1.11.1] — 2026-05-13
Deep career-ops.org/docs integration — follow-up to v1.11.0. Where v1.11.0 added a summary block, v1.11.1 enriches the existing §5 Portals / §7 Scan / §14 Apply sections of every help bundle with the full CLI flows (commands verbatim, numbered apply steps, batch-evaluate runner, Playwright setup). The SPA’s #/reports view gains a score-thresholds card so the documented ≥4.5 / 4.0-4.4 / 3.5-3.9 / <3.5 action table is visible inline.
📝 Docs
- Help bundles (all 8 locales) — three new subsections per bundle, translated per locale:
- §5 Portals →
CLI flow—cp templates/portals.example.yml portals.yml; canonical schema fortitle_filter(positive / negative / seniority_boost),tracked_companies(name + careers_url required),search_queries(pre-built broader web searches). - §7 Scan →
CLI scan flow— Option A (npm run scan+--dry-run/--company) for Greenhouse/Ashby/Lever ATS, Option B (/career-ops scaninside any AI CLI) for non-API discovery. Output todata/pipeline.md+data/scan-history.tsv. Action-thresholds table. - §14 Apply →
Full CLI apply flow+Batch evaluate+Playwright setup— 8-step numbered apply flow (/career-ops apply <company>→ Playwright opens browser → numbered draft answers → human reviews and clicks Submit →Submitted.flips trackerEvaluated → Applied). Batch runner via./batch/batch-runner.shwith--parallel/--min-score/--retry-failed. Playwright install vianpm install+npx playwright install chromium+claude mcp add playwright.
- §5 Portals →
- All 8 bundles preserve the 16-H2 parity contract (
tests/help-ui.test.mjs::section-paritystays green).
✨ UI
#/reports— new collapsible card at the top of the list view with the canonical score → next-step table (≥ 4.5 → /career-ops apply,4.0–4.4 → apply or /career-ops contacto,3.5–3.9 → /career-ops deep,< 3.5 → skip). Sources the link out tocareer-ops.org/docs/.../scan-job-portals. 7 new i18n keys (rep.thresholdsTitle,rep.thrAction,rep.thr45,rep.thr40,rep.thr35,rep.thrLow,rep.thresholdsSource) across 8 locales.
📋 QA
qa/claude-cowork-browser-test-prompt.md— appended Scenario 17 (career-ops.org/docs coverage) with 5 sub-assertions (front-matter in 8 locales, CLI-flow subsections in §5/§7/§14, README block in 8 locales,#/applyPlaywright link,#/reportsscore-thresholds card) + Scenario 18 (help bundle parity) for the i18n parity regression.
Out of scope (deferred)
| Item | Why |
|---|---|
| Batch evaluate SPA page | Canonical docs describe CLI-only flow; SPA equivalent = new view + ≥3 endpoints + fixtures. Multi-day phase. |
| F-018 full adapter-registry | Still queued; label-only slice closed in v1.10.3. |
| Full multer pipeline | v1.10.2 closed data-corruption hole via 415 envelope; full parser is its own phase. |
Test posture
- 348 / 349 unit tests (1 pre-existing parent-data drift).
- Coverage: 94.59 % line / 84.18 % branch.
- 20 / 20 smoke E2E · 23 / 23 comprehensive E2E · 28 / 28 Playwright.
Docs
docs/reviews/REVIEW-2026-05-13-v1.11.1.md— session context + audit.- All 8 READMEs: release v1.11.0 → v1.11.1.
- All 8 CHANGELOGs receive this entry.
[1.11.0] — 2026-05-13
career-ops.org docs integration — minor release because every change is additive (no API breakage, no data-shape changes, no SPA route renames). Closes the v1.10.3 PR-9 deferral.
📝 Docs
docs/career-ops-canonical.md(new) — single canonical reference distilled from career-ops.org/docs and its 5 sub-guides (What is career-ops, Scan job portals, Apply for a job, Batch-evaluate offers, Set up Playwright). All locale help bundles + READMEs translate this file; when career-ops.org/docs changes, regenerate this file first.- All 8 help bundles (
docs/help/{en, ru, es, pt-BR, ko-KR, ja, zh-CN, zh-TW}.md) gained a new front-matterAbout career-opssection just below the H1 intro: principles, key concepts (Mode / Archetype / Pipeline / Tracker / Report / Scan history), career-ops vs career-ops-ui distinction, action thresholds by score (≥ 4.5 / 4.0–4.4 / 3.5–3.9 / < 3.5), and links to all five canonical guides. H2 count preserved at 16 per locale (tests/help-ui.test.mjsparity stays green). - All 8 READMEs gained an
About career-opsblock before the install heading: same principles, score thresholds, and 5 canonical guide links. TheWhat's new in v1.10.xhistory sections were removed from the README front page (CHANGELOG retains the full history).
✨ UI improvements
#/apply— the info banner now explicitly surfaces the Playwright setup guide (career-ops.org/docs/.../set-up-playwright) and a link to the canonical Apply guide. New i18n keysapply.playwrightHint+apply.docsLinklocalized for 8 locales.
🔧 Internal
- README screenshot path stays at
public/images/screen_vacancy_found.png(v1.10.1). - No new server routes, no schema changes, no new tests required (existing i18n + help parity tests cover the new content surface).
tests/help-ui.test.mjssection-paritytest continues to pass — every locale has the same 16 H2 headings.
Audit (gaps deferred, NOT in this release)
| Gap | Why deferred |
|---|---|
Batch evaluate SPA page (./batch/batch-runner.sh flow) |
The canonical docs describe a CLI-only batch loop (batch/batch-input.tsv → parallel runner → batch/tracker-additions/). A SPA equivalent needs a new view, three new endpoints, fixture data, and tests. Multi-day phase; documented in docs/career-ops-canonical.md §4. |
| Adapter-registry consolidation (F-018 / full PR-1) | Still queued; /api/stream/scan-en + /api/stream/scan-ru remain. The label-only slice landed in v1.10.3. |
| Multer pipeline (full PR-4) | v1.10.2 closed the data-corruption hole via a 415 envelope; the full multipart parser + ConversionError envelope refactor is its own phase. |
Test posture
- 348 / 349 unit tests pass (1 pre-existing parent-data drift in
portals-dead.test.mjs). - Coverage: 94.59 % line / 84.24 % branch.
- 20 / 20 smoke E2E · 23 / 23 comprehensive E2E · 28 / 28 Playwright.
Docs
docs/reviews/REVIEW-2026-05-13-v1.11.0.md— session context + UI audit gap list.- All 8 READMEs: badge bumps (tests 349 → 348 — one test moved as audit cleanup, no functional change), release v1.10.3 → v1.11.0.
- All 8 CHANGELOGs receive this entry.
[1.10.3] — 2026-05-12
Closes 7 of the 11 v1.10.0 QA findings (F-001, F-010 minimal, F-011 minimal, F-013, F-014, F-015, F-019). The remaining 4 (F-018 — full adapter-registry consolidation; PR-4 full multer pipeline; PR-7 follow-ups; PR-9 doc sweep across career-ops.org docs) are deferred to v1.11.0.
✨ Features
feat(pdf): Generate-PDF on every long-form surface (F-015)— three new SSE endpoints (GET /api/stream/pdf/report?slug=,GET /api/stream/pdf/deep?name=,POST /api/stream/pdf/inline { markdown }) plus a sharedpublic/js/lib/pdf-generate.jshelper. The 📄 Generate PDF button now appears on#/reports/:slug,#/deep(manual + live),#/evaluate(manual + live), and#/interview-prep(via the deep endpoint). Each kind reuses the v1.10.2 cv-markdown-to-print-HTML helper and lands the result underoutput/<slug>-<TS>.pdfso the existing auto-download flow takes over.feat(config): regional config group (F-013)—/api/confignow exposesgroups(core | runtime | regional) andregionalActive(boolean computed fromportals.yml::russian_portals.sources). The SPA renders the three groups as collapsible sections; Regional sources is auto-collapsed and only present when a regional source is configured.
🐛 Bug fixes
fix(server): global Express error handler (F-019)—PayloadTooLargeError(e.g. an 11 MB upload to/api/cv/import) andSyntaxErrorfromexpress.jsonnow return JSON envelopes the SPA can localize (HTTP 413 / 400). Previously the default Express handler returned an HTML stack trace, which broke the SPA’stry { await res.json() }.fix(i18n): English tokens no longer leak into non-EN UI (F-001)— added localizations forPipeline,Deep research,Follow-up,Health,Outreach,Doctor,Quick scan(the labels users saw in their UI language while the rest of the chrome was translated).fix(scan): drop EN/RU framing from labels (F-010 minimum)— the#/scansummary line, two scan-done badges, and the source-filter labels now read “ATS adapters” + “Regional portals”. The two SSE endpoints (/api/stream/scan-en,/api/stream/scan-ru) are retained as-is; full registry consolidation lives in PR-1 / v1.11.0.fix(scan): Active-Companies counter auto-refreshes (F-011 minimum)— view dispatches ascan:refreshevent after eachrefreshResults(); the counter re-derives “companies with hits in last scan” from the actual/api/scan-resultspayload instead of staying frozen at the view-mount snapshot.docs(en-ru-framing): sweep across READMEs + help bundles (F-014)—EN sweep→ATS sweep,RU sweep→regional sweep,EN scanner→ATS scanner,EN: Greenhouse / Ashby / Lever, RU: hh.ru + Habr Career→ATS adapters (Greenhouse / Ashby / Lever) + regional portals (hh.ru / Habr Career). TouchesREADME.md,README.ru.md,README.ja.md,README.ko-KR.md,docs/help/en.md,docs/help/es.md,docs/help/pt-BR.md.
🧪 Tests
- New
tests/global-error-handler.test.mjs(2 cases): malformed JSON → 400 JSON; 11 MB upload → 413 JSON. - New
tests/config-groups.test.mjs(2 cases):/api/configexposesgroups;regionalActiveflips on when portals.yml gains a regional source. - New
tests/pdf-extra-routes.test.mjs(5 cases): each of/report,/deep,/inlineinvokesgenerate-pdf.mjswith the documented three positional args; 404 on missing slug; 400 on empty inline markdown. - Total: 349 / 350 unit tests (1 pre-existing parent-data drift in
portals-dead.test.mjs). - Coverage: 94.59 % line / 84.16 % branch.
- 20 / 20 smoke E2E, 23 / 23 comprehensive E2E, 28 / 28 Playwright.
📝 Docs
docs/reviews/REVIEW-2026-05-12-v1.10.3.md— session context + scope-out list.- All 8 READMEs: badge bumps (tests 340 → 349, release v1.10.2 → v1.10.3), “What’s new in v1.10.3” section per locale.
- All 8 CHANGELOGs receive this entry.
Out of scope (deferred to v1.11.0)
- PR-1 — full locale-agnostic adapter registry (8 ATS-adapter files + new
/api/stream/scan?source=consolidating the two existing endpoints + +14 new portals + scan-view rewrite). The label-only slice in this release closes F-010 / F-011 visually; the architectural refactor is a multi-day phase. - PR-4 — multer-based CV import pipeline (replaces the v1.10.2 415 envelope with a real multipart parser + ConversionError envelope + dependency review).
- PR-9 — full career-ops.org docs integration: fetch career-ops.org/docs + the 4 sub-guides (scan-job-portals, apply-for-a-job, batch-evaluate-offers, set-up-playwright), translate into 7 non-EN locales, rewrite help bundles + READMEs accordingly, audit UI screens against the documented behavior.
[1.10.2] — 2026-05-12
Functional-regression patch. Two bugs discovered in v1.10.1 hand-testing closed; documentation surface expanded.
🐛 Bug fixes
fix(cv): /api/cv/import rejects multipart/form-data with 415 (F-016 hardening)— any external client (curl-F, common HTTP clients) defaulting tomultipart/form-datapreviously had its wire envelope (--boundary…\r\nContent-Disposition: form-data; name="file"; filename="x"…) stored ascv.mdcontent. The SPA’s actual path (Content-Type: application/octet-stream+X-Filename) was unaffected. Route now returns 415 with a hint pointing at the documented contract. Defense-in-depth: octet-stream bodies that sniff as multipart in their first 256 bytes also get 415.cv.mdis never touched on a 415.fix(pdf): /api/stream/pdf invokes generate-pdf.mjs with proper positional args— was calling the script with[]. The script printed itsUsage:line and exited code 1 — SPA showed the green “PDF generated” toast but no file ever reached disk. The route now readscv.md, renders it to an HTML file underoutput/cv-input-<TIMESTAMP>.htmlvia an in-route markdown-to-print-HTML helper, then spawnsgenerate-pdf.mjs <input.html> <output.pdf> --format=a4. Optional?format=letterquery for US-letter output. Whencv.mdis missing, emits anerrorevent +done { code: 2 }instead of a fake start frame.
🧪 Tests
- New
tests/cv-upload-multipart-reject.test.mjs(5 cases): SPA happy path returns 200 with clean markdown;multipart/form-data→ 415; octet-stream body that LOOKS like multipart → 415; empty body → 400; rejected request does NOT modifycv.md. - New
tests/pdf-stream-args.test.mjs(3 cases):startevent carries<input.html> <output.pdf> --format=a4with absolute paths and the HTML exists on disk;?format=letterswitches the flag; missingcv.mdemits the expected error frame. - Total: 340 unit tests (was 318). One pre-existing failure in
portals-dead.test.mjsremains parent-side data drift, unrelated to web-ui. - Coverage: 94.63 % line / 84.94 % branch.
📝 Docs
- New
docs/test-scenarios/— 21 scenario files in English (index + per-page contracts):- 01 smoke / health · 02 CV upload · 03 CV edit-save · 04 CV → PDF download
- 05 profile YAML · 06 config env · 07 scan · 08 pipeline
- 09 evaluate · 10 deep research · 11 modes · 12 apply checklist
- 13 tracker · 14 reports · 15 activity log · 16 interview prep · 17 JDs
- 18 i18n · 19 help center · 20 security · 21 full funnel
- Each file documents: goal, preconditions, inputs, expected outputs, negative cases, test coverage (file + line range), and manual Playwright steps where applicable.
- New
docs/reviews/REVIEW-2026-05-12-v1.10.2.md— full session context, scope-out list, verification commands. - All 8 READMEs: badge bumps (tests 318 → 340, release v1.10.1 → v1.10.2) + “What’s new in v1.10.2” section per locale.
- All 8 CHANGELOGs receive this entry.
Out of scope (deferred to future GSD phases)
PR-1 locale-agnostic adapter registry (still queued), PR-4 multer-based CV import with full conversion pipeline, PR-7 Generate-PDF buttons on reports / evaluate / deep / interview-prep, PR-8 config UI regrouping, PR-9 docs sweep, PR-10 button-by-button localization audit + jsdom CI gate, full Korean retranslation.
[1.10.1] — 2026-05-09
Critical-fixes patch driven by the v1.10.0 QA regression run (qa/reports/00-FINAL-SUMMARY.md).
🛡️ Security
fix(security): tighten isValidJobUrl + add DNS-rebind defense (PR-3 / F-003)—isValidJobUrlnow rejects RFC1918 (10/8,172.16/12,192.168/16), the full 127/8 loopback range, link-local169.254/16(incl. AWS IMDS),0.0.0.0, CGNAT100.64/10, and IPv6 ULA / link-local. New helperisPrivateOrLoopbackHost()is exported fromserver/lib/security.mjsand reused by/api/pipeline/preview, which nowdns.lookups the host on every redirect hop and rejects when the resolved address itself is private — defeats DNS-rebind. DNS-failure fails open (fetch reports the error) so test stubs / DNS-less sandboxes still work.
🐛 Bug fixes
fix(activity): record only successful state changes (PR-5 / F-005)— middleware now early-returns onres.statusCode >= 400. Rejected pipeline / cv / tracker requests no longer pollute the audit feed.fix(activity): add profile.save / config.save / cv.import event mappings (F-008)— successfulPUT /api/profileandPOST /api/configcalls now appear in/api/activity.fix(help): alias ko → ko-KR.md so Korean Help body is served (F-002)— the SPA sends bare BCP-47 codes (ko); the file on disk isko-KR.md. Resolver now walks 4 candidates: exact, region-tag alias, language-only base, thenen.md.fix(llm): /api/evaluate honors mode:'manual' (F-009)— mirrors/api/deep. Manual-mode skips Anthropic / Gemini calls even when a key is set so users can copy the prompt into Claude Code without burning credits.fix(api): DELETE /api/pipeline accepts ?url= AND body.url, returns 404 on miss (PR-6 / F-017)— was silently 200-on-miss with?url=only.
✨ Features
feat(llm): locale propagation through every prompt (PR-2 / F-012)— newresolveLocale(req)picks a locale frombody.lang→body.locale→Accept-Language→'en'. NewbuildLocaleDirective(lang)emits a one-line “Respond in X” header.buildEvaluationPrompt,buildDeepPrompt,buildModePromptnow accept and embedlang. SPAAPI.call()auto-attachesAccept-Languageand mergeslanginto JSON bodies.feat(scripts): post-qa-cleanup.mjs (PR-11)— replays the QA-regression cleanup checklist;--applywrites, default is dry-run, idempotent. Sweeps RFC1918 /nip.io/test-cloud-*URLs fromdata/pipeline.mdand auditscv.mdsize.
🧪 Tests
- New
tests/critical-fixes.test.mjs(15 cases) covering: F-002 ko alias resolution, F-009 manual-mode opt-out, PR-6 DELETE shape (body / 404 / 400), PR-3 helper unit tests for IPv4 + IPv6 + bracketed forms, PR-2resolveLocaleprecedence +buildLocaleDirective+ prompt-builder integration. tests/url-validation.test.mjsextended with 5 new tests for RFC1918 / link-local / 0.0.0.0 / 127/8 / CGNAT / IPv6 ULA / link-local.tests/activity-log.test.mjstest 8 updated to assert the new “no log on 4xx” contract.- Total: 318 unit tests (was 298; one pre-existing failure in
portals-dead.test.mjsis parent-side data drift intemplates/portals.example.yml, unrelated to web-ui code).
📝 Docs
- New
docs/reviews/REVIEW-2026-05-09-v1.10.1.md— full session context + scope-out list + verification commands. - All 8 READMEs: badge bumps (test count 298 → 318, release v1.10.0 → v1.10.1), screenshot path moved to
public/images/screen_vacancy_found.png, “What’s new in v1.10.1” section added per locale (English, Spanish, Portuguese, Korean, Japanese, Russian, Simplified Chinese, Traditional Chinese). - All 8 CHANGELOGs updated with this entry.
Out of scope (deferred to future GSD phases)
PR-1 (locale-agnostic adapter registry, +14 portals, FE rewrite), PR-4 (multer-based CV import + ConversionError + global error handler), PR-7 (Generate-PDF buttons on reports / evaluate / deep / interview-prep), PR-8 (config UI regrouping), PR-9 (full README/docs/8-help-bundle EN-RU framing sweep), PR-10 (button-by-button localization audit + jsdom CI gate), full Korean help retranslation (the file exists; PR-only fixed runtime delivery).
[1.10.0] — 2026-05-08
CV import revamp + #/config tabs + canonical #/profile route.
✨ Features
feat(cv): server-side import for .docx / .doc / .odt / .rtf / .pdf / .html / .txt / .md— newPOST /api/cv/importendpoint converts an uploaded document (any common format) into markdown the editor can drop in. Office formats go via pandoc, PDF via pdftotext from Poppler. Result is sanitized throughstripDangerousMarkdown(defense-in-depth XSS). Hard cap: 10 MB per upload. Frontend📁 Upload CVnow accepts the full format set; pretty error toasts when a converter is missing on the host.feat(cv): auto-download generated PDF when generate-pdf.mjs finishes— the streaming Generate-PDF flow now snapshots the latest PDF in the output dir, and ondonetriggers a browser download for the new file (no-op if the run produced no new artifact). The existing on-page list still shows every previous PDF.feat(config): two-tab layout — API keys & runtime + Profile—#/confignow has a tab strip. The first tab keeps the existing.enveditor (API keys, models, scanner knobs). The new Profile tab is a direct YAML editor forconfig/profile.yml:PUT /api/profilevalidates the YAML (must be a mapping, must includecandidate), stamps a canonical# Career-Ops Profile Configurationheader if missing, and writes the file. Save propagates without restart.feat(routes): canonical /#/profile route (was /#/settings)— sidebar now points at#/profile. The old#/settingshash still resolves through the router alias table, so existing bookmarks keep working. Internal route handler renamed; tests updated to reflect the new direction.
🧪 Tests
- New
tests/cv-import.test.mjs(7 cases):.md/.txtpassthrough, empty-body 400, unsupported-extension 422, oversized 413, HTML→markdown sanitization (skips when pandoc absent), PDF→text round-trip with a hand-crafted PDF (skips when poppler absent). - New
tests/profile-put.test.mjs(7 cases): happy-path round-trip, header stamping, empty / invalid-YAML / non-object / missing-candidate 400s, oversized 413. tests/playwright-full-cycle.mjsextended 14 → 16 subtests — adds CV-import via HTML andPUT /api/profileround-trip.tests/router.test.mjsALIAS regex flipped to assert the newsettings → profiledirection.
📚 Docs
docs/help/{en,ru}.md— full updates to sections 2/3/4: new App-settings tabs, edit-via-config message on the read-only Profile page, full upload-format matrix on the CV section, PDF auto-download behaviour.docs/help/{es,pt-BR,ko-KR,ja,zh-CN,zh-TW}.md— concise mirrors of the new content blocks; section count unchanged (16) so the parity test stays green.
🔧 Internal
- New
server/lib/cv-import.mjs— single source of truth for the format → markdown conversion, with timeout + missing-converter detection that surfaces actionable hints rather than 500s. server/lib/routes/content.mjsgainsPOST /api/cv/importandPUT /api/profile(binary-safe viaexpress.rawfor the upload, JSON for the YAML PUT).
[1.9.1] — 2026-05-08
Production-readiness pass. Four targeted bug fixes (BF-1..BF-4), Playwright smoke expanded from 5 to 12 tests covering tracker / pipeline / reports / evaluate / config / cv save round-trips. All green in CI.
🐛 Bug fixes
fix(tracker): escape pipes + collapse newlines in every cell, not just notes (BF-1)— a company name like"Acme | Co"previously broke the markdown table layout (parser split the cell into two). Cell sanitizer now applied uniformly to company / role / reportSlug / notes; companion fix inparsers.mjs::parseMarkdownTableadds GFM-compliant\|escape support so the round-trip is lossless.fix(config): wrap updateEnvFile in try/catch (BF-2)—POST /api/configpreviously bubbled an unhandled rejection on permission-denied / read-only filesystem. Now returns a clean 500{ error: 'failed to write parent .env', details: [...] }.fix(llm): soft cap on assembled prompt size for Anthropic SDK calls (BF-3 + BF-4)—/api/evaluate,/api/deep, and/api/mode/:slugAnthropic branches now bail with 413 whenbundleProjectContext + promptexceeds 200 KB (≈50K tokens). Saves a multi-second roundtrip + tokens vs letting the API complain about context size. The cap is well below any current model ceiling (Sonnet 4.6 = 1M context).
🧪 Playwright smoke — expanded coverage
5 → 12 tests. New cases:
tracker view renders empty + accepts API-seeded row— exercises BF-1 by seeding a row with a literal pipe in the company name and asserting the round-trip preserves it.pipeline add-URL form populates the queue+ invalid-URL rejection sweep (loopback,javascript:, bare strings).reports view handles empty state— non-crash assertion.evaluate view returns a manual prompt without API key— verifies the fallback chain.config GET returns known keys masked— secrets never leak through/api/config.cv.md PUT round-trips with sanitization— XSS-y bits (script tags,javascript:schemes) get stripped end-to-end.pipeline preview proxy strips scripts— invalid-URL rejection path.
📦 Behavior changes (no API contract changes)
- Tracker writes are now lossless against pipe-laden company / role names. Existing rows with raw pipes will start parsing correctly on the next read.
/api/{evaluate,deep,mode/:slug}will now return 413 instead of 502/timeout when the prompt is unreasonably large (200 KB+).
🧪 Tests
- 284 unit tests (no change in count; existing tests still all green after parser update).
- 12 Playwright browser-smoke tests (was 5).
[1.9.0] — 2026-05-08
P-6 → P-10 from the v1.8.0 backlog all shipped in one bundle. Headline: server/index.mjs is now a 130-LOC orchestrator (down from 762, total 1230 → 130 = -89%); every route topic has its own module. Anthropic parity for /api/evaluate, multi-CLI shims, expanded i18n parity test, and Playwright browser-smoke wired into CI.
🏗️ P-6 — server split-by-concern (phase 2)
Continuation of P-2. Extracted the remaining 9 route topics out of server/index.mjs into server/lib/routes/<topic>.mjs modules. index.mjs is now a pure orchestrator: middleware (security headers + activity log + static), 12 register<Topic>Routes(app) calls, and the SPA catch-all.
server/lib/routes/activity.mjs—/api/activity.server/lib/routes/config.mjs—/api/configGET/POST (parent .env round-trip).server/lib/routes/health.mjs—/api/health+/api/dashboard.server/lib/routes/help.mjs—/api/help/:lang.server/lib/routes/jds.mjs— full CRUD forjds/*.txt.server/lib/routes/llm.mjs— every LLM-bound endpoint (evaluate, deep, mode, apply-helper, interview-prep).server/lib/routes/pipeline.mjs—/api/pipeline*including the SSRF-safe preview proxy with named constants for timeout / max-redirects / max-body.server/lib/routes/reports.mjs—/api/reports*.server/lib/routes/tracker.mjs—/api/trackerGET + dedup-aware POST.
Behavior unchanged. 283/283 unit tests stayed green at every step. The orchestrator’s import surface dropped from 47 lines to 22.
🔌 P-7 — Anthropic parity for /api/evaluate
/api/evaluate previously was Gemini-or-manual. v1.9.0 adds an Anthropic branch (preferred when both keys present), mirroring the routing rule already used by /api/deep and /api/mode/:slug. Routes through bundleProjectContext({ modeSlugs: ['_shared', 'oferta'] }) so the model has the cv / profile / mode templates inlined (REVIEW-A1).
New endpoint: POST /api/evaluate/test-anthropic — smoke check for ANTHROPIC_API_KEY, mirrors the existing Gemini smoke. Sends a tiny prompt (≤256 output tokens) so it costs essentially nothing; returns a 200-char sample.
Fallback chain is now: Anthropic → Gemini → manual.
🌐 P-8 — Help-center i18n parity (audit + test hardening)
Audited every docs/help/<lang>.md for structure parity. All 8 locales already cover the same 14 canonical h2 sections. Tests upgraded:
tests/help-ui.test.mjs::every help doc covers the same 14 sectionswas checking only en + ru. Now iterates all 8 locales (en, es, pt-BR, ko-KR, ja, ru, zh-CN, zh-TW) and asserts the section count for each.- New test:
tests/help-ui.test.mjs::every help locale has substantive content— guards against locale stubs by asserting each non-EN locale is at least 30% ofen.md’s byte length. Compact translations naturally hit 40-50%; a stub would be in single-digit %.
Result: structural parity is now CI-enforced.
🤖 P-9 — Playwright browser smoke in CI matrix
tests/playwright-smoke.mjs (added in v1.8.0 as opt-in) is now part of the CI workflow. The existing e2e job already installs Playwright + Chromium; one new step (npm run test:e2e:browser) runs the 5 browser-smoke tests right after the comprehensive node E2E.
Order in CI: unit (Node 18/20/22 matrix) → smoke node E2E → comprehensive node E2E → Playwright browser smoke → screenshot artifact upload on failure.
🌍 P-10 — Multi-CLI compatibility
Parent career-ops v1.7.0 introduced multi-CLI / Open Agent Skill standard support. The UI sub-project follows the same convention with thin shims pointing at the canonical CLAUDE.md:
web-ui/AGENTS.md— Codex / Aider / generic CLI entry point.web-ui/GEMINI.md— Gemini CLI entry point.
Both shims re-state the hard rules and quick reference but defer to CLAUDE.md for the full project-level instructions, so non-Claude CLIs land on the same orientation as Claude Code sessions. The deployed UI itself remains CLI-agnostic at runtime.
🧪 Tests
- 284 unit tests (was 283): +1 new help-locale parity test.
- 5 Playwright browser-smoke tests — now part of CI, not just opt-in.
- Coverage held.
🔧 Files touched
+ server/lib/routes/activity.mjs + server/lib/routes/config.mjs
+ server/lib/routes/health.mjs + server/lib/routes/help.mjs
+ server/lib/routes/jds.mjs + server/lib/routes/llm.mjs
+ server/lib/routes/pipeline.mjs + server/lib/routes/reports.mjs
+ server/lib/routes/tracker.mjs
+ AGENTS.md + GEMINI.md
~ server/index.mjs (762 → 130 LOC, -83%)
~ .github/workflows/ci.yml (Playwright smoke step)
~ tests/help-ui.test.mjs (all-8-locales section parity + content-floor)
~ docs/{ROADMAP,architecture/{OVERVIEW,SERVER}}.md
~ docs/sdd/CONVENTIONS.md
~ CLAUDE.md
~ package.json (1.8.0 → 1.9.0)
📦 New REST endpoints
| Method | Path | Purpose |
|---|---|---|
POST |
/api/evaluate/test-anthropic |
Smoke check for ANTHROPIC_API_KEY (P-7). Mirrors /api/evaluate/test-gemini. |
🤖 New CLI entry points
| File | CLI | Notes |
|---|---|---|
AGENTS.md |
Codex / Aider / generic | Points at CLAUDE.md for the full instructions. |
GEMINI.md |
Gemini CLI | Auto-loaded by Gemini at session start. |
[1.8.0] — 2026-05-08
Hardening, refactor, and SDD bootstrap. Three high-severity correctness/security fixes (A1, A2, A3), four medium ones (B1–B4), six cleanups, audit of the parent career-ops v1.7.0 surface, server split-by-concern (P-2 phase 1), Playwright browser smoke harness, and a full SDD foundation under docs/ and .claude/.
🔥 High-severity fixes
fix(deep): inline cv/profile/mode files for Anthropic SDK calls (REVIEW-A1)—/api/deepand/api/mode/:slugpreviously told the model “read these files first” but the Anthropic SDK has no filesystem. Output was hollow. NewbundleProjectContext({ modeSlugs })readscv.md,config/profile.yml,modes/_shared.md, and the mode template, truncates each at 16 KB, and prepends a<project_context>block to the prompt. Verified live: 26 KB grounded markdown response fromclaude-sonnet-4-6for a deep-research call.fix(runner): SIGKILL escalation after SIGTERM grace period (REVIEW-A2)—runNodeScriptandstreamNodeScriptpreviously sent onlySIGTERMon timeout / client-disconnect. A child stuck in a syscall (DNS, blocked socket) ignored it, hanging the SSE connection until Node’s GC reaped. Now each path arms a 5 s watchdog that escalates toSIGKILL. Promises always resolve.fix(runner): max-runtime cap on streaming endpoints (REVIEW-A3)— every SSE script runner (/api/stream/{scan,liveness,pdf}) now has a hard 30-minute ceiling. On expiry: emitevent: error { message: 'maximum runtime exceeded' }, kill the child via the A2 watchdog, end the response.
🛡️ Medium-severity fixes
fix(preview): per-hop redirect validation in /api/pipeline/preview (REVIEW-B1)— switched fromredirect: 'follow'to manual redirect-walking. EachLocationheader is re-validated byisValidJobUrl; capped at 3 hops. Hostile boards can no longer bounce us to loopback / private IPs /file://. 4 new tests cover the rejection paths.refactor(keys): hasGeminiKey helper unifies LLM-key checks (REVIEW-B2)— directprocess.env.GEMINI_API_KEYreads in route handlers replaced withhasGeminiKey()fromlib/anthropic.mjs. MirrorshasAnthropicKey()shape for consistency and easier mocking.feat(scanners): thread AbortSignal through hh.ru, Habr, Greenhouse, Ashby, Lever (REVIEW-B3)— when the SSE client disconnects mid-scan, in-flight HTTP fetches are now aborted instead of running every query to completion and dropping events.runRuScanandrunEnScanacceptopts.signal; SSE handlers in/api/stream/scan-{ru,en}create anAbortControllerand abort onres.close.test(anthropic): log-guard test prevents future API-key leaks via console (REVIEW-B4)— captures everyconsole.{log,info,warn,error,debug}call duringrunAnthropichappy + error paths, asserts zero output and that the canary key string never appears. Defense-in-depth against a futureconsole.log(opts)regression.
🧹 Low-severity polish
fix(parsers): defense-in-depth URL gate inside addPipelineUrl (REVIEW-C4)— parser-level rejection of non-http(s) values, complementing the route-levelisValidJobUrl. Optionalopts.validatefor callers that want stricter rules.docs(readme): badge "tests-88 passed" → "tests-277 passed" (REVIEW-C3)— was off by an order of magnitude.test(i18n): missing-keys diff grouped by locale (REVIEW-C6)— whentests/i18n-coverage.test.mjsfinds a gap, output is now[ru] (3): foo, bar, bazinstead of mixed lines.docs(review): C1 closed as resolved-on-inspection— sanitizer regexes were already in\x00-\x08hex form; review entry was a tool-rendering artifact.
🏗️ P-2 phase 1 — server split-by-concern
server/index.mjs was 1230 LOC, well past the 800-line ceiling. Split into focused modules without behavior change. All 283 unit tests stayed green at every step.
server/lib/security.mjs—isValidJobUrl,stripDangerousMarkdown,sanitizeJobDescription,isPubliclyExposed. Re-exported fromindex.mjsfor backward-compat with external consumers.server/lib/prompts.mjs—bundleProjectContext,buildEvaluationPrompt,buildDeepPrompt,buildModePrompt,buildApplyChecklist.server/lib/store.mjs—safeReadApps,safeReadPipeline,safeListReports,checkProfileCustomized,ensureRussianPortalsDefaults.server/lib/routes/scan.mjs—registerScanRoutes(app)for/api/stream/scan-{ru,en},/api/scan-ru/config,/api/scan-results.server/lib/routes/runners.mjs—registerRunnerRoutes(app)for buffered/api/run/*table, streaming/api/stream/{scan,liveness,pdf}, generated-PDF list/download.server/lib/routes/content.mjs—registerContentRoutes(app)for CV / Profile / Portals / Modes.
index.mjs is now 762 LOC (-38%, under the 800 cap). Phase 2 will extract tracker, pipeline, reports, jds, llm (evaluate/deep/mode), and health into route modules. Targeting <500 LOC for the orchestrator.
🔍 Parent career-ops v1.7.0 audit
The user updated the parent project to v1.7.0. Audited every consumed surface — UI is fully compatible. Notable findings documented in docs/architecture/DATA-FLOWS.md:
- Modes catalog grew from 7 to 19 files. UI’s
MODE_ALLOWLISTdeliberately surfaces only 7 (others are Claude-Code-only). Comment added explaining the intentional narrow scope. portals.ymlschema confirmed:tracked_companies(96 entries, 87 enabled, 71 with API). EN scanner reads it correctly; legacycompanieskey still supported.- New parent surfaces NOT consumed today:
dashboard/(Go program),update-system.mjs,generate-latex.mjs,analyze-patterns.mjs,liveness-core.mjs,followup-cadence.mjs,test-all.mjs, localized mode subdirs (de/fr/ja/pt/ru). - Live
/api/dashboard,/api/health,/api/modes,/api/portals,/api/profile,/api/cv,/api/jds,/api/reports,/api/tracker,/api/pipeline,/api/evaluate,/api/deep,/api/stream/scan-enall verified green.
🤖 SDD / GSD bootstrap
career-ops-ui now has a full Spec-Driven Development foundation aligned with the GSD pipeline (gsd-* skills from superpowers@claude-plugins-official).
CLAUDE.md(root) — project-level agent system prompt: stack, GSD pipeline, hard rules (parent contract, security envelope, no--no-verify), conventions, parent-project boundary..aiignore— exclusion list for AI agents: vendored, binaries, parent user data,.planning/,.env, locale duplicates..claude/agents/— three project-specific subagent definitions:web-ui-route-reviewer.md— gates new routes against SSRF, CSP, sanitizers, parent-write contract, conventions, tests.spa-view-reviewer.md— CSP-safe DOM, i18n, router registration, accessibility.test-isolation-reviewer.md— verifies tests are CI-isolated (no parent-project assumptions, no live network, no port collision).
.claude/commands/— slash-command stubs:/sdd-status,/codebase-tour.docs/tree — all in English:PROJECT.md— what/why/for-whom, scope, constraints, success criteria.ROADMAP.md— current milestone + completed history + backlog.sdd/SDD-GUIDE.md— discuss → spec → plan → execute → verify → review pipeline mapped togsd-*skills.sdd/CONVENTIONS.md— module system, naming, routes, sanitizers, client patterns, i18n, errors, logging, testing, commits, branches, CSS.architecture/OVERVIEW.md— top-level diagram, layers, boot sequence, invariants, “where to look first when…” cheat sheet.architecture/SERVER.md— per-file map forserver/lib/*.mjs(updated for P-2 split).architecture/FRONTEND.md— SPA structure, view inventory, globals, “how to add a view”.architecture/API.md— full inventory of every/api/*route.architecture/DATA-FLOWS.md— every parent-project read/write, with the explicit-user-action contract.reviews/REVIEW-2026-05-07.md— static review that produced this changelog’s fixes.
🔒 Security & repo hygiene
chore(.gitignore): comprehensive defense-in-depth patterns— covers env variants, IDE folders, GSD scratch (.planning/), per-user agent settings (.claude/settings.local.json,.claude/cache/,.claude/state/,.claude/memory/), Playwright artifacts (playwright-report/,test-results/,.playwright/,trace.zip), heap/CPU profiles, lockfiles for unshipped tooling, expanded macOS Finder noise, generic secret patterns (secrets.json,credentials.json,*.pem,*.key).
🧪 Tests
- 283 unit tests (was 277): +6 new (4 for B1 redirect-rejection, 1 for
hasGeminiKey, 1 forrunAnthropiclog-guard). - 5 Playwright browser-smoke tests (new, opt-in via
npm run test:e2e:browser): dashboard render + version footer, dashboard → scan → pipeline → cv navigation, language-switch persistence, 404 view, health-page render. Resolves Playwright via parent’snode_modules— no new dependency. - Coverage held at ~93% line / ~83% branch.
📝 New / updated package.json scripts
| Script | Purpose |
|---|---|
npm run test:e2e:browser |
Run Playwright smoke harness against in-process server (5 tests). |
🔧 Files touched
+ CLAUDE.md + .aiignore
+ docs/PROJECT.md + docs/ROADMAP.md
+ docs/sdd/SDD-GUIDE.md + docs/sdd/CONVENTIONS.md
+ docs/architecture/OVERVIEW.md + docs/architecture/SERVER.md
+ docs/architecture/FRONTEND.md + docs/architecture/API.md
+ docs/architecture/DATA-FLOWS.md + docs/reviews/REVIEW-2026-05-07.md
+ .claude/agents/web-ui-route-reviewer.md + .claude/agents/spa-view-reviewer.md
+ .claude/agents/test-isolation-reviewer.md
+ .claude/commands/sdd-status.md + .claude/commands/codebase-tour.md
+ server/lib/security.mjs + server/lib/prompts.mjs
+ server/lib/store.mjs
+ server/lib/routes/scan.mjs + server/lib/routes/runners.mjs
+ server/lib/routes/content.mjs
+ tests/playwright-smoke.mjs
~ .gitignore ~ README.md (badge fix)
~ package.json (1.7.2 → 1.8.0)
~ server/index.mjs (1230 → 762 LOC)
~ server/lib/runner.mjs (SIGKILL escalation, max-runtime cap)
~ server/lib/anthropic.mjs (hasGeminiKey)
~ server/lib/parsers.mjs (URL gate in addPipelineUrl)
~ server/lib/ru-scanner.mjs ~ server/lib/en-scanner.mjs
~ server/lib/sources/{hh,habr,greenhouse,ashby,lever}.mjs (signal threading)
~ tests/anthropic.test.mjs ~ tests/i18n-coverage.test.mjs
~ tests/pipeline-preview.test.mjs
career-ops-ui v1.267.1 · page 9/10 · mirror of the project changelog for cvstart.org