Changelog

What we shipped.

Page 12 of 13 — completed tasks for 2026-10-23.

Tasks logged

894+

across all rounds

Since

Jan 2026

founding month

Showing

2026-10-23

page 12 of 13

2026-10-23

Completed tasks for this batch, with the status, priority, difficulty, time taken, value score and what changed.
#StatusPriorityDifficultyTimeValueCompleted Task
2534doneP1med45mhigh#2534 fix(search): the search page stopped blanking its own results. `src/components/forms/search-page.svelte` mounted the result rows only when `!isPending`, so every keystroke unmounted the list and left an empty gap under the field until the next search landed - the exact outcome `src/lib/search-staleness.ts` and the component's own comment both say is worse than momentarily stale content. The drift also made the marker unreachable: the condition that unmounted the rows (`!isPending`) is the same comparison `isResultSetStale()` is built on, negated, so `class:opacity-60={resultsAreStale}` could never paint and the helper had no caller that could see it fire. The rows now stay mounted whenever there are rows, and the list carries `aria-busy={isSearching}` so assistive tech defers announcements from the region while the search that will replace it is in flight; the empty busy container is kept for the first search, when there is nothing to keep on screen yet. The todo task assumed the test was stale and the markup current; the other way round was true - `tests/unit/search-busy-indicator.test.ts` had been carrying the contract the helper's docblock describes since the round that introduced `src/lib/search-staleness.ts`, and the contradicting half was the "rows only mount once the request has settled" assertion in `tests/unit/search-staleness.test.ts`, so that file's "keeps a list container mounted while searching" case now pins the new contract (the pending branch is gated on `results.length === 0`, the rows are not gated on the pending flag at all). Mutation-checked both ways: putting `!isPending` back on the rows branch and dropping the `results.length === 0` gate on the pending branch each turn the case red. Verified in Chrome against the dev server: typing one character into a settled "seo" leaves the 20 rows mounted and dims them to 60% for the 150ms debounce window, `aria-busy` flips `true` then `false` on the list while the search is in flight, and the live region still announces once per landed search. The three search suites pass 112/112.
2455doneP2easy10mmed#2455 a11y(header): closed as already satisfied - the mega menu opens when its trigger takes focus. `src/lib/header-panel-toggle.ts` exports `resolveFocusOpenSource()`, and `src/components/chrome/header.astro` binds a `focus` listener to every panel trigger through `bindPanelTrigger()`, so the services mega menu and the six utility dropdowns all open on focus. The reason the panel is open is recorded separately from how it was activated, so a click landing on a focus-opened panel promotes it instead of closing it under the cursor, and a panel closed with Escape does not reopen when the Escape handler returns the focus to its trigger. `tests/unit/header-panel-toggle.test.ts` covers the helper in four describe blocks.
2551doneP2easy20mmed#2551 chore(data): the outcome shape is declared once. `src/data/outcomes.ts` exports the `Outcome` type and the `CaseStudyOutcome` extension, and `src/data/case-studies.ts`, `src/data/services.ts` and `src/data/industries.ts` all import it instead of writing their own `{ before, after, unit }`. The last two call sites that still re-declared the shape inline - both in `src/pages/industries/[slug].astro` - now let the collection's own type flow through, so the guard `tests/unit/outcome-type.test.ts` is green: no data file and no template may declare the shape, and the shared module exports exactly the two types the collections read.
2555doneP2easy10mmed#2555 a11y(contrast): closed as already satisfied - the tool islands clear WCAG AA. The twenty-six islands under `src/components/tools/` painted their secondary text at `text-fg/30` to `/50`, which is between 1.9:1 and 3.1:1 against the light surface where body-sized text needs 4.5:1. The tints are now `text-fg/70` for text, `text-fg/60` for icons - the non-text floor is 3:1 - and `text-fg/30` only on decorative svg strokes, which carry no information. `tests/unit/tool-island-contrast.test.ts` reads the colour tokens out of `src/styles/globals.css`, composites each tint over the surface it is painted on and computes the WCAG ratio, so the guard fails on the arithmetic rather than on a string it happens to be looking for; it sweeps every island in the directory, so a new one is covered the day it is written.
2564doneP2easy25mmed#2564 chore(scripts): the banned-word table is written down once. The list lives in `lib/check-rules-patterns.cjs` and its eight consumers - `scripts/audit-banned.cjs`, `scripts/audit-batch.cjs`, `scripts/audit-case-studies-flesch.cjs`, `scripts/audit-rendered-copy.cjs`, `scripts/check-rules.cjs`, `scripts/lib/flesch-audit.cjs`, `scripts/lib/build-system-prompt.cjs` and `scripts/rule-check.cjs` - all read it from there, so the gate over `src/` and the list the assistant prompt tells the model to avoid cannot disagree. The three documents point at the module instead of repeating it: `AGENTS.md` section 6 carried its own copy of nineteen of the twenty words, and it now names the module and says not to add a second list. The guard also had to read the rule as the spec states it - `specs.md` section 6 writes the list as a sentence, so its last word carried the sentence's full stop and the parser read `elevate.` where the module holds `elevate`; the parser now drops the sentence-final period.
2573doneP1med40mhigh#2573 fix(brand): the brand-token substitution table no longer mints a `dark:` class, and the migration script runs again. `lib/brand-tokens.cjs` held six `dark:` rules - the `dark:text-X-400` half of the emerald, amber and rose families plus the combined light+dark rule that paired them - and the site is light-only (specs.md section 2, AGENTS.md section 21). A migration run over a file holding `dark:text-rose-400` would have written `dark:text-fg/80` back out: the table was the rule's own way of introducing the class the rule forbids. The table also held five warning rules (`text-warning`, `bg-warning`, `border-warning`, `fill-warning`, `stroke-warning`), which are the semantic status utilities the site ships (AGENTS.md section 5), not legacy tokens - and because `\b` matches the hyphen in `bg-warning-bg`, the `bg-warning` rule matched the `-bg` companion too, so a run over the tool islands produced `bg-primary/10-bg`. All eleven rules are gone. The drift check between the shared table and the script's copy now passes: `node scripts/enforce-palette.cjs --dry-run` exits 0 and prints `Would touch 0 files.` - before this change the same command exited 1 with a six-entry `onlyInShared` list and a three-entry `onlyInLocal` list, so the script could not run at all. The check moved out of the script into `lib/brand-tokens.cjs` as `tokenSubstitutionDrift()`, so the guard can call it without running the migration over `src/`; the script now exports its rule table and only migrates when it is the entry point, and it grew a `--dry-run` flag so it can be pointed at the real tree without writing to it. New `tests/unit/enforce-palette.test.ts` (15 cases) pins that the shared table carries no `dark:` rule, that no rule in either table matches a semantic status utility - a probe of every status family and utility shape, not just the handful the tree uses - that the two tables agree in both directions, that the inline-hex rules take their source hexes from `BRAND_FORBIDDEN_HEX` and their destinations from `BRAND_HEX`, that the script imports no name it never uses, and that the script exits 0 over the real tree. Mutation-checked: re-adding one `dark:` rule to the shared table turns three cases red, and re-adding one `bg-warning` rule turns four red; the suite is green again after each revert.
2574doneP2easy10mlow#2574 chore(scripts): `scripts/enforce-palette.cjs` imported `BRAND_HEX` and `BRAND_FORBIDDEN_HEX` and used neither. Both are used now: the script's inline-hex rules take their source hexes from `BRAND_FORBIDDEN_HEX` and their destinations from `BRAND_HEX`, so the file names no colour value of its own - the four destinations it holds are palette tokens, and a forbidden hex added to the shared table without a destination is a hard error rather than a silent miss. The unused `SERP_HEX_OK` and `SERP_FILES` constants went with them: they named three React-era widget files the Astro migration deleted, and nothing read either one. `tests/unit/enforce-palette.test.ts` reads the script's `require` statements and fails on any imported name that appears nowhere else in the file, so the next dead import is caught at the source rather than by a reader noticing.
2577doneP1med50mhigh#2577 fix(origin): the scripts that publish URLs read the published origin. The site publishes one origin and it is written down once - `PUBLIC_SITE_URL`, read through `normalizeSiteOrigin()` in `src/lib/site-origin.ts` - and the canonical links, the Open Graph urls, the sitemap, the RSS feed, the JSON-LD graph and both llms build scripts all read it that way (AGENTS.md section 21). Five scripts did not. `scripts/audit-links.mjs` and `scripts/audit-links-fragments.mjs` named the production host in their default base URL and in the User-Agent they present to every host they probe, `scripts/build-lead-magnets-pdf.mjs` printed a hand-typed host on every page of every lead magnet, `scripts/perf-snapshot.cjs` built its canonical urls from a literal, and `scripts/deep-audit.cjs` stripped a literal prefix off the sitemap entries it read. A staging build therefore audited production, published production urls in its snapshot, and handed the reader a PDF that told them to visit production. The two auditors carried a second, sharper defect: they answered "is this link internal?" with `hostname.endsWith("webgrow24.com")`, a host written down by hand. A run against the dev server therefore classified every link on every page as an outbound link - no link on a locally served page carries the production host - and the suffix test matched `notwebgrow24.com` as internal. It also matched every subdomain as internal, and an internal link is a route the sitemap has to list, so a subdomain link was reported as a missing internal route: a finding about a page that does not exist. New `scripts/lib/site-origin.cjs` resolves the origin, the host and the set of hosts that count as this site, and new `scripts/lib/link-classify.cjs` holds the one href classifier both auditors now import - `scripts/audit-links.mjs` and `scripts/audit-links-fragments.mjs` each carried their own copy, and the sibling contract test carried a third, inlined, so the test asserted against a transcription of the rule rather than the rule. The host rule now folds the `www` alias exactly as `src/lib/internal-links.ts` folds it for the site's own link counting, and the host being crawled counts as internal too. Measured against the dev server: the link audit now reports 425 unique internal routes and 13 unique external urls, where the old suffix rule reported every one of the 438 as external; the fragment audit resolves all 92,172 anchors with zero broken. `scripts/deep-audit.cjs` reads the path off the sitemap url instead of stripping a host, so a staging sitemap no longer produces a route that still carries a host. New `tests/unit/script-site-origin.test.ts` (27 cases) pins the origin resolution, the trailing-slash rule, the host set, the classifier and the five scripts; `scripts/audit-links.test.cjs` now requires the real classifier instead of an inlined copy, and its contract for "internal" changed with the rule - a subdomain is another site now, and a lookalike host never was. The lead-magnet PDFs were rebuilt: a build with `PUBLIC_SITE_URL=https://staging.example.com` produces different bytes from the production build, which is the proof the host moves with the origin.
2580doneP2easy5mlow#2580 test(origin): closed as already satisfied by #2577 - the origin guard now covers the scripts that publish URLs. The guard in `tests/unit/site-origin.test.ts` walked `src/` only, so the llms build scripts and the prompt-builder shim needed cases of their own; the new `tests/unit/script-site-origin.test.ts` adds the other half. It reads the five scripts that publish a URL - the two link auditors, the lead-magnet PDF builder, the perf snapshot and the deep audit - and fails on any of them that writes the site's host down by hand, with the comment stripper refusing to let a doc block that quotes the old literal pass. The five files are named in the test rather than swept, so a new publishing script has to be added to the list deliberately, and the list is pinned to five entries so a deletion cannot quietly narrow the guard.
2565doneP1med45mhigh#2565 fix(tools): the SEO analyzer's Mobile/Desktop toggle now changes the audit. The widget has shipped that toggle since the tool shipped, and the endpoint validated `strategy` and then dropped it on the floor: both buttons produced the same fetch, the same checks and the same score, and the score on screen never said which view produced it. A control that changes nothing is worse than no control, because the reader believes the two numbers are different kinds of number. The strategy now decides three things, and all three live in one new module, `src/lib/seo-analyzer-strategy.ts`, so the endpoint and the island cannot drift apart: the user agent the page is fetched with (a phone asks for the phone markup, and some sites serve a phone its own HTML), the scoring of the viewport rules (a missing viewport tag is critical under the mobile strategy, because Google indexes the mobile render, and an info note under the desktop strategy, because the tag does nothing there), and the label the result carries. A viewport tag that does not ask for `width=device-width` is a new mobile-only warning; the comparison folds the case and the spaces, because `width = device-width` and `width=device-width` mean the same thing. The island now sends the strategy it shows, marks the chosen button with `aria-pressed` inside a group that has an accessible name, drops the score on screen when the strategy changes (that score belonged to the other view) and says so in a live region, and prints "Mobile view" or "Desktop view" next to the audited URL. New `tests/unit/seo-analyzer-strategy.test.ts` (15 cases) pins the rules, new `tests/unit/seo-analyzer-widget-strategy.test.ts` (10 cases) pins the island contract from the source (the repo has no DOM harness, todo #2568), and `tests/unit/seo-analyzer-api.test.ts` grew a describe block (7 cases) that pins the behaviour at the endpoint: the mobile strategy fetches with a phone user agent, the desktop strategy with a desktop one, the response reports the strategy it ran, a missing viewport is critical on mobile and a note on desktop, and the same page scores 75 and 98 under the two strategies. Mutation-checked both ways: pinning the fetch to the mobile user agent turns the desktop-agent case red, and making the viewport rules ignore the strategy turns seven cases red across the two files. Verified in the dev server: `/tools/seo-analyzer` serves the island with the two pressed states and the labelled group, and `POST /api/seo-analyzer` answers with `"strategy":"mobile"` and `"strategy":"desktop"` for the same URL.
2579doneP1med30mmed#2579 docs(specs): the spec now names the prompt builder that is in the tree. The system-prompt section of `specs.md` read "`src/lib/build-system-prompt.ts` still exists and its contracts are locked by `tests/unit/build-system-prompt.test.ts`" - and neither file existed. The TypeScript module and its unit test were deleted in tasks #2157 and #2297, and the brand-voice constant the module read went with them; what remains in the tree is `scripts/lib/build-system-prompt.cjs`, a CommonJS module that reads the contact record, the banned-word list and the site origin from three shared modules. A stale spec is not cosmetic here: `specs.md` is the authority every agent reads before touching the tree (AGENTS.md section 1), so a spec that names a file with no importer sends the next agent to a file that is not there, and a spec that claims a guard "locks" a contract tells them a check exists that does not. The section now names the shim, the `.d.cts` declarations beside it and the three shared modules it reads, says plainly that the earlier TypeScript module and its test were deleted and that nothing imports any of the three, and points at the test that really reads the shim. New `tests/unit/specs-system-prompt-contract.test.ts` (10 cases) pins it: the section is found by its heading rather than an offset, every file path it names exists on disk, it names the shared modules the shim actually reads, and a fixture shows the check fires on a section that names the deleted files.
2587doneP2med30mmed#2587 test(search): the two /search suites can no longer disagree about the template. `tests/unit/search-staleness.test.ts` and `tests/unit/search-busy-indicator.test.ts` both read `src/components/forms/search-page.svelte` from source - the repo has no DOM harness (todo #2568) - and each one held its own private copy of the predicate the result rows are gated on. One said `{#if hasQuery && !isPending && results.length > 0}`. The other said `{#if hasQuery && results.length > 0}`. The component shipped the second spelling, so one suite was red and the other was green, and because the two assertions lived in separate files the contradiction read as two independent facts rather than as a conflict. Task #2534 reconciled the spellings, which fixed the symptom but left the door open: two copies of one contract, each free to drift again on the next edit to either file. New `tests/unit/search-template-contract.test.ts` (8 cases) reads the two suites' own source and closes it. It extracts every `{#if hasQuery ...}` predicate each suite asserts and reads the polarity of the assertion, so a suite that mentions the stale predicate in a negative assertion - which `search-staleness.test.ts` does, deliberately, to prove the component no longer carries it - is not reported as an offender. The guard requires the component to gate the rows on one predicate, requires every suite to pin that same predicate, and requires the two suites to pin an identical set, so a suite that pins a second predicate the other does not is the same disagreement one predicate further on. It also fails when the component carries the pre-#2534 predicate. Mutation-checked on the real file: putting the old predicate back into `tests/unit/search-staleness.test.ts` turns the case red with the offending suite named, and three fixtures cover a stale positive assertion, a drifted predicate and a negative assertion. The suite passes 8/8.
2583doneP2med25mmed#2583 chore(scripts): the palette migration now reaches the templates and the islands. `scripts/enforce-palette.cjs` walked `/\.(tsx?|css|mjs|cjs)$/`, which covered 81 of the 221 files under `src/`; the other 140 - every `.astro` template and every `.svelte` island, the files that hold nearly all the markup - were never migrated, so a legacy token that reached a template stayed there through every run of the script. The read-only half, `scripts/audit-brand-tokens.cjs`, scanned `.astro` and `.svelte` all along, so the two halves of one gate disagreed about what "the tree" means: the auditor could report a finding the fixer could not reach. The scope now comes from `SOURCE_FILE_RE` in `lib/walk.cjs`, the one place the file types are written down, and the auditor reads the same constant, so the fixer can reach everything the auditor finds. `tests/unit/enforce-palette.test.ts` pins it: the two halves share the constant, and every `.astro` and `.svelte` file under `src/` is now in the scanned set. The widened scope immediately found what the narrow one had been hiding: the migration rewrote four files it had never been able to reach - `src/components/ui/bottom-cta.astro` (`hover:bg-white/10` on the navy CTA band), `src/components/ui/industry-grid.svelte` and `src/components/ui/tool-category-filter.svelte` (`bg-white/20` on the selected chip) and `src/pages/index.astro` (`bg-white/10` on the case-study chip) - every one a raw white scrim where the rule asks for the brand background token at the same opacity (`hover:bg-bg/10`, `bg-bg/20`, `bg-bg/10`). `node scripts/enforce-palette.cjs --dry-run` now reports "Would touch 0 files." over the 221 files the gate reads, and the served pages carry the brand tokens: `/` and `/tools` publish `bg-bg/10` and `bg-bg/20` and no `bg-white/` class. The guard was mutation-checked both ways - narrowing `SCAN_INCLUDE` back to the old scope turns four cases red, and dropping `.astro|.svelte` from `SOURCE_FILE_RE` turns three red. Two pre-existing lint failures were fixed in the same pass, so the push gate is green again: `src/pages/tools/index.astro` carried a Biome `format` error and `src/pages/tools/[slug].astro` an `assist/source/organizeImports` error, both left by the #2558 and #2546 rounds; `biome check --write` cleared them and `biome check src/` now reports 225 files, no errors.
2594doneP1med45mhigh#2594 fix(tools): the Core Web Vitals tester no longer keeps the old score after a device change. The island has shipped a Mobile / Desktop toggle since the tool shipped, and the endpoint used it - the phone fetches the page with a phone user agent and its timings are scaled for a slower phone - but the island left the previous numbers standing after the toggle moved. A reader who switched to Desktop kept reading the mobile verdict, the five mobile metrics and the "Mobile-friendly: Yes" line, which is a different measurement of a different render and says nothing about the device the toggle was showing. The device rules now live in one module, `src/lib/core-web-vitals-strategy.ts`: the user agent per device, the timing adjustments per device (1.3x TTFB and 1.4x FCP for a phone, 1 and 1 for a desktop) and the label the interface shows. `src/pages/api/core-web-vitals-tester/index.ts` reads both from it, so the two Chrome user-agent strings it used to inline - the last copy of that rule in the tree - are gone. The island drops the result when the device changes and says so in a live region ("Device changed to Desktop. Run the test again to measure the Desktop view."), a run that finishes after the device moved is dropped rather than shown next to a toggle that no longer describes it, the result now carries the device it was measured on ("Mobile run" / "Desktop run"), and the toggle is a named group with `aria-pressed` on the chosen button, so a screen reader is told which device is selected instead of inferring it from the highlight. The group's label id is scoped to the island instance through `ariaInstanceId("cwv", $props.id())` rather than a document-wide string, which is the rule todo #2503 wants enforced repo-wide. New `tests/unit/core-web-vitals-strategy.test.ts` (9 cases) pins the rules, new `tests/unit/core-web-vitals-device-toggle.test.ts` (19 cases) pins the island and the endpoint contract from the source (the repo has no DOM harness, todo #2568), and three of those cases are mutation controls: dropping the `result = null` from the handler, flipping `stale = true` back to false, or putting a user agent back into the endpoint each turn the guard red. Verified in Chrome against the dev server: a run on `https://example.com` renders "Mobile run - Mobile-friendly: Yes" with the five metrics, and clicking Desktop removes the whole result block and prints the status line, with no stale verdict left behind. The tool's own copy still promises "real-user and lab data side by side" that the widget never shows - filed as todo #2606.
2605doneP2easy20mmed#2605 fix(test): the tool-island contrast sweep now reads a `cn(...)` class list. `tests/unit/tool-island-contrast.test.ts` sweeps every island under `src/components/tools/` for a text tint that fails WCAG AA, and the suite was red on its own control case: "finds a tint inside a `cn(...)` expression" expected `text-fg/50` and got nothing. The extractor's brace matching was off by one - it passed `matchBrace()` the character after the opening `{`, so the depth counter never saw the brace, went negative and reported "no closing brace" instead of the end of the expression. Every class list built with `cn(...)` was therefore invisible to the sweep, which is the shape twenty-six islands carried `text-fg/30` past a guard that read the plain `class="..."` attribute and skipped the conditional one. The pointer now lands on the `{` itself, the control case passes, and the sweep reads both forms. The suite is green at 21/21 with no island left in violation, so the fix closes a hole in the guard rather than opening a new finding.
2593doneP2easy25mmed#2593 audit(tools): the server-side tool endpoints consume every field they validate. The task asked for a sweep of the ten tool endpoints under `src/pages/api/` for a request field they validate and then drop on the floor - the shape task #2565 found in the SEO analyzer, where `strategy` was parsed, defaulted and never used, so the Mobile and Desktop buttons produced the same audit. The sweep covers all eleven endpoints that take a request body: `aio-inspector`, `broken-link-checker`, `canonical-url-checker`, `core-web-vitals-tester`, `heading-order-checker`, `hreflang-checker`, `image-alt-checker`, `keyword-density-checker`, `open-graph-checker`, `seo-analyzer` and `twitter-card-validator`. Nine of them validate one field, `url`, and use it. The two that validate a second field, `strategy`, both use it: the SEO analyzer through `userAgentFor()` and `viewportFindings()` in `src/lib/seo-analyzer-strategy.ts` (task #2565), and the Core Web Vitals tester through `userAgentForDevice()` and `deviceAdjustments()` in `src/lib/core-web-vitals-strategy.ts` (task #2594). No endpoint validates a field it then ignores, so there is nothing to fix. The sweep is what the task asked for and it found none, which is a result: a clean sweep recorded here is worth more than a task left open on the assumption that the sweep is still to be done. The standing half - a guard that keeps every endpoint honest - is filed as todo #2607.
2535doneP1easy15mhigh#2535 fix(audit): the source-paths audit is green on the real tree again. `scripts/audit-source-paths.cjs` exits 1 over `src/`, and its sibling test fails four of its seventeen assertions because of it. The audit's one rule is that inside a comment, backticks mean "a file you can open", and `src/components/ui/service-card.astro` still wrote `card-base.astro` twice in its header comment - the shared card template deleted in task #2491 along with its pair `tool-card.astro`. The comment was stale about the present as well as the past: it claimed the tools grid shared card-base.astro, which stopped being true the moment that template was removed, so the paragraph named a file that no longer exists and described a sharing relationship that no longer held. The paragraph now names card-base.astro without backticks - the audit's own convention for a deletion record - records the task that deleted it, and points at `tools-grid.svelte`, the live file that superseded it. `node scripts/audit-source-paths.cjs` prints "clean. 91 backticked path reference(s) resolved across 259 file(s)" and exits 0; the sibling test passes 17/17 with every mutation fixture still firing, so the audit is still proving it fires rather than merely reporting a clean tree.
2596doneP2med40mmed#2596 test(prompt): the assistant system prompt has a guard. The prompt is the one string the site hands to a model that then speaks to a lead, so a drift in it is not cosmetic, and its contracts did not travel with the code: `src/lib/build-system-prompt.ts` and its unit test were deleted in tasks #2157 and #2297, and the CommonJS shim `scripts/lib/build-system-prompt.cjs` that replaced them has had no test since. What was unpinned was exactly what a drift would break quietly - the append semantics, the purity of the builder, and the rule that the prompt quotes the site's own contact record rather than a copy of it. A prompt that stops quoting the shared record still looks right: it still names a phone number, an email address and an opening-hours line, so a reviewer sees a complete prompt, and only a comparison against `src/lib/locales.ts` can tell "the prompt quotes the record" apart from "the prompt quotes a copy of the record that happens to be right today". New `tests/unit/script-build-system-prompt.test.ts` (23 cases) pins it. The prompt must carry `SITE.phone`, `SITE.email`, `SITE.hours` and `SITE.dateFormat`, and the shim must write none of those four values down by hand - the value assertion alone cannot tell a shared record from a copy that agrees today, so the source is checked too, with the comment stripper refusing to let the doc block that quotes the old inline copy pass. The prompt must name the shared banned-word list in order, read back out of the string the model receives rather than imported, so the assertion is about what the model is actually told. The prompt must state no price, tested with the site's own `PRICE_PATTERNS` from `lib/check-rules-patterns.cjs`, so the gate and the prompt cannot disagree about what a price is. An append block must land after the base prompt separated by one blank line, be added exactly once however often the builder is called, survive byte for byte across fifteen payloads (a lone space, a newline, a trailing tab, leading whitespace, a backslash, a forward slash, a percent sign, an em-dash, a curly quote, a code fence and a 5 KB block), and leave no trailing newline; an empty append must give the base prompt exactly. The builder must be pure: the same input gives the same string, an appended call must not change the next plain call, and the shared records the shim exports must not be mutated by a build. Six controls keep the guard honest - the banned-word extractor reads a list out of a prompt and finds nothing in one that names no rule, the record detector finds a hand-typed value and stays quiet for a source that reads the record, a commented-out literal does not count as a hand-typed value, and the price patterns still fire on a priced sentence. Mutation-checked four ways on the real shim: dropping the blank line between the base prompt and the append turns two cases red, leaking the appended block into the next call through a module-level variable turns two cases red, hand-typing the phone turns the source guard red with `['phone']` named, and replacing the shared banned-word list with a two-word copy turns the order case red. The shim's doc block also cited task #2555, which is the WCAG-contrast task, where the fix that removed its inline contact record was #2556; the citation now names the task that did the work. The origin half of the contract - the shim must name the resolved site host and no other - stays pinned in `tests/unit/site-origin.test.ts`.
2584doneP2easy25mmed#2584 chore(scripts): the brand-token auditor no longer binds a helper it never calls. `scripts/audit-brand-tokens.cjs` destructured `substituteTokens` out of `lib/brand-tokens.cjs` and never called it. The auditor is the read-only half of the brand gate - it reports what it finds and never rewrites a file - so the substitution helper has no call site in it at all. A dead binding reads as a live dependency: a reader checking whether the auditor migrates tokens would find the helper bound and draw the wrong conclusion, and the script's own docblock says both halves "import from `lib/brand-tokens.cjs` so adding a new approved hex here automatically lights up the audit too", which reads as though the auditor runs the substitution table. The import is gone. The check that would have caught it now covers both halves: `tests/unit/enforce-palette.test.ts` already carried an unused-import guard for the fixer script from task #2574, and it now runs over the auditor too. The detector counts whole-word occurrences of each destructured name over the comment-stripped source - `stripComments` from `scripts/lib/source-audit.cjs` - so the binding itself is one occurrence and anything above one is a real use in code; a docblock that names the helper no longer keeps a dead import alive. Two non-vacuity cases keep the check honest: the auditor must bind five names (a parser that stopped matching `require` calls would find no imports and report no offenders, AGENTS.md section 21), and the substitution helper must not be among them. Three mutation fixtures cover the defect itself: a bound-and-uncalled name is reported, a called name is not, and a name that appears only in a comment is still reported. Mutation-checked on the real file: putting `substituteTokens` back into the auditor's destructuring turns three cases red. The suite passes 27/27.
2571doneP1easy15mmed#2571 chore(schedule): the scheduled todo session can no longer re-add a closed number. The session that maintains `todo.md` runs on a five-minute cron, so two runs overlap by design. The prompt now says to re-read `todo.md` immediately before editing it, to delete the finished lines and append the new ones rather than rewrite the file, and to append with the next continuous number only - never a number that already carries a completed row in `changelog.md` or a row in `docs/blocked-owner-actions.md`. The rule was not new; it was stated in the file every agent reads and missing from the prompt that drives the automated one. `AGENTS.md` section 17 now carries it too, beside the write rules, so a human agent and the scheduled session follow one rule. The standing guard was already in place: `tests/unit/todo-changelog-number-hygiene.test.ts` RULE 1 fails the suite when a number is both an open pending entry and a completed changelog row.
2588doneP1easy15mmed#2588 chore(schedule): the scheduled todo session no longer drops a concurrent edit. The defect was observed in this round rather than reasoned about: the session read `todo.md` at the start of the turn, and by the time it came to write, another scheduled run had appended seven tasks and removed four, so the copy in hand was stale by eleven lines. A wholesale write from that copy would have deleted every one of those changes - including task numbers the other session had just allocated, which are permanent and are never reused. The prompt now says to re-read the file immediately before editing and to edit surgically: delete the finished lines, append the new ones, touch nothing else. `AGENTS.md` section 17 carries the same rule for every agent, not only the scheduled one. A stale-copy write is now a rule violation rather than a habit, and the number-hygiene suite fails the build when a wholesale write drops a pending entry without closing it.
2608doneP1med45mhigh#2608 fix(tools): the Core Web Vitals tester prints every unit once. The card rendered "0.18s ms" for the three load timings and "50ms ms" for the interaction timing: the value carried the unit - the endpoint published the load timings as "0.18s" and the interaction timing as "50ms" - and the island printed `metric.unit` again beside it. The tester is the one tool on the site whose whole output is a number, so a number that reads "0.18s ms" is the one defect a reader cannot ignore, and it was on every result the tool produced. The vocabulary now lives in one module, `src/lib/core-web-vitals-metrics.ts`: the value is the bare number and the unit is the unit, so each is printed once. The module also owns the unit each metric is published in (seconds for TTFB, FCP and LCP, milliseconds for INP, no unit for CLS, which is a ratio), the threshold each metric is scored against, and the wording of the line under the number, which now quotes the same measurement the card shows instead of a second rounding of it. The endpoint lost its own `metricStatus`, `metricDescription` and metric array, and the island lost its hand-typed copy of the metric shape and its four copies of the `"good" \| "needs-improvement" \| "poor"` union - both now read the shared type, so the island and the endpoint cannot disagree about what a measurement carries. The card renders the value and the unit side by side with no gap between them: verified in Chrome against the dev server, the DOM is `0.13<span>s</span>`, so the text reads "0.13s", "50ms" and "0.050" where it read "0.13s ms" and "50ms ms" before. New `tests/unit/core-web-vitals-metrics.test.ts` (19 cases) pins it: the value is a bare number, the reading is a number followed by at most one unit, the unit is the right one per metric, the description quotes the reading the card shows, each metric scores against its own band, and the endpoint keeps no metric table of its own. Three non-vacuity cases keep the guard honest - the two readings the card used to print fail the check, a value that carries its own unit fails the bare-number check, and the island check fires when the value and the unit are put back on separate lines. The suite passes 19/19, `biome check` is clean on the three touched files, and `tsc --noEmit` is clean - the last of those needed a one-line fix in `tests/unit/script-build-system-prompt.test.ts` (a `match[1]` read under `noUncheckedIndexedAccess`), which was the only type error left in the tree.
2602doneP1med45mhigh#2602 chore(audit): the brand gate's read-only half now sees what the write-side half rewrites. `scripts/audit-brand-tokens.cjs` reported off-brand hex values and off-brand Tailwind hues, and nothing else, so a raw `bg-white/10` scrim - a class the shared substitution table rewrites to `bg-bg/10` - was reported by nobody. The two halves of one gate answered "is this off-brand?" differently: the fixer migrated the class on sight and the auditor stayed silent, and a gate whose halves disagree is not a gate. Its own docblock excused pure black and pure white as "anti-aliasing artifacts in SVG markup", which is true of a raw hex in a `fill` attribute and false of a Tailwind utility with an opacity modifier. The rule now comes from the table itself: `lib/brand-tokens.cjs` exports `findLegacyTokens()`, which reads `TOKEN_SUBSTITUTIONS` as a detector and reports every legacy class a source string carries, with the class it would become. The auditor calls it for a fourth check and names the class in the finding (`bg-white/10 -> bg-bg/10`), so a reader sees the fix and not only the offence. Overlapping matches are one finding: `hover:bg-white/10` matches the base rule and the hover rule, and the longer match wins, so the report names the whole variant rather than the bare token inside it. The comment handling the three existing checks each carried their own copy of is now one `isInsideComment()` helper, so the answer to "is this documentation?" cannot differ between checks. The tree was not clean: four live `bg-white/N` classes sat in `src/` - `src/components/ui/bottom-cta.astro` (`hover:bg-white/10` on the navy CTA band), `src/components/ui/industry-grid.svelte` and `src/components/ui/tool-category-filter.svelte` (`bg-white/20` on the selected chip) and `src/pages/index.astro` (`bg-white/10` on the case-study chip) - the four files task #2583 had already migrated once, so the migration has been run again and they now carry `bg-bg/N`. `node scripts/enforce-palette.cjs --dry-run` reports "Would touch 0 files." and `node scripts/audit-brand-tokens.cjs --strict` exits 0 over the real tree. New `tests/unit/lib-brand-tokens.test.ts` cases (7) pin the detector: it names the class a rule becomes, reports the hover variant once, finds nothing in a class list that is already on brand, detects exactly what the migration rewrites, and detects every rule the table carries - a rule the sample generator cannot verify fails the case, so a new rule shape has to be understood rather than slipping past the guard. `tests/unit/enforce-palette.test.ts` grew a describe block (4 cases) that runs the real auditor over a temporary tree - never over `src/`, so no fixture is left behind for the next reader - and proves it reports a raw scrim with its file and line, stays quiet over a clean tree, and does not report a legacy class inside a comment. The sibling contract test `scripts/audit-brand-tokens.test.cjs` grew four cases (11 assertions) covering the same ground from the script's side. Mutation-checked on copies in a temp tree, so the shared checkout was never touched: an auditor with the new check removed reports "clean." over a tree that carries a `bg-white/10` class, and a table rule the sample generator cannot verify is reported by the coverage guard. The two vitest files pass 53/53 and the sibling contract test passes 34/34.
2620doneP1med45mhigh#2620 chore(audit): the dead-export audit can now see the Svelte islands. `scripts/audit-lib-exports.cjs` built its caller corpus out of `.ts`, `.tsx`, `.mjs`, `.cjs`, `.js` and `.astro` files, so the 41 Svelte 5 islands under `src/components/` were invisible to it, and an export whose only runtime caller is an island counted as zero-usage. Thirteen exports were in that state - `nextTabIndex` (called from `tool-category-filter.svelte`, `tech-grid.svelte` and `industry-grid.svelte` and from no other source file), `createDebouncedSettle` (`search-page.svelte`, `faq-search.svelte`), `createLatestRequest`, `searchStatusMessage`, `isResultSetStale`, `isSearchPending`, `searchSite`, `highlightMatch`, `POPULAR_PAGES`, `REVIEWS`, `SearchResult`, `prefersReducedMotion` and `onReducedMotionChange` - so the audit filed them under "test-only callers", a bucket that says the export exists only for its test. It would have been worse than mislabelled: the day one of those tests was deleted, the export read as dead and the gate failed on a live helper. The corpus now reads `.svelte` through `SOURCE_EXTENSIONS`, and the target set accepts `.svelte` through `TARGET_EXT_RE`, so `check:components` audits the islands too - 75 files instead of 34, which brings `faq.svelte`'s `FaqItem` export under the audit for the first time. A Svelte island indents the body of its `<script>` block, so the export-declaration matcher tolerates leading whitespace for `.svelte` files only; `.ts` and `.astro` stay matched strictly at column 0, where a loose `^\s*export` would happily match a line of template prose that happens to start with the word "export". The type-versus-runtime split is now decided once, while the file is scanned, and the zero-usage report reads that decision instead of re-reading the line and matching it a second time - the two lists can no longer disagree about what a type export is. The docblock no longer claims the matcher handles `export { A, B }` re-export lists; it never did, and a docblock that says otherwise sends the next reader looking for a check that is not there. `scripts/audit-lib-exports.test.cjs` gains a case that a `.svelte` island is a valid audit target, with an indented island export so the whitespace tolerance is pinned too, and new `tests/unit/audit-lib-exports-svelte-corpus.test.ts` (4 cases) proves the corpus counts an island caller, that an island is a target, and that an island-only export is no longer reported as test-only. The probe identifier in that suite is discovered at run time rather than written down: a literal in the test file would land in the audit's own corpus and make the probe look like it had a caller even with the islands invisible - which is exactly what the first version of the suite did, and the first version passed against a broken script. Mutation-checked both ways: dropping `.svelte` from `SOURCE_EXTENSIONS` turns three of the four cases red plus two sibling assertions, and dropping it from `TARGET_EXT_RE` turns the target case and the same two sibling assertions red. The suite passes 4/4 and the sibling contract test passes 9/9. AGENTS.md section 21 carries the rule, because the defect was not a typo in one script but a habit: an audit that walks the source tree has to walk every extension the tree uses.
2619doneP1med30mhigh#2619 fix(copy): the Core Web Vitals page stops promising a score the tester never produced. `src/data/tools.ts` described the tool as "Get scores for LCP (loading speed), INP (interactivity), CLS (visual stability), FCP (first contentful paint) and TTFB (server response time)" - five scores. The tester publishes no score at all: the endpoint returns five readings, each carrying a status of `good`, `needs-improvement` or `poor`, and the card prints the reading, the unit and the band label ("Good", "Needs work", "Poor"). "Get scores for LCP" asked for a number the reader never got, and the same sentence rode into the page's meta description and out to the search result. The description now says "each rated good, needs work or poor" - the band the card actually shows - and the last FAQ answer no longer claims the tool "covers ... which is everything a page speed test reports", because a page speed test also runs the deeper audits this one does not. New `tests/unit/core-web-vitals-tool-copy.test.ts` (12 cases) pins the copy to the widget: the promise fields may not name a score, the card's three band labels and the absence of a score field in the island's result type are read from source, the endpoint's response object is checked for a `score:` key, and the five metrics the endpoint measures are the five the copy names. Mutation-checked on the real file: putting the old description and the old output note back into `src/data/tools.ts` turns five of the cases red. Verified against the dev server: `/tools/core-web-vitals-tester` serves a 141-character meta description inside the 120-160 band, the string "scores for LCP" is gone from the page, and the FAQPage schema still renders.
2606doneP1med25mhigh#2606 fix(copy): the Core Web Vitals page stops promising field data the tester never fetched. The how-to step said "Review the real-user and lab data" and the output note said "The output shows real-user and lab data side by side, a list of fixes ordered by impact, and the tap-target, viewport and font-size checks that decide mobile friendliness." The endpoint fetches the page once, estimates five readings from the HTML it got back, and returns them with the note "Lab data estimates. For field data, run this URL through Google PageSpeed Insights or Chrome DevTools." Three promises in one sentence were false: there is no real-user dataset in the response, there is no fix list, and there are no tap-target, viewport or font-size checks - the response carries five readings, a pass or fail verdict, a mobile-friendly flag and the note. The copy now says what the reader gets: five readings, each rated good, needs work or poor, plus a pass or fail verdict, and the mobile-friendly line on a mobile run. The FAQ entry that used to explain "Lab data vs real-user data?" now answers the question this page raises - "Are these real-user readings?" - and answers it with "No", naming PageSpeed Insights as the place to get field data. The same guard test pins it: the copy may not pair real-user data with the lab readings, and an FAQ answer may explain real-user data only to deny that this tool shows it.
2617doneP1easy20mhigh#2617 chore(schedule): the scheduled todo session runs once an hour, not every five minutes. The `pending tasks` cron fired at `*/5`, and a round takes 8 to 27 minutes - measured from the last four runs - so a five-minute cadence guaranteed overlap: five sessions in the same checkout at once. The overlap was not theoretical. This round found five busy sessions (11:15, 11:20, 11:25, 11:30 and this one) editing the same tree, and the damage was on disk: six blog `.mdx` files under `src/content/blog/` carried a compiled Vite module (`import {Fragment as _Fragment, jsx as _jsx} from "/node_modules/.vite/deps/astro_jsx-runtime.js"`) instead of their frontmatter, which stopped the dev server from booting at all, and `src/lib/core-web-vitals-metrics.ts` sat unused while its own suite failed - a finished changelog row that outlived the code it described. The cron now fires at `:13` past every hour (`13 * * * *`, Asia/Kolkata), which is longer than the longest measured run, so two runs never share the checkout. The rule that made the overlap survivable - re-read `todo.md` before editing, edit surgically - was already in the prompt (tasks #2571 and #2588) and in `AGENTS.md` section 17; the cadence that made it necessary is fixed at the source. `AGENTS.md` section 17 now records the hourly cadence and why, so the next agent reads one run per hour as the design rather than an accident.
2600doneP1med55mhigh#2600 chore(scripts): the copy gates now read the files the copy lives in. `scripts/check-rules.cjs` walked `.ts|.tsx|.js|.jsx` and `scripts/audit-banned.cjs` walked `.ts|.tsx|.md|.json`, so 176 of the 252 files under `src/` were invisible to both - every `.astro` template (100), every `.svelte` island (41) and every `.mdx` blog post (35), the three file types that carry almost all the published prose. A banned word in a page headline passed the gate that exists to catch it, and a post that quoted a price passed the price gate. Both gates now walk `COPY_FILE_RE` in `lib/walk.cjs`, one constant read by both so the two cannot drift apart again, and `tests/unit/copy-gate-file-scope.test.ts` fails if a copy-bearing file falls out of the walked set. The widened scope found three real findings and four false positives, and each was closed at the source rather than by widening the pattern. "monsoon supply chain disruptions" in `src/content/blog/link-building-for-indian-smbs-2026.mdx` published the banned word "disrupt" and now reads "slowdowns". The two "solutions" in `src/pages/services/[slug].astro` are code comments quoting title suffixes a deleted task removed, so they carry the `@audit-allowlist banned-words` marker - and `scripts/audit-banned.cjs` and `scripts/check-rules.cjs` now honour the same marker, one definition shared with the pricing markers in `lib/check-rules-patterns.cjs`, which also recognises the HTML comment spelling a markdown post can carry (a `//` marker in a post is published as prose). The price half of the gate reads the copy and not the code: a fenced code block in a content post is blanked before the price patterns run, because a shell snippet that prints `$IP` uses a variable and not a currency, and a dollar figure inside a regex replacement string ("$1 $2") is a capture-group backreference. The priced-shape exemptions moved out of `scripts/audit-rendered-copy.cjs` into the one shared table beside the banned-word list, so the two price gates cannot disagree about what counts as a price; the table gained the website-worth calculator's revenue-per-visit benchmark (a figure the tool publishes, not a price the reader is quoted) and the tools.ts score mechanic, and `node scripts/audit-rendered-copy.cjs --all` is green over 117 files where it reported one violation before. The gate reports every match now, not only the first one in a file, so a second occurrence cannot hide behind a suppressed first one. `node scripts/check-rules.cjs` prints "Banned-word hits: 0", "Price-pattern hits: 0" and "Flesch 90 blog gate: PASS" and exits 0 - it exited 1 before this task, on a code comment in `src/lib/header-panel-toggle.ts` that read "start at its far end", which now reads "resume at its far end". New `tests/unit/copy-gate-file-scope.test.ts` (6 cases) pins the scope, `tests/unit/check-rules-patterns.test.ts` grew five cases for the shared table and the marker rules, and both sibling tests grew the widened-scope cases with their fixtures in a temp directory, so a killed test cannot leave a file behind for another audit to read as a finding.
2630doneP1easy25mhigh#2630 fix(metadata): the home page's meta description comes from `buildHomePageDescription()` again. The helper task #2417 added was gone from `src/lib/metadata.ts`, and the home page had gone back to a hand-typed literal: "Marketing websites for Palitana SMBs. Ship in 30 days. Custom apps and SaaS MVPs quoted separately. Fixed scope, senior team, no juniors or offshore handoffs." Two defects in one string. It names Palitana - the town the LLP is registered in - so it asks Google to rank the site for one town while `specs.md` section 2 puts the market at one country with delivery across it, and it is a second copy of the copy, so the helper and the page could drift apart. The guard suite had kept the eight assertions that pin the helper - the length band, the missing town name, the market phrase, the keyword-first lead, the three proof points, the banned-word and price tables, the market parameter and the wiring - so the file could not even load: importing a name the module no longer exports is a module-level error, and none of the 42 cases could run. The helper is back in `src/lib/metadata.ts`, reading the market from `SITE.market` rather than a literal: "Marketing websites for SMBs, startups and MSMEs in India. Ship in 30 days, fixed scope, a senior team, no offshore handoffs. We also build apps and SaaS." - 153 chars, inside the 140-160 band AGENTS section 7 asks for, the primary keyword first, the three proof points the home page actually makes, and the wider service line last so that nothing in the snippet implies a SaaS MVP also ships in 30 days. `src/pages/index.astro` calls it - `description: buildHomePageDescription()` - so the page and the helper cannot drift, and the copy gate scans the string in the module against the one shared banned-word and price table in `lib/check-rules-patterns.cjs`, which it passes with no hit. The guard file had also arrived with CRLF line endings from the edit that added those assertions, and with its imports out of Biome's order; both are fixed, so the file is LF and `biome check` is clean on it. Verified: `tests/unit/metadata.test.ts` passes 42/42, `biome check` is clean on the three touched files, and the dev server serves the home page with the 153-character description above and no town name anywhere in the head.
2641doneP2easy15mmed#2641 chore(shims): the two script helpers now carry their type declarations. `scripts/lib/site-origin.cjs` and `scripts/lib/link-classify.cjs` landed with task #2577 - the origin resolver the publishing scripts read and the one href classifier both link auditors import - and neither came with a `.d.cts` declaration. `tests/unit/scripts-lib-shim-coverage.test.ts` asserts the 1:1 mapping every helper `.cjs` under `scripts/lib/` has a matching `.d.cts`, so the two new modules failed the guard on the commit that introduced them: "scripts/lib/link-classify.cjs is missing the link-classify.d.cts shim". The declaration files now mirror the modules' real exports - `normalizeInternalUrl()` and `classifyHref()` for the classifier, `siteOrigin()`, `siteHost()`, `internalHosts()` and `isInternalHost()` for the origin - and they name no host, so a shim cannot reintroduce the second origin the modules exist to remove. Both follow the house convention: `export declare` for the ambient CommonJS shape, and a docblock that says which module the declarations belong to and why the host is not written down. The shim suite is green: the eight coverage, non-empty, empty-braces and 1:1-mapping files pass 13/13, `node scripts/audit-links.test.cjs` passes every contract assertion, `node scripts/audit-scripts-shim-mutation.test.cjs` passes, and `tests/unit/script-site-origin.test.ts` passes 27/27.

This log reflects completed tasks only. Pending work is tracked in todo.md at the root of this repository.