// What changed here

Site changelog

This site argues that the team publishes its own record rather than describing it. It would be a poor advertisement for that if the site itself changed quietly — so this is every commit that touched it, newest first, in the words it was committed with.

Nothing here is rewritten for presentation. A changelog edited to read well is a different document from a record of what happened, and the second one is the useful one.

  1. 2026-09-1710c44e3

    Hide the fulcrumaxe-cloud batch from the discussions queue

    D#2605-2613 (minus closed D#2608) are worked in ~/fulcrumaxe-cloud with no remote or PRs, so withhold them from /discussions.html and /queue.xml via an explicit number blocklist in fetchQueue().

  2. 2026-09-160ff0165

    Describe what adopters actually get, not what this checkout does

    /start.html said to open the fulcrumaxe checkout rather than your own repo, and described the morning ritual as restoring HEAD and self-healing. Both describe the engine's own 807-line scripts/start-the-day.sh. Anyone who installed the documented way -- /coldstart, which populates their repo -- opens their own repo and runs loop-bootstrap's 301-line copy, which has no self-heal block, no HEAD restore and has never started the dashboard.

    /safety.html and the safety guide said a target repo gets only .autonomous-team/ and the labels. That is true of scripts/coldstart.sh alone; /coldstart also installs CLAUDE.md, the roles, the commands, backend/, scripts/ and hooks/.

    Both pages now describe each route, and say plainly that the two copies of start-the-day have diverged, that it is filed as D#2598, and how to start the dashboard until the fix ships. Translations updated in es/pt/de/zh.

  3. 2026-09-15be648eb

    Keep placeholders and run-on headings out of search answers

    The answer box quoted "The cost record isn't available right now." -- a page's loading copy, captured from the HTML before its script runs -- and then a stat placeholder run straight into the next heading.

    - search.js skips sentences in a page's status vocabulary and fragments that start with a dash, and splits on block boundaries as well as sentence ends. - build-search.py keeps a line break where each block element ends, after collapsing the source's own hard-wrapped newlines. Ranking matches single words and is unchanged; check-search passes.

  4. 2026-09-1568087ee

    Nav that fits every language, search answers, feedback, and the step 7 pages

    - Nav: collapse to the menu button whenever the links would wrap, measured in site.js, instead of one breakpoint that wrapped every translation. - /proposals.html now says three designs throughout. - Search: question words are dropped before matching, the closest sentence the site already says is shown above the results, and a labelled "Search Google instead" link sits below them. - Footer "Was this page useful?" on every page, one anonymous event, translated. - /questions.html + /api/questions: ask in the public Q&A on GitHub via a pre-filled link; nothing is stored here. - /fit.html: repo fit check in the browser against GitHub's public API. - /compare.html: sourced comparison with Devin, Copilot coding agent, Codex and Cursor, checked 2026-09-15. - /guides.html and four problem-first guides, every claim linked to its source. - Follow a queue item: /api/follow (one confirmation per address per day, confirm on POST) and an hourly /api/follow-notify cron that mails a closure once, marked before sending. - Homepage sections reordered by priority in all four languages, with a "Check your repo first" button.

  5. 2026-09-157ae154b

    Add /roadmap.html and put it in the nav in place of Discussions

    One page for what comes next: the live queue with its counts, the three designs for rebuilding the team, the six platform requests, and the ways to ask for or sponsor something. The existing pages stay where they are and the hub links to each, so no URL changes. Discussions stays in the footer.

    Five low-traffic pages (proposals, the three designs, platform requests) shared no nav entry between them; this gives them one without adding to a nav that already wraps.

    Registered with the sitemap, llms.txt and the tour's skip list, "Roadmap" translated in the nav chrome for es/pt/de/zh, and the homepage's search count corrected to 65 pages in English and all four translations.

  6. 2026-09-15bf7e930

    Confirm blog subscriptions by email before sending any posts

    Signing up now stores the address unconfirmed and sends one welcome email with a confirmation link; the digest goes only to confirmed addresses.

    - One confirmation per address per day, claimed with a conditional UPDATE, so the form cannot be used to mail a stranger repeatedly. A failed send hands the claim back. - /api/confirm: GET serves a self-submitting page and only POST confirms, so mail scanners that fetch every link cannot consent for somebody. - An old confirm link cannot re-subscribe an address that unsubscribed. - The confirm and unsubscribe pages share lib/subscription-page.js. - The send-digest dry run reports how many addresses await confirmation.

    Also documents click tracking and the tagline test in FULCRUMAXE-SITE.md, with the vercel metrics queries that read them.

  7. 2026-09-15d763cd2

    Test three homepage taglines, point Try it at /try.html, fix two phone-width bugs

    Taglines: the control sentence plus "review" and "max" (runs on the Claude Max subscription you already have). Each visitor is assigned one, kept in localStorage, and it is emitted as a Vercel flag value so page views and button clicks can be split by tagline. The control stays in the HTML for crawlers and no-script readers. All four translations carry the variants.

    The hero subtitle now says "your own Claude subscription or API key" rather than "your own API budget", matching /start.html and the README.

    Phone width: blog post contents lists ended every entry in a stray "#" copied from the heading anchor, and a .stats grid with an unfilled row (three stats in two columns on /discussions.html) painted the empty cell solid border-colour.

  8. 2026-09-152b7daf2

    Count button clicks, and put analytics back on /ship.html and /history.html

    Vercel Web Analytics custom events for the deliberate actions on every page: buttons, nav links, the install-command copy button, the launch badges, and successful blog and early-access signups. Each event carries the two properties Pro allows, `target` and `section`, derived from the existing markup so no translated page goes stale and new buttons are counted untagged. Body-text links are not tracked.

    /ship.html and /history.html never loaded the analytics scripts and reported zero page views. build-boot.py now owns both Vercel scripts on every page, so that cannot drift again.

  9. 2026-09-15d68fe08

    Keep /ship.html up when GitHub's GraphQL API times out

    /api/ship-stats asked for 100 PRs with up to 30 comment bodies each, a response of over a megabyte. GitHub began answering it with 504s (or a truncated body), and /ship.html showed "Could not read the repo right now". /api/git-metrics had the same query shape and the same failures.

    - Fetch comment bodies separately: pullRequestComments() in lib/github.js runs aliased pullRequest(number:) lookups, 25 per query, in parallel. Strict, so a PR never silently loses its comments. - Save each good response in Turso (lib/snapshot.js) and serve it, marked stale with its original fetchedAt, when GitHub fails. - stale-while-revalidate raised to a day on /api/ship-stats. - status-log records x-fx-upstream-status, so a snapshot-served outage is still logged as one.

    Output diffed against production before deploy: identical.

  10. 2026-09-1325f059d

    Language picker in the nav, and publish the 2026-09-12 log

    The language switcher was a row of links in the footer, which meant a reader had to scroll past the whole argument to find out the argument existed in their language. It is now a select in the nav bar, beside the theme switch and outside `.nav-links`, so it survives the mobile collapse instead of hiding behind the hamburger.

    It is still filled at runtime from /i18n.json rather than baked into the HTML, because which languages have a given page differs per page and a picker that offers a 404 is worse than no picker. The page's hreflang tags are what a crawler reads, so the control only has to work for a reader.

    Three things it needed that were not obvious:

    - It shows the two-letter tag, not the autonym. A native <select> displays the selected option's own text when closed and there is no way to show one string closed and another open, so the longest autonym would have decided the width of the whole bar. The full name is on each option's title, and the control's accessible name is translated like every other chrome label.

    - Arrow keys browse the list instead of navigating. With a closed <select> focused, an arrow changes the value AND fires `change`, so navigating on every change would have left the keyboard unable to reach past the first option.

    - The nav bar did not have room. Adding 73px to a bar with 99px of slack put every width between the 1150px collapse and ~1310px into a sideways scroll. The collapse moves to 1260px, measured rather than guessed.

    Two things this surfaced that were already broken:

    - `.nav-inner` was capped at 76rem, which is narrower than its own contents at every viewport, so nav labels wrapped inside their own slots -- "How it ships" ran down three lines on a 1920px screen. The cap is now 94rem, the labels are nowrap, and the row wraps between items instead. check-render now fails on a nav label that breaks mid-phrase; nothing could see it before, because a wrapped label overflows nothing and its contrast is fine.

    - sync-nav.mjs never walked posts/. The five published posts were each missing nine footer links, the shortcuts button and the version line.

    - check-render.mjs slept 220ms after Page.navigate instead of waiting for the page. On roughly one render in a hundred there was no document.body yet and the audit threw on a different random page each run. It waits on a condition now, and freezes transitions rather than sleeping past them -- which is also 30 seconds faster over 198 renders.

    - publish-post.sh rebuilt every page's chrome and then never rebuilt the 52 translated pages generated from them, so a publish ended on a failed "translations" check and a command the operator had to know by hand. Same hole the provenance step was in before it was filled.

    Also publishes the 2026-09-12 daily log, "Two researchers who never made a tool call".

    Verified: bash tools/run-checks.sh with GITHUB_TOKEN set — all 44 checks pass, including redaction and merge gates. Each new assertion was falsified first: dropping the pre-selection, never revealing the picker, and skipping the label translation each fail the browser test by name, and restoring the old nav geometry fails the new wrapping check.

  11. 2026-09-128d08bb4

    i18n: finish all four languages — 52 translated pages, verified

    Completes the translation of the site's own argument into Spanish, Portuguese (BR), German and Simplified Chinese: 13 pages × 4 languages. This commit adds the last of them (api, day, faq, start, telemetry, the-ratchet, verify) and rebuilds every page that links to them, since the link rewriter only prefixes a path when that translation exists.

    Never translated: anything read live from GitHub. Nine pages are evidence rather than argument — PR titles, review comments, commit messages — and a translated quote is no longer the quote. Those stay English and say why.

    Preserved verbatim inside the translations: element ids, data-sku / data-sponsor / data-copy attributes, prices, merge-gate label strings, file and directory names, and shell commands including the /path/to/your/repo placeholders.

    Two corrections found while verifying:

    - index.html claimed "62 pages, searched in your browser" against an index of 63. Fixed in English and in all four catalogues.

    - check-nojs.py counted a whole Chinese paragraph as one word, because split() needs spaces and Chinese has none. /zh/watch.html scored 15 against the English page's 65 for the same prose. Each ideograph now counts as a word; falsified by stubbing the page down to two characters, which still fails.

    Verified: bash tools/run-checks.sh — all 42 checks pass, including test-i18n.mjs driving real Chrome over all 52 pages (each declares its language, is labelled machine-translated, leaves the English canonical in place, and is one click from the original) and check-links.py resolving 7029 internal links across 119 pages.

  12. 2026-09-12750c632

    Translate six pages into four languages (24 of 52)

    index, safety, watch, lifecycle, cost and try, each in Spanish, Portuguese, German and Simplified Chinese.

    Identifiers stay English throughout, because they are identifiers rather than prose: the gate labels (code-review-passed and the rest), git worktree, the file and directory names, and Discussion where it means the GitHub feature. A reader who goes to check one of those has to find the same string.

    Every element id is preserved, since the page scripts address them — the terminal on /try.html, the calculator on /cost.html and the stepper on /lifecycle.html all work in four more languages without a line of JavaScript changing.

  13. 2026-09-1204fff5b

    Translate the landing page, and make the checks see the translations at all

    es/index.html — the second of thirteen Spanish pages.

    **And the bug that finding it exposed: the translated pages were invisible to every check on this site.** Not links, not accessibility, not weight, not no-script. sitepages.py walks three directories and /es/ was not one, so nothing looked.

    What was hiding there: **thirty broken links.** The link rewriter prefixed every path in TRANSLATE with the language code, but TRANSLATE is the list of pages that will be translated eventually — with two of thirteen done, ten of those links pointed at pages that do not exist. Every one a 404, shipped silently, on the two pages a Spanish reader would land on first.

    It is the same rule the language switcher got right an hour earlier and I did not carry across: offering a translation that 404s is worse than offering fewer. Links are now prefixed only for pages that actually exist in that language, worked out in a first pass before anything renders.

    sitepages.py gained a `translated` flag rather than adding /es/ to PAGE_DIRS, because the builders would fight over those files: sync-nav.mjs would rewrite the translated chrome back to English, build-og.py would generate 52 share cards, build-meta.py would claim canonical tags that build-i18n.py has already set correctly. The four read-only checks opt in; the builders stay out. The comment in sitepages.py says which and why.

    Verified by planting a broken link in /es/ and watching check-links.py name it — which it could not have done an hour ago.

  14. 2026-09-12b7f5372

    Link /try.html from the section that describes it, and translate the chrome

    **The landing page's "Try it" section never linked /try.html.** Zero links inside <main>; the two occurrences were nav and footer chrome. The section literally reads "Prefer to read the plan first?" and then describes the dry run — which is exactly what that page lets you do in a browser. Same miss on /start.html and /faq.html, both of which describe --dry-run to somebody who has not installed anything yet.

    That is the third time in two days: the telemetry aggregate was computed and rendered nowhere, the keyboard shortcuts were built and advertised nowhere, and now the page that lets you try it was unreachable from the three places that describe trying it. The mechanism keeps getting finished while the way in does not.

    A sweep for the same shape across every page turned up mostly noise — the changelog matches everything because it is git history — but one genuine false positive is worth recording: the landing page mentions "telemetry" and does not link /telemetry.html, and it SHOULD not. That is the team's own telemetry store feeding /history.html, a different subject from the opt-in adopter channel. Linking it would have been wrong, and only reading the sentence showed that.

    Also: the nav and footer on translated pages, which you asked about as i18next.

    The honest split is that it is the right shape for part of this and the wrong tool for the rest. i18next swaps strings at RUNTIME, so a search engine hitting /es/safety.html would see English that JavaScript later replaces — and real URLs per language is most of the point. It is ~40 KB against a per-page budget of 80 that /try.html already spends 45 of. And it solves key-to-string lookup, while the prose here is 9,000 words of argument.

    But the chrome IS a key/string catalogue: measured, 8 English strings in the nav and 22 in the footer of the Spanish page. So that part takes the idea without the library — 35 labels in four languages, applied at build time so the HTML still ships translated. Down to 2 English strings, both loanwords that are the same in Spanish.

    The swap is anchored inside <nav> and <footer> and on tag boundaries rather than done as a find-and-replace: "Safety" is a nav label and also an ordinary word in the prose. And re-measuring rather than trusting the fix caught one miss — the shortcuts button reads "<kbd>?</kbd> Keyboard shortcuts", so its label is not plain text between the tags and the first pattern skipped it, leaving the one control that teaches a keyboard convention as the only English in a translated footer.

    tools/check-i18n-chrome.py connects sync-nav.mjs to the catalogue, which nothing did: a new nav item would have appeared in English on four translated sites. Fails both on a missing translation and on an entry for a label that no longer exists.

  15. 2026-09-12435e47b

    Make the site readable in other languages, starting with Spanish

    The machinery, the switcher, the checks, and one page translated end to end. Fifty-one translations remain; this is the slice that proves the shape.

    **The site divides cleanly, and the line is principled.** Measured in a browser: /telemetry.html is 98% the site's own prose, / is 96%, /try.html 87%. But /discussions.html is 98% live GitHub data, /metrics.html 88%, /ship.html 79%. So the argument gets translated and the evidence does not — a Discussion title or a reviewer's comment is a quotation, and a translated quotation is no longer the thing you can go and check. Each language says that in its own words rather than leaving the gap unexplained.

    **The failure mode is not a bad translation. It is a good translation of a sentence that has since changed.** A reader in Spanish would have no way to know the English had moved. So every translation records the SHA-256 of the English <main> it was made from, and --check fails by name on drift. Proven by editing one word in the English safety page — "ships with a ceiling of 1" to "of 3" — and watching es/safety.html fail immediately. That is exactly the sentence where a silent divergence would matter.

    Whitespace-normalised, so reflowing a paragraph does not invalidate a translation that is still accurate.

    Each translated page says up front that it is machine-translated, links the English, and leaves the English canonical — a machine translation must never be what a search engine is told is the authority. Label strings stay untranslated: code-review-passed is an identifier, not a phrase.

    **The language switcher was built with the first translation, not after it.** The keyboard shortcuts shipped findable only by somebody who already knew they existed and had to be fixed in a follow-up; a translation nobody can find is the same mistake more expensively. It reads a manifest written by the tool that builds the pages, so it can only offer a page that actually exists — it stays hidden on /cost.html, which has no translation yet.

    Two bugs caught before they shipped, both in the escape hatch rather than the prose:

    The "see the original" link pointed at itself. The rewriter prefixes every translatable path with the language code, and it rewrote the one link whose entire job was to reach the authoritative English. The notice is inserted after the rewriting now, and tools/test-i18n.mjs clicks it and asserts where it lands.

    The notice rendered outside the <section>, full-bleed against the viewport with none of the page's padding — reading as a browser warning bar rather than as part of the page.

    And the cache-header guard added earlier today caught i18n.json on its first run, which is what it was for.

  16. 2026-09-12e8eed2f

    Start the team with the ritual, then the loop

    Step 4 of /start.html told a new adopter to run `/loop` and stopped there. That matches README.md:73, which is why I checked before changing it — but the README treats `scripts/start-the-day.sh` as an optional aside ("worth reading if you want the full morning-ritual version of this"), and they are not alternatives. They are sequential.

    The page now says both, in order, and says what the first one buys: the ritual pulls main, restores HEAD if a crashed agent left the checkout on somebody else's branch, verifies the state directory survived the night, runs the sweeps, and prints the plan yesterday's session wrote at wind-down. Run `/loop` alone and the team works, but every day starts from nothing.

    The engine README still describes the old shape and now disagrees with the product's own guidance. That needs a PR against the engine rather than an edit here, and is not done.

    Two flaky checks fixed while getting the suite green, both mine, both the same mistake — a fixed sleep standing in for a condition:

    check-search.mjs typed a query and waited 700ms. The body index is a separate file fetched on the FIRST keystroke, so a query that lands before it arrives is scored on titles and headings alone and ranks differently. Passed standalone, failed in the suite, purely on machine load.

    check-degrade.mjs proved its own blocking was real by recovering /day.html — two GitHub round trips, which timed out after an afternoon of API traffic and failed a check about degradation for reasons unrelated to degradation. It recovers /try.html now, whose endpoint is local.

  17. 2026-09-12c043eab

    Let people find the keyboard shortcuts

    I shipped shortcuts whose only documentation was a card you open by pressing '?', which you could only know about by pressing '?'. A feature findable only by someone who already knows it exists is not a feature, it is a secret.

    The footer now carries "? Keyboard shortcuts" on every page. A real button, not a line of prose, so it works for somebody who would never press a key to find out — and showing the key beside it is how they learn it.

    It ships hidden and is revealed by keys.js, for the same reason the version span next to it does: if the script fails to load, no button appears promising something that will not work.

    The search page also says where '/' works, since that is the shortcut somebody is most likely to want and least likely to guess.

    Three assertions added: the footer says the shortcuts exist, it names the key rather than just describing it, and clicking it opens the same card the key does — because two paths to one feature is two things that can break.

  18. 2026-09-12de55f2c

    Cache the build artifacts, and stop the pages jumping under the reader

    **Nine files were served with max-age=0, must-revalidate** — a round trip on every request for something that only changes on deploy. search-index.json is 22 KB and fetched on every visit to /search. tools/check-headers.py now refuses a served data file nobody has decided about, and found three more while I was adding rules for the six I knew: robots.txt, sitemap.xml and llms.txt, all fetched repeatedly by crawlers.

    **And the pages moved under you while you read them.** Measured, in a browser, on production:

    /lifecycle.html CLS 0.774, LCP 4328ms /ship.html CLS 0.812 /try.html CLS 0.102

    Google's "needs improvement" threshold is 0.1. These were eight times it.

    The cause was the same everywhere: a one-line "Reading the record…" being replaced by most of a screen, and empty paragraphs filled by script growing from nothing to two lines — each pushing everything below it down the page several seconds in, exactly when somebody has started reading.

    Two attempts at this were wrong before one was right, and both wrong ones came from guessing instead of measuring. A floor on .lc-stage did nothing, because #lc-app is `hidden` until the data lands and an element not in the layout reserves nothing. Only after asking the browser which nodes actually moved — section.page-section and footer.footer, one shift of 0.809 at 4489ms — was the fix obvious: the LOADING state has to reserve what replaces it, and /lifecycle.html now renders its shell immediately rather than hiding the page until the API answers.

    /lifecycle.html is down to 0.094 and /try.html to 0.053.

    **tools/check-timing.mjs is deliberately NOT in run-checks.sh.** Its CLS figure for /ship.html is 0.809 in the suite and 0.004 when the same page is measured on its own with the same observer — repeatably, both ways. One of them is wrong and I have not worked out which. A check that disagrees with itself teaches you to ignore it, and an ignored check is worse than an absent one, so this ships as a tool you run and read. The header says so.

    The one honest finding it leaves standing is /lifecycle.html's LCP. Its endpoint makes two sequential GraphQL round trips, the second aliasing twelve discussion lookups against a private repo, and no amount of layout work in the page fixes that. That is endpoint work, not CSS.

  19. 2026-09-127d36614

    Say what generated each page, and check the structure a screen reader uses

    **A private URL nearly went onto thirty-seven pages.** The provenance line below started out linking each tool to github.com/johnproblems/formalhosting-support — this site's own repository, which is private. That would have disclosed the path and handed every reader a 404. Caught before it was committed; nothing was pushed or deployed. The lesson is unglamorous: confirm a URL is public before putting it on a public page, rather than assuming it because you can read it.

    check-links.py now refuses any link into that repo or the private engine plane. It needed its own guard because the external-link sweep in check-live.sh would NOT have caught this: that collapses to one URL per host to avoid hammering anyone, so a broken github.com link hides behind whichever github.com link it happens to pick. The two checks are not substitutes and the comment says so.

    **Generated pages now say what generated them.** Thirty-seven of them — every role page, every post, the changelog, the roster, the telemetry schema, the endpoint reference — are written by a tool from a source, and a reader had no way to tell those apart from somebody typing. On a site whose whole argument is that you can check it, that distinction is the point. The tool is named, not linked, and a hand-written page carrying the line has it stripped: a page falsely claiming to be generated is the one thing this must never do.

    **Two real accessibility defects**, found by checking the structure rather than the colour. /roles.html and /watch.html both jumped h1 to h3 with nothing between, which reads to a screen reader as a section that has gone missing. Both were JS-rendered card titles. check-a11y-structure.mjs covers heading order, a single h1, landmarks, a skip link that points at something real, alt text that is not just a filename, and controls with no accessible name.

    And a build-order trap worth recording: build-provenance.py stamps its line after build-role-pages.py generates, so a byte comparison reported all twenty-six role pages stale forever. The generator now ignores the block another tool owns — the same fix its canonical-tag comment already describes for build-meta.py, hit a second time by a second tool. Verified it still catches a genuinely tampered page.

  20. 2026-09-12fc84b4f

    Remove the character rain, and add the shortcuts a 66-page site needs

    You said you had never seen the character rain working. You were right, and here is why: the canvas painted at full alpha, but the element sat at opacity .08 underneath a scanline overlay, so the most any of it ever reached the screen was an alpha of 20 out of 255. Measured in a browser, not guessed — litPercent 100, maxAlphaInCanvas 255, effectiveMaxAlpha 20.4.

    So it ran a full-screen requestAnimationFrame loop on all 66 pages to deliver something nobody could see. Gone: 1,510 bytes of JavaScript, a canvas element from 67 pages, a CSS rule, a dead entry in the launch gate's allowlist pointing at a file that no longer exists, and an orphaned rule in locked.css for an element locked.html never had. The scanline was the half of that effect actually doing the work, and it stays.

    Two features that a site this size should have had already:

    Keyboard shortcuts. `/` searches from anywhere — focusing the box if the page already has one rather than navigating away from what you were reading — `t` takes the tour, `g h` goes home, `?` lists them. Nothing fires while focus is in a field, which is the difference between a shortcut and a bug.

    A link on every heading. Several pages here run to a couple of thousand words and the only way to point somebody at a section was "scroll down to the bit about worktrees". Ids come from the heading text, so a pasted URL says #what-it-can-actually-do-to-your-code rather than #h7, and a collision gets a suffix instead of two links quietly meaning the same place.

    tools/build-boot.py now owns every script each page loads, head and body. The reason is that an injection matching `<script src="x.js">` silently skipped the two pages written `<script src="x.js" defer>` — twice in one afternoon, once removing the rain and once adding these. Hand-copied markup across 66 pages drifts; this fails a check instead.

    Four rounds of this went into chasing my own test rather than the site:

    el.focus() does not trigger :focus-visible in Chrome, so a programmatic focus reports no ring on anything styled that way. That is the second time today.

    /ship.html renders its headings from the API, so their anchors exist with zero size and Chrome correctly refuses to tab to something nobody can see. Tabbing "from the top" was testing the nav, not the feature.

    And the one that cost the most: `transition: opacity .15s` means getComputedStyle straight after Tab returns the mid-transition value. It looked exactly like a CSS rule losing a specificity fight, and I went looking for one that did not exist. The link was always visible; the test was measuring it halfway through fading in.

    The CSS is hardened anyway — :focus alongside :focus-visible, because a link that is invisible while it holds focus is a bug whichever pseudo-class the browser picked.

  21. 2026-09-12efd389e

    Wait for the document before reading localStorage in the theme test

    The test touched localStorage immediately after drive() started the first navigation, which meant reading it on about:blank — SecurityError, and a theme assertion that failed for a reason unrelated to themes.

    It passed locally every time because the harness answers in milliseconds, and failed on the first run against production. A race that only one of your two environments can lose is a race you will keep losing at the worst moment.

    Confirmed the deployed boot script was byte-identical to local before touching anything, rather than assuming the failure was real.

  22. 2026-09-12cfe164e

    Follow the reader's machine, and check the paths nobody looks at

    Ten more, and the defects behind them.

    **The site never read prefers-color-scheme.** It has had a light theme since launch and offered it only to people who went looking for the switch, so a light-mode desktop opened a black page. Worse, theme.js called apply() on init and saved unconditionally — merely LOADING a page wrote "terminal" to localStorage as though the reader had chosen it, and from then on the system preference could never apply. Visiting once silently opted you out of your own machine's setting. Nothing was wrong with either function alone, which is why no unit test would have found it.

    An explicit choice still wins in both directions, and the site now follows a mid-visit system change for readers who have not expressed one.

    tools/build-boot.py owns everything that runs before first paint — the theme and the tour loader — instead of both being hand-copied into 66 heads.

    **Nothing checked what a page does when its API is down.** Eleven pages render from a live endpoint, and the failure path only runs when GitHub is throttling or a deploy half-landed: exactly the moments when a page saying nothing is most confusing. check-degrade.mjs blocks every /api/* call at the network layer — harsher than a 500, because fetch() rejects rather than resolving !res.ok — and asserts each page says something a reader can act on. All eight pass; removing one catch block fails it.

    **Eleven external hosts, never checked.** check-links.py reads the tree, so it listed them and stopped. Now check-live.sh fetches them. The first version checked host ROOTS and reported api.producthunt.com as dead — its root 404s while the widget URL the site actually uses is fine. Checking a host is not checking a link, so check-links.py emits one real URL per host instead.

    **The telemetry aggregate was computed and never shown.** The endpoint has returned it since it was written. Asking people to opt into something and then never publishing the result is the shape of a request nobody should grant. Totals across installs only; a per-install breakdown would turn anonymous counters back into identifiable ones, and the endpoint offers no way to ask.

    **Animation is not a matter of taste for everyone.** A full-screen character rain, a typewriter terminal and a blinking cursor all claimed to honour prefers-reduced-motion and none had been confirmed in a browser. check-motion.mjs samples the canvas twice and asserts it is identical — then asserts it DOES move without the setting, because otherwise the first half proves nothing.

    The same pass checks focus visibility in all three themes, and its first draft blamed the site for its own method: el.focus() does not trigger :focus-visible in Chrome, so every element styled that way reported no ring. Driven by real Tab presses it passes, and removing the outline rule fails it.

    **Search was never checked for ranking, only for freshness.** An index containing the right page is useless if the right page is eighth. Two of my eight expectations were wrong rather than the site being wrong — /anatomy.html genuinely carries a literal "Merge gates" heading — but one was a real gap: this site's titles are sentences ("What it costs to run"), so a one-word query matched them only as a substring and tied with everything else containing the letters. "cost" returned the cost-analyst role above the cost page. The URL slug is now a scoring signal.

    Also: /telemetry.html carries a copy-pasteable curl for filing a report, and the 404's suggestions no longer predate five of the site's most substantive pages.

  23. 2026-09-1214038c9

    Add a guided tour, and stop reporting throttling as a broken check

    Thirty-six top-level pages is past the point where a nav bar is a map. The tour is a route through sixteen of them in the order the argument actually goes: what this claims, watch it happen, check the evidence, read the limits, then install.

    Every stop is a REAL page load, not a modal with a screenshot in it. That is the whole design constraint — a tour of a site about checking things should put you on the actual page with the actual live data and get out of the way. So the bar is docked to the bottom rather than floating over the middle, because a modal that covers what it is describing is a tour of itself.

    Each step says what to LOOK AT rather than what the page is about. A tour that reads out the heading you can already see is a tour nobody finishes.

    **The failure mode for a feature like this is not being wrong — it is going quietly out of date.** So every top-level page must appear in the route or in the skip list WITH THE REASON it is off it, and the build refuses otherwise. Proven by adding a page and watching it fail by name. Twenty pages are skipped on purpose: forms, feeds, a search box, an essay reached from elsewhere.

    The loader is a ~200 byte inline stub, not a script tag. Most visitors never take the tour, and fetching 5 KB of tour code on all 66 pages to serve the few who do is a cost paid by everyone.

    sessionStorage is the memory that makes wandering recoverable: follow a link out of the route and the bar comes back where you left it. Finishing strips the parameter, so a copied URL is the page rather than somebody else's position in a tour.

    tools/test-tour.mjs walks all sixteen stops in a real browser, asserts each lands on the right path carrying the right position, that finishing cleans up, and that leaving the route and coming back resumes.

    Two things found while doing it.

    A secondary rate limit was reporting as a bare 403 with a stack trace — indistinguishable from a token missing a scope, while the primary quota read 5000/5000 the whole time because secondary limits are about burst rate, not volume. That sends somebody looking for a permissions bug that does not exist. There is a RateLimited type now, and the two GitHub-backed checks skip loudly with the wait time instead of crashing. Verified against four synthetic responses: both rate-limit shapes classify as RateLimited, and a genuine permissions 403 still fails as loudly as before.

    And /anatomy.html failed the contrast check on a run that had passed before, because the page picks its pull request live and this time picked one that had actually been sent back — rendering an amber "Sent back" label at 2.16:1 on a light ground. Four hardcoded ambers were bypassing a --warn token that already carries a light-theme value. Worth noting for later: a contrast sweep over live-data pages does not see every state on any one run, so passing once is not the same as always passing.

  24. 2026-09-12fb52693

    Publish /api.html — the endpoint reference the registry was always for

    I said the endpoint work was "live coverage plus a real API reference" and then shipped only the coverage. endpoints.json has existed since, with nothing on the site reading it. This is the half that was owed.

    Generated from the same registry the live check drives, so the page cannot list an endpoint that does not exist or miss one that does — and --check now fails when the page and the registry disagree, not just when the registry is stale. A reference maintained alongside the handlers is a reference that drifts from them, which is how 27 endpoints came to be undocumented in the first place.

    Grouped by what each group is safe to do rather than alphabetically, because that is the distinction a reader needs first: 18 open (a GET with no side effects, called by the live check every run), 3 authenticated (refuses without a secret, and the check asserts the refusal), 7 that write (changes state or costs money, and nothing automated touches them). Open endpoints link to themselves; the other two groups deliberately do not.

  25. 2026-09-12a8a9c03

    Give /api/inbox its own secret instead of borrowing the cron one

    Putting the inbox behind CRON_SECRET made "read what came in" and "mail the entire subscriber list right now" the same privilege. Those are not the same act and should not share a key.

    CRON_SECRET is also the wrong shape for this. Vercel injects it into cron invocations by itself, so the right state for it is unretrievable and never typed by a person — which is exactly how it is set on this project, and why there was no way to call the inbox by hand. A key a platform guards and a key you use at a terminal are different keys, and the fix is the second key, not a more readable first one.

    INBOX_SECRET now, and an unset one answers 503 naming the variable and where to set it, rather than a bare failure indistinguishable from the endpoint being broken.

    Verified that neither key opens the other's door: the cron secret is refused by the inbox, the inbox secret is refused by the digest, and each still works on its own endpoint.

  26. 2026-09-12e62c982

    Read the intake back, budget the scripts, and pick your own day

    The rest of the ten, and what each was actually fixing.

    **The intake was write-only.** Three endpoints write to this database and nothing read any of them back: the web form and /api/report ping a webhook if one is configured, and /api/telemetry pings nothing at all. With no webhook set, a report arrived and was seen by nobody. GET /api/inbox reads all three behind CRON_SECRET. Deliberately read-only — no triage state, no delete, no reply, because those would be a second system and the point is to be able to read what came in without opening a database console.

    It shows an install id truncated to eight characters (enough to spot a repeat caller, not enough to paste anywhere useful) and for the web form reports whether a contact address exists rather than what it is. A missing table reads as "nothing yet", because a table is created by its first write and an intake nobody has used yet is not a failure.

    Verified end to end: 401 with no secret and with a wrong one, a real telemetry row read back correctly, and gone from the inbox after DELETE — so the erasure promise on /telemetry.html holds from the operator's side too.

    **The endpoint registry caught the new endpoint by itself.** Adding api/inbox.js failed the build until it was classified, which is exactly the hole it was written to close.

    **A JavaScript budget, separate from the page budget.** The 400 KB first-visit budget is dominated by the stylesheet and generous enough that a page could triple its scripts without approaching it. Script bytes cost parse and execution, not just transfer, and the interactive pages have grown real programs — /try.html is 45 KB across five files. That is fine; 80 would not be, and nothing would have said so.

    **/day.html threw away the list of days.** The endpoint has returned `available` since it was written and the page ignored it, so the only way to reach another day was to guess a date and put it in the query string. There is a picker now, deep-linkable, because "look at this day" is a thing somebody will want to send. The first version silently showed nothing — `available` was never assigned, a replacement that did not match — and the browser test found it within a minute.

    **Both derived pages can go stale invisibly.** /api/day and /api/lifecycle pick automatically from a recent window, and both keep working perfectly while narrating something from six weeks ago: every timestamp is internally consistent and nothing looks wrong. They publish `ageDays` now, check-live.sh fails past 30, and /day.html says so on the page past 14.

    **Search could not find the words these pages teach.** "wind-down" matched nothing; "ceiling" matched two pages, neither of them the one that demonstrates it. The body index is capped at 1400 characters and the tool already warns that bodies are fetched on the first keystroke — so the fix was not to index more but to put the words that matter where a reader meets them first. Each interactive page also gained a plain-prose section saying what it does, which serves anyone reading without JavaScript.

    That rebuild surfaced another stale claim: the landing page said "59 pages, searched in your browser" and the index had 62. build-search.py has warned about that mismatch all along; nobody had run it and read the note.

    **HowTo structured data** for the three walkthrough pages, with breadcrumbs. The steps are written out rather than scraped from headings — they are the page's argument, and a heading scrape would quietly change meaning the next time a heading was reworded.

  27. 2026-09-1277c1844

    Check every endpoint, every theme, and every contrast ratio

    Four things nothing here could see, and the defects they found.

    **23 of 27 endpoints had no live check.** Not carelessness — nothing listed them, so "have we covered them all?" was a question you could only answer by reading a directory and remembering. tools/build-endpoints.py generates that list and refuses to build when an endpoint appears without being classified, or stays classified after being deleted. Both proven by adding a file and removing one. check-live.sh now probes all 18 safe endpoints and asserts the 2 authed ones REFUSE — a 200 there would mean a secret stopped being required, which is worse than an outage.

    That check earned its keep immediately by catching my own mistake: I had classified /api/unlock as authed from its name alone. It reports when the launch gate opens so the countdown has one source of truth. It unlocks nothing. I had not read it.

    **Every screenshot ever taken of this site was the terminal theme.** There are three. tools/check-render.mjs renders all 32 pages in all three, at 390px and 1280px, and computes WCAG contrast from getComputedStyle — what a reader actually sees, after cascade, inheritance and whichever theme block won. First run: 94 findings.

    The terminal was invisible in light mode, at 1.26:1. `.term-pane` paints itself black in every theme while its text took --fg from the active one, which in light is #16201a. Near-black on black. A picture of a terminal is a terminal in every theme, so its tokens are pinned to the pane now rather than inherited.

    --muted failed AA in all three themes — 2.56, 2.33 and 2.72 against the panels it sits on. It labels stats, eyebrows, table headers and the footer: small text, where the bar is 4.5. Raised in each theme to clear it.

    The play button had no determinate contrast at all. It is a coloured glyph on a translucent disc over a poster image, so "the background" is whatever frame is behind it — over a bright one it disappears, in any theme. A translucent overlay does not fix that; it averages with it. The disc is opaque now and carries its own colours, which is the only way the glyph has a ratio.

    The badge page's dark swatch had the terminal's bug in miniature: a fixed #050706 panel whose caption still inherited --fg-dim. The LIGHT swatch has had an explicit override all along — this is the other half of the same idea, missed because nobody had looked at the page in another theme.

    **And one finding was my checker's fault, not the site's.** /status.html builds its activity strip from glyphs at font-size 0 whose background is the visual; the glyph exists only as an accessible name. Scoring their text contrast reported 1:1 across three themes. A check that cries wolf gets switched off, so the check changed rather than the markup: text sized to nothing is not text anybody reads.

    192 renders now pass. Falsified in both directions before being trusted — a planted colour fails the contrast half, a planted width fails the overflow half.

  28. 2026-09-1267d092b

    Add opt-in telemetry and a machine-facing report intake

    Two endpoints and a disclosure page, for the half of the bug/feature idea that was never built: something FULCRUMAXE running on somebody else's machine can talk to.

    POST /api/telemetry anonymous counters, opt-in DELETE /api/telemetry erase everything for one install id GET /api/telemetry the schema and the aggregate, sending nothing POST /api/report one structured bug or feature request from an install GET /api/report what it accepts /telemetry.html the disclosure, generated from the schema

    This collects data from third parties' machines, so the constraints came first and the code is arranged around them.

    **The schema is closed and lives in one file.** lib/telemetry-schema.js is what the endpoint validates against AND what generates the disclosure page, so the page cannot describe a field the server does not accept, or omit one it does. Proven by adding a field to the schema and watching --check reject the page.

    **Anything outside it is dropped before storage and named back in the response** — not truncated, not stored as free text, not logged. A client sending `repo` is told on its first report rather than discovering later that the field never did anything. Verified against the live endpoint with a payload carrying a repo name, a home path, a planted GitHub token and an invented failure kind: all four came back in `rejected`, and the row in the database holds the schema and nothing else.

    **There is no field for free text.** Every value is an integer, a version matching a fixed pattern, or a member of a closed set. The failure half is ten fixed names a client maps its own errors onto, because an error *string* would carry paths, repo names and occasionally credentials.

    **The IP address is never stored and never derived from** — including for rate limiting, which keys on the random install id instead. An IP is the one identifier an HTTP endpoint gets for free, and storing it would make every other precaution here decorative.

    **Erasure needs no account.** DELETE with the id removes every row. Knowing the id is the only claim anybody could make to it, and asking for more would mean collecting more.

    /api/report is separate from the web form on /bugs.html rather than a flag on it, because the form is filled in by a human who chose every word and this is called by software. It REFUSES a summary containing a path, stack trace, diff, email address or anything credential-shaped — refuses, not strips, because stripping leaves a mangled report that looks deliberate. Left alone, the one thing an automated reporter will do is paste the error it just caught.

    A claim on the site had to change. /verify.html said the long record is built from "the team's own telemetry store, which does not leave the machine" — true, and an endpoint that phones home from adopters' installs would have falsified a sentence already published. It now distinguishes this project's own store from adopters' installs and points at the disclosure page. Finding that before shipping rather than after is the entire reason that page exists.

    tools/test-telemetry.mjs throws twelve forbidden things at the schema — repo name, owner, path, branch, Discussion title, error message, stack trace, username, email, token, IP, hostname — and asserts each is rejected BY NAME and absent from what was stored. Plus: a forbidden key smuggled inside `counts`, a hostname used as an install id, an implausible counter (clamped, not believed), and that the published example actually validates. One assertion I wrote collapsed to a single weak check through an operator-precedence slip and would have passed against a served schema missing every field but one; it now compares field-for-field in both directions, and fails on a deliberately drifted schema.

    Filed D#2565 on the engine repo for the client side: the dial (ceiling 1, because there is no level 2 to want), where the install id lives and why not in the repository, when it sends, and the one place in the system where failing silently is correct — nobody's morning should be worse because a website was down. It carries an open question for the panel rather than a decision: whether an agent should ever file a report automatically, or whether that stays operator-invoked.

  29. 2026-09-12822ec36

    Add /day.html — a whole session, from start-the-day to wind-down

    /try.html is provisioning. /lifecycle.html is one change through the machinery. This is the thing both of those are in service of: a working day, driven from your keyboard. Type /start-the-day, read what the morning ritual reports, walk the day an hour at a time, then write tomorrow's plan and take it with you.

    The day it opens on is real and it reads like one: first event 08:49, last 18:29, 37 pull requests opened, 36 merged, 106 review comments of which 20 sent a change back, and 19 new Discussions filed — nearly all of them found by doing other work. Opens and merges interleave because several changes move at once, which is the single most informative thing about how this team works and is invisible in any per-PR view.

    What is real, and what is not, said here as plainly as on the page:

    The events every open, review, merge and filing with its real timestamp, read from the two repositories. Exact. The ritual the sections start-the-day.sh prints and the steps .claude/commands/start-the-day.md sets out, read from the engine by tools/sync-ritual.py. Real, quoted. The ritual OUTPUT reconstructed. /try.html could capture coldstart because --dry-run is echo-only; the morning ritual pulls main, runs eight self-heal fix-ups and writes a plan file. It is not run to build a web page, and a page that mutated the operator's checkout to render itself would be a bad trade however good the transcript. The plan derived from the day's own public record. The team's real plans live in .autonomous-team/, excluded from the public export by owner decision, and this does not republish them or pretend to be one.

    sync-ritual.py fails closed on a ritual step it cannot explain, and proved it on its first run by rejecting section 1b — a working-tree divergence check I had not accounted for. Better to be told than to ship a step the page narrates as if it understood it.

    The wind-down plan is downloadable, and carries the standing mistakes-to-avoid list verbatim from the command file — a team writing down what it got wrong and reading it back every morning is the most persuasive artifact on the site, and it was sitting in a file nobody had published.

    Also here, prompted by the question the page failed to answer:

    /day.html now says where the instructions live, because the interview's generated CLAUDE.project.md looks thin and nothing explained why. Three layers, separate on purpose: the canonical CLAUDE.md carries the Team Lead Operating Protocol and is engine-synced, so every project gets the same one; CLAUDE.project.md is only an overlay with what an interview can honestly know; and each spawned role reads its own brief from backend/spawn_templates/ — a code-reviewer never loads the Team Lead's protocol.

    Refactors, both made because the second page needed what the first had:

    assets/js/term.js — one terminal, used by both pages. Two would drift, and the symptom would be one page feeling broken while the other did not. Porting /try.html onto it broke the file rendering, and its own test caught that on the next run.

    tools/browser.mjs — one CDP harness, used by both tests. Its first frame sent sessionId: null before there was a session to attach to, which CDP rejects outright rather than ignoring. until() now reports on success too: a silent wait is still an assertion, and staying quiet left the run claiming fewer checks than it made.

    tools/test-day.mjs drives the whole session — 14 assertions, including that the page does not pretend to have the private plan file, that real merges and sent-backs appear on the timeline, and that the plan it writes is for the following day. Its first draft failed twice for its own reasons, both fixed in the test rather than worked around in the page: it checked for the plan-file disclaimer before the ritual had printed that far, and it walked the day on a fixed iteration count that ran out before the hours did.

  30. 2026-09-12ba60136

    Rebuild /try.html as something you actually drive, and add /lifecycle.html

    The first /try.html typed at you. You pressed a key and it performed a command — a demo wearing a terminal's clothes, and it taught less than reading the transcript would have. This replaces it.

    You type the command. You answer the coldstart interview — the real one, all 19 core questions with the engine's own prompts, defaults, choices and its own explanation of what each answer changes, read from questions.json. Then the REAL generator runs on what you typed and hands you the two files it produces, to read on the page or download.

    Three of the four things on the page are the engine's, not ours:

    the dry-run output captured from scripts/coldstart.sh --dry-run the questions scripts/coldstart-interview/questions.json, verbatim the files scripts/coldstart-interview/generate.py, run on POST

    Only the margin notes are ours, and the page says so.

    Running somebody else's program on a web server needs an argument, not a shrug. generate.py states that it is a pure function of its inputs; that was checked rather than believed. tools/sync-interview.py vendors it, and REFUSES to vendor a version that imports subprocess or reads os.environ — proven by feeding it a doctored copy, which it rejected by name. All three files are already published in the public mirror, so this vendors a copy to run, not to disclose.

    The showpiece is the safety ceiling, and it is not a diagram. Ask for external-system autonomy at level 5 and the generated config.json comes back at 2, because that dial's ceiling cannot be raised. No prompt, no override flag, no warning to dismiss — the number in the file is simply the smaller one, and the page shows you where it overruled you.

    /lifecycle.html is the other half of a change's life. /anatomy.html shows a pull request end to end, but a pull request is the second half: before any code there is a Discussion, a panel of roles arguing in rounds, and a decision. That has never been on this site — api/discussions.js publishes a 320-character blurb and a comment COUNT, never the comments.

    The two halves live in different repositories and are joined by an exact key: every merged PR body ends "Closes D#<n>", machine-written, present on all 30 recent merges. Anchored on "Closes" specifically — PR #187 cites four Discussions and only one is the thing it was opened to do, so taking the first mention would credit that merge to a Discussion it merely quotes.

    The result reads as one story: filed 12:24, first perspective at +5m, pull request opened at +57m, reviewed, gated, merged at 14:22. One hour fifty-eight from a sentence to a merge, and the Discussion still open because it declared two pull requests and one has landed.

    Found while building it, and fixed:

    A merged PR reporting a FAILED check. "PR mutation evidence" ran twice on #192 — failed, re-run green — and ciSummary counts runs, so a clean merge read as carrying a failure. True of the run, false of the pull request, and /anatomy.html and /ship.html have been saying it too. Added supersession as NEW fields rather than changing the existing counts, so nothing already published shifts underneath those pages. Where no timestamp orders the runs, the FIRST is kept: with no ordering signal there is no evidence anything superseded anything, and the safe direction is to go on reporting the failure rather than to explain it away.

    Google Fonts, blocked since the CSP was written. style-src allows 'self' and tawk.to; every page has been importing Fira Mono from fonts.googleapis.com and having it refused, on every load, for the life of the site. Confirmed against the live headers before touching it. Removed rather than allowlisted — nobody noticed its absence, and adding two Google domains to the policy of a site whose argument is "check everything we claim" buys a font at the cost of a third-party request on every page.

    ?number= on the lifecycle endpoint silently answered about a different pull request when the one asked for had a thin Discussion. A pin is a deliberate act and now wins, flagged as incomplete.

    The interview opened at "[2/20]" — the scenario tab had already answered question one, so a visitor arrived at question two having been asked nothing.

    Checks, each proven to fail before being trusted:

    tools/test-try.mjs drives a whole session in real Chrome over DevTools Protocol — types with real key events rather than setting .value, because the page listens for keydown and an assignment would test a path no visitor can reach. Thirteen assertions: wrong command refused, --dry-run enforced, output redacted, real questions, bad choice rejected, ceiling enforced in the generated file, files downloadable. Breaking the answer path fails four of them.

    The harness now serves vercel.json's real Content-Security-Policy. It had served none, so a page could pass every local check and have half its behaviour blocked in production — which is how the font had hidden. The Google Fonts bug was the first thing it found.

    check-redaction.mjs covers the new surface. Discussion COMMENT bodies have never been republished before; every comment on every discussion a merged PR closes is now scanned. Disabling redaction on that path fails it — on a comment carrying an HTML status marker.

    check-gates.mjs checks PANEL_ROLES against roles.json. A renamed role would otherwise empty the panel silently, with every comment reading "role not stated" and nobody the wiser.

    The harness learned to run Python handlers, driving the module's real BaseHTTPRequestHandler over the same interface Vercel presents rather than a shim that would diverge. Its first draft passed a smoke test and then silently dropped every POST body: node lower-cases header names and the real headers object is case-insensitive, so the handler's own Content-Length read as absent and every answer fell back to its default.

  31. 2026-09-1287a97b7

    Add /try.html — run the coldstart plan before installing anything

    An interactive walkthrough rather than a replay: you type the command (or press any key and watch it type itself), the output arrives a line at a time, and each step explains itself in a note beside the line it belongs to.

    The terminal output is REAL. tools/build-terminal.py runs `scripts/coldstart.sh --dry-run` against the engine checkout and records what came back, for both an existing repo and a new project. A fabricated terminal on a site whose argument is "check everything we claim" would be a strange thing to build, so the page says which half is captured and which half is ours.

    --dry-run was read before being trusted: the branch at coldstart.sh:231 is echo-only and exits zero before any mutation, which is what makes this page possible at all.

    Output goes through lib/redact.js — the site's one implementation — so the home directory in the captured paths cannot reach the page by a route that does not already strip it. Verified: no /home/ survives into terminal.json.

    Annotations live beside the capture and are keyed by a substring of the line they explain. If a step changes upstream the key stops matching and the build fails by name, rather than the page quietly teaching a step that no longer exists.

    Two things fixed while watching it run:

    The notes column auto-scrolled to keep the newest in view, which meant a reader arrived at the end having been shown and then scrolled past every note before it. On a page whose job is teaching, the notes now accumulate and stay put; the terminal is the thing that moves.

    ?run=1 starts it without a click, so the page can be linked as "watch this happen" — and so the running state is testable, which a click-only component is not. ?scenario= picks which.

    Reduced motion renders it instantly rather than typing.

  32. 2026-09-115adf141

    Record the first weekly metrics snapshot

    2026-W37, read from the live /metrics.json: 8.4 minutes median to a first review, 73% of merged PRs clean first time, merges landing in 11 of 24 hours, 26 of 100 PRs carrying a repeated CI check name.

    This is the point of the exercise rather than the numbers themselves. Every figure on /metrics.html samples the last 100 pull requests, which roll over in about a week, so the page can show a value but never a direction. One reading is not a trend and the page says so — it draws nothing until three exist. The weekly cron takes it from here.

  33. 2026-09-112cbc0eb

    Stop the vertical video card dominating the pages it sits on

    22rem looked like a reasonable number and rendered 626px tall, which owns whatever page it lands on. Capping the width is the only lever on a 9:16 card, because the aspect ratio then sets the height — so the number has to be chosen against the rendered result, not the other way round.

    Two pages, two problems, so two treatments:

    On the landing page it is one featured item in a column of sections, so it is simply smaller: 15rem, which is 240x427 rather than 352x626.

    On /watch.html it sits in a grid beside 16:9 cards, where a tall column next to short ones reads as a mistake however well-proportioned it is on its own. It now lies down — poster left at 8.5rem, caption right — so the card is ~240px tall, close to what a 16:9 card is at the same column width, and the row sits level.

    Both grow back to 22rem once PLAYED. TikTok's embed has a 325px minimum width, so a compact facade would otherwise hand the player a box it cannot use; video.js adds `video--playing` on the click that loads the iframe.

    Below 480px the grid card stands back up — too narrow to lie down without the caption becoming a sliver.

  34. 2026-09-11f9d5b52

    Send the digest from Vercel, where the key already lives

    The CLI needed RESEND_API_KEY on the operator's machine, which means copying a production secret onto a laptop to do a routine thing. /api/send-digest runs the send on the deployed site instead, and a daily cron drives it.

    GET is a dry run and POST sends, so opening the URL in a browser cannot mail the list by accident; the cron asks explicitly with ?send=1. It reads the LIVE /feed.json rather than the working tree, so it mails what the site actually published — a send racing a deploy would otherwise link to a page that is not there yet.

    Three guards, each for a specific way this goes wrong:

    The ledger row is written BEFORE the mail goes out, keyed by post URL, so a crash mid-send fails closed. A missed email is recoverable; a duplicate to the whole list is not.

    The first run backfills. An empty ledger makes every post in the archive look unsent, and the daily cron would have walked backwards through it, one a day, to a list that had already read them. Everything but the newest is recorded as `backfilled` — never sent, now permanently ineligible. Caught by reading a dry run that said alreadySent: 0, before this shipped.

    A five-day age cap on the automatic choice, as defence in depth if the ledger is ever lost. An explicit ?post= bypasses it: asking for an old post by name is deliberate, drifting into one is not.

    Rendering moved to lib/digest.js and the CLI now imports it. Two renderers for one email would drift, and the difference would only show up in somebody's inbox.

    Verified against the harness: 401 without the secret, 401 with a wrong one, and a dry run naming the 09-11 post with one recipient. The production ledger is seeded — five older posts backfilled, none sent.

  35. 2026-09-117c0295e

    Add canonicals, test the launch gate, and record the site's own health

    Ten features. Two found gaps that had been there since launch.

    No page had a <link rel="canonical"> and no post declared og:type=article. That matters more than usual here because posts moved to /posts/ and the old paths 301 — a canonical is how a search engine is told which of two resolving URLs is the real one. tools/build-meta.py owns both, and getting it right cost two rounds of failing checks, each worth recording:

    build-meta.py and build-og.py both inserted before <link rel="icon">, so whichever ran last displaced the other and both then reported every page stale forever. They now anchor on different links.

    Generated pages are created AFTER the stampers run, so role pages and the changelog shipped carrying watch.html's canonical. Each generator now rewrites its own, and build-meta finds it already correct — without that the generator and the stamper undo each other on alternate runs.

    test-gate.sh. middleware.js is the one security boundary this site has and nothing tested it; the gate has been open a week, so a regression would have been invisible until the moment it mattered. Nine assertions, including that a WRONG preview cookie does not reach the page. The first version reused one port across both phases, the teardown did not take, and it reported the gate failing to open when it was simply still shut — each phase now gets its own port, removing the shared resource rather than sequencing access to it.

    Endpoint health. The hourly cron now asks this site's own endpoints for an answer and records it beside the activity reading, through SITE_URL so it exercises the real deployment rather than a loopback. An expired token shows up as an endpoint that stopped returning 200 instead of as pages quietly missing their numbers.

    /api/version exposes the deployed commit and the footer shows it — /verify.html explains how to check the site against the repository and quietly assumed a reader knew which revision they had.

    /feed.json beside the RSS, /feeds.html naming all four feeds, a 404 that suggests what you meant (/metrcs.html resolves to /metrics.html at 0.73; /zzzzz.html correctly suggests nothing), search-term highlighting built from DOM nodes rather than innerHTML, and aria-live on the status paragraphs JavaScript replaces.

  36. 2026-09-1128ea5af

    Harden the headers, audit what works without JS, and reject a CSS split

    Ten items, and one of them is a change measured and then thrown away.

    Splitting style.css was the plan. A tool was written, it worked, and it proved no page could end up missing a class it uses — then the measurement killed it. One immutable stylesheet is fetched once and reused across 61 pages; sliced, a reader fetches a new one per page. A one-page visit: 14.8K becomes 8.9K. A five-page visit: 14.8K becomes 44.5K. This site has 2,831 internal links and is built for people who read several pages, so the split saves 6K on a bounce and costs 30K on an engaged visit. The tool is deleted; the note in FULCRUMAXE-SITE.md is the record.

    check-nojs.py measures what a reader with scripting off actually sees, now that half the site draws itself from an endpoint after load. The bar is deliberately low — a page need not WORK without JS, it must say something. One page was under it: the post-checkout receipt, at 39 words. Fixed by giving that page real copy about what to do if the confirmation never resolves, which it needed anyway.

    check-meta.py catches duplicate titles and descriptions — invisible on the page, visible only in a search result, and pages here are stamped out by copying. Duplicates and absences fail; LENGTH IS ADVICE. Eight descriptions sit over the recommended limit and all eight are deliberate. Failing on that is how a check earns a reputation for crying wolf, which is the lesson check-a11y.py taught when its first draft flagged six correct checkboxes.

    A Content-Security-Policy written from what the pages actually load rather than copied from a template, plus frame options, nosniff, referrer and permissions policy. 'unsafe-inline' is in there and stated rather than hidden: the theme switch runs inline before paint. check-live.sh now asserts the headers are really served, since a policy in vercel.json that never reaches a response is a policy nobody has. It also exercises the /posts/ redirects — a redirect nobody tests is one that quietly stops working, and those URLs are in feeds people already subscribe to.

    Reading aids: breadcrumbs (visible from post.js, BreadcrumbList written into the markup by build-jsonld.py, because structured data must not depend on a script a crawler may not run), a citation block, and related posts scored from the search index so there is no second notion of what a post is about.

    "New since your last visit" on the blog, entirely local — the timestamp never leaves the browser, and the visit is recorded after marking so the first load does not consume the marker it was meant to show.

    /metrics.html draws the weekly snapshot log, but only past three readings. Two points are a comparison, not a trend — the same call made over the send-back rate, kept consistent.

    Filed D#2553 on the engine repo for role-stamped review comments, which is what per-role metrics need; it references D#2550 as the shared root cause so triage can merge them.

  37. 2026-09-11a1bae49

    Start the metrics record, show the queue's oldest item, fix lastmod

    Ten things. Two of them are the site admitting something.

    Queue age on /discussions.html. Every figure there describes how fast things close, which only describes the items that did. The oldest open Discussion is 96.6 days against a 4.8-day median — exactly what a page full of fast close times should not be allowed to hide. Buckets rather than a mean, because one very old item would drag an average and conceal that most of the queue is fresh. Ages and counts only; no title crosses over from the private repo.

    Sitemap lastmod was computed from mtime, and mtime is wrong: a fresh clone or a CI checkout sets every file to the moment it was written, so the sitemap had been telling search engines that all 60 pages changed today, every time it ran on a clean machine. It now reads git log, and falls back to mtime with a warning rather than silently.

    The weekly metrics snapshot. Everything on /metrics.html samples the last 100 PRs, so the sample rolls over entirely and nothing there can ever show a trend. tools/snapshot-metrics.py appends one reading a week to history/metrics-log.json, committed back by the workflow. Append-only and one per ISO week, so a missed week stays visibly missed instead of being back-filled with today's numbers.

    /metrics.json — the same payload at a stable path. /verify.html tells people how to check by hand; this lets them check by script.

    security.txt at /.well-known/, with its Expires field watched by check-freshness.py and failing 30 days out. An expired one is worse than none: it tells a researcher the contact is unmaintained. This project asks people to let agents write to their repositories, so the route matters.

    The activity clock is now on the landing page as a sparkline — it was the most arresting thing the site has and it was three clicks deep. Same honesty: empty hours drawn, caption says autonomous, not unattended.

    "/" focuses search from any page, ignored while the caret is in a field so a slash typed into the bug report does not navigate away.

    Heading anchors on posts. The logs get quoted a section at a time and the ids already existed; clicking one copies the full URL rather than only jumping. Hidden until hover or focus — always visible it competes with the heading, hidden from keyboard users it may as well not exist.

  38. 2026-09-11cc04346

    Add /metrics.html, and publish the finding that undercuts the claim

    Ten derived metrics from the 100 most recent merged PRs, on a new page backed by /api/git-metrics. The endpoint keeps `exact` and `heuristic` in separate objects so a page cannot present one as the other by accident.

    The activity clock is the reason this page is worth having. The obvious chart was going to be "look, it merges at 3am" — and the data says otherwise: merges land in 11 of 24 hours, 13 are silent, because the loop runs while the operator's machine is on. Empty hours are drawn rather than skipped, since a chart that dropped them would show a team working constantly, which is the impression the data does not support. The team is autonomous, not unattended, and hiding the gap would be selling the first half of that by concealing the second.

    The rest, all real: 100 of 100 PRs opened by the team's own account; median 8.4 minutes to a first review comment, fastest 24 seconds; cycle time split into waiting, reviewing and merging; 26 of 100 PRs had a repeated CI check name, which is the exact shape the 2026-09-11 log is about; diff-size distribution; files that change together; and a shipping streak.

    Two heuristics kept apart and labelled: first-pass rate and revert detection. The page states outright that first-pass will NOT match /ship.html's sent-back figure and why — different questions over different sets, neither the other's complement.

    Guards inside the derivations, each for a failure it would otherwise have: co-change skips PRs touching more than 12 files, because one sweeping change across 60 manufactures 1,770 meaningless pairs that would dominate the ranking.

    Also: - The feed caps full content to the newest 10 posts and warns past 400 KB. Carrying every body took it from 4 KB to 93 KB at six posts, and every subscriber's reader fetches all of it on every poll. - check-freshness.py watches feed.xml: a blog that quietly stopped is a site presenting old work as current. - The workflow checks a pull request's Vercel preview rather than production, skipping loudly without the URL. Production is checked on the schedule only — at push time the deploy has not happened and it would pass for code that is not live. - style.css is fingerprinted, so it is now served immutable for a year.

  39. 2026-09-115fb7e4b

    Move posts under /posts/, and stop counting the same thing twice

    Five daily logs were sitting at the site root beside faq.html. vercel.json permanently redirects the old flat paths, so every URL already published still resolves — including the one in a feed a subscriber already holds. Moving them does re-notify subscribers, because a feed guid is the post URL; with one subscriber that cost nothing, and at twenty it would have been rude.

    The move is why tools/sitepages.py exists. Six tools each carried their own page glob, so adding a directory meant finding all six and getting all six right. They now share one list — and the first version of it immediately proved the point by letting build-og.py generate 26 role-page cards and overwrite the tags build-role-pages.py had written. page_paths(dirs=...) now exists for that one caller, and the reason is in the docstring.

    check-freshness.py. Several pages publish numbers from committed files and nothing checked their age; a visitor reading "median cost per merge" had no way to know whether it was measured last week or in July, and neither did we. It prefers a declared timestamp, falls back to mtime, and LABELS which it used — mtime is a weaker signal that a fresh checkout resets.

    Full-content feeds. /feed.xml carries each post's body in content:encoded alongside the blurb, so a reader in a feed client gets the article rather than a teaser and a round trip.

    The print stylesheet. These are incident reports people save and paste into their own postmortems, and printing one produced a black page with green text. Print inverts to ink, drops the chrome, keeps headings with their sections, never splits a transcript, and writes out every link destination, since a printed page cannot be clicked.

    Reading time — and the more interesting half of it. post.js counted the DOM, build-jsonld.py counted the markup, and they disagreed by 31 words with neither obviously wrong. The gap was not the problem; having two implementations was. post.js now reads the build's number.

    Stylesheet fingerprinting, which must run last because it rewrites every page. check-links.py now follows in-page anchors too — 118 of them — so a renamed heading cannot silently break a contents entry. check-live.sh joins the weekly workflow, on the schedule only: on a push the deploy has not happened yet and it would pass for code that is not live.

  40. 2026-09-11e027eb3

    Check the deployment and the links, and make the long posts readable

    Six features. Two of them check things nothing was checking.

    check-live.sh asks the LIVE site what it is serving. Every other check here reads the working tree, and none of them can tell you a deploy half-landed, a rewrite is missing, or an endpoint is 502ing on an env var nobody set. Its sharpest assertion diffs the served /feed.xml against the committed one — same file, so a difference means the deploy did not land what was committed. It sleeps between requests, because firing a dozen at once trips Vercel's own bot mitigation and that looks exactly like the site being down.

    check-links.py verifies all 2,663 internal links resolve. Nothing had ever checked one. A renamed page leaves 404s that go unnoticed precisely because the person who renamed it is the least likely to click the old links. Internal only: external links are somebody else's uptime.

    Reading aids on posts, all built from the page and the blog index at runtime so none of it is per-post work:

    - A contents list from the post's own H2s, only past four sections. Heading ids are assigned at runtime, so posts stay plain prose and every section becomes linkable for free. - Previous and next, read from blog.html — already the one list of posts there is, so deriving the order from it keeps that true rather than adding a second list. - A subscribe prompt at the end, which is the one moment a reader has evidence about whether they want more. The form lives on /blog.html, which is where they are least likely to be.

    Organization and SoftwareApplication markup on the landing page. The posts carried Article data; the thing they are about carried none. The offers block says free AND says it spends your own Claude credential — "price: 0" alone would be exactly the half-truth /cost.html exists to avoid.

    Verified against the live site: all twelve pages 200, unknown paths 404, both /roles rewrites resolve, three feeds parse, served feed matches the repo, four endpoints return sane JSON, the badge renders SVG.

Showing the last 40 changes.

This page is generated by tools/build-changelog.py from this repository’s git history. Nothing on it is written by hand.