Changelog

GitHub

Every notable change, release by release. The same file ships in the repository and is translated into all 19 languages.

[1.233.1] — 2026-09-10

Fixed — the guide never described the control v1.233.0 added, and the same section still carried instructions that stopped being true 200 releases ago.

Fixed

DOC-1 — the control v1.233.0 introduced was not described anywhere in the guide. Every dropdown now leads with Use the default (…), which expresses a state the UI previously had no way to show: the key is not set. The difference from picking the same value out of the list below is invisible and consequential — Use the default (llama3.2) writes nothing and keeps handing you the project’s default as it moves, while choosing llama3.2 explicitly writes OLLAMA_MODEL=llama3.2 and leaves you on the old value, silently, the next time that default changes. That is precisely the drift CONFIG-2 and CONFIG-3 were about: the mechanism was repaired in code, and there was nowhere to read about it. Three bullets now sit in the existing ### Behavior list across all 17 bundles, covering the entry itself, the rule that a field you never touch is never written, and the toast counting removals as well as writes. The heading gates could not have caught this — they count H2/H3, and coverage is not a count. DOC-2 — a row in the ### Recognized keys table named no key, and the one concrete instruction it gave was wrong. The Key cell held (server uses default UA) — a parenthesised note where a key name belongs — describing HH_USER_AGENT, which v1.19.0 removed from KNOWN_KEYS and from the UI. The row then told the reader to register an application at dev.hh.ru and supply its UA string, which has bought nothing since v1.65.0, when the hh.ru adapter switched to scraping the public search site with a browser User-Agent of its own. Two further mentions were equally stale: an Optional checks bullet describing a health row deleted in v1.28.1, and a troubleshooting row repeating the dev.hh.ru advice. All three are gone from all 17 bundles. The troubleshooting row is rewritten rather than deleted, because half of it is still true — since July 2026 hh.ru serves HTTP 451 to non-Russian IPs, so a Russian residential address genuinely is required; what was wrong was the registration step and the reference to a key that does not exist. The keys table now says what it is. ### Recognized keys lists 6 of the 45 keys while its heading promises a registry; the provider families are covered in prose elsewhere in the same section. One sentence ×17 now states that it is a selection and points at #/config for the complete, grouped list. No keys were added — this closes the ambiguity rather than expanding the table.

Notes

tests/help-recognized-keys.test.mjs is the gate that was missing. It checks the first column of every help table that actually names config keys against the live KNOWN_KEYS, fails on any bundle still mentioning dev.hh.ru, and fails on any bundle that does not document the Use the default entry. Scoping matters: several bundles carry other tables whose first column is a backticked uppercase word — PDF in the export matrix — so a table qualifies only when at least one of its own rows names a real key. Confirmed on the pre-fix bundles: all three cases red. 3018 → 3021 tests; help stays 32 H2 / 122 H3 in all 17, because everything landed inside existing headings.

[1.233.0] — 2026-09-10

Fixed — three findings in the #/config save path. CONFIG-3 was caused by v1.232.1’s own fix.

Fixed

CONFIG-3 — a deliberate choice that happened to equal the seeded value was discarded as “untouched”. Caused by v1.232.1’s own fix, whose comment claimed the code could “tell a display default apart from a user who deliberately chose that same value”. It could not. initial conflates two origins: seeded from .env (equal means the field was not edited — correct to skip) and seeded from defaultValue (equal is ambiguous — never opened, or opened and agreed). With HOST absent the control shows 127.0.0.1; typing that exact value and saving posted {} and left the key absent. The only route to pinning it was to enter a wrong value, save, then set the right one back — a dead end nobody would find. Nothing misbehaved, since an absent key and a key equal to the default resolve identically, but the UI showed a state the file did not contain and pressing Save again never reconciled them. Fixed by recording seededFromFile beside initial: where the seed came from the file nothing changes, and where it came from defaultValue, dirty becomes a valid second signal. dirty cannot be the general basis — it fires on type-and-revert — but here it is the only remaining evidence, and it costs at most the one field the user was working in, never CONFIG-2’s eighteen, for which dirty is empty because nobody opened them. Removing a key reported “· 0”. updateEnvFile only ever returned what it wrote, so a deletion had nothing to count and the toast read Settings saved · 0 — which a user reasonably takes as “nothing happened” while the key had in fact been removed. POST /api/config now answers with removed as well, computed against the pre-write snapshot so it names keys that genuinely were present rather than every key the request asked to clear, and the toast counts both. A select could be set but never cleared. None of the 18 dropdowns offered an empty option, so the form could not send '' for one — text fields can simply be emptied, dropdowns could not. Together with CONFIG-3 that closed a loop for an unset select: it could not be pinned, because its value equalled the seed, and once pinned it could not be released. Every select now leads with an explicit “Use the default (…)” entry, and an unset key selects that instead of the default value. v1.57.1’s intent survives — the label still names the value the server will use — but it now says so rather than impersonating a choice, which is what made CONFIG-3 possible in the first place. select-remote needed care: the live OpenRouter catalogue calls replaceChildren, which wiped the entry, so it is re-created there too. One new i18n key, config.useDefault, across all 17 dictionaries.

Notes

Checked during this pass and deliberately left alone, so the record is complete. lang in every POST body is a literal allowlist entry, not a prefix rule — verified by behaviour: lang → 200 while langx, lang_evil and LANG all → 400, exactly like any unknown key. facts.json is absent from the deployed site by design — 22 files import it at build time and the values are inlined into the HTML, so the version token in the page is what to check, not the file; §2.3 of the v1.231.5 prompt was misleading on this. /api/cv/import treating an extensionless upload as text is documented and deliberate, so a clipboard save without an extension still works; the route converts and returns, never writes or executes, and .exe/.zip are refused explicitly. Unit baseline unchanged at 3018 — every fix is in the client or in one route’s response shape. The gates are browser cases: tests/playwright-config-save-scope.mjs grows from six to eleven (111 → 116 browser tests), and the five new ones were confirmed failing against the old code first. One of them exists purely to keep CONFIG-2 from returning: an untouched Save must still post nothing.

[1.232.1] — 2026-09-10

Fixed — Save on #/config wrote fields the user never touched. Not a v1.232.0 regression; it is older.

Fixed

CONFIG-2 — Save on #/config wrote fields the user never touched. Two individually sound decisions combined into a write: v1.57.1 seeds an unset control with its defaultValue so the field shows the value the server will really use (correct, and unchanged), while save() consulted dirty for secrets only and sent every other field regardless. So the value put there for DISPLAY travelled to the server as if the user had chosen it. Measured on a fixture with two keys set, changing one dropdown posted 28 keys and pinned 18 the user had never opened — each its own default; with nothing changed at all, Save still posted 27. Nothing behaves differently on the day, because the written values are the defaults. What changes is the STATUS of each setting: from unset, follow the project default to pinned in .env. These curated lists move between releases — v1.232.0’s own notes cite gemini-2.0-flash dropping out — so the next time a default changes, every user who ever pressed Save is silently left behind on the old one. It is CONFIG-1’s shape again: that wrote nonsense, this writes something plausible, which is exactly what makes it harder to notice.

Notes

The comparison basis is the part worth reviewing. Comparing against the loaded cfg.values — the obvious fix, and the one first tried — is not enough: an unset select shows its defaultValue while the stored value is '', so the two differ honestly and all seventeen model dropdowns are still written. It cut the posted body from 27 keys to 19, not to one. Save now compares against what each control was seeded with, which is the only expression of “untouched” that separates a value placed there for display from a user who deliberately picked that same value. dirty is not the basis either — it fires when someone types a value and puts the original back. Also fixed: the toast’s key count lived inside t()’s fallback argument, and config.saved exists in all 17 dictionaries — so the number never rendered and a Save of twelve keys looked identical to a Save of one. It now reads Settings saved · 1. Nothing else moved. The v1.57.1 prefill stays, secrets are still sent only when touched, and the server-side grandfathering from v1.232.0 stays as the guard for direct API callers, who never go through the form. Test baseline is unchanged at 3018 because the fix is client-side; its gate is tests/playwright-config-save-scope.mjs, six browser cases now registered in test:e2e:browser (105 → 111), all six confirmed failing against the old code first.

[1.232.0] — 2026-09-09

Fixed — two defects from a browser QA pass, neither of them from v1.231.5.

Fixed

PROFILE-1 — #/profile scrolled the whole page sideways. Each profile field is a .card in a .card-row grid of repeat(auto-fit, minmax(220px, 1fr)), and the value was an anonymous <div> carrying only inline font styles — so its computed overflow-wrap stayed normal. A LinkedIn vanity URL offers CSS no legal break point, which makes the element’s min-content width the entire string; grid items default to min-width: auto and refuse to shrink below that, so the card burst its track, then the row, the main column and finally the document. Measured on a fixture profile, cards overflowed at 768, 1024, 1280 and 1440 px, and at 1280 px it reached the page as 23 px of real horizontal scroll. Width-dependent, which is why it survived so long: at ≤ 420 px the media query drops to one column wide enough to fit, and between 768 and 1024 px the text merely spills out of the card without moving the document. Fixed with a shared .card-value class carrying overflow-wrap: anywhere, plus .card-row > * { min-width: 0 } as a backstop. anywhere, not break-word — only anywhere is counted when the browser computes min-content, so break-word would have wrapped the text and left the track exactly as wide. dashboard.js now uses the same class; health.js already carried word-break: break-all and needed nothing. CONFIG-1 — POST /api/config accepted any value for a select field. {"LLM_PROVIDER":"not-a-provider"} returned 200 and was written into the user’s .env. Nothing errored afterwards: the resolver fell back to whichever key was configured, /api/status/providers reported a healthy provider, and the setting the user actually chose silently had no effect — surfacing only much later, when the fallback stopped coinciding with intent. The validator checked key names, type, length, newlines and three per-key formats, but never whether a value belonged to its field’s domain. LLM_PROVIDERS — that exact 19-value list — has been exported from env-config.mjs all along; the validator simply never consulted it. It does now.

Notes

Only LLM_PROVIDER is enforced, and that is a deliberate narrowing of the report. The 16 model dropdowns are left unvalidated: their real domain is the vendor’s catalogue, which moves between our releases, so enforcing our curated list would stop a user selecting a model the provider already serves — this repo’s own suites configure gemini-2.0-flash, which the curated list dropped. The harm analysis points the same way: a wrong model fails loudly at call time, while only a wrong provider fails silently. select-remote is excluded for the same reason, and a drift test still proves the server and browser never disagree about what the dropdown offers. An already-stored value is grandfathered, because a browser test proved the strict version unusable. public/js/views/config.js resends every non-secret field on every Save, touched or not, so refusing a pre-existing bad value froze the entire page: the user could no longer change anything until they fixed a field they had never touched. Only changes are refused now, which still blocks the reported defect — a new bad value cannot get in — while leaving an existing one repairable through the dropdown, which offers valid options only. Also closed: OLLAMA_API_KEY accepted writes through the API but had no field in #/config, so anyone behind an authenticating Ollama proxy could not set it from the app at all. It has a descriptor now (secret, two new i18n keys ×17), and a test asserts the server’s 45 writable keys and the UI’s 45 descriptors stay equal. Neither defect came from v1.231.5 — that release’s diff was package.json, the lockfiles and docs. 3013 → 3018 tests, plus two browser cases in the newly-registered tests/playwright-card-overflow.mjs.

