Changelog
GitHubEvery notable change, release by release. The same file ships in the repository and is translated into all 19 languages.
[1.250.0] — 2026-10-12
The map page becomes workable: a job locator above the map pans to any visible job and opens its peek (desktop included), and a text search narrows the map by company or title — with both surviving reloads.
Added
- Job locator: a select above the map lists the currently visible (post-filter) jobs sorted by company+title; choosing one flies to its circle — zooming into the spread so clusters separate — and opens its peek card. The peek opens on desktop too: the locator’s whole purpose is «show me this job», which navigating away would defeat. The options mirror the active filters on every render, so the select never offers a job the map isn’t showing.
- Text search: a search input narrows the map by company+title substring (case-insensitive matching; the input keeps the case as typed). The query persists in map-prefs across reloads, Reset clears it, and the locator’s options narrow with the visible set.
Fixed
- The docs FAB no longer covers the map’s attribution: with the new toolbar the map shifted down and the Leaflet attribution’s bottom-right corner landed under the floating button. The FAB is now hidden on
#/mapat every width (on mobile it already was); the docs assistant stays reachable from every other page. - The two mobile-map height specs move their floor 600 → 540 px: the toolbar legally takes ~90 px of the viewport, and the specs assert «the map fills most of the viewport», not an exact pixel value.
Notes
- Drive-time MILES are deliberately NOT part of this release — the owner deferred them to the next one.
- The locator resolves the drawn layer via a
__jobback-reference: the first attempt used_point, which L.Circle already owns — the lookup silently failed and the peek never opened. A property-name collision with a library is invisible until the feature is exercised. - The 4 new i18n keys enter all 18 dictionaries and the assembled-dict snapshot is regenerated.
[1.249.0] — 2026-10-11
The job map is now relevance-first: strong jobs lead with a calm teal-to-grey scale, score and commute filters persist, phones get a full-height map with tap-to-peek, and the tracker shares commute filtering and sorting.
Added
- Upstream commute integration: a read-only, no-store projection of career-ops-plugin-commute v0.2.0 joins jobs by canonical posting URL without exposing router, email or other private plugin settings. A configured home is a neutral reference marker and never enters job counts or clusters.
- Decision filters: All / 3.0+ / 3.8+ / 4.2+ score presets, independent unevaluated and unknown-commute toggles, profile/30/45/60/90-minute commute ceilings, persistent preferences, Reset and explicit Fit results. Filter changes never reframe automatically.
- Usable phone map: the map owns the usable viewport; one bottom sheet serves filters and marker peek. First tap shows company + title, score/fit and commute, while tapping the card opens the job. Desktop keeps hover plus direct click.
- Tracker commute lens: Location and Drive columns appear when commute data exists, with filtering, sorting and an over-profile warning. New UI copy is translated across all 18 locales.
Notes
- Not coupled to the Fighter90 fork: the integration reads the stable upstream plugin files only, so an original-upstream career-ops checkout works once it carries
career-ops-plugin-commutev0.2.0. The UI does not run routing/geocoding, refresh commute data, edit plugin settings or expose private router/email configuration.
[1.248.8] — 2026-10-11
Parent parity: the fork and web-ui sync to the upstream’s 115-commit delta — a new PageUp source, tracker URL cells parsed as markdown links, the Assessment state — plus the ta/ja language work: looping reports are cut and named, the heading skeleton keeps A–G stable, and the script share reads prose only.
Added
- PageUp source (
provider: pageup): scrapes the vendor-hostedcareers.pageuppeople.comlisting tables — one tenant, one entry, paginated with the no-fresh / short-page stop rules and a cappedmax_pages. The registry is 105 EN adapters / 110 sources; PageUp appears in the#/scanSource filter (FALLBACK + live registry). A branded domain is deliberately not claimed — point the entry at the vendor host. - Tracker URL cells parse as markdown links (parent #3516): the parent writes the
URLcell as[ashby](https://…);parseApplicationsnow extracts the href (the dedup key) at parse time. Bare URLs and placeholders pass through verbatim. - The Assessment state (between Responded and Interview) flows through the live states.yml read; the built-in fallback list is updated for parity.
Fixed
- A looping report is cut and named, never filed silently (CAR-74): the 46 587-char ta report repeated Block B and never reached D–G or the score. A repeated block heading now cuts the report at its first occurrence, the rejection reason names the loop (
report looping (block B repeated), …), and non-EN prompts carry a ready## A) … ## G)heading skeleton so the machine format and the language mandate stop competing (the hard mandate had dropped D–G entirely, A–G ✗ 2/3 on prod). - The script share reads prose only (CAR-75): tables, the SCORE_SUMMARY block, code and URLs say nothing about the report’s language. A Japanese report with Latin tables scored 26–27 % own-script under the full-text share and flaked against the 30 % gate; prose-only holds it deterministically.
- /api/evaluate surfaces the language warning (CAR-76): both provider tails push
report not in <locale>when the saved report is off-language, and remote-qa prints the warning codes as a table column (codes only — the log is public).
Notes
- Not ported (documented): the scan-ats-full infrastructure (fetchListing/listingIdentity/_connection-scope — web-ui has no reverse-ATS sweep), the liveness expired-history recorder (web-ui writes no scan-history; the pure
planExpiredHistoryRowsports when a writer appears), coverage-check/discover/story-bank template/pdf –ats/text mode/domain_filter (CLI-only), the dedup-tracker req-id fix (web-ui has no dedup-tracker), README.vi.md (web-ui’s locale set is its own). - The parent fork (Fighter90/career-ops) was synced first: main = db11bee7, the merge defended ADR-0001/0002 — providers/telegram.mjs survives (upstream deleted it on their side) and the Unicode-boundary LOCATIONISH_RE is kept (upstream reverted to ASCII
\b, which never fires next to Cyrillic; the revert merged with zero conflicts, exactly the shape the pipeline warns about).
[1.248.7] — 2026-10-11
The report-header role now reads the parent’s actual H1 convention — # Evaluation: {Company} — {Role} — instead of assuming # <Role> — <Company>, so the tracker no longer carries «Evaluation:
Fixed
- The role is read from the header in the parent’s format: v1.248.7 strips the localized prose label first (
Evaluation:,Оценка вакансии:,Оцінка:, and 16 more locale variants — longest first, same pattern shape as the prose-label regex in parsers.mjs). After a label the format isCompany — Role→ the role is the RIGHT side; without a labelRole — Company→ the left side; a part that names the company decides regardless. The role is never the company and never a bare label word — the cleaned post line is the fallback. (v1.248.6 read the left side and producedEvaluation: Sveakas the role on every auto-pipeline tracker row.) - cleanup-plan reports line counts, not file counts:
— N dirty-URL line(s)lines are summed (the N), not counted as one line each — a file with 5 dirty URLs reported 1, not 5. - A wrong-language evaluate report warns instead of saving silently: the docs assistant answers on-language but the evaluate drifted (ta own-script share 22 % and 2 %, reproduced 2/2 on prod; ja flaked once and passed on the retry). The evaluation prompt now opens AND closes with a mandatory language instruction — the entire report including tables — and the pipeline validates the saved report’s language: a drift surfaces as a
report not in <locale>warning on the done event and in the step detail, while the (otherwise valid) report still saves. - No model chatter before the H1: «I’ll evaluate this job description… Reading files…» preamble is sliced off at save — the report starts at its heading line.
Notes
- Not ported and not touched (verified in the v1.248.6 regression): the embed fetch, the post line structure («Компания : X» with a space before the colon), the «## Rejected» dedup, the domain-root rejection before the fetch, recon/link-check honesty, and the cleanup modes. The language check is a WARNING, not a rejection — a report whose content passed validation (A–G + score) still saves; the operator sees the drift (
report not in <locale>on the done event and in the step detail) and can re-run the evaluation.
[1.248.6] — 2026-10-10
Post-regression polish for the Telegram chain: a real post keeps its line structure (so «Компания : X» and the role line are found where they actually are), the tracker role comes from the report header instead of a raw post line, and the operational surfaces (recon, cleanup-plan, link-check) report what they claim to report.
Fixed
- A t.me post no longer glues into one line:
extractTelegramPostTextturned every tag into a space — including<br>— so the line-anchored patterns (^Компания:) never fired and the role fell back to the first 100 characters of the whole post.<br>(and a closing</blockquote>) now become a newline before tag-stripping, and numeric HTML entities (&#NN;,&#xNN;) decode to their characters. The company self-label pattern widened to^Компания\s*[:—–-]\s*(.+)— the live post said «Компания : Sveak» with a space before the colon, and the tracker got the channel name instead. - The tracker role comes from the report header: the pipeline computed the merged role (header H1 → post line fallback) for its gate but wrote the RAW
guess.roleinto the slug, the tracker row and the done event. The header’s H1 follows the# <Role> — <Company>convention, so the role side is taken (the company doesn’t ride along); the post-line fallback is cleaned — no hashtags (#удаленка), no emoji, ≤80 characters. ## Rejectedno longer duplicates: every rejection appended a fresh## Rejectedheading todata/pipeline.md. The section is created once; each rejection appends its line INTO the existing section (two rejections in a row → one heading, two lines).- A domain root is rejected before the request: the
pathnamecheck ran afterfetchJobDescription, so a junklinkedin.com/cost a full page download (fetch: done) before being declined. The check now fires before the fetch — a junk URL costs nothing. - Recon computes the skip-contract signal instead of asserting it:
honours rejected:is nowyesonly if the deployedauto-pipeline.mjsactually containsmarkPipelineRejected(grep of the fetched source), and the marks count no longer prints0twice (grep -calready prints0on no-match; the|| echo 0fallback duplicated it). - cleanup-plan reports numbers per category: t-role reports to move, dirty-URL pipeline lines, test-row tracker lines — instead of one
grep -cthat counted its own «no unscored -t-role-.md reports» success line (a clean dataset printed a phantom «1»). - link-check treats github.com 5xx as unverified: three runs in a row 503’d on
github.com/…/blob/main/…links from Actions runners while the same URLs returned 200 from other networks — counted behind the bot wall (like 401/403/429), not as broken links.
Notes
- Not ported and not touched (verified working in the v1.248.5 regression): the embed fetch itself, the pipeline skip contract, the offline
_setLookuptests, the uk ordinal acceptance, word-boundary role keywords, and the cleanup-apply workflow mode. Thecleanup-planper-category counting reads the cleanup script’s own output labels (no valid SCORE/dirty-URL line/test-row line) — renaming those labels would silently break the counters.
[1.248.5] — 2026-10-10
Post-regression hardening: Telegram single-post entries now fetch the post text itself (the embed form), a rejected entry leaves the eval timer’s pool server-side, and a Ukrainian report’s native block numbering is accepted — plus one test that secretly needed the live network is offline for good.
Fixed
- t.me entries finally evaluate: a single-post page serves only the channel header in plain HTML (~0.2 KB — «View in Telegram» and nothing else), so the model got an empty description and every Telegram entry died at evaluation. The fetch now rewrites
t.me/<channel>/<id>(and thet.me/s/…feed form) to the embed form (?embed=1&mode=tme) — same host, SSRF validation unchanged — and reads the post from.tgme_widget_message_text(fallback:og:description). No widget text → rejected at the fetch step (telegram post has no text,rejected: true) before any LLM tokens are spent. - The eval timer stops re-paying for rejected entries (server-side contract): a rejected entry is now marked in
data/pipeline.md— its line leaves the URL fence and lands in a## Rejectedsection with a short reason (idempotent, never duplicated). The timer picks bare URLs from the fence, so a marked entry is skipped naturally — no dependence on the timer script’s internals. Recon printspipeline rejected marks: N+honours rejected: yes (server-side)instead of grepping the timer script. - Ukrainian reports pass A–G validation again: a uk model numbers the blocks with its own alphabet order (А Б В Г Д Є Ж), and the lookalike pass only counted А/В/Е — exactly blocks C, D, F, G went missing on prod (2/2 reproductions). The validator now accepts the Ukrainian ordinal sequence when the headings appear in order (the same discipline as abjad for ar and Devanagari for hi), and the evaluation prompt for every locale now demands Latin
A–Gexplicitly. - The role keyword gate fires on words, not substrings:
linkedin.com/andx.com/roots passed the gate becauselead/itlived inside unrelated page chrome. Keywords match on Unicode word boundaries now, and a domain root (a URL with no path) is rejected before the LLM call. - A unit test no longer needs the live network: the no-hints test (
tests/auto-pipeline-jd-gate.test.mjs) stubbed the HTTP transport but still performed a real DNS lookup — green in CI only because the runner had connectivity. safe-fetch gained a_setLookup()hook (the resolver itself is replaceable in tests; production code never overrides it), and the test resolves offline.
Added
cleanup-plan/cleanup-applyworkflow modes in deploy.yml: the prod QA-junk cleanup (post-qa-cleanup) runs server-side without SSH from a laptop. The log carries numbers only — no file names or line previews, because the log is public and file names contain company names. The Verify step is skipped for cleanup modes.
Notes
- Not ported: the parent’s word-boundary location compiler and USPS state table (documented since v1.248.1). The rejected-entry skip contract lives server-side (marking in
data/pipeline.md), not in the timer script — the script is outside the repository and deliberately not edited. - The
telegram post has no textrejection is a validation-driven stop, not a fetch failure: the SSE error payload distinguishes it withrejected: true, and plain fetch failures (404 etc.) keep the bare error shape.
[1.248.4] — 2026-10-10
The role-resolution chain for Telegram entries: a Cyrillic post with a real vacancy now yields a named report instead of a rejected unknown-role file — and the LLM call only fires for entries worth evaluating.
Fixed
- The role chain works for Cyrillic posts: the keyword list gained разработчик/программист/инженер/аналитик/менеджер/руководитель/тимлид/лид/дизайнер/тестировщик/QA/архитектор/специалист/директор/DevOps/SRE (the old list was EN-only — 0 of 40 real posts got a role), the report header is consulted (
parseReportHeader— the model writes the role into the H1), and the first meaningful line of the post is the last resort. - The identity gate moved BEFORE the LLM call: an entry with neither a company nor any role hint now fails at the fetch step (
rejected: true) — no tokens burned on an evaluation that was going to be rejected anyway. t.me/s/<channel>/<id>no longer names the company «S» — the web-preview prefix segment is skipped; both link forms resolve to the channel.- «Компания: X» self-labels on Telegram posts are honoured (and «…в компании X» mid-sentence for messenger hosts).
Added
auto-pipelinefailures carryrejected: true— the server-side eval timer uses the flag to skip the pipeline entry instead of retrying it every 2 hours.
Notes
- Not ported: the parent’s word-boundary location compiler and USPS state table (documented since v1.248.1).
- The README «Translations» blockquote is now generated from one canonical locale list, with a CI parity test (17 links per file, own never linked) — ar/pl/uk gained the block; the
|-vs-·separator drift is gone.
[1.248.3] — 2026-10-10
Follow-up to the regression round: the eval timer can no longer file nameless Telegram-junk reports under any circumstances, the QA-cleanup can no longer delete real Telegram postings, the map attribution is visible again, and the dashboard chips read ≥ 4.5:1.
Fixed
- The auto-pipeline validates BEFORE saving: a report without a 0–5 score or with missing A–G blocks ends the run at the evaluate step (
rejected: truein the SSE payload) — no report file, no tracker row. The t.me entries that cleared the v1.248.2 length gate produced two more junk reports after that release; with this fix the pipeline’s nameless entries are rejected and the run reportscompany/role not identifiable. guessCompanyRoleno longer invents a company from messenger hosts —t.me,telegram.me,vk.com,linkedin.comand friends are EMPTY_DOMAINS: a Telegram post falls back to its channel name, and a nameless entry is rejected instead of filed asunknown-role.post-qa-cleanupreads the header score first: auto-pipeline strips the SCORE_SUMMARY block before saving, so the old summary-only check flagged every saved Telegram posting as junk — on prod 8 of 41 carry a header score and--applywould have deleted them. Header score (viaparseReportHeader) is now the primary signal; the summary block is the fallback. The dry-run prints a per-file reason.- The Leaflet attribution is visible on #/map — the docs FAB covered it (an OSM-licence violation); the corner is lifted 96px.
- Dashboard chips clear 4.5:1 —
dash-chiplabels and the--manualvariant moved to--foggy-strong(were 4.31:1).
Added
auto-pipelinefailures carryrejected: truein the SSE error payload — the server-side eval timer uses the flag to skip the pipeline entry instead of retrying it every 2 hours.
Notes
- The ar/ja evaluation drift did not repeat on the re-run (see v1.248.2).
- The prod cleanup itself still awaits an explicit user yes (CAR-61c); with this fix the cleanup can no longer delete the real Telegram postings.
[1.248.2] — 2026-10-10
Regression round: the eval timer can no longer turn pipeline junk into «Last evaluation», SCORE parsing survives real-world formats, and the tracker finally speaks your language.
Fixed
- Empty-JD reports are never written. The auto-pipeline’s JD gate rose from 50 to 200 characters before any LLM call — placeholder pages (
example.com/qa-v167-…) now end as an SSE error + console warning instead of a…-t-role-<ts>.mdreport that the dashboard showed as «Last evaluation» (42 such files on prod). - SCORE_SUMMARY parsing survives real formats: «4,2/5», «4.2 / 5», «4.2», «SCORE :» all validate now; a comma with exactly 1–2 digits is decimal, with 3 it is thousands (the old code read «4,200» as 4.2 and passed the 0–5 gate). A failed check logs only the
SCORE:line — never report content (CI logs are public). - Tracker statuses are translated: stage tabs and status badges render localized labels in all 18 locales; the canonical value in data, filters and URLs is untouched.
- Honest ETA: «⏱ ~30s» became «~2–4 min» across evaluate/advisor/market-report (the real range is 86–285 s), and the evaluate subtitle reads A–G.
- Accessibility: the config API-keys counter and the pipeline counter now clear 4.5:1 (was 4.31:1); a facet chip with an empty value is no longer rendered without an accessible name.
- UI polish: the saved-search Delete button reads destructive only while a search is selected (disabled otherwise); the profile EMAIL card no longer clips at 1440 px; the live-evals pill gained icon/text spacing including RTL; the docs FAB no longer covers the Leaflet attribution; the help «?» no longer wraps under the stats tabs; the usage view gained air between its subtitle and range tabs; health now says career-ops-ui.
Added
scripts/post-qa-cleanup.mjs: dry-run by default,--applywith automatic backups (*.bak-<ts>, reports moved toqa/cleanup-backup-<ts>/) — ZZ-QA-TEST tracker rows,*-t-role-*reports without a valid SCORE, andexample.compipeline entries.
Notes
- The ar/ja evaluation drift from the regression round did not repeat on the re-run (both: lang ✓ + A–G ✓) — the language-check ta entry and the SCORE parser hardening ship as defense-in-depth.
- The prod cleanup itself awaits an explicit user yes: the dry-run plan precedes any
--apply.
[1.248.1] — 2026-10-09
location_filter gains the parent’s always_allow and block_hard tiers (CAR-60) — a multi-country location string survives a blocked-sibling list, and an explicit hard block can no longer be rescued.
Fixed
always_allowbeatsblock: withblock: [romania, poland, croatia]andalways_allow: [united kingdom], the workable merged cell «London, United Kingdom · Romania · Poland · Croatia» passes — the home region is an option (workable #4806’s consumer case, now in the parent’s own form).block_hardbeatsalways_allow: country-level terms that must never be rescued by an always_allow hit elsewhere in the string.strict: truenow countsblock_hardas a restricting tier; thedocs/helplocation_filter section documents both tiers ×18.
Notes
- Deliberately not ported this time: the parent’s word-boundary location keyword compiler and its USPS state-name table (web-ui keeps case-insensitive substring matching; the table serves US-centric configs — file under CAR-60 if a US config needs it). The workable consumer test’s interim allow-list form stays as a second verdict alongside the new parent-form check.
[1.248.0] — 2026-10-09
Parent parity: five upstream provider/liveness fixes ported — boards that moved their APIs keep working, and a closed-posting banner on its own line is no longer missed.
Fixed
- Gupy follows the API to
portal.gupy.ioand sends the filter names the API actually reads (workplaceType/type) — the old plural names were silently ignored;countryis no longer sent (a configured country now draws a naming warning instead). Confidential employers drop on an exact label match («Confidencial Seguros» survives), andmax_pagesis capped at 100 — the API answers 400 past offset 10,000, and the truncation warning now says to narrow keywords instead of raising the cap. - Workable keeps every country of a multi-country posting: siblings listed under the same job URL fold into one row («London, United Kingdom · Romania · Poland · Croatia») instead of the first country winning.
- Liveness: a banner on its own line is not part of the line above — «Sign in if you want to apply» / «Get notified when this position is filled» under a posting is read as the closing clause it is, while a genuine «until filled» spanning lines still reads active.
- Radancy reads a sibling
job-locationelement in the legacy card markup (Barclays-family layouts put the location after</a>) — rows without their own location stay empty rather than borrowing a neighbour’s. - SmartRecruiters fallback job links keep the configured slug — a posting with a missing/untrusted
refsynthesizes its public URL from the tenant slug, never from the display name.
Added
- No-ports, documented: the upstream
_httpproxyfetchImplfix (web-ui has no proxy branch — porting would defeat the DNS-rebinding guard’s identity check and add an undici dependency the repo deliberately avoids); upstreamverify-ats(+226 lines) andcv-facts(web-ui implements its own ATS scoring and CV parsing); the parent dashboard Go TUI and CLI scripts (web-ui does not shell into them); a Teamtailor import reorder (cosmetic).
Notes
- Not ported (follow-up filed): the parent’s
always_allowlocation-filter tier — workable’s consumer test survives a UK block via it, but web-ui’s location filter does not have that tier; the port expresses the same consumer verdict through an allow-list test instead.
[1.247.0] — 2026-10-09
The tail of the CAR-49…58 design sweep is done — every remaining minor and polish finding, plus the dark map the previous release deliberately deferred.
Fixed
- The map renders a real dark theme. Zoom/layers/attribution went dark in v1.246.0; the tiles themselves now follow: the dark mode renders an OSM tile layer with a CSS inversion (no vendor keys), and a keyed Carto-dark preset is available via
MAP_TILE_DARK_URL(its host is allow-listed in the CSPimg-src). The layer remounts live on theme switches. - Yellow callouts have dark variants (
config,batch, hero warning) via the v1.246.0 callout token mechanics — and a regression caught on the way:.callout astyling overpowered.btn-primary; now scoped:not(.btn). - Apply info-card URL no longer truncates mid-word (overflow-wrap anywhere).
- CV header buttons are one icon system (text-only; the 📁/📄/💾 emoji mix is gone).
- Tracker status tabs are one case — sentence case, folded across locales (CJK/ar/hi/ta handled without gender forms).
- Topbar icons are a single stroke-SVG set — the 🔔/🌙/🩺 emoji mix is gone; the theme button renders sun/moon via CSS, so the app no longer rewrites button text.
- The dashboard Pipeline card lost its stray pink highlight (ring is hover/focus-only now).
- Saved-search Delete is visibly destructive (
.btn-danger, not a neutral ghost). - Pipeline preview has a real empty state (title + hint + CTA, tracker pattern).
- Time/cost meta is one pattern —
UI.pageMetarenders ⏱ + cost as a single line under the view title (evaluate/deep/auto/mode-page/orientation). - Auto-pipeline lost its ✨ — the only decorated h1 in the app is back on the shared left edge.
- Batch docs URL is an inline link (no bare mid-paragraph wrap).
- interview-digest / orientation have designed empty states (title + hint + CTA).
- Career-plan export is disabled until a plan exists, actions on one row.
- stats: the duplicate «Market report» heading is gone and the region placeholder fits (shorter copy ×18, fixed width).
- Config no longer leaks absolute filesystem paths — server-side
displayPath()renders~/…or the bare file name. - The LLM_PROVIDER helper is three short lines (×18 copy +
pre-line), not a 150-char mono wall. - Profile EMAIL card keeps addresses on one line (ellipsis; wrap only ≤480px).
- Health text no longer breaks inside words («Run buttons» stays whole; overflow-wrap anywhere).
- Help inline code survives RTL (LTR-isolated, nowrap, scrollable;
career-opsin docs/help/ar.md is now coded as a token). - The hero «Live evals» pill no longer collides in ru/ar (flex-wrap, non-shrink icons).
Added
- Dark-tile preset (
MAP_TILE_DARK_URL+ attribution env) documented in-code; the CSPimg-srcaddition covers the Carto host for it.
Notes
- The dark-tile default is a CSS inversion of OSM tiles — keyless Carto now gates its basemaps behind an API key (verified by request), so the keyed preset is opt-in, not the default.
[1.246.0] — 2026-10-09
All ten top findings from the CAR-48 senior-design sweep are fixed — the blocker and all seven majors included.
Fixed
- Apply dark: the info-card link is visible again. The card kept its light surface in dark mode; the “Need Playwright? See …” link sat at ≈1.0:1. Theme-aware
.callout--info(surface/border/text/link tokens) puts the dark link at ≈7.3:1. - RTL no longer flips numeric compositions. Score pills (“1.5 / 5”), report bands (“≥ 4.5”, “< 3.5”) and the dashboard “/ 5.0” get directional isolation (LTR-isolated spans); the ar dictionary rephrases numeric ranges in words where prose reads better.
- CV markdown stays LTR. The textarea and the preview render
dir="ltr"in Arabic; the chrome around them stays RTL. - Reports DATE column never wraps an ISO date again (nowrap + tabular numerals).
- The sidebar ends above the USAGE HUD (scrollport reserve + fade) — the last nav item is no longer scrolled under the panel; mirrored for RTL.
- Mobile scan @390: “Save search” is fully visible — the controls row wraps and compresses instead of clipping at the viewport edge.
- Map controls follow the dark theme (zoom, layers, attribution). The tiles themselves stay as-is by design: the tile origin is pinned server-side by the CSP
img-srcdirective, and client-side layer swapping would mean changing a security header. - The chat FAB no longer covers interactive content — page scroll padding clears it (Portals Disable buttons, Reports score pill, the two-pager input, Leaflet attribution).
- Activity log speaks the UI language: 22 action slugs localized ×18 locales (with a raw-id fallback for unknown ones) and the filter chips translate; the TARGET column stays — auto-pipeline fills it.
- Assessments forms get real labels (sentence case,
htmlFor/idbinding) instead of placeholder-only fields.
Notes
- Not in this release: dark map tiles (needs a CSP
img-srcaddition for a dark tile origin — a server-side change, deliberately not done client-side); dark variants of the yellow callouts (config,batch) — the callout token mechanics are in place for a follow-up.
[1.245.0] — 2026-10-09
Tamil (தமிழ்) joins as the 18th UI locale — the first new language since the 17-locale wave, with full key parity.
Added
- Tamil (
ta) — 1449/1449 keys with full parity toen, zero placeholders lost, hi-style Indic-tech conventions (providers, model names, paths and commands stay Latin).detect()acceptsta,ta-IN,ta-LK; Tamil is LTR (locked by test). - Full docs/help/ta.md (33 H2 / 125 H3, source counts verified against the live registry, Hermes canary anchors, verbatim YAML), plus README.ta.md and CHANGELOG.ta.md mirrors.
- Enumeration gates 17 → 18: LANGS, detect probes, i18n audit, changelog parity, RTL guard, CI
langs— which also picks uphi, previously missing from the CI list.
Notes
- Not in this release: the cvstart.org landing translation (the landing stays 17 locales for now — a separate task); the site content sync already ships docs/help/ta.md and the ta changelog.
- Not in this release: Tamil report parsing —
report-header.jsREPORT_LABELS stays 17; it arrives with parent-repo Tamil report generation.
[1.244.2] — 2026-10-08
Scan page polish: the launcher, status bar, reposts panel and filters are redesigned as one consistent chrome — and the results table can no longer be stretched by board-side data.
Fixed
- Salary cells keep only the money range. Some boards put the whole benefits blurb in the salary field (“$243K – $286K • Offers Equity • This role is also eligible for medical benefits, 401(k) plan…”) — the visible cell now shows the money chunk and moves the blurb to the tooltip (was: a 6-line cell stretching every row).
- Rows with an empty title render the company (then a locale-neutral dash) instead of a giant blank cell.
- The seniority badge never wraps in its fixed-width column.
- Unscored rows show a muted «◎ —» with a tooltip explaining the two-pager compare found no matching keywords (was: nothing, which read as broken next to scored rows).
- The aux-height layout contract now measures the content element inside the aux cell — a td stretches to its row’s height, which masked real wraps.
Added
- Scan chrome redesign: launcher card (aligned control row, dominant primary button), terminal status bar (status dot idle/running/done/error, reduced-motion safe), reposts disclosure (styled header, count badge, capped panel with sticky header — a 1,560-cluster dataset rendered a 155,000-px panel before), and the filters block as a uniform responsive grid with an aligned footer.
Notes
- Tests 5045 → 5068 unit; browser layout contracts extended (aux one-line bound).
- Not in this release: URL-hash filter state and keyboard row navigation (follow-ups); live-LLM regression × 7 locales blocked earlier by upstream credits (unblocked — CAR-44).
[1.244.1] — 2026-10-08
Fixed
- Scan icons announce real values. The boost/fit/score icons’ accessible names leaked the raw dict templates (“Fit: {band}”, “Boosted by {by}”) —
{band}/{by}/{score}are now substituted (band word, booster keyword, numeric score). [HIGH a11y, caught live in the v1.244.0 regression round] - Title pipes split correctly. A 4-segment title like «C++ | Rust | Go Developer | Onsite» no longer lifts “Go Developer” out as a “country” — the split demands the segment resolves to a real country (conservative
Countries.detectCountry). - Meta dates render for every row.
daysSinceaccepted date-only ISO, but the API serves full timestamps for ~83% of dated rows — those rows showed no date segment.
Notes
- Everything else in #/scan is identical to v1.244.0 — only the three regression fixes above changed.
- Deliberately not done: URL-hash filter state, keyboard row navigation and
5 lakhs INRparsing (all pre-existing follow-ups).
[1.244.0] — 2026-10-08
The scan results read like a list, not a wall: one posting = two lines, the signals are icons, and a 700-row scan is a paginated table instead of a 37,000-px page.
Fixed
- Row anatomy. Every posting renders as a bounded two-line row: line 1 the title, line 2
company · location · source · date · work-type. The boost badge, the title-fit band and the fit score became icons with localized accessible names and tooltips (screen readers read «Соответствие: сильное» / “Fit: strong”, not a wall of words); trust and relocation badges folded into the same rail. Seniority and salary stay as desktop-only auxiliary columns. - Title hygiene. A trailing
| Germany | Remotesegment is split out of the title into the meta line — Unicode-aware, only when the tail is a real work-type marker and the middle segment is place-shaped (legit pipes like “C++ | Rust | Go Developer” never split). - No more horizontal sprawl. Fixed table layout,
min-width: 0, ellipsized one-line title/meta with the full text in the tooltip: zero page overflow and zero internal table scroll at 1440 px and 390 px (the old page overflowed by 675 px at phone width). - Pagination. Results render 50 per page (25/50/100/200 selectable) — a 700-row scan is now ~4,600 px tall instead of ~37,000 px, and the DOM holds ~1,000 nodes instead of 5,500+.
Added
- Layout contract tests: the two-line row, the icon accessible names (en + ru), the pagination node budget and the title-fit count/order guard run in the browser suite — the redesign cannot silently regress.
Notes
- Tests 5036 → 5045 unit (browser suite gained the layout contracts), coverage mean 98.14 % line / 89.34 % branch (floor 96/86).
- What the scanner finds, the scoring formulas and
last-scan.jsonare untouched — title-fit stays an annotation. - Not in this release: URL-hash filter state and keyboard row navigation (follow-up);
5 lakhs INRparsing (pre-existing).
[1.243.2] — 2026-10-08
Fixed
- Deployable release cut as v1.243.2. The v1.243.1 tag was re-pointed after publishing, and the server’s
git fetch --tagscorrectly refuses to clobber an existing tag — so v1.243.1 was never deployable. This tag carries the identical tree (the tracker outcome-modal window-listener fix on top of v1.243.0) plus this version bump. Nothing else changed; the published npm 1.243.1 tarball already contained the fix.
Notes
- App code is identical to v1.243.1 — only the deployable tag is fresh (the server’s
git fetch --tagsrefused the re-pointed v1.243.1). - Deliberately not done: force-updating the server-side tag (published tags are never moved).
[1.243.1] — 2026-10-08
Fixed
- The «Record outcome» modal actually closes on navigation now. The v1.243.0 guard listened for
hashchangeondocument, where that event never fires — the fix shipped dead and the modal still blocked the next view (caught live on production by the v1.243.1 regression round). The guard listens atwindow; the test now dispatches there too and was verified red against the broken code.
[1.243.0] — 2026-10-08
The browser layer keeps your work safe and speaks your language: no unsaved-edit losses, no silent failures, no silent English in a translated UI, AA contrast everywhere.
Fixed
- Unsaved edits survive re-renders. The CV editor keeps a dirty buffer and restores it on every render — Cancel, a language switch, or navigating to CV Studio now prompts instead of discarding; the career plan keeps its generated text the same way. Load failures in Config/CV Studio/career-plan/memory/two-pager surface an error and disable Save instead of quietly overwriting the file with blanks.
- The “Record outcome” modal no longer survives navigation. Opening it on
#/trackerand clicking another view left it blocking the whole page (found in the v1.242.1 two-stand regression) — it now closes on route change; its scan-view siblings stop their SSE streams and detach listeners on navigation too. - Salary and keyword parsing reads every script. Keyword chips use Unicode word boundaries (Cyrillic,
c++,.netfilter correctly instead of to zero rows); one shared salary parser fixes$182.9K - $240K,от 100 000 руб,100 тыс. руб.,50 000 Kč(was 50M USD) and range semantics ($100-150K); must-have “Germany or Netherlands” no longer counts as both matched and violated. - Streams settle exactly once. A dropped SSE connection delivered the error twice and could leave Generate disabled forever; every terminal state (done/error/close/throw, including a killed child) settles one path and re-enables the button. The auto-pipeline flushes its final buffered frame instead of dropping it.
- Privacy and correctness in the little paths. The bug-report body no longer carries the evaluated job URL or report slugs; CV privacy masks bare
linkedin.com/in/…links, ALL-CAPS names, and stops mangling EU dates; country resolution takes the longest match and refuses to guess when two countries are named (“Cambridge, MA” no longer → UK); filename downloads keep Cyrillic names. - Router and a11y. The skip link no longer 404s into
ontent; the active nav item carriesaria-current="page"; one<main>/one<nav>; Escape closes the mobile drawer (which is now truly out of the tab order when closed);scroll-padding-topkeeps focus visible past sticky bars; the sticky banner no longer covers the topbar. - Contrast is AA in both themes — dark toasts were white-on-white (1.10:1): toasts, buttons, banners and links on
--rauschnow resolve through theme-invariant tokens, guarded by computed WCAG contract tests (≥ 4.5:1). - Everything else from the round: evaluate renders the truncation warning; memory/config/two-pager refuse to Save over a failed load; scan stops orphaning its SSE; scan results classify API-backed boards from the server registry instead of a drifted regex; the tracker’s “Proceed with Caution” is a warning, not a badge-red; stats fold localized statuses; bar charts read correctly in RTL; the docs FAB no longer covers content on phones;
GET /app.jsanswers 404 instead of the index shell;GET /api/runnerslists its actions.
Added
- Danish joins auto-detection — a
da-DKbrowser now gets the Danish UI (the dict was complete, detection was missing it);zh-Hant*maps to Traditional Chinese.cv-diagnostics— the résumé checklist — is fully localized (28diag.*keys × 17). 26 field-spec labels added; 31 dead keys removed; provider lists no longer claim Hermes is “last in the auto order”; the docs-fab/menu aria-labels are localized.
Notes
- Tests 4812 → 5036 unit, browser 118 + new navigation/contrast contracts, coverage mean 98.15 % line / 89.34 % branch (floor 96/86).
- Not in this release: the
#/scanredesign (Phase 4 — v1.244.0, spec drafted); a dedicatedexport.docxFaileddict key (the failure reusescommon.error);5 lakhs INRstill parses as 5 (pre-existing).
[1.242.1] — 2026-10-08
Fixed
- The release archive ships no
node_modulessymlinks again. The v1.242.0 git tag carried a rootnode_modulessymlink andsite/node_modules(a blanketgit add -Aslipped them past the trailing-slash ignore patterns), which failed the server deploy’s tar extraction — the deploy rolled back safely and production stayed on 1.241.1. Both symlinks are untracked and the ignore patterns widened; the npm tarball was never affected (npm excludesnode_moduleswhen packing).
Notes
- App code is identical to v1.242.0 — packaging only. The sources-correctness content ships to production with this deploy (v1.242.0 never reached production; its server deploy rolled back on the tar failure).
- Deliberately not done: re-tagging v1.242.0 (npm already published 1.242.0; the registry tarball is clean — npm excludes
node_moduleswhen packing).
[1.242.0] — 2026-10-07
The scanner’s data layer is trustworthy again: dead sources are alive, a malformed answer can no longer masquerade as an empty board, and a hostile board URL can no longer redirect a scan.
Fixed
- Dead sources live again. justjoin (the API envelope changed to
{data, meta.next.cursor}) reads its full catalog through a cursor walk with the new salary fields; nofluffjobs answers 400 withoutsalaryCurrency— the parent’s required query is sent and results paginate (pageTo). himalayas and jobicy saw only the newest ~20 postings of 100k+ — both now walk their full catalogs (offset / cursor pagination with page caps). - A 200 with the wrong shape throws on page 1. ~40 sources read malformed or challenge answers as “0 postings” and the scan “succeeded” silently. New
server/lib/sources/_shape.mjsguards (requireArray/requireContainer/requireObject) make the failure loud across every family; a later-page failure keeps the pages already collected; pagination stops on the raw page length, not the filtered count. - SSRF: substring vendor checks replaced with parsed exact-host pins. lever (
clever.comcontainslever.co), greenhouse (5 hosts + the legacyboards[.eu].greenhouse.iopath-slug hosts), ashby, smartrecruiters, workable, gem REST, ibm, arbeitsagentur, hecklerkoch (www only), workingnomads (http://169.254.169.254was reachable past the DNS guard), remoteok, remotive, rss, successfactors.redirect:'error'everywhere; job URLs must behttps:on the pinned host (javascript:/data:/ off-host dropped). - One misconfigured portals entry can no longer abort a whole scan.
resolveAdaptercatches adapter throws,detectApiis wrapped per company, throwing adapters returnnull; comeet fetch errors redact?token=before they reach logs or quarantine records. - Pagination and freshness. workday walks tenants past 100 postings (offset pages,
MAX_PAGES); telegram keeps the NEWEST posts and paginates?before=instead of quietly keeping the oldest; tencent walks on whenCountis missing; taleo parses French heading tables; avature stops on raw page length; UTC date stamps (amazon off-by-one east of UTC, torre, workingnomads, trudvsem); HTML entities decoded (geekjob, getmatch, hh); mycareersfuture emits the full 12-field job shape. - Remote detection tightened. arbeitsagentur, vdab, rippling, teamtailor: contract-percentage (“Verpleegkundige (100%)”,
bundesweit) and the word “distributed” (“Distributed Systems Engineer”) no longer tag on-site roles Remote.
Added
tests/adapters-pin-coverage.test.mjscompletes the host-pin branch coverage the pins introduced.
Notes
- Tests 4419 → 4812 unit, browser 118. Coverage mean 98.15 % line / 89.36 % branch (gate floor 96 / 86).
- Not in this release: the client findings from the same review round (Phase 3 — views, libs, CSS/a11y, i18n);
sources/habr.mjsstill lacks a dedicated test file (ratchet exemption).
[1.241.1] — 2026-10-07
Live evaluations work end to end in every locale, the job map places long multi-location postings, and a deploy no longer kills a running scan.
Fixed
- Evaluations see the whole method. The context budget (144 KB) still cut the end of
modes/oferta.mdonce the CV and profile were inlined, so every live evaluation missed its last blocks. The budget is now 192 KB and the prompt soft cap 256 KB, defined once inllm-dispatch.mjs. - Reports in the chosen language. The language instruction now opens the prompt as well as closing it, and ordinary words (role, team, design…) are written in the report’s language; only names, technologies and word-for-word quotes stay English. Fixes Hindi and Japanese reports that were mostly English.
- Slow evaluations no longer end in HTTP 502. The evaluation timeout is 300 s (was the provider default, 180 s).
- Hindi block letters. A report that spells the A–G letters in Devanagari (
## ए),## बी)…## जी)) is recognised, andSCORE:accepts local digits (३.८,٣٫٨,3.8) and a decimal comma. - Job map: long locations. A cell like “Berlin Office · Berlin · Germany · Bosnia · …” (300+ characters) was refused with HTTP 400 and the posting had no marker. The first real place is used now (
·,;,|,/, line breaks; “Remote …” segments, “(HQ)” and “Office” are skipped); only a query over 2000 characters is refused. - Deploy vs. the hourly scan. The deploy holds the scan and auto-evaluation timers, waits for a running one to finish (up to 15 min), restarts, and always resumes the timers — rollback included.
Added
- Job map on cvstart.org in all 17 languages, and in the English and Russian READMEs.
- Job map screenshots in every language — one per locale (
scripts/capture-map-screenshots.mjs), in all 17 READMEs, on cvstart.org and in a new help subsection, “How to use the map” (§33, ×17).
Notes
- Tests 4404 → 4419, browser 118. Remote QA (all routes, site, links and live LLM × 17 locales) green on production.
- Not in this release: streaming evaluations — the 300 s timeout stays a hard limit (a slow local model can still hit it), and the Hermes bridge keeps its non-streaming call.
[1.241.0] — 2026-10-07
Hardening release from a 28-agent code review (~190 verified findings): the security, process-crash and data-loss classes are closed, plus a job map contributed by @bullitt186.
Added
- Job map (
#/map) — postings from the pipeline, the last scan and the tracker on an OpenStreetMap map, geocoded through Nominatim with an identifying User-Agent, a 1 req/s queue, back-off and an on-disk cache. Leaflet is served from this server (no CDN); the CSP only gains the tile origin inimg-src. Contributed by @bullitt186 (#381). - Request guard — the server refuses a
Hostit does not serve (DNS rebinding) and cross-site writes and side-effecting GETs (/api/stream/*,/api/run/*,/api/geocode). A reverse proxy that forwards a public name lists it inALLOWED_HOSTS; the deploy workflow writes that systemd drop-in from the public URL. - Coverage gate —
npm run test:coverage:gate(scripts/coverage-report.mjs) prints per-file line/branch coverage and fails below a ratcheting baseline.
Fixed
- A malformed request no longer stops the server. Express 4 ignored rejected async handlers, so a wrong-typed field in
/api/deep,/api/auto-pipeline,/api/career-plan,/api/trackeror a NUL byte in/api/statsexited the process; they are now JSON 400s, and any other rejection becomes a 500. - An aborted or failed scan no longer wipes the last results.
last-scan.jsonis kept when a scan is stopped or every source failed, and a single-company scan merges instead of replacing. One misconfiguredportals.ymlentry no longer aborts the whole scan. - Evaluations see the whole method. Mode files were cut at 16 KB, so
oferta.md(92 KB) lost blocks C–G; per-file caps are now 128 KB for modes and 64 KB for the CV, and a cut is reported. Empty or cut-off answers fail instead of writing an empty report. - SSRF: redirect hops are re-checked for private targets, every DNS answer is checked, the scan deadline covers the body,
*_BASE_URLkeys only go to the vendor’s host (Hermes/Ollama: loopback or LAN), pandoc runs with--sandbox. - Event-loop stalls: two quadratic regexes (HTML stripping, markdown sanitising) replaced with linear scanners.
- Data loss: deleting a pipeline row kept the other rows’ compensation;
$&in a URL corruptedpipeline.md; mock-interview, networking and deep-research saves overwrote earlier files; interview history kept the first 40 turns instead of the last. - LLM layer: auto-pipeline and deep research honour
LLM_PROVIDERand every provider through the shared dispatch, record usage and report truncation; Gemini evaluate/test no longer save a report; OpenAI usesmax_completion_tokens, and a Codex-onlyOPENAI_MODELis sent asgpt-5; the job description is fenced as untrusted data. - Report, deep and inline PDFs pass
--skip-fact-check(the CV fact check blocked any report with numbers); the profile’s page format is no longer overridden. - Scanners: quarantine is keyed by URL (fixing a URL retries it), within-run dedup by canonical URL, blank list entries no longer match everything, atomic writes,
skipped_*history rows are not “seen”. - Process control: the SIGKILL escalation never fired; batch runs kill their whole process group on disconnect.
- CI and release: CI now runs the changelog-parity, workflow and i18n gates; least-privilege workflow permissions; production secrets only under the
productionenvironment; the npm package now shipsdocs/help/. - Tests: no test touches the network or the real parent project any more (an explicit
CAREER_OPS_ROOTis always honoured); copied algorithms replaced by the production code.
Notes
- Tests 4056 → 4404, browser 118. Server coverage baseline: line 96.7 %, branch 86.3 % (
scripts/coverage-baseline.json). - Specifications now live in one folder,
docs/sdd/specs/; the program plan isdocs/sdd/PLAN.md, the invariant→gate matrixdocs/sdd/TRACEABILITY.md. - Operator note: behind a reverse proxy set
ALLOWED_HOSTS=<public host>; an Ollama/Hermes base URL on a public host is now refused. - Not in this release, deliberately (see
docs/sdd/PLAN.md): the sources-correctness pass (justjoin and nofluffjobs are dead upstream-side, ~40 sources still read a malformed 200 as an empty board) → v1.242.0; client, CSS and a11y review findings → v1.243.0; the#/scanredesign → v1.244.0; the Tamil locale → v1.245.0.
[1.240.0] — 2026-10-06
Parent parity — career-ops main @ 62905981 (VERSION 1.35.0, 177 upstream commits since b39931e). Six new sources — 103 → 109 (104 EN + 5 RU) — fixes mirrored into eight existing providers, and the checklist pipeline format, contributed by @bullitt186.
Added
- Six new sources — 103 → 109 (104 EN + 5 RU), 98 → 104 EN adapters.
- ADP Workforce Now (
workforcenow.adp.comwithcid+ccId) —$skippagination by rows actually returned; a response with neitherjobRequisitionsnormetathrows instead of reading as an empty board; opt-in detail enrichment (adpWorkforcenow.fetchDetails, default 25, cap 100). - Gupy (
employability-portal.gupy.io) — board-wide keyword sweep, newest-first with an early stop onsince_days;workplaceTypeis read as the singular pt-BR string so hybrid postings are not lost; confidential career pages are dropped. - JazzHR (
*.applytojob.com) — card parser with a bare-anchor fallback so a partial redesign still yields postings; posting links with zero parsed cards throw, a truly empty board returns[]. - Startup Jobs (
startup.jobs/feeds/jobs) — RSS, one request; a malformed, HTML or truncated body throws instead of returning[](the parent’s post-merge review fix,08fe5d06); CDATA and comments are masked before item matching. - Taleo (
<tenant>.taleo.net,tre.taleo.netrefused) — shell GET thensearchjobsPOST; a shell with no portal id throws as a private board; columns matched by heading. - UKG Pro / UltiPro (
recruiting[N].ultipro.{com,ca}) —Top=50+Skip,totalCountnever trusted as a stop condition, page cap 100 (clamped at 1500), opt-in detail pages parsed with a string-aware brace walk.
- ADP Workforce Now (
Fixed
- Pipeline in the parent’s checklist format — upstream career-ops writes
data/pipeline.mdas## Pending/## Processedsections of- [ ] url | …rows, which the UI read as 0 pending and wrote to by appending a fence at the end of the file, where the parent never looks.parsePipeline,addPipelineUrlandremovePipelineUrlnow read and write that shape (dedup against processed rows too; compensation as a labelednote: comp …segment); the fenced format is unchanged. Contributed by @bullitt186 (#380). - Country folded into location — Ashby (primary
address.postalAddress), Breezy (country.name) and Recruitee (flatlocation) append the country unless the name already carries it as a whole word, so a UK-primary, US-secondary remote role is no longer dropped by a location filter. - MokaHR — the
hire-r1.mokahr.comregional host is accepted (allowlist is exactlyapp.mokahr.com+hire-r1.mokahr.com), API POST and job links use the board’s own host, and an in-bandsuccess:falsenow throws instead of reading as an empty board. - Avature —
article--jobscards parse (title from the header anchor, location from theicon-addressspan). - SAP SuccessFactors — a
/go/<Category>/<id>[/<offset>]/saved-search URL resolves to the tenant root; it used to hit…/tile-search-results/, which answers 200 with zero tiles. - Deutsche Bahn — db.jobs blanks any results page that holds a posting it cannot render; the walk now sorts by
pubExternalDate_tdtwith 1000 per page (5 pages max), stops on the raw hit-anchor count, and throws on a missing results header or unparsed posting links instead of returning a short board. - HigherEdJobs — an Incapsula challenge answered with HTTP 200 used to parse as zero items and read as an empty board; it now throws.
- A test read the real parent project on a developer machine —
tests/reports-list-cache.test.mjssetCAREER_OPS_ROOTto a temp directory with nocv.md/portals.yml, whichpaths.mjsignores, so locally it counted the parent’s reports (6 instead of 3) while CI, with no parent beside it, passed.
Notes
- Tests 3782 → 4056. New suites:
sources-adp-workforcenow,sources-gupy,sources-jazzhr,sources-startup-jobs,sources-taleo,sources-ultipro,sources-location-country-fold,sources-deutschebahn-blanked-page,sources-higheredjobs-challenge-page, plusparsers-pipeline-checklistfrom #380. - Parent: the fork was brought to upstream
main(144 → 0 commits behind) with the four ADR-0002 divergences verified intact; upstream’s revert of the CyrillicLOCATIONISH_REdid not come through. - Already shipped in v1.239.0, not repeated: AppliTrack, NEOGOV, Red Rover, SchoolSpring, OCC Mundial, Greenhouse embed boards, Workday
myworkdaysite.com. - Not ported, with reasons: trusted-proxy egress (
CAREER_OPS_TRUST_PROXY_EGRESS,_http.mjs/_ip-guard.mjs/_dns-cache.mjs) — web-ui scans through its ownhttp-json.mjstransport; thescan-history.tsvcolumn registry and language-aware requisition dedup — web-ui reads positions 1–12, which did not move, and dedups by URL; SmartRecruitersrequisitionId/language— no consumer in web-ui’s job shape; GemisoCountry→ country name — needs the parent’s 249-entry alpha-3 table, deferred; CLI-only changes (modes, updater, doctor, PDF/ATS payload, tracker scripts, Go dashboard, Singapore mode).
[1.239.4] — 2026-09-29
Patch from the v1.239.3 live regression.
Fixed
- Korean and Traditional Chinese evaluations reported a missing score summary. Those reports run to 10–11 thousand characters, and the summary comes last, so it fell past the 8192 output-token limit. Evaluations (and the auto-pipeline) now allow 16384 tokens, and when a provider still cuts an answer off, the first warning says so instead of listing missing blocks.
- The live check read a short German answer as not German (3 German stop words, no English ones). A list-shaped answer can hold few stop words; with no English ones at all, three are now enough.
- Tests 3778 → 3782.
Notes
- The deep-research and mode routes keep their 8192-token limit: their answers are not long enough to reach it in the live runs.
[1.239.3] — 2026-09-29
Patch from the v1.239.2 live regression.
Fixed
- Arabic evaluations still failed the A–G check after v1.239.2 fixed Japanese and Hindi. A masked heading outline (no report text in the log) showed why: the Arabic report writes the block letter in Arabic, in abjad order, whatever the prompt asks. The check now accepts أ ب ج د هـ و ز as A–G when it stands alone near the start of a heading, as it already accepted Cyrillic look-alikes; the same letters inside an Arabic word do not count.
- The live check prints that masked heading outline when an A–G check fails, so the next case can be diagnosed from the log.
- Tests 3776 → 3778.
Notes
- The v1.239.2 note said the check would not be loosened; for Arabic it now is, because three live runs showed the prompt alone does not hold the Latin letter.
[1.239.2] — 2026-09-29
Patch from the v1.239.1 live regression.
Fixed
- An Arabic evaluation translated its block letters and a Japanese one dropped the score summary. v1.239.1 restated only the language as the prompt’s last line. That line now also says the Latin letter A–G in each heading and the
---SCORE_SUMMARY---block (markers and keys in English) must not be translated, and asks for the language’s own script. - Tests 3775 → 3776.
Notes
- The report check is not loosened to accept translated letters or markers: the prompt keeps the format stable instead.
[1.239.1] — 2026-09-29
Patch from the v1.239.0 live regression.
Fixed
- Japanese and Ukrainian evaluations came back mostly in English. The language instruction sat at the top of the prompt, before tens of kilobytes of English project files and the job description. The prompt now restates the language as its last line.
- The live check reported a failed scan when the server was already running one (the hourly scan, retried right after the deploy). The server refusing a second scan is correct; the check now waits and retries, up to 30 minutes.
- Tests 3774 → 3775.
Notes
- The server’s single-flight scan is not relaxed: a second scan is still refused with
SCAN_BUSY. Only the check changed.
[1.239.0] — 2026-09-29
Parent parity with career-ops @ b39931e: five new sources and three mirrored fixes.
Added
- Five new sources (98 → 103; English adapters 93 → 98): AppliTrack (
applitrack.com/<district>), NEOGOV (SchoolJobs / GovernmentJobs), Red Rover (jobs.redroverk12.com/org/<id>) and SchoolSpring (<district>.schoolspring.com) for school and public-sector hiring, plus OCC Mundial, Mexico’s largest job board (explicitprovider: occwith search queries). Each is pinned to its host, HTTPS-only and refuses redirects. - Tests 3500 → 3774.
Fixed
- Greenhouse embed boards (
job-boards.greenhouse.io/embed/job_board?for=<board>) resolved to a board named “embed” and failed; the board now comes from?for=, and only on a real Greenhouse host. - Workday boards on
myworkdaysite.com(/recruiting/<tenant>/<site>) were not recognised; they now scan, with posting links on the same host. - A report file name with parentheses (
042-acme-(berlin)-….md) was cut at the first)in the tracker; the whole name is kept now.
Notes
- CLI-only parent changes are not ported — PDF and CV templates, the scaffolder, the batch runner, the career-profile import, ATS keyword coverage, interview-mode translations, the
piCLI host: web-ui does not run them. - Opening such a report from the tracker still needs the report route to accept parentheses in a file name; that route is not changed in this release.
[1.238.3] — 2026-09-27
Pages stay responsive while a scan runs, and remote QA logs no longer leak the production host.
Fixed
- The dashboard and reports took seconds to load, and timed out during a scan.
/api/reportsand/api/dashboardread and parsed every report on every request — about 3.7 s on the production server, blocking every other request; with a scan running, page loads hit 30-second timeouts. Parsed report headers are now cached per file and refreshed when the file changes, so a listing costs one file check per report (500 reports: 430 ms → 3 ms locally). - A remote QA finding could print part of the production hostname into a public log. GitHub masks a secret only where its full value appears, and an error message cut to 180 characters ended mid-host. Findings and whole reports are now redacted before they are cut.
- Every AI evaluation was flagged as malformed. The prompt never asked for the
---SCORE_SUMMARY---block the report check requires (the parent’s evaluation scripts do), and a translated report translated the word “Block”, so the check missed its sections. The prompt now asks for the summary and for the block letter in every heading, the check accepts a translated word before the letter, and the summary block is removed before the report reaches the page or is saved.
Added
- Tests 3488 → 3500.
Notes
- The scanner still runs inside the server process; this release removes the other big blocker but does not move the scan to a worker.
[1.238.2] — 2026-09-27
Fix for the 502s the production server still showed while the hourly scan ran.
Fixed
- Pages and assets returned 502 while a scan ran. Node closes an idle keep-alive connection after 5 s; Caddy reuses idle upstream connections for up to 2 minutes. While an in-process scan kept the server busy, Node closed sockets Caddy had just sent a request on, and Caddy answered 502 connection reset by peer (62 times during one scan, with the server itself healthy). The server now keeps idle connections for 125 s — longer than the proxy — so the proxy always closes first.
Added
deploy.yml→verifyshows why the proxy answered 502 (error kinds, addresses masked), 502s per minute since the last restart, and the hourly scan’s outcome.release.ymlcan create a missing tag when started by hand, so a release can be cut where tags cannot be pushed.- Tests 3486 → 3488.
Notes
- The scan still runs inside the server process and can keep it busy for seconds; moving it to a worker is not part of this patch.
[1.238.1] — 2026-09-27
Stability patch from the first live regression of the production server: one scan at a time, and site fixes.
Fixed
- Two scans at once crashed the server. Every
GET /api/stream/scanran the full scanner inside the server process with no guard; a scan started from the UI overlapped the hourly timer and the process died with JavaScript heap out of memory (twice, with 502s meanwhile). Scans are now one at a time: a second request gets an SSEerrorwith codeSCAN_BUSYand nothing starts. The deploy workflow also raises the server’s heap limit (heap_mb, default 448 MB). - cvstart.org
/helpand/changelogscrolled sideways on phones (and ja/zh/changelogon desktop) — long code and table-of-contents lines now wrap. - Hindi was missing from the sitemap’s language alternates; the site’s i18n check now compares the sitemap map with the locale registry.
- Help §19 told translators to add a key to “all 8” language files — there are 17.
Added
- Remote regression (
.github/workflows/remote-qa.yml): every route × every language on the deployed app (read-only), every page × language × phone/desktop on cvstart.org, every link in the READMEs and the wiki, and — on request — one real scan plus live AI calls in every language. - Server deploy workflow (
.github/workflows/deploy.yml):recon,deploy(with a byte-exact rollback) andverify(smoke test plus stability report). - Tests 3481 → 3486.
Notes
- The scanner’s own memory use is not reduced in this release: the guard stops two scans running at once and the heap drop-in raises the ceiling. Profiling a real 98-source scan needs the production network.
- The hourly
career-ops-scantimer is not changed;reconnow shows which commands it runs.
[1.238.0] — 2026-09-27
Parent parity — career-ops main @ 993085ce (VERSION 1.34.0, 182 commits since de2224a9). Four new sources, ten mirrored provider fixes, a scanner-wide SSRF gap closed, and a free title-fit hint on scan results.
Added
- Four new sources — 94 → 98 (93 EN + 5 RU), 89 → 93 EN adapters.
- Eploy (
provider: eploy) — reads the tenant’s/live-jobs.xmlsitemap on any branded host (HTTPS only; IP literals,localhost,*.local,*.internaland single-label hosts refused). A non-sitemap response (a login page) throws instead of passing as zero jobs. Detail pages are opt-in (eploy: { fetchDetails, detailLimit }). - HiringRoom (
*.hiringroom.com) — JSON-LD/portal/jobsfirst, legacy/jobscards as fallback;http://posting URLs in the payload are rebuilt on HTTPS. - PeopleSoft Candidate Gateway — detected by the
/psc/{site}/EMPLOYEE/HRMS/c/HRS_HRAM_FL.HRS_CG_SEARCH_FL.GBLpath on any branded host. It follows PeopleSoft’s same-origin session redirects itself (max 10 hops, every hop re-pinned to the tenant origin and checked against private addresses), replays the “load more” form, and marks a partial walk (peoplesoftIncomplete) rather than reporting it as complete. - PrevueAPS (
*.prevueaps.ca) — resolves the site id from the jobs page, then reads the JSON API.
- Eploy (
- Title-fit hint on
#/scanrows. Each result carries a coarse strong / related / weak fit chip comparing the posting title with the target roles inconfig/profile.yml(target_roles.primary+ archetype names). It is a free keyword estimate: it never filters, reorders or changes counts, and the tooltip says so. Computed when results are served, so a profile edit shows on the next refresh. Translated ×17. Mirrors parent #3261.
Fixed
- Security — the DNS-rebinding guard never ran on a real scan.
http-json.mjsonly checked resolved addresses when handed the bare globalfetch, but both scanners injectmakeTimeoutFetch(), so every scan skipped it. A source pointed at a hostname resolving to10.xor127.0.0.1was fetched. The guard now runs insidemakeTimeoutFetchitself, raced against the scan timeout so a stalled resolver cannot hang a scan. Found in the review of this release. - Security — IPv6 addresses embedding an IPv4 address were not treated as private.
http://[::ffff:127.0.0.1]/(which the URL parser rewrites to[::ffff:7f00:1]) passedisValidJobUrl. Both the dotted and hex forms of::ffff:a.b.c.d/::a.b.c.dare now classified by the embedded address. Parent_ip-guard.mjs. - Rippling moved to the v2 board API (
ats.rippling.com/api/v2/board/<slug>/jobs), with pagination (default 10 pages, max 50), a host lock on job links, and slug validation. A pasted API URL now yields its real slug instead ofapi. - Recruitee — multi-location offers list every place (
A · B),(Sample)demo postings and title-less rows are dropped, and an offer is kept when either of its two URLs is valid. - Pinpoint — seeded demo postings (the doubled demo video) are dropped.
- Telegram channels — a first line with several
|is no longer read asTitle | Company, and more role words are recognised. The fork’s Cyrillic-awareLOCATIONISH_REis untouched (ADR-0002). - Workday — a first-page 422/401/403 is probed once; Workday’s maintenance page or outage redirect marks the board dead (quarantined in strict mode). The CXS and detail requests now refuse redirects.
- BambooHR — a redirected
/careers/listis triaged through the public embed feed: empty means a live board with no openings, anything else is a dead board. - Heckler & Koch / Rheinmetall — a bare
heckler-koch.com/rheinmetall.comis rewritten towww.so the no-redirect transport still reaches it. Ashby refuses redirects. beesite paces pages with the cancellabledelay(). - Title filters fold accents on both sides (EN and RU scanners), so
Développeuris caught by adeveloppeurnegative. - Cooldown company matching understands non-Latin names and corporate forms (CJK/Korean markers, Turkish dotted İ). CJK names used to collapse to an empty key and never match.
location_filter.strict: truefails closed on an empty location when an allow or block list is set.- Liveness — new public-API checks for Greenhouse embeds (
gh_jid), Arbeitsagentur and We Work Remotely; SmartRecruiters now decides by the posting’sactiveflag. - PeopleSoft — a relative “load more” form action resolves beside the search page, not at the origin root.
Notes
- Tests 3210 → 3481. New suites:
sources-eploy,sources-hiringroom,sources-peoplesoft,sources-prevueaps,title-fit,fetch-timeout-dns-guard,cooldown-company-match-unicode,liveness-api-more-rungs,ru-scanner-title-accent-fold. - Not ported, with reasons: location-filter Unicode word edges (web-ui’s filter is substring-based, so there is no edge to fix — a deliberate full port of the parent’s location filter is future work); same-title requisition dedup (web-ui dedups by URL only); aggregator repost warnings, blacklist domain scope, unverified-zero receipts and
scan-runs.tsvmigration (web-ui has none of those surfaces); Workday facet-split and truncation tags (web-ui fetches one page per board); BambooHRenrichDate(no date-enrichment hook); the parent’s_http.mjssleep refactor across ~20 providers (web-ui already uses a shared cancellabledelay); updater, doctor, PDF, tracker/merge-tracker,validate-profile,discover-new-companies,web/Next.js app, GitHub bots — CLI-only or relayed. - Kept deliberately different from the parent: Heckler & Koch / Rheinmetall stay HTTPS-only (no
http→httpsupgrade); a dead Workday board in a normal scan still returns[]rather than failing the scan.
[1.237.1] — 2026-09-22
Patch from an external QA pass on v1.237.0. The Jobstreet fix was confirmed from outside for the first time: on the AU, NZ, HK and MY hosts the old /id/job/<id> form returns a clean 404 and the new /job/<id> reaches a real Cloudflare-guarded route; on id.jobstreet.com the reverse holds. The lost links were real. Two defects were found, both in what v1.237.0 itself added.
Fixed
- The book link’s label was localized but its target was not. All 17 SPA locales and all 17 cvstart.org locales pointed the Agentic Coding Design Patterns footer link at the English edition, while the one translated label — Russian, «Паттерны агентного кодинга» — promised a Russian book. A new
data-i18n-hrefapplier (same shape asdata-i18n-title, restricted to absolutehttps://values) and afooter.patternsUrlkey sendruto the Russian edition and every other locale to the English one. The regression test asserts the invariant in both directions: a translated title implies a localized URL. - SEEK is migrating hosts, and the transport refuses redirects.
www.seek.com.au→au.seek.comandwww.seek.co.nz→nz.seek.com: the detail pages already 301, and both host pairs serve the v5 search API at 200 today. Becausehttp-json.mjssetsredirect:'error'(an SSRF defence worth keeping), the day the API path starts redirecting an AU/NZ entry on the old host would fail — and with only thewww.hosts allowlisted nobody could migrate.au.seek.comandnz.seek.comare now inALLOWED_JOBSTREET_HOSTS; the old hosts stay, so existing config and already-built links keep working. Look-alikes (au.seek.com.evil.com,au-seek.com) are still refused. - Wiki: the Agentic Practices row in the Home documentation map was the only one of twelve not escaping
|inside[[…|…]], so GitHub cut the cell — dead link, description hidden. One character.
Notes
- Counts unchanged at 94 sources (89 EN + 5 RU), 89 adapters. Tests 3201 → 3210 (+5 footer-link locale, +4 SEEK hosts).
- Not closable from outside, recorded rather than claimed: Oracle Cloud (Akamai blocked both REST probes), the Personio pin and
appendWorkType(need aportals.ymlentry, and the UI has no delete for one), and the liveness string cases (no controllable page; 40 live probes gave 12 live, 28 inconclusive, 0 false expired). - Help ×17 now list both host forms for
AU-MainandNZ-Main. Heading structure unchanged at 32 H2 / 122 H3.
[1.237.0] — 2026-09-21
Parent parity — career-ops main @ 93c4302b (VERSION 1.33.0, 75 commits pulled from career-ops-hq/main). No new sources this time: four mirrored fixes, and two of them were losing postings here silently.
Fixed
- Jobstreet / SEEK — every Australian, New Zealand, Singapore, Malaysian and Hong Kong posting linked to a dead page.
parseJobstreetItembuilt the detail URL as${baseUrl}/id/job/${id}unconditionally. The/id/segment is not a path — it is the Indonesian locale prefix, and it only exists on the Indonesian hosts. Every other SEEK-platform host answers 404 on/id/job/<id>. web-ui had already addedwww.seek.com.au,www.seek.co.nz,sg.jobstreet.com,my.jobstreet.comandhk.jobsdb.comtoALLOWED_JOBSTREET_HOSTS, so the scan happily returned those postings — each one pointing at a 404. The path is now keyed on the host:ID_LOCALE_HOSTS(id.jobstreet.com,www.jobstreet.co.id,jobstreet.co.id) keeps/id/job/, everything else gets/job/, and an unparseable origin falls through to the common path instead of throwing. Two of our own tests had encoded the bug — they assertedhk.jobsdb.com/id/job/…andwww.jobstreet.com/id/job/…— and were corrected. - Oracle Cloud — a short page mid-list ended the walk and dropped the tail of the board. The stop condition was
listLen === 0 || listLen < PAGE_SIZE, but ORC serves fewer rows than the limit in the middle of a list because some rows are filtered server-side. Measured upstream on American Express:TotalJobsCount454, pages of 200 / 199 / 54 — the 199 was read as the end and the last 54 postings, 12% of the board, were never fetched. Now an empty page always ends the walk; when the tenant reports a total, paging continues untiloffset + PAGE_SIZE >= total; only a tenant with no total to check against ends on a short page. This matches the wider convention that an API “may return fewer results than the number requested … even if not at the end of the collection” (Google AIP-158).hasMoreremains deliberately ignored — some tenants returnhasMore:falseon every page of a 7000-job board. With short pages no longer ending the walk,MAX_PAGESis now the only backstop against a tenant reporting a bogus total, so it is covered by its own test. - Liveness — “This role is closed” was not recognised, and a false expiry is permanent. The pattern was
/this job (listing )?is closed/i, which misses the equally commonroleandpositionphrasings; on one board upstream measured 111 of 111 uncertain postings using “role”. Widened to/this (?:job|role|position)(?: listing)? is closed\b(?!-)/i. The trailing\b(?!-)is a compound-adjective guard:\balone rejectsclosedownbut still matches real prose like “This role is closed-loop control of the platform”, and the lookahead is what stops it. - Liveness —
job expirednow sits in a soft tier that cannot beat a visible Apply button.HARD_EXPIRED_PATTERNSis checked before the apply-control test, so anything placed there wins over a live posting./\bjob expired\b/ifalse-fires on four real live-page shapes — a “Similar jobs” carousel entry, a “Hide job expired” filter chip, a footer FAQ, and the closed-loop prose above — because the classifier receives the whole page’s text. It now lives inSOFT_EXPIRED_PATTERNS, checked after the apply control and before the listing-page heuristic, returningexpired_body_soft; a bare “JOB EXPIRED” banner with no apply control still expires. This direction matters because a falseexpiredis written to scan history asskipped_expiredand then dedup-filters a real job out of every later scan, indefinitely, unlessscan_history.recheck_after_daysis set.
Added
- Personio — an explicit
personio: <slug>pins the tenant. Many companies embed their Personio board as an iframe on a branded careers page, socareers_urlpoints at the company’s own domain while the feed lives at<slug>.jobs.personio.de; without a pin those boards resolve to nothing. The slug is charset-restricted by an anchored allowlist (/^[a-z0-9][a-z0-9-]{0,62}$/i) and the resulting URL is still routed through the existingnew URL()parse, HTTPS check andPERSONIO_HOST_REhost gate — the pin is never a way to reach another host. A slug carrying.,/,@,:,.., whitespace, a leading-, or exceeding the 63-character DNS label limit falls through to today’sapi/careers_urlbehaviour. Unlike the parent, web-ui’s host regex is case-sensitive, so the pin is normalised throughnew URL()—AcmeGroupresolves toacmegroup.jobs.personio.derather than building a host its own allowlist would then reject. - Jobstreet — opt-in
appendWorkTypesuffixes the title with the listing’s work types, e.g.Strategy Consultant [Part time], so title filters and triage can see employment type at scan time. Off by default; a missing or emptyworkTypesis a no-op.
Notes
- Source and adapter counts are unchanged: 94 sources (89 EN + 5 RU), 89 EN adapters. This release moves only the test count, 3164 → 3201 (+13 jobstreet, +7 Oracle Cloud, +9 liveness, +8 Personio).
- The parent deleted
providers/telegram.mjs, consolidating ontelegram-channel. web-ui registers both as user-facing sources, so the deletion is not followed: it would drop the registry from 94 to 93 and silently break anyone who hastelegramconfigured. Revisit only as a deliberate, announced removal. jd-skill-gap.mjsgained an uncontracted “what we are looking for” heading form and a bold-marker strip. web-ui relays that script rather than mirroring it, so the fix arrives with the deployed parent and needs no change here.- Not ported, with reasons:
lib/latex-escape.mjs,browser-extract.mjs,openai-eval.mjs,update-system.mjsand the scaffolder (web-ui neither mirrors nor shells into them); the Go dashboard TUI andweb/config form (separate surfaces);doctor.mjsandanalyze-patterns.mjs(read-only relays whose fail-soft absorbs the change). - The fork’s Cyrillic
\p{L}divergence inproviders/telegram-channel.mjssurvived the upstream merge intact and was verified byte-for-byte against a pre-merge copy.
[1.236.0] — 2026-09-18
Parent parity — career-ops main @ 6a9c84c (VERSION 1.33.0, 128 commits). Two new sources, and three mirrored fixes — two of which were losing postings here, silently.
Added
Two scanner sources, 92 → 94 (89 EN + 5 RU). Python.org Jobs (pythonorg) reads the board’s public feed; employer attribution is mandatory, so a row whose title names no employer is dropped rather than backfilled with the portal entry’s name. Generalist World (generalist-world) scrapes the board’s cards and fails loud on markup it cannot parse — a card shape that changed throws instead of reading as an empty board, which is the failure mode that makes a scraped source untrustworthy. Both appear in the #/scan Source filter, both are host-pinned (anchored regex, HTTPS-only), and both are driven end-to-end by fake-transport suites (43 and 36 cases).
Fixed
A Workday role open in several cities was dropped by a location filter. Workday’s list endpoint answers a multi-location posting with a count where every other posting carries a place: "53 Locations". That is not a location, and buildLocationFilter matches by case-insensitive substring — so a role open in Austin and 52 other cities matched no allow: [austin] entry and was discarded, while the same role listed singly passed. Measured upstream across three tenants: 53 of 291 postings were placeholders, so this is the ordinary case, not an edge one. The posting’s real places exist only in the CXS detail document, so each placeholder now costs one extra GET against it, capped at 200 per entry — past the cap the placeholder is left visible rather than faked, because an unresolved posting must never read as a resolved one. A failed or placeless detail leaves the posting exactly as the list returned it: a visible "53 Locations" is honest about what could not be resolved, whereas a blanked location would silently pass every filter. The same GET also carries startDate, so an enriched posting is dated exactly instead of by the list endpoint’s relative prose ("Posted 30+ Days Ago" → undefined); anything that is not a bare YYYY-MM-DD is left alone rather than run through Date.parse, whose non-ISO fallback is implementation-defined.
A stated level no longer collapses two requisitions into one repost. "Insurance Specialist II" is the employer’s own statement that this requisition is not its sibling — a different pay band, scope and req number under one base title. The tokenizer could not see it: w.length > 3 drops every roman numeral up to VIII and every single digit, so "Insurance Specialist" and "Insurance Specialist II" tokenized identically, scored a perfect Jaccard ratio, and the repost detector flagged the second as a re-listing of the first. web-ui’s role-matcher.mjs had no level handling at all, so this lands the parent’s whole mechanism: levels are extracted with roman and arabic folded onto one number ("Nurse II" and "Nurse 2" are the same statement — and that pair wrongly differed before), two stated levels must agree, and a level on one side alone is treated as a loose rewrite unless each title also carries a word the other lacks, which is two disagreements rather than a decoration.
An Avature board pinned to a filter walked the whole global board anyway. Avature’s search UI generates opaque facet params (?42386=[812132] is a country filter), and an operator who pins a narrowed board in portals.yml expects the scan to walk that. resolveSearch rebuilt the URL as ${origin}${path} and dropped the query string outright — so every request, the first one included, walked the tenant’s entire board: more pages, more requests, and postings the operator had deliberately filtered out. Facets now ride every paginated request, with our own jobOffset applied last so a stray same-named facet can never hijack pagination.
Security
devalue 5.8.1 → 5.9.2 (transitively via astro), closing the one open Dependabot alert — a denial of service on malformed input. Site-only dependency; no runtime code changed.
Notes
Not ported, each with the reason. The parent gave lever a 30s and manfred a 25s timeout against its shared 10s default — a no-port: web-ui’s scan transport already allows 60s (DEFAULT_SCAN_TIMEOUT_MS), so both boards have more headroom here than the fix grants there. iCIMS’ doubled careers- prefix cannot occur here either: web-ui takes the tenant origin straight from api/careers_url and never constructs a host from a tenant name; its new fallback-host walk is a feature for the parent’s looser config, not a defect fix. deutschebahn and manfred gained retry-on-transient-abort, which web-ui’s 60s ceiling makes far less pressing — noted rather than ported, so a future long-walk timeout has a known first thing to try. Everything else in the 128-commit delta is CLI, verifier, dashboard or web/ (the parent’s own frontend): liveness-browser, check-jd-archive, verify-ats/verify-portals/verify-cv-facts, update-system, set-status, audit-portals, discover-ats, scaffolder, test-all, plugin manifests.
Fork divergences re-verified after the pull: the Cyrillic \p{L} fix in providers/telegram-channel.mjs, the fork’s own providers/telegram.mjs, hermes in web/src/lib/clis.ts. Upstream career-ops-hq/main is fully merged (0 commits missing). 3066 → 3164 tests; help now states 94 adapters (89 EN + 5 RU) in all 17 bundles, and the heading structure is unchanged at 32 H2 / 122 H3.
[1.235.0] — 2026-09-16
Parent parity — career-ops main @ 68e6b94 (VERSION 1.33.0, 42 commits). Two mirrored fixes; one of them was losing MokaHR postings here, silently.
Fixed
A second job from the same MokaHR board vanished instead of being added. url-key.mjs reduces a posting URL to a comparison key, and it dropped the fragment unconditionally — reasonable, since almost every fragment is presentation-only (#apply, #section-2). MokaHR is the exception in our own registry: its postings are …/social-recruitment/{tenant}/{id}#/job/{n}, where every job on a tenant shares the path and the posting id exists only in the fragment. So all of them normalized to one key. Measured before the fix: paste two different MokaHR jobs into the pipeline and you end with one line — no error, no warning, nothing to notice; the scanner’s freshness filter (en-scanner.mjs) reads the same key, so once any job from that tenant was in scan-history, every other one was discarded as “already seen”. normalizeUrl now promotes a recognized #/job/{id} or #/jobs/{id} route into a comparison-only query key before the fragment is dropped — MokaHR keeps its established mokahr_job_id name so previously-written keys stay comparable, any other host gets an internal _career_ops_fragment_job_id. The promoted id is appended, not set: a URL already carrying that param holds a different posting’s id, and overwriting it would merge exactly the two things the key exists to keep apart. Every unrecognized fragment still collapses, which is the half that was already right.
accept-encoding is now pinned on every source request. Left unset, Node negotiates whatever its undici build offers; from Node ≥ 23 that includes zstd, and amazon.jobs’ zstd response comes back truncated at exactly 1024 bytes with a 200 status — so the failure reads as Unterminated string in JSON at position 1024, like a malformed API rather than a transport bug. Every request now sends gzip, deflate, br. On our runtimes this is preventive, not a repair: Node 18/20/22 — the CI matrix and the server (22.22.1) — send gzip, deflate, verified directly rather than assumed, so nothing was truncated here; what changes today is that br is offered, which those versions do not request by default but decode correctly. The default is applied case-insensitively, which is the part worth reviewing: header names are case-insensitive and fetch joins two same-named entries into one comma-separated value, so a caller passing Accept-Encoding: identity would otherwise have been sent gzip, deflate, br, identity.
Notes
Headers stay a plain object at the fetchImpl boundary, where the parent hands fetch a Headers instance. That is a deliberate divergence, not an oversight: web-ui’s fetch helpers take an injectable fetchImpl, and 45 source suites read opts.headers['User-Agent'] off what they capture — a Headers instance has no bracket access, so the faithful port is of the behaviour (case-insensitive override, caller wins) rather than of the implementation. The caller’s object is copied, never mutated, since several sources share one header constant. The parent’s companion fix — applying a default user-agent case-insensitively — is a no-port: web-ui injects no default UA at all, each source sets the one its board requires, so there is no default for a caller’s User-Agent to collide with.
Two new suites and three new cases pin the pair: tests/http-accept-encoding.test.mjs (the exact pinned value, each of the four fetch helpers, and a caller override under three capitalizations — asserting a single accept-encoding key survives, which is what the merge bug produced two of) and tests/parsers-pipeline-hash-route.test.mjs, which works the user-visible seam rather than the key: two MokaHR jobs must both reach the pipeline, a re-paste of the same one under four spellings must still collapse to one line, and a cosmetic fragment must not become an identity. All were confirmed failing first. The pin itself lives at the transport, not only in those helpers: 25 of the 92 sources call the injected fetchImpl directly and never pass through them, so makeTimeoutFetch — the wrapper both scanners actually inject — applies it, and that is the one chokepoint every source shares. (The parent needs no equivalent: providers/_http.mjs is its only transport.) A structural guard now fails CI if a new source calls fetch directly instead of taking the transport it was handed, and a behavioural case drives greenhouse, one of the 25, through the wrapper to prove the header reaches the wire.
Not ported, with the reason: liveness-browser.mjs (headed-Playwright reuse guard — web-ui does not drive a headed browser), check-jd-archive.mjs (no web-ui relay), test-all.mjs (parent’s harness; the fork’s vpFixtureEnv divergence is intact), web/ (the parent’s own frontend, not mirrored), plugin manifests and SIGNATURES.md (repository-only). batch/batch-runner.sh (ampersands escaped before sed interpolation) and jd-skill-gap.mjs (resolves cv.md against the data root) are relayed read-only — web-ui shells into the parent’s copies, so both land with no code change here, and the jd-skill-gap one makes the /api/jds/:id/skill-gap relay more correct: cv.md now resolves from the data root instead of whatever directory the process was spawned in. Fork divergences re-verified after the pull: the Cyrillic \p{L} fix in providers/telegram-channel.mjs, the fork’s own providers/telegram.mjs, hermes in web/src/lib/clis.ts. Sources unchanged at 92 (87 EN + 5 RU), so help is untouched. 3042 → 3066 tests.
[1.234.0] — 2026-09-15
Parent parity — career-ops main @ 56cce8f (VERSION still 1.32.0, 58 commits past 1.32.0). One mirrored fix across 15 sources, plus a guard so it cannot come back.
Fixed
A single malformed job id no longer empties the whole page. encodeURIComponent throws URIError on a lone UTF-16 surrogate, and a JSON payload can carry one — JSON.parse('"\\uD800"') keeps it. Fifteen sources built each posting’s URL from a host-controlled id or slug inside the parse loop, so one bad posting aborted the loop and lost every job on that page; nothing was logged as a source failure because the exception unwound before the per-company catch had anything to report. The parent fixed it in providers/_safe-url.mjs (safeEncodeURIComponent → null on URIError, rethrow anything else) and routed each loop’s id through it, dropping just the one posting on null. That helper is now server/lib/sources/_safe-url.mjs, and the same change is applied to the thirteen mirrored sources — alibaba, arbeitsagentur, bamboohr, feishu-jobs, garena, jibeapply, manfred, meituan, mokahr, phenom, thehub, tkms, vdab — and to two web-ui-only sources the new guard surfaced on its first run, jobstreet and trudvsem, which had the same loop shape and the same latent throw. Scope is deliberate: only API-sourced ids go through the helper. Config-derived values keep their strict encode and fail loud — garena’s office (a portals.yml segment) stays on the throwing urlSegment, while its API id gets a null-returning idUrlSegment; csod’s corpName and 4dayweek’s SLUG_RE-validated slug are allowlisted by name with the reason.
rheinmetall’s title fallback could throw on a malformed href. When a card has no headline the title is reconstructed from the URL slug via decodeURIComponent, which throws on a bad percent-sequence in the scraped href — the same one-bad-row-kills-the-page shape, on the decode side. It now decodes defensively and falls back to the raw slug.
The sources registry skips _-prefixed files. Auto-discovery imported every .mjs in server/lib/sources/ except itself and warned on anything without export const meta — so the helper produced a boot-time warning. The parent’s convention is adopted: a leading underscore means not a source; such files are neither imported nor warned about. An undeclared file without meta still warns, and the discovery test now asserts both halves (confirmed red against the old filter). The site build’s sync-assets.mjs counts adapter files on disk the same way, so the helper no longer trips its silent-drop guard.
Notes
tests/sources-url-encoding-surrogate.test.mjs mirrors the parent’s suite in three parts: the helper’s contract (a paired surrogate — an ordinary emoji — still encodes; a throw from the value’s own toString is the caller’s bug and propagates), per-source behaviour for all fifteen (one bad id + one clean id → no throw, clean kept, bad dropped, each driven through the source’s exported pure parser or, for thehub, its fetcher with a fake transport), and a source-level guard: every converted file must import the helper and call it, no file may reference it without importing it, and no unreviewed file may build a job URL with a bare encodeURIComponent — a new source with the old shape fails CI with the offending line number. All fifteen per-source cases were confirmed failing before the port.
Everything else in the parent delta is not ported, with the reason: batch/batch-runner.sh --cli is a CLI-only flag (web-ui does not shell into the batch runner); invite-match.mjs now resolves set-status.mjs from the code root — web-ui does not relay it; company-funded.mjs gained the same safe-url change internally and its --json output shape, which /api/company-funded parses, is unchanged; test-all.mjs moved a timeout budget only (the fork’s vpFixtureEnv divergence is intact); web/ is the parent’s own frontend and is not mirrored; AGENTS.md/SIGNATURES.md are repository-only. Fork divergences re-verified after the pull: the Cyrillic \p{L} fix in providers/telegram-channel.mjs, the fork’s own providers/telegram.mjs, hermes in web/src/lib/clis.ts. Source count unchanged at 92 (87 EN + 5 RU), so help is untouched. 3022 → 3042 tests.
[1.233.2] — 2026-09-10
Fixed — two high-severity CodeQL alerts on the #/config save path. No behaviour changes.
Fixed
Two high-severity CodeQL js/remote-property-injection alerts, closed by making an existing invariant structural rather than asserted. POST /api/config copies saved values into process.env so no restart is needed, and that loop iterated Object.entries(safe) — a map built from the request body — with an explicit __proto__ / constructor / prototype guard inside it. The code was safe: safe is populated twenty lines earlier by walking KNOWN_KEYS and copying only those, so the property name was never user-controlled. But read on its own the loop says assign to a property whose name came out of an object built from the request body, and CodeQL cannot follow the constraint across two loops. It now iterates the constant array directly, looking each key up in safe, so the property name is provably a module-level literal. The prototype guard went with it — a hardcoded list of SCREAMING_SNAKE names cannot contain one, and a runtime check against an impossible case is noise. Behaviour is identical; the alerts were dated 2026-09-09 only because v1.232/v1.233 shifted the line numbers, not because the loop was new.
Notes
Dismissing the two as false positives was the alternative, and the repository has a documented practice for exactly that. Fixing was preferable here: an assertion in a comment is worth less than a shape the analyser — and the next reader — can verify without following a constraint across twenty lines.
The test that pinned the old guard was a grep of the source text for the literal k === '__proto__' || … expression, which proves a string is present, not that the endpoint is safe. It is replaced by two stronger checks: a structural one asserting the loop reaches process.env[k] by iterating KNOWN_KEYS (and that it does not iterate the request-derived map), confirmed to fail against the old shape; and a behavioural one in config-endpoint.test.mjs that posts __proto__, constructor, prototype and an unlisted name as raw JSON — an object literal would not do, since { __proto__: … } in source sets the prototype instead of creating an own property, so JSON.stringify would have sent {} and the test would have proven nothing. 3021 → 3022 tests.
career-ops-ui v1.267.1 · page 2/10 · mirror of the project changelog for cvstart.org