[1.231.5] — 2026-09-09

Security — seven advisories closed, one of them a critical remote-code-execution. No source file changed.

Security

Merged from Dependabot: multer 2.2.0 → 2.3.0 (high — denial of service via a file-descriptor leak on abort) and svgo 4.0.2 → 4.1.0 in the site (high + medium — removeScripts let executable links through namespace and content handling, and incompletely sanitized executable HTML inside foreignObject). Then five more, surfaced by the rescan those merges triggered. Every affected range was already satisfied by the existing ^ specifier, so each needed an update rather than a manifest edit: astro 7.1.0 → 7.3.2 in the site — critical, remote code execution through AVIF image optimization (fixed in 7.2.8), and with it an authorization bypass from a missing path-segment boundary check when stripping the configured base (7.2.4); sharp 0.35.3 → 0.35.4 — high, two libheif vulnerabilities; and js-yaml 4.3.1 → 4.3.2 in both manifests — high, maxTotalMergeKeys did not limit CPU use for empty merge sources.

Notes

“Dependencies only” is not the same as “risk free” here, so this was not shipped on the audit result alone. js-yaml parses every config the app reads and multer accepts every upload it takes, and Astro moved a minor version. Verified: npm run test:ci 3013/3013, the browser suite 4/4, and a full site rebuild producing the same 86 pages, all 17 changelog mirrors and a correct facts.json. npm audit reports 0 vulnerabilities in both manifests. Test baseline is unchanged because no source file changed.

[1.231.4] — 2026-09-07

Fixed — v1.231.3 fixed the aftermath of the Doctor click, not the moment. Reported again, from mobile and from desktop.

Fixed

withSpinner still rewrote the button while the request was in flight. v1.231.3 made it snapshot the child nodes and restore them in finally, and the spans did come back — but the busy cue still ran button.replaceChildren(document.createTextNode('⏳ ' + original)), so for the whole length of a doctor.mjs run the 36 px square held the text ⏳ 🩺Doctor and became a wide pill that broke the row. On desktop it rendered as a jammed ⏳🩺Doctor. Restoring the children fixed the aftermath, not the moment, and an assertion that only looked at the end state passed it. A button that has element children is now never rewritten at all. Its cue is the .is-loading class, which the stylesheet already dimmed and which now swaps the icon for an hourglass in CSS, so the geometry cannot change: 36 px idle, 36 px in flight, 36 px after at 320 px. Text-only buttons — 23 of the 24 call sites — keep the prepended hourglass, unchanged since v1.58. On desktop the button reads ⏳Doctor while running, widening by the hourglass and returning, which is what every other button in the app has done for years.

Notes

The lesson is about the assertion, not the code. The v1.231.3 gate clicked Doctor, waited for aria-busy to clear, and then measured — so it could only ever see the state the finally block had already repaired. The new browser check holds the promise open itself rather than racing a real request, samples the button mid-flight, and asserts the width is unchanged, both spans still exist and nothing overflows. A first draft sampled after a real click and skipped the assertion when the request outran it; that would have been a lock that passes by luck. 3012 → 3013 tests. Both new checks were confirmed failing against the v1.231.3 code before this shipped.

[1.231.3] — 2026-09-07

Fixed — clicking Doctor broke the mobile top bar. Reported from a phone the day v1.231.2 shipped.

Fixed

UI.withSpinner flattened the button it was decorating. Its busy cue went through textContent: save button.textContent, write '⏳ ' + original, write it back. Assigning textContent replaces every child with one text node — and v1.231.2 had just split each top-bar action into .btn-ico + .btn-label spans, hiding the label under max-width: 900px. So the first Doctor tap destroyed both spans for good: the mobile rules had nothing left to match, the label came back as bare text, and the 36 px square grew into a 🩺Doctor pill that spilled over the theme toggle. It never healed — only a reload restored the markup. The fix snapshots the child nodes and restores them with replaceChildren; text-only buttons, which is most of the 24 call sites, round-trip exactly as before. A dataset.spinnerBusy flag stops a re-entrant call from snapshotting the hourglass as though it were the original content.

  • FIND-3 — the search field had collapsed to 8 px on a 320 px phone. Putting the top bar on one row left the searchbar as the only flexible item, and this changelog said so a release ago: it “may shrink to nothing, so no negative space exists to distribute”. It did shrink to nothing — 8 px of input at 320 px, 28 px at 340 px, one character of a 21-character placeholder. Clickable and typable, but no longer legible as a field. No min-width could fix it: at 320 px the bar holds 32 px of padding, a 40 px menu and 162 px of actions, with no 120 px left to give. So below 420 px — the width at which the field finally clears 100 px on its own — it now hides behind a magnifier and expands across the whole bar on tap: 182 px at 320 px, 222 px at 360 px, 252 px at 390 px, with the menu and the other actions stepping aside and no overflow at any width. The disclosure keeps one accessible name and reports state through aria-expanded, so nothing renames itself under the user, and the 🔍/✕ swap is pure CSS — no JS writes into that button, which is what broke Doctor in this same release. Above 420 px nothing changes: 109 px at 421 px, 447 px at 1280 px, exactly as before. Found by a browser regression pass against the live build.

Notes

This is the same defect class as the applyI18n() trap noted in v1.231.2 — code assigning textContent to an element that now has element children — reached through a different caller. v1.231.2 fixed the caller it knew about and shipped past this one. The lesson generalizes: splitting an element’s content into child spans makes every existing textContent writer on that element a latent bug, and the writers are not always in the file you are editing. Locked twice. tests/with-spinner-preserves-children.test.mjs runs the real withSpinner against a fake button in node:vm and asserts the spans survive a full cycle plus the busy-state contract (returns the result, disables in flight, sets and clears aria-busy, restores the prior disabled). tests/playwright-narrow-viewport.mjs clicks Doctor in a real browser at 320 px and asserts the button width is unchanged, the label is still display:none, the actions share one row, and nothing scrolls. Both were confirmed to fail against the old code before the fix landed. 3009 → 3012 tests (the browser case is opt-in and not in test:ci).

[1.231.2] — 2026-09-06

Fixed — the mobile top bar wrapped into a second row, the server ran an older parent, and the cvstart.ru redirect 404ed any path that already named a language.

Fixed

  • The mobile top bar is one row again, with icon-only actions. The action buttons were forced onto a full-width second row, so a phone showed [☰ · search] above [🔔 🌙 Диагностика Открыть Scan] — two wide pills on a line of their own. Doctor and Open Scan are now 36 px squares (🩺 / ⚡) beside the bell and the theme toggle, and everything fits one row at 320 px. Measured, not eyeballed: document width equals viewport at 320, 360, 390 and 430 px, and across 560–760 px — the band where the buttons used to overlap the search pill. Locale no longer affects the mobile layout at all, because the label is hidden and the button is a fixed square; above 900 px the labels return unchanged. The accessible name comes from aria-label, since a display:none label is dropped from the name computation.
  • The server ran parent 1.31.0 while the release was built against 1.32.0. v1.231.0 was a parity release, but /opt/career-ops/src held 1.31.0 files, so /api/health on the production server reported parentVersion: 1.31.0. 95 runtime files were synced and two removed (plugins-registry/theirstack.json, retired upstream; lib/context-budget.test.mjs, moved to tests/). Reported by browser QA against the live surface.
  • cvstart.ru prepended /ru/ to every path, whatever was already there. cvstart.ru/ru/help became cvstart.org/ru/ru/help and cvstart.ru/en/help became cvstart.org/ru/en/help — both 404. The first is what you get by copying a URL from the main site; the second locked English visitors out of the English site entirely, since any language prefix was overwritten. A path already naming one of the 16 locale prefixes now passes through, en is stripped (cvstart.org serves English at the root and has no /en/), and everything else still gets /ru/. The locale test is anchored, so /enterprise/ is not mistaken for en.

Changed

  • 98 superseded QA prompts moved to qa/archive/superseded-prompts/. qa/README.md has documented since v1.137.0 that only the current per-release delta stays at the top level — the rule was then not applied for 95 releases. The README now says archiving happens when a release ships, and its own stale baselines are corrected (≥1238 → ≥3009 tests, 13 → 17 locales, 19 H2 / 75 H3 → 32 H2 / 122 H3).
  • Five more prompts left the top level — they described themselves as current and were not. REGRESSION-FINAL.md called itself “the single authoritative, version-agnostic regression prompt”, but its §§11–15 are five closed cycle ledgers (v1.55.x → v1.59.7) and its stated help baseline was 28 H2 / 103 H3 across 16 locales against 32 / 122 and 17 today — a QA agent following it would have failed §9 on the first step. QA-REGRESSION-PROMPT.md carried an unversioned name and v1.226.0 content. QA-REGRESSION-PROMPT-v1.76.0-FULL.md was pinned 155 releases back, REGRESSION-PROMPT-FINAL.md closes the v1.58.52 → v1.59.10 cycle, and QA-FULL-REGRESSION.md duplicated all of them at a v1.131.1 baseline. PARENT-SYNC-WORKLIST-v1.26.0.md (the parent is now 1.32.0) went with them, and the dated UX-AUDIT-2026-07-06.md moved to reports/. Four drivers remain: this release’s delta prompt plus FUNCTIONALITY-CHECK.md, UX-AUDIT-PROMPT.md and DESIGNER-EXPORT-PROMPT.md, whose own baselines are refreshed to v1.231.2 actuals. qa/README.md is rewritten to describe the folder that exists.

Notes

  • Two specificity traps were walked into while fixing the top bar and are now commented in app.css: the desktop .topbar / .topbar-actions / .btn-ico rules are declared lower in the file than the max-width: 900px block, so at equal specificity they win — the mobile gap silently stayed 24 px (which is what overflowed 320 px) and the icon stayed hidden (which rendered the buttons as empty squares). The mobile rules are one class deeper on purpose.
  • applyI18n() assigns el.textContent, so the i18n key moved from the <button> onto an inner <span class="btn-label">; left on the button it would wipe the icon span on every language change. tests/qa-report-fixes.test.mjs BUG-008-tb now asserts the key appears inside #btn-doctor rather than as an attribute on it — the same invariant, checked where it now lives.
  • The parent deploy’s first rsync aborted mid-transfer on a file that no longer exists locally, leaving a partial deploy that reported success — the same failure shape as the dangling-symlink trap recorded in QA-REGRESSION-PROMPT-v1.228.3.md. Parent deploys are now verified by checksum, never by exit code.

[1.231.1] — 2026-09-06

Fixed — v1.231.0’s count sweep rewrote the account name in every link.

Fixed

  • Every Fighter90 link became Fighter92. The v1.231.0 source-count sweep replaced 90 with 92 under a digit-boundary guard — (?<![0-9])90(?![0-9]) — which correctly skips 290 and 901 but matches the 90 inside Fighter90, where the preceding character is a letter and the token simply ends. 562 links across all 17 READMEs, all 17 help bundles and the site’s help mirrors, plus 138 in the wiki, pointed at an account that does not exist; the providers.png banner and every “edit on GitHub” link 404ed. Reported by a user who noticed the missing image.
  • Five historical release references shifted. The same release’s 85 → 87 EN-adapter sweep turned v1.85.0 into v1.87.0 in the README and help bundles, inventing a release that was never cut.

Notes

  • A digit boundary is not enough. It guards a number against neighbouring digits, not against being part of an identifier or a version string — and both classes are dense in these files. The counts themselves are unchanged and correct: 92 sources, 87 EN adapters, 3009 tests.
  • The parent repository was untouched: its counts were never swept.

[1.231.0] — 2026-09-05

Added — two scanner sources from parent career-ops v1.32.0 (Collage, Telegram strict), Gem’s REST mode, and one defect the port surfaced in the parent’s own code.

Added

  • Two new sources, 90 → 92 (87 EN + 5 RU), ALL_ADAPTERS 85 → 87. Both zero-token.
    • Collage (provider: collage) — Collage HR’s public job-site API. The path segment is an explicit job-site address the tenant chose, not a company-name slug, so it is read from api: or from a secure.collage.co/jobs/<address> careers URL and never guessed — a guessed address would scan somebody else’s board. An entry naming neither fails loudly.
    • Telegram (strict) (provider: telegram-channel) — reads the same t.me/s/<channel> preview pages web-ui’s existing telegram source reads, with the opposite trade. telegram keeps every post and attributes an unlabelled one to the channel handle; telegram-channel emits a row only when the post names an employer AND links to a vacancy page. Digests, hidden-employer posts (“название скрыто”, “our client”) and contact-only posts are dropped. Low recall by design — upstream measured 32-77% of posts passing on HR-curated channels — but every row carries a real employer and a real link. Both ship, as they do in the parent: they are different products, not duplicates.
  • Gem gained an opt-in REST mode alongside its GraphQL path (api: https://api.gem.com/job_board/v0/<board>/job_posts). Gem documents this unauthenticated surface for custom career pages. It is never derived from a board id — a derived URL would be a guess about which surface a tenant exposes — and the GraphQL path stays the default, so nothing changes for an entry that does not set api:. Ported from parent #3783.

Fixed

  • telegram-channel’s location filter could not see Cyrillic, and named cities as employers. The parent’s LOCATIONISH_RE used \b, which is ASCII-only and never fires next to Cyrillic — so every Russian alternative in it (москва, спб, офис, удалёнк*) was unreachable, and Senior Engineer | Москва returned “Москва” as the employer. That is precisely the wrong-field-as-fact the whole attribution policy exists to prevent, and it landed on the provider’s primary audience: a Russian-language channel writes its location in Cyrillic. web-ui’s port uses Unicode lookarounds (\p{L} / \p{N}), matching what the parent already does one constant below in ROLE_WORD_RE. Pinned by a test that fails against the ASCII form. Not yet fixed upstream.

Notes

  • The parent’s Workday facet-recovery work (#3851, #3874) needed no port. It prioritizes configured locations when a split is chosen and tags a split whose facet under-covers the board. web-ui does not paginate or facet-split Workday at all — one POST at offset: 0 — so neither code path exists here. Same conclusion as v1.229.0, re-checked rather than assumed.
  • providers/_types.js changed only in documentation (the optional Job.salary field).
  • Tests: 2962 → 3009 (+47).

[1.230.0] — 2026-09-05

Changed — three provider fixes from parent career-ops v1.32.0, and one defect the port surfaced here.

Added

  • Welcome to the Jungle takes a server-side Algolia filters expression (wttj: { filters: "offices.country_code:FR AND contract_type:full_time" }). A keyword alone cannot narrow a global board: Algolia’s relevance ranking, not the scanner’s own title/location filters, decides which max_hits results come back, so anything past the cap is invisible no matter how well it matches — the scanner’s filters run afterwards, on what already arrived. A filter shrinks the result set instead of re-ranking it, which is what makes a scan exhaustive rather than a sample, so with one the per-query cap rises from 200 to 1000. With a filter, queries becomes optional — the empty query means “everything that passes the filter”. Ported from parent #3757.

Fixed

  • Radancy no longer warns “truncated” when a tenant simply has fewer postings than its own banner claims. The parent checked data-total-results against nine live tenants: five matched, but four — a small one and a huge one both — served 10–56% fewer unique postings than advertised, with no duplicate rows anywhere. The banner overstates what the tenant’s search index actually serves, so comparing the accumulated count against it false-positived on a majority of boards. The warning now fires only when our own max_jobs/max_pages stopped the walk, which is the only case the caller can act on; totalResults stays in the message as context and never decides. Ported from parent #3839.
  • Radancy cache-busts every JSON fragment request. A caching layer in front of that route replays stale responses on some tenants — the same posting reappears pages later while another is never served at all, reproduced nine runs out of nine upstream. Cache-Control/Pragma request headers changed nothing; a random per-request parameter fixed it. It must be unique per call, never derived from the page number: a deterministic parameter still gives the cache a stable key, and therefore the same stable, wrong mapping. Ported from parent #3839.
  • Greenhouse office lists are ordered deterministically. Greenhouse does not promise the order of a job’s offices[] between responses, and the set recovered from the /offices tree is in its traversal order. Both reach the posting’s location, so a board that re-orders its offices rewrote that string — and a posting nothing had changed about read as new. The parent fixed the set (#3839); web-ui had a second instance of the same defect the parent does not have — the offices[0] fallback used when a job carries no location of its own — and both are now sorted.

Notes

  • The parent’s iCIMS fix (#3728) needed no port. It reads every jobLocation entry from a detail page’s JSON-LD, because some tenants emit an all-UNAVAILABLE entry ahead of the real address. web-ui’s iCIMS takes the location from the list page’s HTML instead and never opens a detail page, so that code path does not exist here.
  • The parent’s _http.mjs change needed no port. It extracts isRefusedRedirectError so discover-ats.mjs and the retry layer stop disagreeing about the same error object. isRetryableError’s behaviour is unchanged, and web-ui has no second consumer to share the verdict with — extracting a helper for one caller would be noise.
  • The parent’s location-aware dedupe (#3751) is not mirrored. It lives in the CLI’s scan.mjs scan-history pipeline; web-ui’s scan route keeps its own.
  • Registry unchanged at 90 sources (85 EN + 5 RU). Tests: 2956 → 2962 (+6).

[1.229.0] — 2026-09-03

Added — four scanner sources from parent career-ops v1.31.0: Built In, Feishu Jobs, Garena and MokaHR.

Added

  • Four new sources, 86 → 90 (85 EN + 5 RU), ALL_ADAPTERS 81 → 85. All four are zero-token: no API key, no login.
    • Built In (provider: builtin) — the US tech board, board-wide across its nine markets. Employers post there directly, so it is an aggregator like RemoteOK, not a per-company source. It ships no default query: an entry with neither queries: nor categories: scans nothing and says so, because a shared source must never carry one user’s search terms. A market host that is not on the allowlist is refused, never quietly replaced by the national board — a typo must not silently widen the scan.
    • Feishu Jobs (provider: feishu-jobs) — 飞书招聘, the endpoint every tenant’s own careers page calls. Two host shapes and nothing else: jobs.bytedance.com exactly, or a *.jobs.feishu.cn tenant. They use different job-page paths, so one URL template would have produced dead links for half of them.
    • Garena (provider: garena) — single-company, one fixed host. office shapes the job LINK, never the listing: upstream verified live that every office code, invented ones included, returns the same board.
    • MokaHR (provider: mokahr) — 国内 HR SaaS. The response is encrypted: an AES-128-CBC envelope whose key ships alongside the ciphertext. That is obfuscation rather than security, but it means a plain JSON parse sees nothing, so decryption is part of reading the board at all. Two tenants are refused at config time because their robots.txt excludes the careers path.

Fixed

  • htmlToText could leak an incomplete tag opener into plain text. safe &lt;img src=x onerror=1 came out as safe <img src=x onerror=1 — the opener has no closing > for the tag strip to consume, so it survived. Each decode is now followed by a strip, and a trailing opener loses its angle bracket: the text stays readable and is inert. Ported from the parent (#3491).

Notes

  • Nothing was ported from the parent’s Workday facet-split. It recovers tenants whose CXS backend clamps pagination at offset 2000 — web-ui does not paginate Workday at all (one POST at offset: 0, 100 rows), so there is no clamp to work around. web-ui’s own 100-row ceiling is a separate, blunter limitation and not this release’s subject.
  • The parent’s verify-cv-facts and merge-tracker fixes needed no port: web-ui runs those scripts from the parent checkout rather than mirroring their logic, so they arrive with the parent.
  • Salary from Built In is formatted to a display string here. The parent’s parseSalary returns { min, max } for its own filters; web-ui’s job contract carries salary as text, and letting the object through would have put [object Object] in the tracker.
  • Tests: 2909 → 2956 (+47).

[1.228.5] — 2026-08-30

Fixed — the reported version described the file on disk, not the code being run.

Fixed

  • /api/health re-read package.json on every request, so a stale process reported a version it was not running. QA found a local instance answering version: 1.228.4 while returning 404 for /api/ping, a route added in 1.228.3 — started before the deploy, serving the old code, and reporting the new file’s version. That is exactly how a deploy that copies files but never restarts looks like a success: the one string an operator checks is the one that cannot see the problem. It is the same class as the v1.228.1 symlink, where rsync aborted mid-transfer and still reported success.

    The version is now captured when the module loads, so it describes the running code. A stale process reports the OLD version — the truth, and visible immediately. parentVersion stays per-request: it describes the parent checkout, which this process does not load and which can legitimately change underneath a running server.

[1.228.4] — 2026-08-30

Fixed — the unauthenticated liveness probe did filesystem work on every request.

Fixed

  • GET /api/ping read and parsed package.json per request. It is the only endpoint reachable without credentials, so a read plus a JSON parse on every hit is a denial-of-service lever handed to anyone — CodeQL flagged it as missing rate limiting. The version is now read once when the route is registered; it cannot change without a restart anyway, so the handler does no I/O at all. Rate limiting would have capped the damage; removing the work removes the lever.
  • The profile owner’s name being masked off loopback is now pinned by a test. /api/health already replaced it with hidden on a non-loopback bind — but only because that row shares the hidden ?? value guard with the project root. Nothing tested it, so an edit giving the row its own value would have leaked a real person’s name to the LAN with nothing to notice. Raised in review of v1.228.3.

[1.228.3] — 2026-08-30

Fixed — the docs assistant could not answer in English, Telegram company names arrived truncated, and a monitor had no way to see the service was up.

Fixed

  • The docs assistant answered “no matching help sections were found” in English while the same question worked in Russian. Retrieval was fine; the context builder was not. buildAskPrompt did if (ctx.length + sec.body.length > MAX_CONTEXT) break — a break, so one oversized section ended the loop and left the context empty, and the assistant truthfully reported it had nothing. The English §5 had grown to 16 081 bytes against a 14 336-byte budget (partly because v1.228.0 added telegram_channels to it); the Russian §5 was 13 009 and fit. Same ranking in both languages — §5 was first — only the size differed.

    Two changes: a section past 6 KB is now split at its ### boundaries, so telegram_channels is its own retrievable chunk (1 340 bytes) that ranks first in both languages; and an oversized section is skipped or truncated rather than ending the loop. The guide went from 32 chunks to 75.

  • A Telegram company label yielded the sentence after it, not the name. Компания: FinCore Technology (продуктовая разработка, высоконагруженные системы) became FinCore Technology (продуктовая разработка, высоконагруженны — capped at 60 characters, cut mid-word. Three of 141 rows. The name now ends at the first separator that introduces a description — a spaced dash (so Coca-Cola survives), a comma, semicolon, pipe or opening parenthesis — and any remaining cap falls on a word boundary. This source attributes a company as fact; a truncated sentence is as wrong as a guessed name, only less obviously so.

Added

  • GET /api/ping — a liveness probe that leaks nothing. { ok, version } and no more. /api/health reports absolute paths, the profile owner’s real name and which API keys are set: legitimate for an authenticated operator, never for the public internet. A monitor behind a Basic-auth gate previously had no way to see the service was up at all.

[1.228.2] — 2026-08-30

Fixed — a two-word search in /api/scan-results matched the phrase, not the words, and under-reported badly.

Fixed

  • ?q= now matches every term independently instead of the literal phrase. The query was a single includes(q) over title + company + location, so продакт менеджер found only rows carrying those two words in that exact adjacency — 32 of the 162 that carry both. Менеджер продукта and Менеджер по развитию продукта were invisible to it: the same words, reordered or with filler between them.

    This is how a narrow match becomes a wrong answer. The Telegram assistant asked for all Продуктовый менеджер reported 9 — faithfully, because 9 was what the endpoint said. The count it quoted was honest; the endpoint’s was not. Word order and the words between are not something a person typing two words intended to pin.

    Matching is AND across terms, so widening precision costs nothing: a row must still carry every term. менеджер разработчик returns zero rather than the union.

[1.228.1] — 2026-08-29

Fixed — a dangling symlink in the repo that silently truncated deploys.

Fixed

  • .claude/skills/refero-design was a tracked symlink to a path that has never existed here. It pointed at ../../.agents/skills/refero-design, which resolves to .agents/ at the repo root; there is no such directory, and the link arrived in the v1.118.0 parity pack from a machine where the surrounding folders nested differently. Anyone cloning the repo has been getting a broken link ever since.

    It was not inert. rsync aborts on it mid-transfer — a deploy today reported success while the server kept running the previous version, and the mismatch only surfaced because the version was checked afterwards. The parent project’s node test-all.mjs also crashes outright when a web-ui checkout sits inside its tree, because its fixture copy walks the working directory and cannot stat the link. Nothing referenced it: the only refero matches in the repo are the Polish word preferowany.

[1.228.0] — 2026-08-29

Added — Telegram channels as a scan source, and a paged query mode on /api/scan-results.

Added

  • Telegram channels are now a scannable source. A telegram_channels: block in portals.yml lists them; each is read from its public web preview at https://t.me/s/<channel>. They live in their own block rather than in tracked_companies because they are not employers — the scanner filters that list through detectApi(), and a channel has no careers URL to detect. Registry: 85 → 86 sources (81 EN + 5 RU), ALL_ADAPTERS 80 → 81.

    Telegram publishes no RSS and the Bot API cannot read a channel a bot does not administer, so neither obvious route works for someone else’s job channel. The /s/ preview is plain server-rendered HTML — no auth, no JS, no cookie. Verified across 16 channels: 15 returned 20 posts each on a bare GET.

    channel: accepts every way a person writes it — rabotaphp, @rabotaphp, https://t.me/rabotaphp, even a link to a specific post. The parser anchors on the three things a Telegram redesign is least likely to move: data-post="<channel>/<id>" (which both splits the page into post windows and carries the only stable URL a post has), the message-text container, and the ISO <time datetime>.

  • GET /api/scan-results gained a paged query mode. ?q=<text>&limit=50[&region=ru|en][&set=filtered|fresh][&offset=N] returns { total, returned, offset, limit, set, rows }, where total is the count before paging. No parameters still returns the bare snapshot unchanged — the #/scan table depends on that shape.

Fixed

  • The Telegram assistant was answering from a fraction of the data. Its skill already pointed at /api/scan-results, but that route only ever returned the whole snapshot — ~2 MB on a real box — which does not fit in an agent’s context. Asked to “find all Продуктовый менеджер” it reported 9 matches where the snapshot held hundreds, and told the user it was “working with a limited local index”. That was true, and the cause was that it could see the endpoint but not consume it. With the paged mode the same question now costs 2 KB and returns an honest total. The skill was also rewritten: it had said to summarize the new postings, which points at fresh (last run only) rather than filtered (everything kept).

Notes

  • A channel post is prose, not a job record. Unlike every other source here, there are no structured fields to read: the source lifts a title from the first substantive line, best-effort company/location/salary from explicit labels (Компания:, Локация:, a currency amount), and leaves the full text in the description. Separating real postings from ads and digests is left to the existing title_filter — the same one already tuned for other sources. Treat these as leads, not listings.
  • A company is never guessed. With no Компания: label the row is attributed to the channel (@rabotaphp), because a wrong employer enters the tracker as fact.
  • An empty parse is an error, not “no vacancies”. t.me answers a private or missing channel with a redirect; returning zero rows there would read as a quiet day and hide a typo in the config forever. jobGeeks from the original list does exactly this (HTTP 302) and is deliberately not configured.
  • Remote detection uses a Unicode word boundary rather than \b: \b is ASCII-only, so a space before a Cyrillic word is not a boundary and \bудал never matched “удалёнка” — the same trap the title filter hit in v1.227.3, caught here by a test before it shipped.
  • 15 channels configured and verified live: 299 posts across 15/15 channels, salary_pm yielding 13 salary figures out of 20. 30 new tests. Test suite: 2865 → 2895.
  • The README test counts jumped by hundreds, and no, this release did not add them. The localized READMEs carried unit-test totals frozen at 1856 / 1945 / 1955 / 549 / 419 / 284 / 2527 depending on the file — each stale since a different release, and several contradicting the badge at the top of the same page. They now all read 2895, verified by running the suite rather than by copying a badge. Playwright (70 / 90 / 12 → 101), e2e smoke (20 → 21), route modules (12 / 32 → 37), smoke flows (12 → 22) and the test-file count (218 → 329) were stale in the same way. The Total row also repeated the unit count, so it under-summed the three suites below it; it now reads the real sum (3040).

[1.227.5] — 2026-08-29

Fixed — the regional scan ran the server out of memory, and a Playwright suite flaked on its own teardown.

Fixed

  • The regional scan OOM-killed the server. ru-scanner accumulated every raw hit from every query and only deduplicated and filtered at the very end. The query list is deliberately full of near-synonyms (Golang, Go разработчик, Golang разработчик, Senior Go…), so the same vacancy comes back once per query and the array held several times the unique count — with descriptions attached, which are the bulk of a job object and are almost all discarded by the filters anyway. Measured at 742 MB of heap on a real 21-query run. The server caps Node’s heap at 490 MB (V8 sizes it from the box’s 956 MB of RAM), so the scan crashed the service with FATAL ERROR: Reached heap limit — four times in one day. Dedup and filtering now happen per query, which took the same run to 177 MB, a 76% reduction. Reported counts are unchanged: the unique total is carried by a Set of URL strings, so Total found still means what it always did.
  • net::ERR_SOCKET_NOT_CONNECTED failed CI at random. Three Playwright suites call window.stop() to halt in-flight requests before asserting no console errors fired. Chromium reports that single deliberate act under three names depending on where the socket was: ERR_ABORTED when it cancels the request, ERR_EMPTY_RESPONSE mid-flight, and ERR_SOCKET_NOT_CONNECTED when the socket was already gone. The shared benign-console filter listed only the first two, so the same teardown failed a run at random. It surfaced on the locale sweep in tr — the suite with the most navigations, hence the most chances to lose the race — while playwright-forms and playwright-smoke, which also call window.stop(), were one unlucky run away from the identical failure.

Notes

  • Why the scan only started failing now. The query list grew from 14 to 21 when product-manager searches were added. The old implementation’s memory scaled with total hits, so +50% queries pushed it past the box’s ceiling. Nothing was wrong with the new queries — they exposed an allocation pattern that had been one config change away from failing for a long time.
  • The dedup change is behaviour-preserving, and that is tested rather than asserted. tests/ru-scanner-dedup-order.test.mjs pins the awkward direction — a later duplicate that FAILS the filters must evict an earlier one that passed, because the old end-of-run pass kept only the last copy and then filtered it — and runs a 200-trial randomised equivalence check against the legacy accumulate-then-filter implementation.
  • The console filter stays narrow. Only the cancelled-request family is benign. A refused connection, DNS failure, connection reset, any 5xx and every uncaught JS exception still fail the assertion, pinned by new tests so the widening cannot creep.
  • Two earlier hypotheses were tested and discarded rather than shipped: a Caddy timeout (its config is clean, flush_interval -1 is correct for SSE) and Node’s 5-minute default requestTimeout (a scaled-down reproduction showed it does not terminate a long streaming response). The journal’s Reached heap limit was the actual evidence. Test suite: 2860 → 2865.

[1.227.4] — 2026-08-29

Fixed — word: / stem: were still ignored by content_filter; v1.227.3 only fixed the title filter.

Fixed

  • content_filter did not honour the keyword prefixes. v1.227.3 taught title_filter the parent’s word: / stem: prefixes but left content_filter on its own raw lower.includes(k), so a prefixed entry there was still matched as the literal text "word:java" — the same silent no-op, one filter over. content_filter reads the job DESCRIPTION and its default has always been a plain substring, which is why a bare negative java rejects every posting that merely mentions “JavaScript”; the prefix is how you opt one entry out of that. Ported the parent’s compileContentKeyword, which shares the prefix machinery but deliberately does not auto-anchor short keywords: the title filter anchors 2–3 letter acronyms because “COO” inside “Coordinator” is always wrong, whereas a short run inside a paragraph of prose is routinely intended (aws, gcp, sql, go).

Notes

  • Caught in AI review of the v1.227.3 PR, which noticed the code did not cover what its own comment claimed — the comment said “title_filter / content_filter” while only the title path was wired. The comment is now explicit about what each filter does: both honour the prefixes, only the title filter auto-anchors acronyms.
  • Also verified from that review, and unchanged: switching the 2–3 letter acronym rule from \b to the Unicode boundary is behaviourally identical for ASCII and stricter only where it should be — COO_lead and coo1 are still kept (underscore and digit are word characters under both), Coordinator kept, Директор COO and VP «Продукт» correctly dropped. 14 edge cases checked.
  • Unprefixed content_filter entries keep their previous matching byte-for-byte, a blank/absent description still passes untouched, and a bare word: / stem: matches nothing rather than everything. 6 new cases in tests/content-filter.test.mjs. Test suite: 2852 → 2858.

[1.227.3] — 2026-08-29

Fixed — word: / stem: title-filter prefixes were silently ignored, turning those filter lines into no-ops.

Fixed

  • web-ui did not implement the parent’s word: / stem: keyword prefixes. title_filter (and content_filter) default to case-insensitive substring matching, which is why a bare negative intern also rejects “International Product Manager” and “Internal Tools Engineer”. Rather than flip that default — a breaking change for every configured install — the parent made precision opt-in per entry: word:intern matches the whole word only, stem:agent must start a word and may continue (separating “Agentforce” from “Reagents”). web-ui had neither, so it matched a prefixed entry as the literal text "word:intern" — which appears in no job title. The line became a silent no-op: the same portals.yml filtered correctly through the CLI and not at all here, so every intern posting the user had excluded still reached the #/scan table. Ported from the parent’s title-keywords.mjs, including the Unicode-aware boundary (\p{L}\p{M}\p{N}_) rather than \b, which is ASCII-only and matched mid-word in accented or Cyrillic titles.
  • The 2–3 letter acronym rule now uses that same Unicode boundary. It previously used \b, so coo and word:coo disagreed on non-ASCII titles — two spellings of one rule.

Notes

  • Found while checking a live portals.yml that had just adopted word:intern specifically to stop “International Product Manager” being dropped by a bare intern. The prefix worked in the CLI; web-ui quietly kept every intern posting. Behaviour is now identical in both.
  • Defaults are unchanged. An unprefixed keyword keeps exactly its previous matching: substring for phrases and anything containing non-letters (.NET, SAP , L&D), boundary-anchored for 2–3 letter acronyms. A bare word: / stem: with nothing after it is treated as a typo and matches nothing — as a negative, an empty pattern would match every title and veto an entire scan from one stray colon.
  • 6 new cases in tests/title-filter.test.mjs covering both prefixes, the Unicode boundary, the bare-prefix typo, positive-side use, and that unprefixed defaults are untouched. Test suite: 2846 → 2852.

[1.227.2] — 2026-08-29

Fixed — #/config scrolled sideways on narrow phones.

Fixed

  • MOBILE-1: #/config overflowed horizontally on viewports ≤ 352 px. Every select-kind field carried an inline min-width: 300px. An inline style beats the stylesheet, so the control could not shrink with its container: at a 320 px viewport the select’s right edge landed at 353 px, the page grew a horizontal scrollbar, and controls were pushed off the right edge. Affected all 18 provider/model selects (cfg-llm-provider and every cfg-*-model). Now min-width: min(300px, 100%) + max-width: 100%, so the 300 px comfort width applies only when there is room. Devices at or below the threshold: iPhone SE (1st gen, 320 px), older Android, Galaxy Fold cover screen.

Notes

  • Why every earlier mobile pass missed it. 360 px and 375 px have just enough slack to hide the overflow, and the Playwright suites drive a 1280-wide window. Nothing in CI had ever measured a viewport narrower than 375 px. New tests/playwright-narrow-viewport.mjs sweeps the form-bearing routes at 320 px and fails on any horizontal overflow, naming the widest offending element in the assertion message; a second case pins that no #/config select extends past the viewport. It was verified to reproduce the bug first (scrollWidth 353 > clientWidth 320, 18 selects at right edge 353). Playwright: 99 → 101.
  • Known, deliberately not gated yet: 280 px (Galaxy Fold cover screen) still overflows by ~5 px, from an unrelated cause — long content inside the config card’s <details> blocks and the 256 px sidebar against a 280 px viewport, not the inline min-width this release fixes. Reflowing at a width no mainstream phone reports is separate work; the suite’s WIDTHS list is ready for it.
  • Also corrected the v1.227.1 QA prompt, which named the follow-up route as #/modes/followup. Mode pages register bare (Router.register(cfg.slug)), so the route is #/followup; #/modes/followup correctly 404s. Documentation only.
  • Unit suite unchanged at 2846 (the new coverage is browser-level).

[1.227.1] — 2026-08-29

Fixed — the help guide’s source count contradicted itself, and its GitHub banner was printed as raw markup in the app.

Fixed

  • §17 stated a total and a breakdown that did not add up. Every help bundle read “the server/lib/sources/ registry ships 85 adapters — 78 English + 5 Russian”. 78 + 5 = 83, not 85; ja/ko had drifted further, to 77 (= 82). Each new source bumped the total and left the breakdown alone, and the two numbers had been diverging for several releases. The breakdown is now 80 EN + 5 RU in all 17 locales. This mattered beyond cosmetics: docs/help/<lang>.md is the ONLY grounding corpus for the “Ask the docs” assistant, so a user asking how many sources are supported got two contradictory numbers out of one sentence.
  • The same sentence pinned a stale version anchor. “As of v1.213.0 the registry ships …” survived in 16 locales while the count moved twice (83 in v1.219.0, 85 in v1.226.0). The anchor was already repaired once by hand in v1.210.1 and simply drifted again. It is now removed in favour of version-free wording — the count itself is asserted against the live registry, which cannot go stale.
  • The provider-logo banner printed as escaped markup at the top of #/help. v1.225.0/v1.225.1 recorded it as “stripped in-app by UI.md() (no image support)”. That conflated two things: UI.md() is escape-first — it HTML-escapes every source byte before any markdown transform, which is the correct XSS boundary but the opposite of removal. Nothing removed the banner, because no code ever tried, so all 17 locales opened #/help with 268 characters of literal <p align="center"><img src="https://…. A new shared ingress (server/lib/help-markdown.mjs::stripGithubOnlyBlocks) now drops standalone HTML image blocks on read, for both GET /api/help/:lang and the docs-assistant retrieval corpus. The bundles keep the banner on disk, so GitHub still renders the showcase.
  • The cadence board’s “#” column had been blank since v1.117.0. It read entry.appNum, but followup-cadence.mjs emits num — a field name that never existed in the payload. Both are now read. The v1.227.0 “Next up” line inherited the same mistake and is fixed with it.

Notes

  • These four went undetected because nothing gated them. The help gates (canonical-docs-coverage, help-ui, help-ru-config-section) count HEADINGS — 32 H2 / 121 H3 — and no test had ever read a number inside a paragraph or looked at what the help ingress actually serves. Two new suites close that: tests/help-source-counts.test.mjs (the stated total equals SOURCES.length; the breakdown parts equal the live EN/RU split and sum to the total; the paragraph pins no version) and tests/help-banner-strip.test.mjs (the banner stays on disk for GitHub, never survives the strip, every bundle’s first rendered line is its heading, the strip stays narrow, and both consumers route through it). Both were verified to fail against the exact bugs before being committed. Test suite: 2837 → 2846.
  • No registry, scanner or route-contract change; source count remains 85 (80 EN + 5 RU), ALL_ADAPTERS 80.

[1.227.0] — 2026-08-28

Added — SEEK Hong Kong (JobsDB), a due-only follow-up lens, and confidential employers now show who fronted them.

Added

  • JobsDB Hong Kong via the existing Jobstreet/SEEK source. Hong Kong runs under the JobsDB brand but sits on the same SEEK platform behind the same v5 endpoint, so it needs no separate source — only hk.jobsdb.com in the host allowlist and siteKey: HK-Main. Registry is unchanged at 85 sources (80 EN + 5 RU) and ALL_ADAPTERS 80: this is a new MARKET on an existing adapter, not a new one.
  • Due-only lens on the follow-up cadence board (#/modes/followup). A new checkbox narrows the board to what is actionable right now. Due-ness is read from the urgency that the parent’s followup-cadence.mjs already computed (urgent / overdue) — never from the tracker status, which is applied/responded/interview and would match nothing. When nothing is due, the board names the nearest scheduled follow-up instead of showing a bare empty state. Logic lives in the pure public/js/lib/followup-view.js, mirroring the parent’s followup-view.mjs.
  • Confidential employers are attributed to the agency that fronted them. A posting whose employer is withheld is recorded as ? in the tracker with the recruiter in the Via column. #/tracker showed a bare ?, so several confidential rows were indistinguishable and the logo resolver had nothing to match. Such a row now reads “Confidential · via ”, resolves its logo from the agency name, and is findable by that name in the search box. Presentation only — the canonical company field is never overwritten with the recruiter. New pure public/js/lib/company-presentation.js, mirroring the parent’s company-presentation.mjs.

Fixed

  • Jobstreet/SEEK was calling a deprecated endpoint. The source still pointed at chalice-search v4 (/api/chalice-search/v4/search), which SEEK retired; the parent migrated to /api/jobsearch/v5/search and web-ui never followed. Every Jobstreet/SEEK entry now queries v5. The v5 item shape is read natively (job URL built from id, locations[0].label, advertiser.description, salaryLabel), the v4-only solrFields projection parameter is gone, and an entry whose portals.yml still pins the dead v4 path keeps its market hostname while the path is rebuilt on v5 — no user edit required.

Notes

  • career-ops 1.30.0 parity (parent HEAD 8d64f65 → 6cc46a4; the parent’s VERSION still reads 1.30.0 — release-please lags its main). Not ported, with reasons: iCIMS JSON-LD location fill — web-ui’s iCIMS source has no enrichDate() detail-page hook at all (list-pages only), so there is no code path to fix; nonfatal CLI stderr on clean exit (#1974) — web-ui’s runner decides failure from the exit code alone and never had the stderr-keyword heuristic that bug lived in; Turbopack path tracing — web-ui has no bundler; cv-sync-check crash/CODE_ROOT fixes — relayed read-only and fail-soft, so parent-side fixes land without a web-ui change; Playwright sequencing in scan.mjs, Block H application answers, the updater/doctor/update-system guards, the recursive syntax gate, the Go dashboard, and the jd-skill-gap/verify-cv-facts self-test additions — CLI-only surfaces web-ui does not shell into, or self-tests with no behavior change. Test suite: 2818 → 2837.

[1.226.0] — 2026-08-27

Added — two Vietnamese job sources: ITviec and CareerViet.

Added

  • ITviec + CareerViet (Vietnam). Two host-pinned Vietnamese boards join the scanner via provider: itviec / provider: careerviet entries: ITviec (itviec.com, tech-focused) and CareerViet (careerviet.vn, broad coverage). Both are zero-token HTML scrapers with SSRF host-pinning, HTTPS-only, per-company fail-soft, and page caps. Registry now ships 85 sources (80 EN + 5 RU), ALL_ADAPTERS 80 — both appear in the #/scan Source filter.

Notes

  • career-ops 1.30.0 parity (parentVersion 1.29.0 → 1.30.0). Not ported — CLI/community-only or already covered: the Hired Wall, /calibrate, clean-markers, template packs, doctor, pdf/LaTeX, and the eval/tracker/scan CLIs web-ui does not shell into; the _html-to-text quoted-angle fix and detect-reposts roleFuzzyMatch were already in web-ui; the title-filter stem: prefix is deferred (web-ui filtering lives in en-scanner). Test suite: 2818.

[1.225.1] — 2026-08-27

Fixed — the provider-logo banner now renders in the README and help.

Fixed

  • The v1.225.0 banner did not display on GitHub: the standalone images/providers.svg had an invalid font-family (nested double-quotes) that GitHub rejects, the <img> used a repo-relative src (raw HTML <img> does not resolve relative paths on GitHub), and the <p> block had no trailing blank line so it merged with the next line. It now points at a rendered images/providers.png via an absolute raw URL, with blank-line separation; the SVG is also corrected to valid XML.

Notes

  • Docs-only; no code or test change. The cvstart.org showcase and the app’s own provider tiles were already correct (inline SVG, not this file). Test suite: 2784.

[1.225.0] — 2026-08-27

Added — a provider-logo banner in the README and help guide.

Added

  • Provider-logo banner. A single self-contained images/providers.svg — the 18 LLM providers with the real brand logo (simple-icons, CC0) for the 11 that publish one and a brand-colored monogram for the 7 that don’t — now heads the README (×17) and the in-app help guide. (The app renders the real logo tiles itself; the banner is GitHub-rendered and stripped in-app by the XSS-safe markdown renderer.)

Notes

  • Docs-only — no code or test change; the SVG is inline (no remote asset). Scan sources unchanged at 83. Test suite: 2784.

[1.224.0] — 2026-08-26

Changed — the LLM provider tiles now show real brand logos.

Changed

  • Real provider logos. The provider tiles — Settings API-key fields, the Usage rows, the dashboard chip, the Settings “Active” summary, the ⚡ eval result, and the onboarding banner — now render the real brand logo for the 11 providers that publish an open-source icon (Anthropic · Gemini · OpenAI · Qwen · OpenRouter · GitHub · DeepSeek · Kimi · MiniMax · Mistral · Ollama; single-path SVGs from simple-icons, CC0). The 7 without a published icon (Hermes · GLM/Z.ai · Grok · Together · Fireworks · BytePlus Ark · Volcengine Ark) keep the brand-colored monogram. Still CSP-safe by construction: the logo path is an inlined static constant — no remote asset, no innerHTML — exactly like the monogram.

Notes

  • No route or behaviour change; the same ProviderLogo.el(slug) API. provider-logo.js grew (the inlined logo paths). Scan sources unchanged at 83. Test suite: 2784.

[1.223.0] — 2026-08-26

Fixed — a successful ⚡ live eval was painted as an error; plus install the OpenWorker coworker straight from its GitHub URL or .zip.

Fixed

  • ⚡ eval result badge tone. A successful live evaluation is no longer coloured as a failure. The in-process live providers (OpenRouter, DeepSeek, …) return no subprocess exit code, so the badge’s strict code === 0 test was false and every success rendered red (while still reading “exit 0”). The tone now follows the outcome — only a subprocess that exits non-zero paints it red; a code-less live result is badge-ok, and the “· exit N” suffix shows only when there is a real exit code.

Changed

  • Install the OpenWorker coworker from its GitHub URL / .zip. The coworker’s docs now describe the one-command installer (curl … | bash, idempotent — it reuses an existing career-ops / web-ui) and the three OpenWorker install paths (GitHub URL · .zip · single-file import) across docs/integrations/openworker.md, in-app help §32 (×17), and the README.

Notes

  • Closed a long-standing coverage gap: the Recruitee source now has an end-to-end test for the v1.214.2 quoted-angle fix (a > inside a tag attribute must not leak into the description). Scan sources unchanged at 83. Test suite: 2783.

[1.222.0] — 2026-08-26

Changed — the extended-provider field hints (DeepSeek … Volcengine Ark) are now localized in all 17 languages.

Changed

  • Localized provider field hints. Every config.<slug>Hint for the 11 OpenAI-compatible providers added in v1.216.0–v1.217.0 (DeepSeek · GLM/Z.ai · Kimi · MiniMax · Mistral · Grok · Together · Fireworks · Ollama · BytePlus Ark · Volcengine Ark) — the API-key, model, and base-URL hints under Settings → API keys — is now translated across all 17 locales instead of falling back to English. Signup URLs, model ids, and the ⚡ live-eval marker are preserved verbatim.

Notes

  • No behaviour change — the English hintFallback in field-specs.js still guards a missing key, and a new contract test asserts every field-descriptor hintKey resolves in all 17 locales. Scan sources unchanged at 83. Test suite: 2779.

[1.221.0] — 2026-08-26

Security — DNS-rebinding defence-in-depth on the scanner fetch path.

Security

  • The scanner HTTP core (fetchJson / fetchText in server/lib/http-json.mjs) now resolves each source’s hostname on the real network path and refuses to connect if it resolves to a private, loopback, link-local, CGNAT, or cloud-metadata address (e.g. 169.254.169.254) — closing a DNS-rebinding vector where a public hostname is served a private A record. Scanner hosts are already pinned to public registrable domains, so this is defence-in-depth; the user-supplied-URL path already had stronger connection-pinning (safe-fetch.mjs, since v1.20.1). The guard runs only on the real fetch transport — an injected test fetchImpl is never resolved, so the mocked source suites are unaffected.

Notes

  • No behaviour change for a healthy scan (public boards resolve to public IPs). Scan sources unchanged at 83. Test suite: 2775.

[1.220.0] — 2026-08-26

Added — scan several Get on Board categories from one entry. The board splits leadership and ML/data roles out of programming, so an EM or data search now covers them without a second portal entry.

Added

  • Get on Board: multiple categories per entry. A getonbrd portal entry can now set categories: [programming, operations-management, machine-learning-ai] (or a single category:) instead of only the default programming feed — postings are deduped by URL across categories, and the list is capped at 12. Existing entries are byte-identical (the default is still programming).

Notes

  • Server-only; the SSRF host-pin (www.getonbrd.com) + redirect:'error' are unchanged, and a bad category slug is rejected before any fetch. Scan sources unchanged at 83. Test suite: 2771.

[1.219.0] — 2026-08-26

Added — Torre joins the scanner, and every provider label is now correct. A new LatAm-heavy remote board, a fix that stopped the a16z sweep short, and the last places that named providers from a stale short list now use the full 18-provider roster.

Added

  • Torre (provider: torre) — the pan-LatAm talent marketplace behind torre.ai, a public zero-auth opportunity search carrying remote roles that never reach Greenhouse/Lever/Ashby. Configure search (+ experience) and optional remote_only; one capped request per entry. The scanner registry now ships 83 sources (78 English + 5 Russian).
  • Provider monogram on the onboarding banner — the top-of-page “Live eval” chip now shows the active provider’s brand tile (joining Settings, Usage, the dashboard chip, and the ⚡ result).

Fixed

  • The a16z Speedrun scan stopped early on a rotating board. When a listing was deleted between page requests, the feed returned a 49/50 page mid-sweep; that short page was treated as the last one, silently truncating the scan (149 of ~300 jobs). It now trusts the feed’s own total_pages and only falls back to a short page when the feed doesn’t report one — an empty page always ends iteration, so an over-reporting feed can’t run away.
  • Stale provider names. The global onboarding banner resolved the active provider from a 4-entry map (Anthropic/Gemini/OpenAI/Qwen) and showed a raw slug for the other 14; it now uses the shared 18-provider label + monogram. The Settings “which providers” note (“seven providers …”) now lists the full 18.

Notes

  • Get on Board’s new multiple-categories-per-entry option isn’t wired here yet — the web-ui adapter builds the category into the endpoint URL, so it needs a source-level refactor; queued. Test suite: 2768.

[1.218.0] — 2026-08-26

Added — provider brand tiles (and correct names) everywhere a provider is shown. The dashboard “live evals” chip, the Settings active-provider summary, and the ⚡ evaluation result now each carry the provider’s monogram and name it correctly across all 18.

Added

  • Provider monograms on the dashboard chip, the Settings “Active” summary, and the ⚡ evaluation result header — matching the tiles already in the Settings key fields and the Usage page.

Fixed

  • Stale provider labels. Three surfaces resolved the name from a 5-entry map (Anthropic/Gemini/OpenAI/Qwen/OpenRouter) and fell back to the raw slug — the ⚡ evaluation result even mislabeled every non-Anthropic provider as “Gemini”. All three now use the shared ProviderStatus.label (18 providers).
  • Settings key count showed “/ 7”; it now reflects the real provider total (18).
  • The LLM_PROVIDER hint’s slug list (… / ollama) gained ark / arkcn in all 17 locales, matching the auto-order arrow list.

Notes

  • Client-only, CSP-safe (the same inline-SVG monograms). No server change. Scan sources unchanged at 82. Test suite: 2758.

[1.217.1] — 2026-08-26

Test hardening — endpoint coverage for the full provider roster.

Added

  • A parametrized run<Provider> test asserting the endpoint, default model, and Bearer auth for Kimi, MiniMax, Mistral, and Fireworks, so every OpenAI-compatible wrapper now has a direct endpoint check (not just structural coverage).

Notes

  • No behavior change. Scan sources unchanged at 82. Test suite: 2756.

[1.217.0] — 2026-08-26

Added — Ark joins the LLM roster (18 providers). BytePlus Ark and Volcengine Ark — ByteDance’s Doubao models — are now ⚡ live-eval providers, wired through the same OpenAI-compatible core with a region base-URL override and their own brand tiles.

Added

  • BytePlus Ark (ARK_API_KEY, international) and Volcengine Ark (ARK_CN_API_KEY, China) — OpenAI-compatible Chat Completions via the shared runOpenAICompatible() core. Set ARK_MODEL / ARK_CN_MODEL (a Doubao model name or your ep-… endpoint id); ARK_BASE_URL / ARK_CN_BASE_URL switch region. Both join the auto order (last) and each shows its monogram in Settings + Usage. The web UI now spans 18 live-eval providers.

Notes

  • Same trusted-config + http(s) scheme-guard path as the rest of the roster (never the SSRF job-URL validator). Scan sources unchanged at 82. Test suite: 2755.

[1.216.0] — 2026-08-26

Added — nine more LLM providers, each with its own brand tile. Your ⚡ live evaluations can now run through DeepSeek, GLM (Z.ai), Kimi (Moonshot), MiniMax, Mistral, Grok (xAI), Together, Fireworks, or a fully local Ollama — one key away, each marked by a monogram beside its field in Settings.

Added

  • Nine OpenAI-compatible providers. DeepSeek, GLM (Z.ai), Kimi (Moonshot), MiniMax, Mistral, Grok (xAI), Together AI, Fireworks AI, and Ollama (fully local, no key). Set a key — or OLLAMA_BASE_URL for Ollama — in App settings and it joins the auto order after Hermes; pin any one with LLM_PROVIDER. Together also hosts Thinking Machines’ Inkling (thinkingmachines/Inkling). The web UI now spans 16 live-eval providers.
  • Provider monogram tiles. A CSP-safe, brand-colored initial tile marks every provider beside its key field in Settings and its row on the Usage page — built with inline SVG, no external logos, nothing leaves your machine.
  • Each provider surfaces on #/config (key + model dropdown; China-endpoint base-URL fields for GLM and Kimi), /api/status/providers, /api/health, and the Usage cost roll-up (editable list prices in server/lib/llm-pricing.mjs).

Notes

  • Provider base URLs are trusted config reached through runOpenAICompatible() with an http(s) scheme guard (Ollama’s loopback is allowed) — never the SSRF job-URL validator. Scan sources unchanged at 82. Test suite: 2752.

[1.215.0] — 2026-08-26

Added — run your whole job search from OpenWorker. A new coworker drives this pipeline from Andrew Ng’s open-source AI coworker app, and the in-app help now covers it so the Ask-the-docs assistant can walk you through it.

Added

  • OpenWorker coworker. A code-free OpenWorker coworker — Fighter90/career-ops-coworker — runs the full job search (scan, score, tailor, track, follow up) and can launch this dashboard, all from your desktop. It’s a one-file persona shipped in 17 languages, verified installable against OpenWorker’s own loader.
  • Help §32 “Run it from OpenWorker” (all 17 locales) documents it, so the in-app Ask the docs assistant and the floating docs button can answer questions about it. Also linked from the landing footer, the README, the wiki, and docs/integrations/openworker.md.

Notes

  • Docs + site only — no app-code change. Scan sources unchanged at 82. Test suite: 2742.

[1.214.2] — 2026-08-25

Fixed — the shared HTML→text pipeline (Greenhouse / Recruitee descriptions) no longer leaks tag attributes into the description when an attribute value contains a > character.

Fixed

  • HTML descriptions with > inside an attribute are stripped cleanly. The tag stripper behind the Greenhouse and Recruitee descriptions used a naive match that stopped at the first >, so a tag like <a title="salary > 100k"> left the trailing attribute text (100k">) in the description. It now respects quoted attribute values (single or double), strips markup before decoding entities (so an encoded quote inside an attribute can’t become a false delimiter), and still preserves a literal <> in plain text.

Notes

  • Test suite: 2742. Scan sources unchanged at 82. Nothing else new from the parent this round: the remaining changes are relayed read-only (follow-up / weekly-digest / rejection-latency / salary-gap fixes need no web-ui change) or aren’t web-ui surfaces (an external X/Xquik BYO-key plugin, CLI/verify fixes). The opt-in items — SmartRecruiters detail descriptions, detect-reposts aggregator skip, stem: / word: title prefixes, DNS-rebinding guard — stay queued.

[1.214.1] — 2026-08-25

Fixed — Ashby job descriptions are now capped at the same length as Greenhouse and Recruitee, so a content filter behaves identically across all three boards and one board can’t bloat the scan cache.

Fixed

  • Ashby descriptions now honour the same length cap as the other boards. v1.214.0 fed Ashby’s posting body to the content filter, but — unlike Greenhouse and Recruitee, which cap at 4000 characters — Ashby’s was mapped straight through uncapped (some ran 4× longer). So the same content_filter keyword could match on Ashby but not Greenhouse depending only on where it fell in the text, and one board could add hundreds of KB to the scan cache the #/scan page loads. Ashby is now capped to the same 4000 characters (its text is already plain, so it’s truncated, never tag-stripped).

Notes

  • Test suite: 2738. Scan sources unchanged at 82.

[1.214.0] — 2026-08-25

Fixed — job descriptions from Greenhouse, Ashby, and Recruitee now feed the content filter, so keyword / country / visa filters actually match those boards; and “Associate ” (including academic ranks) is no longer misfiled as entry-level.

Fixed

  • Content filters now work on Greenhouse, Ashby, and Recruitee. These boards now carry each posting’s full text as its description, so a content_filter (or a country / visa keyword filter) matches the job body instead of passing the board through blind. Ashby and Recruitee ship the body in their list payload for free; Greenhouse already did. All three strip through one shared HTML→text pipeline.
  • “Associate Director” / “Associate Professor” is senior, not entry-level. The seniority classifier (used by skip_tiers:) treated the word “associate” as an entry-level marker even when it prefixes a senior title. It now reads “Associate ” — including academic ranks (Associate Professor, Associate Dean) — as senior, while keeping genuine junior variants (Associate Attorney, Associate Editor) as entry-level, and still filing a leading “Intern, Associate Dean” as an internship.

Notes

  • Test suite: 2736. Scan sources unchanged at 82. Queued for a follow-up: opt-in SmartRecruiters description enrichment (a per-posting detail fetch), detect-reposts’ aggregator: true board skip, and the anchored-keyword stem: / word: title prefixes — each an opt-in config surface rather than a default-behaviour change. The DNS-rebinding hardening remains queued for its own security release.

[1.213.0] — 2026-08-22

Added — MyCareersFuture, Singapore’s national job bank, as a scan source. Fixed — Greenhouse postings now carry their full text so content filters work, and remote Ashby roles are no longer hidden behind a city-only location.

Added

  • MyCareersFuture (Singapore) (mycareersfuture.gov.sg) — a new zero-token scan source for Singapore’s national public job bank, run by Workforce Singapore. Pick it in the #/scan Source filter, or add a provider: mycareersfuture company with an optional keywords list (it falls back to your profile’s target roles, like Job Bank). Reads the public search API, host-pinned, no key.

Fixed

  • Greenhouse postings can now be content-filtered. Greenhouse boards are fetched with their full posting body, decoded to plain text as the posting’s description — so a content_filter (or a country/visa keyword filter) that reads the description now actually matches Greenhouse roles instead of passing them through blind.
  • Remote Ashby roles are no longer dropped by a city filter. Ashby keeps the work model (Remote/Hybrid/Onsite) separate from the office city, so a fully-remote role still read as e.g. “San Francisco” — and a location filter blocking that city hid a role you could take. “Remote” is now appended to the location when the role is remote, and workplaceType wins over a stale isRemote flag so an office-anchored Hybrid role isn’t mislabeled.

Notes

  • Scan sources: 82 (77 English + 5 Russian). Test suite: 2724. A DNS-rebinding hardening (validating a hostname’s resolved address before connecting) is queued for a dedicated release — it needs a web-ui-specific design rather than a direct port.

[1.212.1] — 2026-08-21

Fixed — the cvstart.org landing under-counted the scanner’s job sources (it showed 80 and omitted Job Bank (Canada)); it now matches the app’s 81 again, and the site build fails loudly if the two ever disagree.

Fixed

  • The landing’s “Job sources” count is back in sync with the app. After v1.212.0, cvstart.org showed 80 boards and was missing the new Job Bank (Canada) chip, while the app, the scan dropdown, and the help guide all listed 81. The landing builds its list by loading the live scanner registry, and one source failed to load in that build because of how it pulled in a YAML dependency — so it was silently dropped. Job Bank now loads that dependency lazily, the same way the rest of the app does at scan time, so it always appears.
  • The site build now refuses to ship a mismatched source count. If the registry ever enumerates fewer sources than exist on disk (the signature of a source that failed to load), the build fails with a clear message instead of quietly publishing the wrong number.

Notes

  • App behaviour is unchanged — the scanner always had all 81 sources; only the landing page was affected. Scan sources: 81 (76 English + 5 Russian) — unchanged. Test suite: 2687.

[1.212.0] — 2026-08-21

Added — Job Bank (Canada), the federal national job board. Removed — EchoJobs (its feed is now bot-blocked). Fixed — Consider boards return results again, and multi-location Lever roles no longer hide half their locations.

Added

  • Job Bank (Canada) (jobbank.gc.ca) — a new zero-token scan source for Canada’s federal national employment service, a high-volume board no aggregator covers well. Pick it in the #/scan Source filter, or add a provider: jobbankca company with an optional keywords list (it falls back to your profile’s target roles). Reads the public ATOM feed, host-pinned, no key.

Removed

  • EchoJobs — retired. Its public feed is now behind bot protection and returns nothing, so keeping it only wasted a scan slot.

Fixed

  • Consider-powered boards return results again. Consider now requires an anonymous handshake (a GET that seeds a session cookie + CSRF token) before it accepts the search request; without it the request was silently rejected and the board looked empty.
  • Multi-location Lever roles no longer hide half their locations. Lever puts one primary city in location and the rest in allLocations; reading only the primary made a role open in Barcelona AND Montevideo look Barcelona-only (and wrongly dropped by a location filter). Both are now merged.

Notes

  • Gentler 250 ms inter-page pacing (up from 150) on the paginated boards, to stay polite to single-host careers sites. Scan sources: 81 (76 English + 5 Russian) — unchanged (Job Bank in, EchoJobs out). Test suite: 2685.

[1.211.0] — 2026-08-19

Added — Yourator, a Taiwan tech job board. Fixed — accented company/title entities now decode everywhere, and a company with an accent in its name is no longer wrongly flagged.

Added

  • Yourator (yourator.co) — a new zero-token scan source for the Taiwan tech & digital job market. Pick it in the #/scan Source filter, or add a company with provider: yourator. It reads the public JSON API (no key, no browser), walks every page of the board, and emits each posting’s real employer link (its own ATS) with tracking params stripped.

Fixed

  • Accented named entities now decode everywhere. The shared HTML decoder gained the Latin-1 letters (&eacute; → é, &ccedil; → ç, …), so a European board that writes D&eacute;veloppeur or Fran&ccedil;ais no longer lands that literal in a job title, the tracker, or a generated document. (Uppercase names stay uppercase — &Eacute; is É, not é — and a lookup like &constructor; now resolves to itself.)
  • A company with an accent in its name is no longer wrongly flagged for being on its own domain. “Işık” now folds to “isik” and matches isik.com.tr; “Société Générale” matches societegenerale.com. The old check deleted accented letters instead of folding them to their ASCII base.

Notes

  • Scan sources: 81 (76 English + 5 Russian). Test suite: 2667.

[1.210.1] — 2026-08-19

Fixed — Habr Career vacancy titles and company names with “&” or quotes no longer arrive garbled.

Fixed

  • The Habr Career source now decodes HTML entities in the title and company name before they flow on. The server-rendered cards arrive escaped (“Changellenge >>”, “Demand Forecasting & Inventory Optimization”, “ООО "М-ТЕХ"”), so an undecoded “&” silently failed a user’s own “&” title filter — the exact symptom the previous release closed on five other boards — and company names reached the tracker and reports mangled. Entity-decoding is now complete across all six affected sources.

Notes

  • Test suite: 2644.

[1.210.0] — 2026-08-19

Added — Senjob, the scanner’s first African job board (Senegal); sharper title matching on five more boards.

Added

  • Senjob (senjob.com) — a new zero-token scan source for Senegal, the scanner’s first African board. Select it in the #/scan Source filter, or add a company with provider: senjob. It reads the public listing over plain HTTP (no key, no browser), pins every request to senjob.com, and — parsing HTML — treats a listing that suddenly yields nothing as a broken board (a surfaced error) rather than a silently empty country.

Fixed

  • Titles with an ampersand no longer drop jobs on five boards — on beesite, Cornerstone (csod), Hacker News “Who is hiring”, Phenom and TKMS, titles arrive HTML-escaped, so an escaped “&” in a role like “R&D Engineer” failed your own “r&d” keyword and the posting vanished silently (a “sales & marketing” veto never fired either). Titles — and Phenom locations — are now decoded before filtering.

Notes

  • Scan sources: 80 (75 English + 5 Russian). Test suite: 2643.

[1.209.0] — 2026-08-17

Added — the in-app help now covers recording an application outcome, and “Ask the docs” can point you to it.

Added

  • The Tracker help (§11) gained a “Record an outcome” section in all 17 languages, walking through the Outcome button: pick what happened (rejected / offer / hired / declined / ghosted / advanced to interview), preview what it will do, then record — which logs the result, archives the CV and cover letter you submitted, and syncs the row’s Status for you. The floating “Ask the docs” assistant reads the help guide, so it now guides you to that button instead of only suggesting you edit the Status by hand.

Notes

  • Each help bundle is now 31 H2 / 119 H3 (was 118); the parity gates were bumped to match. Docs-only — no code or behaviour change. Suite: 2625.

[1.208.2] — 2026-08-16

Fixed — on a phone the notification and theme buttons no longer sit on top of the search box.

Fixed

  • v1.208.1 stopped the top-bar buttons from overlapping the page heading, but on a narrow-but-not-narrowest phone — especially in languages with longer button labels — the whole bar still crammed onto one row, so the 🔔 and 🌙 buttons could land on top of the search box. The action buttons (notifications, theme, Diagnostics, Open Scan) now always drop onto their own full-width second row on a phone, so the search box stays fully readable and nothing overlaps.

Notes

  • On a phone the top-bar action buttons move to a full-width second row, removing the fragile “almost-full row” band where the layout distributed leftover negative space as overlap. A Playwright guard now reproduces the exact trigger — a long-label locale across the 565–640px band — and asserts the top-bar controls never share pixels. Suite: 2621.

[1.208.1] — 2026-08-16

Fixed — on a phone the top-bar buttons no longer overlap the page.

Fixed

  • v1.208.0 wrapped the top-bar action buttons (Diagnostics, Open Scan, notifications, theme) onto a second row on narrow screens, but the bar kept a fixed height — so the wrapped row spilled out and sat on top of the page heading. The bar now grows to fit its rows and the content flows below it.

Notes

  • The top bar’s fixed height became a min-height, so it expands with its content on any width (desktop is unchanged — one row always fits). A Playwright guard now also checks the top bar does not spill over the page. Suite: 2621.

[1.208.0] — 2026-08-16

Fixed — the app fits a phone screen now: no more sideways scrolling.

Fixed

  • On a narrow screen the whole app used to slide sideways — the top bar, tables, help articles and settings tabs all pushed past the right edge. Now every page fits any width: the top-bar buttons wrap onto a second row, wide tables and code blocks scroll inside their own box, help stacks its table-of-contents above the article, button/tab rows wrap, and long paths or URLs break instead of stretching the page.

Notes

  • Root cause was the classic flex/grid min-width: auto trap plus a couple of unwrapped wide elements; fixed with min-width: 0 on grid items, overflow-wrap on markdown/titles, a scrollable markdown table, and the help grid stacking at the mobile breakpoint. A Playwright guard asserts 0 horizontal overflow at 375 px across the main routes. tests/playwright-smoke.mjs. Suite: 2621.

[1.207.2] — 2026-08-16

Fixed — AI plans and career-orientation profiles no longer render as a raw code dump.

Fixed

  • Some models wrap their whole answer in a markdown … code fence. When that happened, the development plan and the career-orientation profile showed up as a monospace code block instead of a formatted document with headings and lists. The wrapping fence is now removed — only when it wraps the entire answer and is explicitly markdown/md, so a genuine python/js/bare-``` code answer is left untouched.

Notes

  • Handled once in the shared LLM-declutter step (cleanLlmMarkdown), so every AI route benefits, and inner code blocks inside the wrapped answer survive. tests/llm-output.test.mjs (+3). Suite: 2621.

[1.207.1] — 2026-08-16

Fixed — the landing page no longer overflows sideways on small phones.

Fixed

  • On a narrow phone the hero — the headline, the intro line, and the install terminal — could be clipped off the right edge because a long install command and the layout columns would not shrink to the screen. They now fit any width; the install command scrolls inside its own terminal box.

Notes

  • Also hardened a flaky end-to-end smoke check that could fail on a transient resource 404 — it now ignores benign network noise (favicon / connection / failed-resource) like the sibling checks, while still catching real script errors. No application behavior changed. Suite: 2618.

[1.207.0] — 2026-08-15

Added — record an application’s outcome straight from the tracker.

Added

  • Every tracker row gets an Outcome action: pick what happened (rejected, offer received, hired, offer declined, no response, advanced to interview), add an optional note, preview the resulting status, then record it. Recording archives the submitted CV & cover-letter artifacts and syncs the tracker to the canonical state — one deterministic action instead of hand-editing the tracker.

Notes

  • New POST /api/outcome relays the outcome CLI: dryRun:true is a read-only preview (matches the row, reports the resulting state, writes nothing); a real call records it. Write-safety: the outcome type is whitelisted to the known set and every text field is control-char-rejected before the shell-out (array args, spawn — no shell). tests/outcome-route.test.mjs. Suite: 2618.

[1.206.0] — 2026-08-15

Documentation — the in-app help guide now covers the five newest features, in all 17 languages.

Added

  • The built-in help guide — and the “Ask the docs” assistant that answers from it — now documents five recently shipped features: Setup doctor (Settings — checks your CV & profile for gaps and leftover example data), Discover ATS boards (Portals — find a company’s careers board automatically), the “still live?” check (Tracker — whether a posting is still open), the “reuse a past CV?” hint (CV Studio — flags when a previously tailored CV already fits a new job), and the Skills log (Analytics — record self-assessment scores). Five new subsections, translated across all 17 languages.

Notes

  • Help-guide structure grows to 31 H2 / 118 H3, parity-gated across every locale. Reference docs refreshed: docs/architecture/API.md documents the five routes behind these features, and the route/version counters in CLAUDE.md and docs/sdd/CONVENTIONS.md are current (36 route modules). Suite: 2610.

[1.205.0] — 2026-08-15

Added — a Skills log to record practice-test / assessment results.

Added

  • A new Skills log (Analytics → Skills log) lets you record a skills self-assessment — company, platform, skill, score %, and an optional note — appended to data/assessments.tsv, with a newest-first list of past entries. Zero-token, deterministic; the parent CLI owns the file format.

Notes

  • New GET /api/assessments (relays assessment-log.mjs’s default JSON list; fail-soft {available:false}) + POST /api/assessments (explicit write: fields passed as array args to assessment-log.mjs add, so the parent owns the TSV). Write-safety: every text field is rejected if it contains a control character (a TAB would break a column, a newline would inject a row) → 400 before any write; score/threshold whitelisted to 0–100; lengths bounded. tests/assessments-route.test.mjs. Suite: 2610.

[1.204.0] — 2026-08-15

Added — a “Setup doctor” panel in Settings that flags an incomplete or example-data CV/profile.

Added

  • Settings → Setup doctor now runs a zero-token check of your cv.md and config/profile.yml and lists any blocking issues (missing files/fields) and warnings (leftover example/placeholder data, hardcoded metrics) — so you catch an incomplete setup before it weakens your scans and tailoring. Read-only; re-run with one click.

Notes

  • New read-only GET /api/cv-sync-check relays the parent’s cv-sync-check.mjs, which prints human text + an exit code (no --json); the route does light structured parsing of its stable ERROR: / WARN: lines into {ok, errors[], warnings[]} — the banner, not the exit code, decides success. Fail-soft {available:false} on standalone installs. tests/cv-sync-check-route.test.mjs. Suite: 2602.

[1.203.0] — 2026-08-15

Added — a “reuse a past CV?” hint in CV Studio.

Added

  • When you open a saved job description in CV Studio, the app now compares it against your other saved JDs (deterministic word overlap, zero tokens) and tells you whether the closest match is similar enough to reuse that tailored CV, reuse it with edits, or tailor a fresh one — so you don’t regenerate from scratch for a role you’ve already targeted.

Notes

  • New read-only GET /api/jds/:name/reuse relays the parent’s jd-similarity.mjs (Jaccard overlap + a seniority guard; JSON {decision, score, reason}) once per prior JD (fan-out capped at 25, best match wins); fail-soft {available:false} when the script or prior JDs are absent. tests/jd-similarity-reuse-route.test.mjs. Suite: 2594.

[1.202.0] — 2026-08-15

Added — discover a company’s ATS job board from #/portals and start tracking it.

Added

  • On #/portals, type a company name and the app probes Greenhouse, Ashby, and Lever for its public job board — zero LLM, zero browser — and shows the boards that exist and currently list ≥1 job. One click adds a chosen board to the companies your scanner watches. All probing is read-only; the write to portals.yml happens only when you click Add.

Notes

  • New server/lib/discover-ats.mjs (fixed-host, charset-validated slug probe through the DNS-pinned safeGet, ≤12 probes/request, cross-origin redirect → not resolved) + POST /api/portals/discover (read-only) and POST /api/portals/track (explicit write: withFileLock + surgical text splice + re-parse guard + atomic rename; only known ATS hosts, idempotent). Reuses the scanner’s own adapter registry to confirm each board and count jobs. i18n ×17. tests/discover-ats-resolver.test.mjs + tests/discover-ats-route.test.mjs. Suite: 2588.

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