https://ryanlindsey.me/writing/armature/ --- title: "Agents that deliver across repositories" description: "Armature is a Claude Code plugin that gives an agent a GitHub Projects board to work from: it picks the next item across every repository the board covers, claims it, works it on a branch, and opens a pull request it is not allowed to merge. Why it is shaped that way, and what publishing it cost." publishedAt: "2026-09-12" pillar: agentic-engineering canonical: "https://ryanlindsey.me/writing/armature/" --- **TL;DR.** [armature](https://github.com/ryanlindsey/armature) is a Claude Code plugin that gives an agent a GitHub Projects board to work from, and is a companion to the [superpowers](https://github.com/obra/superpowers) plugin set. It picks the next actionable item across every repository the board covers, claims it before any code is written, works it on a branch, and opens a pull request that it is not permitted to merge. It exists because a platform built from several repositories does not answer "what is next" from inside any one of them, because my businesses require implementation artifacts in a tracker rather than a docs folder, and because my work does not happen in a single sitting. Version 0.3.4, MIT, and it builds itself. ## Two things a repository cannot hold I am an engineering leader who still ships code, and nearly all of it is written by agents. They run asynchronously and I check in on them through the day rather than watch them work. This toolchain is how I run all of that consistently and reliably. Two requirements shape it, and neither of them is interesting on its own. The first is that epics and tickets have to live somewhere real. Not somewhere convenient for me, and not somewhere convenient for the agent: somewhere that counts as a record. A tracker that other people can read, link to, and audit after the fact is part of what running software as a business means to me right now, and a plan that exists only as a file inside the repository it describes is not that. The second is that my work does not happen in one continuous stretch. I start something, I get pulled away, and I come back to it hours or days later. Whatever I build has to survive being put down. That means the state of what is in flight cannot live in my head or in a session transcript. With a single repository, both requirements are easy and you can meet them by hand. They stop being easy the moment the platform is more than one repository. ## What a single checkout cannot see Here is the thing that actually forces the issue. A platform composed of several repositories will routinely need changes in more than one codebase to deliver a single bug fix or feature. The API changes in one repository, the client that consumes it changes in another, and the types they share change in a third. That is not a design failure. It is the normal shape of a system that has been split along sensible lines. The consequence is that "what should I work on next" is not a question any single repository can answer. Nor is "what does this piece of work touch." Standing inside one checkout, an agent can see the code and the git history and nothing else. It cannot see that the item it is about to pick up has a sibling two repositories over, and it cannot see the epic that explains why either of them exists. GitHub already solves the tracking half of this properly, and it is worth being precise about that, because the tempting version of this post is one that invents a deficiency to fix. A Project spans repositories. It can have a default repository set. Issues should be enabled on every repository you work in. None of that is missing, and a post that claimed otherwise would deserve to lose the reader at that sentence. What is missing sits on the agent's side. The board knows where the work is. The agent has no way to read it, no way to choose from it, and no way to record what it has taken. Armature is that layer, and nothing more ambitious than that. In practice it means three tools do the deciding. `board_next` returns the next actionable item along with the reason it won, ordering by epic and then by issue number, and returning a blocked explanation instead when nothing is actionable. `item_get` reads one item and reports the epic it belongs to together with the repository that epic lives in, which is frequently not the repository the agent is currently standing in. `item_claim` moves the item to the board's claimed status and verifies the state on both sides of the write, refusing if something else moved it first. Epic membership is not a convention armature invented. It is GitHub's native sub-issue parent link, read back off the board, so the structure a human sees in the Projects UI and the structure the agent works from are the same structure. ## Where spec-driven development puts its artifacts Armature is built on spec-driven development, and specifically on the version of it that the [superpowers](https://github.com/obra/superpowers) skillset encodes: brainstorm the design, write the spec, turn the spec into a plan, execute the plan with tests first. That loop works, and it is most of why agent output is reviewable at all in my experience. Left alone, it writes its artifacts into the repository. The spec becomes a markdown file, the plan becomes another one, and both end up in a docs directory next to the code. For the agent that is fine. For everyone else it is the wrong place: the plan for a feature that spans three repositories has to be committed to one of them, where it is invisible from the other two and invisible to anyone who is reading the tracker rather than the codebase. Combined with superpowers, armature moves that artifact layer. The specs and the implementation detail become epics and issues on the board, and the repository goes back to holding code. That is the part of this I would defend hardest, because it is the part that makes the loop legitimate rather than merely convenient. The record of what was planned and why lives where a record belongs. I should be exact about how much of that armature does today, because the direction and the shipped behavior are not the same thing. The reading side is complete: armature resolves the board, walks epics and children across repositories, and works items off it. The authoring side is partial. `item_create` will create an issue, add it to the board, and set it to the board's todo status so that `board_next` can return it without a second call, but it cannot parent that issue to an epic, and you have to set that link yourself afterward. A dedicated planning command is designed and not yet shipped. In 0.3.4, armature is very good at working a board that has been populated and only somewhat helpful at populating it. ## What the model costs in plumbing Once work legitimately crosses repositories, three things follow that a single-repository tool never has to think about. Each one is a consequence of the model rather than a complaint about GitHub, and each is a few lines you can read. **A reference has to say which repository it means.** Issue number 278 exists in every repository that has had 278 issues, and they are unrelated. Armature refuses a bare number outright, in [`server/ref.ts`](https://github.com/ryanlindsey/armature/blob/main/server/ref.ts): > "278" is not a work item reference. Issue numbers are not unique across repositories on a board, so a bare number names a different issue in each. Use owner/repo#number, for example acme/web#278. Every reference into and out of the tool surface is qualified, and armature never emits a bare number itself. This is the rule that stops an agent from confidently editing the right issue number in the wrong repository. **Qualified references are correct and tedious**, so a repository may claim a short name for itself in its own `.armature.json`, and other repositories on the board can then write `checkout#278`. Armature reads that file from every repository on the board over GraphQL rather than keeping a central registry. The rule that makes it safe is in [`server/providers/github/aliases.ts`](https://github.com/ryanlindsey/armature/blob/main/server/providers/github/aliases.ts): > Both acme/web and acme/checkout declare the alias "checkout". An alias is a fact a repository states about itself, so exactly one may claim it. Change one .armature.json. **Armature has to find the board at all**, and the answer is a precedence chain in [`server/config.ts`](https://github.com/ryanlindsey/armature/blob/main/server/config.ts): an environment variable, then the repository's own `.armature.json`, then a user-level default, then derivation. Derivation asks GitHub which boards contain this repository and takes the answer only when there is exactly one, which is why most repositories need no configuration file. A repository can legitimately sit on several boards, and when it does, armature stops and names them rather than guessing. Only the repository can say which board governs its work, so that is where the answer goes. ## Claiming, and why a human merges The claim is what makes stopping safe, and it is the piece that answers my second requirement rather than my first. An item is claimed on the board before a line of code is written. The state is on the board, not in the session, so an interrupted run leaves a visible fact behind: this item is in progress, in this repository, and here is the branch. When I come back tomorrow, or when a different session starts, the board is the thing that remembers. That is the whole reason the claim happens first rather than at the end, and it is why `item_claim` verifies state before and after its write instead of assuming the move landed. The other rule is that armature never merges. It opens the pull request, moves the item to the board's review status, reports the link, and stops. There is no flag for this and no configuration option, and the skill that carries the loop states it three times. That was deliberate from the beginning of the public version rather than a lesson learned afterward. It is written into the design spec dated 2026-09-03, the day the repository was created: _"Armature never merges."_ An agent that can both write the code and decide the code is acceptable has removed the only checkpoint that was doing any work. I am willing to let an agent choose what to work on next. I am not willing to let it decide what lands. ## What publishing it cost Armature is the open-source and mature iteration of a private, local set of skills I had been running for several months, which tied spec-driven development, subagent-driven workflows, and Projects-with-epics-and-children together for my own work. Armature runs every day, delivering production features for [Pixelsonly Racing](https://ryanlindsey.me/work/silent-failure). The public repository is days old. The practice is not, and the repository's creation date is a publication date rather than the age of the idea. The difference between those two things is most of the engineering. A private set of skills can hardcode my board and my repositories, because I am the only person who will ever run it and I know the answers. A published plugin meets personal repositories and organization repositories, boards it has never seen, and repositories that sit on more than one of them. The three mechanisms above are what that cost. So is the release history, which is a short run of fixes with names like _start the server before resolving config_ and _name the classic token in the missing-credential message_: extraction friction surfacing the moment the thing met a repository its author had not been thinking about. The clearest artifact of the conversion is in the repository, in the design spec, and I would rather point at it than describe it. It contains a table titled "What the prior command loses," which walks line by line through what the private version encoded in prose and sorts each line into deleted or survived. The pattern the table exposes is that everything encoding API behavior was deleted and became a typed tool, and everything encoding policy survived into the skill. The spec's own summary of it: > Roughly 68 lines become 20. What survives is judgment, which does not rot. That is the argument for the shape of the whole plugin in one sentence, and I did not find it by theorizing. I found it by counting what was left. ## What you can check Armature builds armature. The repository is public, its own `.armature.json` is committed at the root and points at the board that governs it, the release history is on GitHub, and every pull request in it was merged by a human because the tool that opened them cannot merge. The design spec, the skill, and every error message quoted above are readable in the source. If you want to know whether the loop described here is the loop that actually runs, that is the place to check, and it will answer. One last thing is worth ending on. Armature's design spec names its governing failure rule as **fail loud, never partial**, and attributes it directly to an incident on the Pixelsonly Racing platform: a workflow that discarded hundreds of driver sessions while every run stayed green. Anything that could return a partially correct answer raises instead. There is deliberately no fallback to raw `gh` commands when the tool surface is unavailable, because degrading to prose would route every write back through exactly the traps the design removes, and would make the plugin safest when it works and most dangerous when it breaks. Two tools, built for unrelated reasons, where I drew the same line in the same place: build the capability, then constrain it on purpose. That is not a preference I would have claimed about myself before I noticed I had done it twice. --- https://ryanlindsey.me/writing/agent-native-site/ --- title: "An agent-native personal site on Cloudflare" description: "Two Workers, a Model Context Protocol server, a resume in four formats, and 791 tests. What this site is built from, and the five findings that cost the most to learn, four of which are versions of one sentence: a green check is not evidence." publishedAt: "2026-09-11" pillar: building-in-the-open series: name: "Building in the open" order: 1 canonical: "https://ryanlindsey.me/writing/agent-native-site/" --- **TL;DR.** This site is two Cloudflare Workers. One serves the pages, the resume in four formats, the chat endpoint, a queue consumer and two crons; the other serves a Model Context Protocol server with eight public tools, three resources and a scoped-token tier behind it. It was built in seven days, nearly all of the code written by agents, and the source is [public](https://github.com/ryanlindsey/ryanlindsey.me). This post is the build log. The first half is what is actually running. The second half is what it cost to learn, and most of that is versions of one sentence: a green check is not evidence. ## The dry run passed and the Worker could not start On the first day of this build, `wrangler deploy --dry-run` reported success on a Worker that workerd would refuse to boot. The reason is specific and it is worth having. An assets-only Worker cannot hold an `ASSETS` binding. With zero on-demand routes, Astro emits no server bundle, and a Worker that binds assets with no script is not a thing that starts. The dry run did not catch it because a dry run resolves the adapter's redirected configuration, and that configuration has already stripped the binding. It was answering a question about a file that would never be deployed. What caught it was a smoke harness that reads the real configuration and boots the real runtime. Nothing clever, and I want to be flat about that. The harness was not a sophisticated test. It was the only thing in the loop looking at the artifact instead of at a description of the artifact. That is the first day, and it turned out to be the whole build. A fair objection before any of this counts for anything: this is a personal site. It serves a resume, some writing and a protocol endpoint, and on most days nobody is looking. Findings from a system under no load are cheap, and I am not going to pretend otherwise. What I would say for them is that almost none of these are load problems. Most are checks that were green about the wrong thing, and a check can be wrong about the wrong thing at any traffic level. The low stakes are also why I found them at all, because I had time to go looking. The one finding below that did need traffic to surface took 140 requests to produce, which is not much traffic either. ## What is actually running here Two Workers, and the split is not cosmetic. `ryanlindsey-me` serves the site: static assets, the on-demand routes, a queue consumer, and two daily crons, one that refreshes the resume PDF at 05:17 UTC and one that runs the transcript retention sweep at 05:47. `ryanlindsey-me-mcp` serves the protocol endpoint, the chat backend, the fit engine, the model judge, a rate limiter Durable Object, and its own cron at 05:32 that refreshes the retrieval corpus. They exist as two Workers because of one binding. Workers AI has no local emulator and never will; the Vite plugin's own table names it as permanently remote. Once the site gained its first on-demand route it gained a server bundle, and `astro build` began instantiating the site Worker's bindings at build time. An `ai` binding in the site's configuration therefore makes a plain build open a remote proxy session and demand an API token. The check suite in CI is designed to hold no Cloudflare credential at all, so that build can never run there. Moving `ai` and `vectorize` onto the Worker that actually queries the corpus fixed it, and it was the better architecture anyway: the Worker that reads the index became the Worker that fills it. The two Workers name each other. The site's `MCP` binding forwards protocol, fit and chat work; the protocol Worker's `SITE` binding reads published documents back. That cycle is legal because a service binding resolves when it is called rather than when the Worker is defined. Its practical consequence is that any test harness booting one Worker has to declare the other, or workerd will not start. Everything else is ordinary. Pages prerender by default and individual routes opt out. D1 holds the audit trail, KV holds configuration and caches, two R2 buckets hold published and private documents, Queues carries notifications, Analytics Engine carries traffic. **How this site is deployed.** Two Cloudflare Workers. Visitors and agents reach the site Worker at ryanlindsey.me, which serves the pages, posts, case studies and the résumé, prerendered with some routes rendered on demand, plus llms.txt, the markdown variants and the feeds; it hosts the chat page and the form surfaces, and runs the queue consumer that sends the notification email. It calls the MCP Worker at mcp.ryanlindsey.me over a service binding for the MCP endpoint, for chat and for form posts; the MCP Worker calls back the same way to read published documents. The MCP Worker serves the MCP endpoint statelessly, rebuilding the server for each request, alongside the streamed chat inference and the discovery routes; it holds the one Durable Object in this system, which enforces the rate limits, and it is where inference runs: through AI Gateway to Anthropic, and through Workers AI into Vectorize for retrieval. Both Workers share D1 for the audit log, tokens, transcripts and eval runs, KV for caches and flags, R2 for generated PDFs and documents, Analytics Engine for request telemetry, and a queue that carries high-intent events to the email. Analytics Engine and the queue are independent sinks; neither feeds the other. One note for anyone comparing this against the architecture document the site was built from: that document puts the chat and analysis endpoints on the site Worker, and they are on the other one, for the binding reason above. Every figure and diagram published here is generated from the two `wrangler.jsonc` files and the two Worker entry points instead, because printing the plan's version on a page whose premise is that its numbers are checkable would be self-refuting. The one design decision I would defend hardest is the authorization boundary, and it is a boundary rather than a filter. A request is resolved into a grant in exactly one module, on the protocol Worker only. Site routes treat the token as an opaque string and ask over the service binding what it unlocks, because a second implementation that agrees with the first proves nothing and drifts the moment either changes. The private document bucket is not a flag on the public document layer; it is a different bucket with a single reader, and the public layer's environment interface does not name it. A test asserts that at the type level. A public code path cannot leak a private document by forgetting a condition, because it holds no reference to the bucket. Tools on the scoped tier are registered only for a request whose grant already carries the matching scope, so an unauthenticated listing cannot enumerate a name that exists. As of this build: 791 tests across 52 files, run against real Workers inside workerd rather than against mocks, with the site Worker booted from the adapter's build output so the tests exercise the artifact that ships. ## A green check is not evidence This is the spine of the whole week, and I did not arrive at it once. I arrived at it five times, each time in a form that the previous one would not have caught. **Deleting the code left the tests green.** The series navigation had an ordering guarantee. Removing the `.sort()` call that provided it left all six of its tests passing. The guarantee was not being tested; the shape of the output was, and the shape happened to be right for the fixture. **A feature that never executed once, while four of its six tests passed.** Every content route on this site serves markdown, either at a `.md` path or by content negotiation on the `Accept` header. The negotiation code was unreachable. `assets.run_worker_first` defaults to false, so a request matching a prerendered asset is served from the asset store and never arrives at the Worker at all. Four of that feature's six required tests passed anyway, because four of them assert that HTML comes back, and HTML is what you get by default. Only the two that asserted markdown could ever have noticed. The fix is the part worth copying. Rather than adding assertions, I changed what the passing ones were looking at: the HTML branch now sets `Vary: Accept`, a header only the negotiation code writes. Disabling the feature had previously failed 4 of 19 tests. After the change it failed 16. The vacuity was closed by changing the subject. **A tripwire whose patterns matched nothing.** A guard existed to fire if the RSS feed ever shipped unrendered markdown. It carried three patterns, for a heading, a link and a code fence. Measured against real feed output, they matched an empty list. Every item in that feed opened with a YAML frontmatter block, which none of the three patterns describe. A sibling test asserted that the frontmatter was the desired output. So the guard was inert, and the thing it was guarding against had a test certifying it as correct. **A check whose calls never reached the code it guards.** The protocol server has an adversarial check: fire hostile queries at a tool and assert that the response cannot contain a class of language the server is not permitted to produce. It is the highest-stakes assertion in the repository, because every string in that tool surface is read by strangers' agents by design. It passed for days. It had never reached the handler. A sibling test in the same block had drained that tool's rate limit bucket milliseconds earlier, so both hostile calls were refused by the limiter, and the string they were asserting against was the refusal message. A refusal message satisfies the condition trivially. The test's own comment claimed the opposite. It now uses its own client address, and asserts the specific sentence that a rate-limit refusal cannot produce. There is a general rule sitting under that one. A guard that works by enumerating the strings it forbids has to write those strings down, in a file, in a repository, permanently. The check on the server's instruction text inverts that: it asserts the text matches the reviewed version exactly. It carries only sanctioned language, so the guard cannot be the thing that introduces the problem it exists to prevent. **And the fifth, which is mine.** Every local gate passed: clean install, typecheck, lint, build, and the full suite. The pull request opened, both platform builds went green, and GitHub Actions failed on `npm run build`, the one command that had passed on my machine minutes earlier. The reason is the `ai` binding described above. It passed locally only because a developer machine has a wrangler login, and CI deliberately does not. I had verified this exact class of assumption on day 1, by running the whole check sequence against an isolated `HOME` to prove it needed no credentials. I had the technique. I had written it down. I did not apply it to the build. ```sh TMPHOME=$(mktemp -d); HOME="$TMPHOME" npm run build ``` The first four were tests certifying code they did not exercise. This one was a complete local verification suite certifying a build that could not run anywhere but my laptop. **A green check is not evidence, including when the check is the one you ran yourself, carefully, on purpose.** There is a quieter version of the same problem that ran underneath everything for two days. A measurement partway through the week found that `node_modules` did not match `package-lock.json`: the installed wrangler and Astro adapter were each one version behind what the lockfile pinned. CI runs `npm ci`. So every green local run had been made against a dependency tree CI would never install, and the adapter is the package that generates the configuration the test harness boots. Reinstalling and re-running gave identical counts, so the drift had changed no behavior. That is not the point. The point is that the local run is the primary evidence in a repository whose CI cannot run most of these tests, and local evidence gathered against a tree CI will never install is weak evidence whether or not it happens to agree. One more thing belongs in this section, and it is not a check that was green. It is where the wrong things came from in the first place. **The plan was wrong eight times in one day, and that was the normal case.** Every one of those was mine. I wrote a value into a plan and then trusted the plan instead of the artifact it described: a verification gate demanding a build file this adapter version never emits, a draft rule stated backwards, an instruction to add a canonical link that had been on every page since day 2. The sharpest was a task I spent writing a warning that RFC 9309 named groups do not inherit from the wildcard, which then specified the line for the wildcard group only. Stated once, in the one group the crawlers it names would never read. Six of the eight were caught by an implementer asking instead of complying. Nearly all of the code here is written by agents, and that ratio is the argument for working this way rather than a footnote about tooling. The plan being wrong is the expected case. What matters is how fast wrong surfaces, and a question asked before the work starts surfaces it faster than a review at the end does. ## Some questions only production can answer The escalation of all of the above is that verifying against the local runtime is also not enough, because the local runtime is a different system. Three findings landed on the same afternoon, from one scripted round trip run immediately after a deploy whose only job was to fail. **Seven of eight tools failed on the primary endpoint.** The protocol is reachable at two hosts, the apex and a dedicated subdomain. In production the subdomain was perfect and the apex returned 522 on everything that read a document. The apex is a Cloudflare custom domain, and a Worker on a custom domain fetching its own hostname does not resolve outward. It fails. For a request that arrived on the apex, the document read was a fetch to the hostname of the request being served. No test could have expressed that. The harness points the site origin at a local address with both Workers running locally, so the two entry paths are indistinguishable there. The gap exists only in deployed edge routing. The fix was a service binding, which dropped in cleanly because the type it satisfies was already narrow enough to accept either. The regression guard is the sharp part: the smoke suite now reads documents with the site origin pointed at a host that resolves nowhere, so the test can only pass if the read never leaves the runtime. **The rate limiter did not limit anything.** Both origins served 140 requests in one second against a documented sixty-per-minute bucket, with zero refusals, and the audit table had never recorded a single refusal against 310 successes. The binding was present and correctly configured. Cloudflare documents that API as "permissive, eventually consistent, and intentionally designed to not be used as an accurate accounting system," with counters cached on the machine the Worker happens to run on and updated asynchronously. That is a shield in front of a busy origin, and it is not a control. It had been adopted because the plan asked for a rate limit and the platform offers a thing called a rate limiter. It is now a Durable Object: one object per tool and client, a token bucket, single-threaded and strongly consistent. The probing is more useful than the bug. The first two attempts, 65 sequential calls and then 121, both returned zero refusals, and both were inconclusive rather than damning: a burst that straddles a wall-clock minute boundary produces zero refusals against a perfectly healthy fixed-window limiter. Only the parallel burst inside a single second could settle it. The same arithmetic had already produced a flaky test earlier the same day, and two independent investigations were nearly misread in the same way by the same mechanism. **And one tool's success path had never executed anywhere.** The plan said the vector index was locally simulated under the harness. It is not; the binding throws. The right response, and the one taken, was to delete the passing test that had been written for it rather than stub the index or assert the failure, because a green test there would have proven nothing. That was recorded as a risk with a named consequence: the live round trip would be the first execution of that path anywhere, not a confirmation of one. It ran, and it worked, and it was verified by string-searching the returned excerpt against the fetched document rather than by reading it, because a chunk-index mismatch degrades into the document's opening paragraph, which looks entirely plausible to a human skimming. The honest version of this theme is not "test in production." It is that a local simulation is a different system, and the cheapest way to find out which parts differ is a scripted live round trip, run once, immediately after deploy, whose job is to fail. ## A comment is part of the code, and it rots This repository documents why rather than what, and the comments are unusually rich. That richness cost a production outage and put a false sentence into a published document, both in the same week. The worst instance took the primary endpoint down. A comment written on day 3 explained why reading published documents over the site's public origin was safe: the two hostnames are different Workers, so the fetch could not loop back. It was true when it was written. A change the following day added the forward from the site origin, which made it false. Both changes were reviewed thoroughly. They were never reviewed together, and nothing re-read the comment. That is the 522 above. Later in the week a single session produced eleven false claims about the code, in comments, in page copy, in a published risk register and in an architecture diagram. Every one entered the same way: a comment about the code was trusted in place of the code. The sharpest was a comment stating that the chat model is never shown a URL it could cite. It is shown one per source, by a function twenty lines below the comment. That sentence was copied into the published risk register before anyone checked it, so a document whose entire purpose is to state controls precisely enough that a reader can verify them shipped with a verifiably false sentence in it. The transferable rule is narrower than "comments rot," and I think it is the more useful one. **In a repository whose comments are rich and confident, the comments are an untrusted source for any document that claims to be checkable.** Richness reads as authority, and that is exactly what makes them dangerous as a citation. The standing ruling for the code itself is four words: make the claim true, or make it honest. A published metrics tile on this site read "Median response time: 0 ms." That was not a bug. The duration is measured inside the Worker, and Workers pin the clock across synchronous execution, so a request served from memory measures exactly zero and carries no network time at all. The number was right and the label was wrong, and only one of those is what a reader takes away. The tile now says "Median Worker time per request." The best finding of the week came from the same problem running the other way. Writing the risk register is what exposed a real defect in a control. One module wrapped untrusted corpus documents in a hard-coded three-backtick fence, while every other untrusted span in the system computed a fence from the content it was wrapping. CommonMark closes a fenced block at the first fence at least as long as the opener, so a document carrying its own fence run closed the wrapper early and put its remainder outside the boundary, in the position where instructions live, in a prompt whose system text promises that fenced content is data. A published post on this site has fourteen such blocks. This was live. The cause was that the helper had been copied into four modules and the fifth site that needed it got a literal instead; it is now one module and six call sites, with a test proven red against the literal first. It was found by writing the document, not by reading the code. **Having to state a control precisely enough to publish it is itself a test of the control,** and it is cheaper than the test you would otherwise have written. ## The platform's helpful suggestion was wrong Trusting a platform's remediation is a distinct skill from trusting its documentation, and I learned it on day 1. Workers Builds validates the Worker name in the dashboard against the `name` in the wrangler configuration it finds in the configured root directory. Two Workers, one repository root, and only one of them can satisfy that. The mismatch failed the build, and the dashboard offered to fix it by rewriting the site Worker's configuration to carry the protocol Worker's name. Applying that would have pointed the site's configuration at the wrong Worker and broken the site deploy. The remedy was worse than the fault, and it was presented as a one-click fix. The stronger version of the same mistake is mine, not the platform's, and it is the rate limiter above: the plan asked for a rate limit, the platform offers a thing called a rate limiter, and the thing called a rate limiter says in its own documentation that it is not an accounting system. Nobody misled me. I read the name and not the paragraph. A smaller one, worth it for the second-order lesson. A single unsatisfiable peer dependency, a checker pinned below the TypeScript major that had just shipped, failed at install before any check could run. The real problem was not the red pull request. It was that the update configuration grouped every package into one, so one unsatisfiable dependency blocked every dependency update in the repository indefinitely. Left alone, nothing would ever have updated again. The fix ignores exactly one major version of exactly one package, with a comment naming the upstream constraint so a future reader knows when to remove it. The note I kept from that day is the useful part: a standing red check trains you to stop reading them. ## What is deliberately still open A build log that only lists what closed is a brochure. These are open as of publication. - **A schema-rejected tool call is neither audited nor rate limited.** The protocol SDK throws at input validation before the handler runs, so a caller can hammer every tool with invalid arguments without appearing in the audit table or drawing from a bucket. This was identified two days before the audit table started being used as evidence, and handed forward in prose rather than as a task. Two items went forward that way and neither was picked up, which is its own finding: a handoff that is not in the next day's plan is not a handoff. - **Nothing audits `git log`.** The vocabulary scan reads tracked files. Pull request titles were considered, because the release tooling consumes them. Commit message bodies were never in scope, and a message body is a published surface. - **A predeploy guard that refuses a dirty tree.** `wrangler deploy` bundles the working tree, not the committed tree, which is a sharper fact than it looks when a temporary route is what you are holding locally. Proposed, not built. - **An eval suite that publishes an honest fraction.** One probe in eight fails per run, and it is a different probe each run, on unchanged inputs. A failure that moves is a measurement problem rather than a bug, and this repository has now hit that shape three times. The trap is a prompt that grows three paragraphs of increasingly specific instruction with no measured improvement. The deeper version is that a control requiring an invariant output is a poor fit for a stochastic generator, and the right answer is probably to move the check off the model rather than to write more prompt. The measurement that decides it has not been run, so the page publishes the fraction rather than a rounder number. - **A gateway rate limit that reports itself as an authentication failure.** The inference gateway has a wholesale limit separate from the per-gateway setting, which is not published, and exceeding it returns `2018: Invalid User Credentials`. That cost three diagnostic runs chasing a credential problem that did not exist. Raising the gateway's own limit sixfold did not clear it, because it was never the binding constraint. Pacing calls twenty-five seconds apart did. The last one has a second half I am still annoyed about. The remedy Cloudflare's own error message recommends is to add your own provider key. Adding one to this gateway broke all inference: every call failed, and the gateway log asked for a balance top-up while unspent credit sat on the account. Removing the key restored service immediately. Both features are in beta and the reproduction is clean. ## What I now ask of a passing check The pattern I would carry to any build, and it is not about Cloudflare. Almost every finding above is the same shape at a different altitude. A dry run answering for a file that will not deploy. A test asserting the shape of an output rather than the guarantee that produced it. A test suite certifying a feature that never executed. A local verification run certifying a build that only runs locally. A comment describing a system that has since changed underneath it. A published document repeating a comment nobody checked. In every case the check was green, and in every case the green was about something adjacent to the thing I cared about. So the question I now ask of a passing check is not whether it passes. It is what would have to break to turn it red, and whether that is the thing I am actually afraid of. This week it was not, five times over, and one of those was a suite I had run myself and trusted completely. The site's source is [on GitHub](https://github.com/ryanlindsey/ryanlindsey.me), the [AI policy](/ai-policy) states the controls and the risk register beside it, and [the metrics page](/ops) publishes the traffic, the spend and the eval pass rates, including the fraction that is not a whole number yet. --- https://ryanlindsey.me/writing/terminal-setup/ --- title: "A terminal built for driving agents" description: "The macOS terminal I use to keep shipping code as an engineering leader. Ghostty, zsh with Sheldon and Starship, the Rust CLI replacements, and a Claude Code configuration tuned for reviewing agent work you were not present for. Every file copy-pasteable, every opinion marked as one." publishedAt: "2026-09-09" pillar: agentic-engineering canonical: "https://ryanlindsey.me/writing/terminal-setup/" --- **TL;DR.** This is the macOS terminal I use to keep shipping code as an engineering leader: [Ghostty](https://ghostty.org), a light zsh stack (no Oh My Zsh, just [Sheldon](https://sheldon.cli.rs) and [Starship](https://starship.rs)), the usual fast CLI replacements, and a [Claude Code](https://docs.claude.com/en/docs/claude-code/setup) configuration. Every file below is copy-pasteable. Three choices are shaped by reviewing agent work you were not present for; the rest is a good ordinary terminal, and I have marked the places where my opinion should probably lose to yours. ## Why a dotfiles post needs a thesis There are a lot of terminal setup posts and most of them are the same post. Here is what makes this one different, and if it does not apply to you, you should skim the config and ignore the argument. I am an engineering leader who still ships code. Between the day job and my own projects, I do not get long uninterrupted stretches, and anything I build has to survive being put down and picked up several times. Agents are what make that workable, and they change what I need from a terminal. I am usually not watching an agent work. I start something, leave, and come back to a finished run I have to review carefully enough to put my name on. Three of the settings below exist because of that, and all three are about coming back rather than about watching: 1. **A very large scrollback buffer.** The output I need is often an hour old and thousands of lines back, because I was not there when it was produced. A default buffer discarded it long ago. I run roughly 100MB and have never seen it roll over. 2. **Fast splits.** Agent in one pane, tests or logs in the other. Reviewing a run after the fact, what the code now does matters more than the agent's account of how it got there, and I want both on screen without hunting for a window. 3. **Notifications.** The agent tells the operating system when it has finished or needs an answer. This is the one that actually buys time back: a question that would otherwise sit unanswered until I next happened to look gets handled in the gap between two meetings, or at the end of an evening. Everything else here is a fast, quiet, pleasant terminal that happens to also be good for this. If you take nothing else from the post, take the scrollback setting and turn the notifications on. ## Before you start - **macOS on Apple Silicon.** Most of this works elsewhere. The Homebrew prefix (`/opt/homebrew`) and the `macos-*` keys in the Ghostty config are the parts that differ on Intel Macs and on Linux. - **[Homebrew](https://brew.sh) installed.** - **Enough terminal comfort to run a command and edit a text file.** You do not need to know zsh; every file here is given in full and every line that matters has a comment. - **About twenty minutes**, most of which is Homebrew downloading things. A warning worth reading before you paste anything: this replaces your shell configuration. If you already have a `~/.zshrc` you care about, copy it somewhere first. ```sh cp ~/.zshrc ~/.zshrc.backup ``` ## 1. Install everything first Nothing will work until section 3, because almost every tool here needs a line in your shell config to activate. Install first, configure second, and do not panic in between. ```sh # Terminal emulator, plus a Nerd Font. The font is not optional: the prompt # and the file listings use glyphs that a normal font renders as blank boxes. brew install --cask ghostty font-jetbrains-mono-nerd-font # Shell stack brew install sheldon starship atuin zoxide fzf # Faster replacements for the standard tools brew install eza bat ripgrep fd git-delta jq gh tree htop # Node version manager. Section 7 covers the alternatives if you prefer another. brew install fnm ``` Claude Code installs separately. A Homebrew cask and an npm package both exist, but the official installer is what I run, because it drops a self-updating binary into `~/.local/bin` and stays out of your package manager's way. Follow the [setup docs](https://docs.claude.com/en/docs/claude-code/setup) rather than trusting a command pasted into a blog post that may have aged. ## 2. Ghostty Ghostty is GPU-accelerated, native on macOS, and configured with one plain text file. That last property is why it is here: the whole configuration is greppable, diffable, and reviewable in a way that a preferences pane is not. Create `~/.config/ghostty/config`. No file extension. ```ini title="~/.config/ghostty/config" # ---- Appearance ---- theme = light:"Catppuccin Mocha",dark:"Catppuccin Mocha" font-family = "JetBrainsMono Nerd Font" font-size = 14 window-padding-x = 12 window-padding-y = 12 unfocused-split-opacity = 0.7 cursor-style = block cursor-style-blink = true cursor-color = #8F00FF cursor-invert-fg-bg = false adjust-cell-height = 12% # ---- macOS behavior ---- macos-option-as-alt = true macos-titlebar-style = tabs mouse-hide-while-typing = true copy-on-select = clipboard confirm-close-surface = false # ---- Scrollback ---- # Large buffer so a long agent session does not truncate mid-transcript. # The value is in bytes; this is roughly 100MB. Raise it if you tail big logs. scrollback-limit = 104857600 # ---- Shell integration ---- shell-integration = zsh shell-integration-features = cursor,sudo,title # ---- Keybinds: splits ---- # Agent in one pane, tests or logs in the other. keybind = cmd+d=new_split:right keybind = cmd+shift+d=new_split:down keybind = cmd+opt+left=goto_split:left keybind = cmd+opt+right=goto_split:right keybind = cmd+opt+up=goto_split:up keybind = cmd+opt+down=goto_split:down # Shift+Enter works natively in Ghostty, so agents that use it to insert a # newline without submitting need no keybind here. ``` Reload with `Cmd+Shift+,`. Two notes on the theme line. It takes separate `light:` and `dark:` values and follows the macOS appearance setting, but I have set both to the same theme on purpose: I want the terminal to look identical regardless of what the system thinks the time of day is. If you would rather it follow along, give the keys different values, for example `theme = light:"Catppuccin Latte",dark:"Catppuccin Mocha"`. Run `ghostty +list-themes` to see everything installed. The scrollback number is the one setting I would argue about with anyone. 100MB sounds absurd until the first time you want to read what an agent did forty minutes ago and find that the buffer rolled over. ## 3. The shell: zsh and Sheldon I do not use Oh My Zsh. It is a large framework, most of it loads on every shell start, and I want four plugins rather than two hundred. [Sheldon](https://sheldon.cli.rs) is a plugin manager configured with a single TOML file, and `zsh-defer` lets the heavy plugins load after the prompt appears instead of before it. Create `~/.config/sheldon/plugins.toml`: ```toml title="~/.config/sheldon/plugins.toml" shell = "zsh" [templates] defer = "{{ hooks?.pre | nl }}{% for file in files %}zsh-defer source \"{{ file }}\"\n{% endfor %}{{ hooks?.post | nl }}" # Lazy-loading helper, so heavier plugins do not block the prompt. [plugins.zsh-defer] github = "romkatv/zsh-defer" # Extra completion definitions. Adds to fpath, so it must load before compinit. [plugins.zsh-completions] github = "zsh-users/zsh-completions" # Inline command suggestions drawn from your history. [plugins.zsh-autosuggestions] github = "zsh-users/zsh-autosuggestions" # Syntax highlighting. Keep this LAST: it wraps every widget defined before it. [plugins.fast-syntax-highlighting] github = "zdharma-continuum/fast-syntax-highlighting" ``` Then fetch them: ```sh sheldon lock --update ``` Now `~/.zshrc`. This is the whole file, not an excerpt: ```zsh title="~/.zshrc" # ---- Homebrew (Apple Silicon) ---- # Canonically this belongs in ~/.zprofile so it does not re-run in every # subshell. It is here to keep the setup to one file; move it if you prefer. eval "$(/opt/homebrew/bin/brew shellenv)" # ---- PATH ---- export PATH="$HOME/.local/bin:$PATH" # ---- Plugins (sheldon) ---- # Sources completions, autosuggestions and highlighting. # zsh-completions adds to fpath, so this runs BEFORE compinit below. eval "$(sheldon source)" # ---- Completion system ---- autoload -Uz compinit compinit zstyle ':completion:*' matcher-list 'm:{a-zA-Z}={A-Za-z}' 'r:|=*' 'l:|=* r:|=*' zstyle ':completion:*' menu select # ---- History: large, shared between sessions, deduplicated, timestamped ---- HISTFILE="$HOME/.zsh_history" HISTSIZE=100000 SAVEHIST=100000 setopt SHARE_HISTORY HIST_IGNORE_ALL_DUPS HIST_REDUCE_BLANKS HIST_VERIFY \ INC_APPEND_HISTORY EXTENDED_HISTORY # ---- Tool integrations ---- eval "$(zoxide init zsh)" # smarter cd: `z foo` jumps to the best match source <(fzf --zsh) # fuzzy finder: Ctrl-T files, Alt-C directories eval "$(atuin init zsh)" # SQLite history; claims Ctrl-R, so it goes AFTER fzf # ---- Aliases: modern replacements ---- alias ls='eza --group-directories-first --icons' alias ll='eza -lah --group-directories-first --icons --git' alias la='eza -a --group-directories-first --icons' alias lt='eza --tree --level=2 --icons' alias cat='bat --paging=never' alias catp='bat' # the same thing, with paging alias grep='rg' alias find='fd' # ---- git and gh convenience ---- alias gs='git status -sb' alias gd='git diff' alias gl='git log --oneline --graph --decorate -20' alias prs='gh pr list' alias prv='gh pr view --web' # ---- Prompt. Must be near the end. ---- eval "$(starship init zsh)" # ---- Node (see section 7) ---- export PATH="$HOME/Library/Application Support/fnm:$PATH" eval "$(fnm env --use-on-cd)" # ---- Package manager (see section 7) ---- # pnpm itself comes from a corepack shim in ~/.local/bin. PNPM_HOME stays on # PATH only because that is where `pnpm add -g` links global packages. export PNPM_HOME="$HOME/Library/pnpm" case ":$PATH:" in *":$PNPM_HOME:"*) ;; *) export PATH="$PNPM_HOME:$PATH" ;; esac # Do not prompt before fetching the version a project pins in `packageManager`, # so interactive and non-interactive shells behave identically. This one is # specifically for agents: a command running in a shell you cannot see should # never stall on a confirmation you cannot answer. export COREPACK_ENABLE_DOWNLOAD_PROMPT=0 ``` Ordering is the only genuinely fiddly thing in that file, and it bites in three places: - `sheldon source` runs **before** `compinit`, because `zsh-completions` works by adding directories to `fpath` and `compinit` is what reads `fpath`. - `fast-syntax-highlighting` is **last** in `plugins.toml`, because it wraps every widget defined before it and cannot wrap what does not exist yet. - `atuin` initializes **after** `fzf`. Both want `Ctrl-R`, and the last one to bind it wins. Get any of those backwards and nothing errors. You simply lose a feature quietly, which is worse. ## 4. The prompt: Starship Two lines: information on the first, the character you type against on the second. The language modules render only when the relevant files are in the directory, and the command duration appears only when something took longer than two seconds, so the prompt stays quiet until it has something to say. `~/.config/starship.toml`: ```toml title="~/.config/starship.toml" format = """ $directory\ $git_branch\ $git_status\ $package\ $nodejs$python$ruby$rust$golang$java\ $cmd_duration\ $line_break\ $character""" add_newline = true [character] success_symbol = "[❯](bold green)" error_symbol = "[❯](bold red)" vimcmd_symbol = "[❮](bold green)" [directory] truncation_length = 3 truncate_to_repo = true style = "bold cyan" read_only = " " [git_branch] symbol = " " style = "bold purple" [git_status] style = "bold yellow" conflicted = "=" ahead = "⇡${count}" behind = "⇣${count}" diverged = "⇕⇡${ahead_count}⇣${behind_count}" untracked = "?${count}" stashed = "*${count}" modified = "!${count}" staged = "+${count}" renamed = "»${count}" deleted = "✘${count}" # Only appears when a command took more than two seconds. [cmd_duration] min_time = 2000 format = "[ $duration]($style) " style = "bold yellow" # Reads package.json, Cargo.toml, pyproject.toml and friends. [package] symbol = "📦 " format = "[$symbol$version]($style) " style = "208 bold" # Language modules: shown only when relevant files are present. [nodejs] symbol = " " format = "[$symbol($version )]($style)" style = "green" [python] symbol = " " format = '[$symbol($version )(\($virtualenv\) )]($style)' style = "yellow" [ruby] symbol = " " format = "[$symbol($version )]($style)" [rust] symbol = " " format = "[$symbol($version )]($style)" [golang] symbol = " " format = "[$symbol($version )]($style)" [java] symbol = " " format = "[$symbol($version )]($style)" ``` The git status counts are the part that earns its place when an agent is working. A glance tells you how many files it has touched and whether it has staged anything, without running a command. ## 5. The tools themselves Every one of these is a faster, friendlier replacement for something you already use. None of them is required. All of them are aliased in the `.zshrc` above. | Tool | Replaces | Why | | -------------------------------------------------- | ------------- | ------------------------------------------------------------- | | [`eza`](https://eza.rocks) | `ls` | Icons, git status per file, tree view via `lt` | | [`bat`](https://github.com/sharkdp/bat) | `cat` | Syntax highlighting; `catp` when you want paging | | [`ripgrep`](https://github.com/BurntSushi/ripgrep) | `grep` | Dramatically faster recursive search, respects `.gitignore` | | [`fd`](https://github.com/sharkdp/fd) | `find` | Sane defaults and an argument order you can remember | | [`zoxide`](https://github.com/ajeetdsouza/zoxide) | `cd` | `z partial-name` jumps to the directory you visit most | | [`fzf`](https://github.com/junegunn/fzf) | nothing | Fuzzy finder. `Ctrl-T` for files, `Alt-C` to change directory | | [`atuin`](https://atuin.sh) | shell history | Searchable SQLite history, synced across machines | | [`git-delta`](https://dandavison.github.io/delta/) | the git pager | Diffs you can actually read | | `jq`, `gh`, `tree`, `htop` | nothing | JSON, GitHub, directory trees, processes | Aliasing `grep` to `rg` and `find` to `fd` is a real opinion and not everyone's. The two tools take different flags from the ones they shadow, so a command you copy from documentation may fail in a way that is confusing until you remember why. If that trade sounds bad, drop those two alias lines and call `rg` and `fd` by name. ## 6. History and navigation This trio is what makes the terminal feel fast, and it is the part people notice when they sit down at my machine. **atuin** replaces `Ctrl-R` with full text search over a SQLite database of every command you have run, synced between machines. Two settings are worth changing from the defaults. `~/.config/atuin/config.toml`: ```toml title="~/.config/atuin/config.toml" # Enter runs the selected command. Tab puts it in the prompt to edit first. enter_accept = true [sync] records = true ``` Everything else in that file I leave alone. Atuin's `secrets_filter` is on by default and keeps token-shaped strings out of the history database, which is worth knowing about and worth leaving on. **zoxide** gives you `z` for jumping to directories by fragment, ranked by how often and how recently you go there. After a week it is faster than any bookmark system you would design. **fzf** provides `Ctrl-T` to insert a file path into the current command and `Alt-C` to change directory, both fuzzy. ## 7. Node versions and package managers This is the most opinionated section, and it is the one where your preference should probably beat mine. Both choices below are defaults I am happy with, not arguments I want to win. I have listed the serious alternatives with links to their own documentation rather than explaining each one, because each has better installation instructions than I would write. ### Node versions I use **[fnm](https://github.com/Schniz/fnm)**. It is fast, and `--use-on-cd` in the `.zshrc` above means that entering a directory with a `.nvmrc` or `.node-version` file switches Node automatically. That automatic switch is the whole reason for the choice: an agent running a build in a project you have not thought about for a month gets the right runtime without being told. | Alternative | Worth it if | | ------------------------------------ | ----------------------------------------------------------------------------------- | | [nvm](https://github.com/nvm-sh/nvm) | You want the one everyone has heard of, and shell startup speed does not bother you | | [Volta](https://volta.sh) | You want the toolchain pinned per project and shimmed, with no shell hook at all | | [mise](https://mise.jdx.dev) | You manage several languages and want one tool for all of them | | [asdf](https://asdf-vm.com) | Same reason as mise, with a longer history and a bigger plugin ecosystem | Any of these will work with everything else in this post. Swap the two fnm lines in the `.zshrc` for whatever your choice tells you to add. ### Package managers I do not install a package manager globally at all. **[Corepack](https://nodejs.org/api/corepack.html)** ships with Node, reads the `packageManager` field from a project's `package.json`, and fetches and runs exactly that version. The package manager becomes a property of the project rather than of my machine, which is the behavior I want as soon as two repositories disagree. ```sh corepack enable ``` That is the entire setup. In a project pinning `"packageManager": "pnpm@11.22.0"`, running `pnpm install` gets that version whether or not you have ever installed pnpm. The `COREPACK_ENABLE_DOWNLOAD_PROMPT=0` line in the `.zshrc` above is there for agent work specifically. Without it, corepack asks for confirmation the first time it fetches a new version, and a command running in a shell you are not looking at will sit there waiting for an answer forever. If you would rather install one directly: [npm](https://docs.npmjs.com) ships with Node and needs nothing, [pnpm](https://pnpm.io) is what I pin in most projects, [Yarn](https://yarnpkg.com) is the other mature option, and [Bun](https://bun.sh) is worth a look if you want the runtime and the package manager to be the same program. One thing I have deliberately left out. If you install from or publish to a private registry you will need authentication, and the tempting move is to export a token into your shell environment so it is always there. Do not do that, or at least do not do it without deciding to. Every process you start inherits your environment, including every command an agent runs, and a token that is always present is a token that is present during the mistake. Put the credential in a project-scoped config file or supply it to the single command that needs it. ## 8. The agent layer: Claude Code This is the part that is actually about agentic work rather than about having a nice terminal. `~/.claude/settings.json`: ```json title="~/.claude/settings.json" { "permissions": { "defaultMode": "auto", "allow": ["Bash(cd:*)", "Bash(ls:*)", "Bash(grep:*)", "Bash(cat:*)", "Bash(find:*)"], "deny": [ "Bash(rm -rf:*)", "Read(//**/.env)", "Read(//**/.env.local)", "Read(//**/.env.*.local)", "Read(//**/.env.development)", "Read(//**/.env.production)", "Read(//**/.env.test)", "Read(~/.ssh/**)", "Read(~/.netrc)", "Edit(//**/.env)", "Edit(//**/.env.local)", "Edit(//**/.env.*.local)", "Edit(//**/.env.development)", "Edit(//**/.env.production)", "Edit(//**/.env.test)", "Edit(~/.ssh/**)", "Edit(~/.netrc)" ] }, "effortLevel": "xhigh", "theme": "dark", "tui": "fullscreen", "inputNeededNotifEnabled": true, "agentPushNotifEnabled": true, "skipAutoPermissionPrompt": true, "skipWorkflowUsageWarning": true } ``` Add your own entries for whatever else on your machine holds a credential. Mine also covers the local stores belonging to the GitHub CLI, the npm client, and Cloudflare's deploy tool. The [settings documentation](https://docs.claude.com/en/docs/claude-code/settings) lists the full syntax. Taking the parts in the order they matter: **The deny list is the important half, and it is important precisely because of the allow list above it.** `defaultMode: "auto"` means the agent acts without asking permission for each call, which is the only way long autonomous runs are tolerable. The cost of that convenience is that the boundary has to be written down somewhere instead of being enforced by you clicking approve. These paths are the ones where reading the file is the incident. Note what is **not** in the list: the `.env.example` convention. Denying every `.env.*` wholesale would block the file that exists specifically to be read, and the agent would then guess at your configuration rather than look it up. Precision costs a few more lines and is worth them. **Notifications** (`inputNeededNotifEnabled`, `agentPushNotifEnabled`) are the second of the three agent-shaped choices from the top of this post. The agent tells macOS when it has finished or needs an answer, which is what makes it reasonable to start something and go do something else. **`effortLevel: "xhigh"`** asks for maximum reasoning depth. It is slower and it costs more. For non-trivial work I have not regretted it. ### Plugins and skills Claude Code loads plugins from marketplaces you name yourself. Mine, current as of writing: ```json title="~/.claude/settings.json" { "extraKnownMarketplaces": { "claude-plugins-official": { "source": { "source": "github", "repo": "anthropics/claude-plugins-official" } }, "anthropic-agent-skills": { "source": { "source": "github", "repo": "anthropics/skills" } }, "superpowers-marketplace": { "source": { "source": "github", "repo": "obra/superpowers-marketplace" } }, "cq": { "source": { "source": "github", "repo": "mozilla-ai/cq" } }, "cloudflare": { "source": { "source": "github", "repo": "cloudflare/skills" } }, "armature": { "source": { "source": "github", "repo": "ryanlindsey/armature" } } }, "enabledPlugins": { "superpowers@superpowers-marketplace": true, "cq@cq": true, "code-simplifier@claude-plugins-official": true, "frontend-design@claude-plugins-official": true, "document-skills@anthropic-agent-skills": true, "example-skills@anthropic-agent-skills": true, "armature@armature": true } } ``` The two I would recommend to anyone doing this kind of work: - **[superpowers](https://github.com/obra/superpowers-marketplace)** adds process skills, the most useful of which force a design conversation before any code gets written. Most of my bad agent outcomes have been the agent confidently building the wrong thing, and this is the cheapest fix I have found for that. - **[cq](https://github.com/mozilla-ai/cq)** is Mozilla AI's shared knowledge commons. It surfaces the gotchas a model's training data missed, which is disproportionately about stale version numbers and integration quirks. Most of my work is on Cloudflare, so I also run [Cloudflare's skills](https://github.com/cloudflare/skills), which bias the agent toward retrieving current documentation instead of recalling a version of the platform that has moved on: ```sh /plugin marketplace add cloudflare/skills /plugin install cloudflare@cloudflare ``` Substitute your own stack. The general principle is the part to take: a skill that makes the agent go and read current documentation is worth more than any amount of prompting about being careful, because the failure it prevents is the agent being confidently correct about last year. The last one on that list is mine, and it is here because it is genuinely part of this setup, not because you should install it: - **[armature](https://github.com/ryanlindsey/armature)** (v0.3.4) gives an agent a board to work from. It picks the next actionable item across several repositories, claims it so nothing else takes the same one, works it on a branch, and opens a pull request that closes it. I merge those pull requests myself, every time, and it cannot merge them for me. That constraint is the reason it fits the way I work rather than an incidental detail. A fragmented day still produces shipped work, because starting a unit of work no longer requires me to hold the whole context, and the merge stays where accountability for what lands has to stay. Why it is built this way is [its own post](https://ryanlindsey.me/writing/armature). Here it is one more line in the plugin list. Configuration for [MCP](https://modelcontextprotocol.io) servers lives outside this file. The rule I hold to is that credentials are referenced by environment variable and never written into a config file, which is what makes these files safe to show you. ## 9. Check that it worked ```sh exec zsh # reload the shell ``` Then, in order: ```sh starship --version # prompt is installed sheldon source | head -3 # plugins resolve atuin status # history database is live fnm current # node version manager responds claude --version # agent responds ``` And four things to look at rather than run: - `ll` shows icons and per-file git status. Blank boxes instead of icons means Ghostty is not using the Nerd Font; check `font-family`. - `Ctrl-R` opens atuin's search interface, not zsh's. If you get the plain one, atuin is initializing before fzf. - `Cmd+D` splits the window; `Cmd+Opt+arrow` moves between panes. - Typing a command you have run before shows a grey completion after the cursor. That is `zsh-autosuggestions`, and it means Sheldon is loading. If the prompt appears noticeably slowly, the usual cause is `compinit` running before `sheldon source`. Check the order. ## A note on secrets Every file in this post is safe to publish, and that is a property of how they are written rather than luck. No credential appears in any of them. Tokens live in the environment or in a secrets manager, and configuration references them by name. That discipline is worth more now than it used to be. A configuration file with a token in it used to be a file on your laptop. It is now a file that an agent can read, quote into a transcript, and paste into a commit, and it will do all three without malice if nothing stops it. The deny list in section 8 exists for exactly that reason, and the reason I can show you these files is that the credentials they need are named in them rather than written into them. Check yours at the moment you share them rather than the moment you wrote them. Configuration accumulates, and the version you remember writing is not the version on your disk. --- https://ryanlindsey.me/work/silent-failure/ --- title: "A green run is not proof: building an agent platform around silent failure" description: "A production coaching agent for a sim racing team, built solo on Cloudflare and Claude. Every control in it exists because something failed without saying so." publishedAt: "2026-09-06" canonical: "https://ryanlindsey.me/work/silent-failure/" --- A GitHub Actions workflow of mine discarded roughly 639 driver sessions. There was no error, no failed run, and no alert. [`concurrency.cancel-in-progress: false`](https://docs.github.com/en/actions/how-tos/write-workflows/choose-when-workflows-run/control-workflow-concurrency) does not mean "queue everything": GitHub keeps at most one pending run per group and cancels the older one when a new one arrives. Under a retry storm that policy throws work away by design, and the only reason I know the number is that I went looking afterward. Nothing was broken. Every run that executed was green. That is the failure mode I now build against, and this is the system I built against it. ## Context I founded and manage Pixelsonly Racing, a competitive sim racing team, and I am the only engineer on the platform that supports it. Four drivers, which is a medium-sized roster in this sport, and we take on new ones. I am also driver zero, which is why 67% of last month's inference spend is attributable to me. The product is focused rather than narrow, and the difference shows up first in the companion app. A driver finishes a session and the PC app reads the simulator's local telemetry file, then helps them decide which of it is worth sending at all: what the capture actually contains, which sessions have laps clean enough to measure against, and which ones would add nothing that is not already on record. Pressing upload is the end of that judgment rather than a substitute for it. Some minutes later the driver gets a debrief: where they lost time on that track in that car, corner by corner, measured against their own best lap in that same session, and what to drill next time out. Everything between those two events is automatic. It works, and the evidence I trust most is not a number. Drivers read their own data more easily than they could before the platform existed, which is the thing they say back to me unprompted. Development targets are written automatically out of each debrief, filed under the session they were set for, and drilled in the next practice session at that circuit. The loop from session to diagnosis to a target a driver takes back on track runs without me in it. The roster grows, so the platform is built ahead of it. A hard gate on the roadmap holds new drivers behind per-driver spend budgets and delivery observability, and the line in it reads "not a consequence of driver 12." Controls that arrive with the driver who needed them are controls that arrived late. ## Constraint Three things had to hold at once, and the third one shaped everything. **Per-driver isolation, with every exception named.** A debrief is personal performance data about a person who races alongside everyone else on the platform. One driver seeing another's numbers is a trust failure before it is a security one, so private is the default and every crossing has to be a deliberate, enumerated one rather than a consequence of how a query happened to be written. Two crossings exist, and each is bounded by a test rather than by a promise. The team page, which every driver on the roster can open, shows roster context: who has driven lately and how much, what a rule has flagged and the cutoff it fired on, and where each driver sits in a development spread. Most of that the sport already publishes. A session with its date, circuit, and car exists upstream and in public before this platform ever sees it, so putting it in front of a teammate discloses nothing that was not already available. The one genuinely personalized panel is the development spread, and it names nobody: no names, no ordering, no team average, only a driver's own marker against the shape of the roster. A driver can see where they sit. They cannot see where anyone else sits. So the test the page passes is "already public, or nobody named," which is cheaper to keep than a disclosure policy and stronger to hold than one. What it does not show at all is another driver's telemetry, their debrief, or the corner-level figures underneath any of it. The second crossing is an explicit driver-to-coach relationship: a coach reads their own students' debriefs and data, because that is the job, and reads nobody else's. What holds without exception is the negative. No driver acquires a read path to another driver's raw telemetry or session analysis, no relationship is implicit, and nothing anywhere ranks drivers against each other. The code justifies that last one on product grounds rather than security grounds: leaderboards are an anti-feature here. **A minutes-long paid model call inside a request-shaped runtime.** A debrief run spends about 282 seconds waiting on the model. Nothing about an HTTP request, a queue message, or a chat command is shaped like that. **One operator who cannot watch it.** This is the constraint that produced the system's character. I am not going to notice a quiet failure. So the requirement was not "make it reliable," which is unachievable and also unfalsifiable. The requirement was that when it does fail, it has to say so, to somebody, in a way that survives me not looking. ## Intervention The driver never chooses anything the system can be wrong about. The companion app requests [presigned upload URLs](https://developers.cloudflare.com/r2/api/s3/presigned-urls/), and **the [Worker](https://developers.cloudflare.com/workers/) builds every storage key server-side** from the token-authenticated driver. The app never picks a key; it adopts the ones it is handed. On completion the Worker reads the manifest and refuses a session that has zero clean laps, is a duplicate, or is over budget, **and tells the driver which**. A refusal that the driver can read is a different object than a refusal that only exists in a log. Accepted work is enqueued, and a [Queues](https://developers.cloudflare.com/queues/) consumer starts a [Workflow](https://developers.cloudflare.com/workflows/), a durable execution run that resumes rather than restarts, whose instance id is `{driver}--{session_id}`. A duplicate dispatch therefore resolves to the run that already happened rather than starting a second paid one. Idempotency here is a spend control as much as a correctness control. That queue is where the 639 sessions went. It is worth being precise about why I moved, because the obvious reasons were not the real ones. Cost was not the reason: that path was about 6% of billable minutes. Latency was not the reason: 88% of a run is the model call, so changing hosts saves roughly 30 seconds out of 320. **Reliability was the reason.** For a per-session workload, cancel-the-older-one is lossy by design, and a queue-backed consumer with an idempotent instance id is lossless by construction. I did not need a faster pipeline. I needed one that could not throw work away without telling me. The one genuinely non-obvious platform choice is Workflows, and it is an economic one: [billed CPU time excludes time spent awaiting I/O](https://developers.cloudflare.com/workflows/reference/pricing/). Those 282 seconds of waiting cost nothing. That single property is what makes a multi-minute paid model call a sensible thing to run inside a Worker at all. Storage is a lakehouse split. Objects in [R2](https://developers.cloudflare.com/r2/) are truth; [D1](https://developers.cloudflare.com/d1/), the relational index over them, is derived and rebuildable. The scope limit matters more than the pattern: it is **telemetry-only**. Authored coaching state, the briefs and benchmarks and targets, has no stored object to replay from, so for that data D1 is a genuine second source of truth with its own durability contract. "Just rebuild it from the object store" is written down as a rule, and it is written down alongside the case where it does not apply. A rebuild invariant you have not scoped is a rebuild invariant you will discover the limits of during an incident. ## Mechanism ### Isolation by object addressing, not by a `WHERE` clause Drivers authenticate through a separate broker that mints a short-lived ES256 JWT, verified against a published public key. Each driver's agent is a [Durable Object](https://developers.cloudflare.com/durable-objects/), a single addressable instance with its own embedded SQLite storage, and it is reached like this: ```ts return env.DRIVER_AGENT.getByName(claims.slug).fetch(request); ``` The instance name **is** the verified slug from the token. [`getByName`](https://developers.cloudflare.com/durable-objects/api/namespace/#getbyname) is the only way anything in the codebase addresses that class, and no route accepts an object id. Each driver's working memory and conversation transcript live in a physically separate database inside a separate object. An unverified caller resolves to no slug, so there is no instance to address. For the data the agent holds, this is stronger than row-level scoping, and the reason is worth stating plainly: there is no shared table for a bug to leak across. A forgotten predicate in a query over a shared table is a data breach. There is no equivalent mistake available here, because the isolation is in the addressing rather than in the filtering. The relational layer cannot make that claim and should not pretend to. Every statement there is scoped by `driver_id` on both sides of every join, which is filtering, and filtering is the thing you can forget. Two habits keep it honest. The store interface takes the **subject's** `driver_id` and never the caller's, so the question of whether this caller may read that subject is answered once, above the store, instead of being re-litigated inside every query. The coach relationship reads through that single check rather than around it. And the cross-driver surfaces are kept deliberately few and deliberately dull: roster activity and rule flags on the team page, a coach reading their own students, each through its own query rather than by relaxing the scoping on a general one, and neither returning telemetry. A short enumerated list is a thing that stays reviewable. A general-purpose cross-driver read is not. The same idea runs through storage. An artifact key names its owning driver in its fourth segment: ``` telemetry/ibt-lap/1/{driver}/{track}/{car}/{session}/lap-NNN.json.gz ``` So the artifact route authorizes from the key alone, by string equality between that segment and the subject the caller is entitled to read. For a driver that subject is themselves and nothing else, which means there is no lookup to get wrong: **the key structure is the access control**, and another driver's valid key reads as not-found rather than as forbidden. None of that is a claim about intent, so it is tested against the runtime that ships. `agent.workers.test.ts` runs inside [workerd](https://github.com/cloudflare/workerd), the open source runtime Workers actually execute on, through [`@cloudflare/vitest-pool-workers`](https://developers.cloudflare.com/workers/testing/vitest-integration/), reading the real [`wrangler.toml`](https://developers.cloudflare.com/workers/wrangler/configuration/), against real Durable Object storage and a real database seeded from the production migrations. The assertions are named for the behavior rather than the code: an agent for driver A cannot read driver B's storage, and each tool separately cannot be pointed at another driver's history, fuel, debrief, or observations. Those are behavioral tests in the real runtime, not shape tests against a mock. ### Evals that refuse to be green The output is prose, so the scorers are property-based rather than string-matching. A test that pinned strings would fail on every improvement, which trains you to stop reading it. Each scorer instead states a shape an answer must not have or a claim it must carry, and names the document that owns that rule. Three decisions in the harness are the ones I would defend in an interview. **Known-open cases run, report, and do not gate.** A suite that is red on day one is a suite nobody reads. So a known-open failure is visible without blocking, and the report also calls out a known-open case that _starts passing_. The ratchet runs in both directions, which is what stops the known-open list from becoming a place where problems go to be forgotten. **A deliberate run that measures nothing is red.** No opt-in, no key, an unset secret: every one of those paths ends at a failing coverage assertion rather than a skipped test. A green and empty eval reports that the engineer is comfortable having asked the system nothing, and that is a worse state than a failing one because it looks like the good state. **A ratchet between prose and code.** The scorers carry unit tests asserting that the vocabulary and style documents still say what the scorers enforce. Editing either document fails CI until somebody comes back and re-reads the rules. A written-down contract and its enforcement drift apart silently otherwise, and this is the cheapest mechanism I have found for making that drift loud. The conversation harness has a specific origin. Three consecutive releases in one day each fixed a different instance of one behavioral bug class. Every fix was correct. Nothing in the system could tell anyone whether the class was still open, and that gap was the actual defect. ### Why the advice is bounded The honest answer to "how do you know the coaching is any good" is that **there is no outcome study**. No control group, no counterfactual, no measured causal effect on lap time. A four-driver roster and a few dozen debriefs cannot support that claim, so I do not make it. What I built instead is a structural bound. Every target is a time the driver already drove, in the same session, on the same track, in the same car. The reference is their own lap. The system cannot invent a target that is faster than the driver has been, because the target _is_ something they did. The worst realistic failure is therefore a misattributed location for time that is genuinely recoverable, rather than a fabricated goal. That is a much smaller failure, and it is one a driver can catch and report. When a driver does report one, it is settleable in a single read-only query, because the engine's own hint and the exact figures a claim was minted from are both persisted next to the claim. That has already overturned a diagnosis I had stated confidently and wrongly. ## Outcome Live with real users since June, with the queryable index dating from the cutover on 2026-07-22. Every message delivered since delivery observability existed carries a recorded delivery status, and none of them records a failure. The honest footnote is that the deliveries before that point carry no status at all, so the clean record covers the period since I could see it, and that is the only period it is entitled to cover. **A debrief run costs $2.06**, averaged over 20 August runs and measured from a spend ledger that prices each response from its own usage block, including cache read and write multipliers. Not estimated, and not the number in my own documentation, which still says $0.85 and is stale by roughly a factor of two after the analysis substrate grew, the tool-use cap tripled, and a model's introductory rate reverted. Enforcement is that ledger plus a dispatch gate, defaulting to a $60 per driver per month budget in code. **The replay eval's own gate is unmet, and the report says so.** The bar is at least 10 usable bundles across at least 2 drivers and 3 tracks. I have 2 bundles. I wrote that bar before any numbers existed, which is the only order in which writing a bar means anything, and the harness reports the shortfall every time it runs rather than quietly scoring what it happens to have. I would rather ship a governance artifact that says it is not yet satisfied than one that was tuned until it passed. ## What I'd do differently **Two things are still open, and only one of them tells me when it happens.** The one I can state plainly: deploying the Worker while a debrief is running can abort that run. A debrief is a multi-minute paid call, so there is a real chance one is in flight at any given moment, and shipping a change to an unrelated part of the Worker is enough to end it. The retry lands and the driver gets their debrief late rather than not at all, so this is a far smaller failure than the 639. It is the same shape, though: a platform behavior I never chose, quietly deciding that live work is expendable. The fix is a dispatch check for in-flight runs before a deploy takes, and it is on the list rather than in the system. The one I cannot state plainly is the more interesting problem, and it is the reason this section exists. Each debrief runs in a [container](https://developers.cloudflare.com/containers/) sandbox, and for a stretch roughly half of first attempts died mid-run with the Durable Object reporting that it was no longer active. Retries landed, and one session needed three. I have not seen it lately. Debriefs are completing and no driver has reported anything. What I cannot tell you is whether it is fixed, because that failure path captures zero forensics and books no spend, which is the worst available combination: I cannot reconstruct what happened, and the ledger that is supposed to be my second witness saw nothing either. So the honest status is open and under investigation, with no evidence available to close it. That is the real cost, and it is not the flakiness. From where I am standing, a genuine fix and a quiet stretch look exactly the same. Every other failure in the platform was made to announce itself, and this is the one I have not yet made announce itself. It is a straightforward instrumentation job that I have deprioritized behind features more than once, and writing this down is partly an attempt to stop doing that. **And I would distrust platform defaults much earlier.** Three of the eight incidents I have written up share one shape: a tool quietly answered a question I did not know I was asking. `cancel-in-progress: false` answered "should concurrent runs queue?" with a policy I had not read. A pinned CLI version turned out to be a model capability table, clamping output to a limit set before the current model generation existed, so an environment variable raising that limit was a no-op; dependency tooling cannot see a version inside a container build instruction, and that blind spot cost two outages three days apart. Generated files that were committed to the repository were permanently stale by design, because a deploy regenerates every one and never commits it back, and a month-old copy was read as live production state and reported as an outage that was not happening. The fix for the last one generalized into the rule I would put on the wall: **no file in this repository can tell you what is deployed. Only the deployment can.** So those files are ignored now, an endpoint returns what the live bundle actually carries, and a daily check compares it against source. That check catches both directions: a source change that fired no deploy, and a deploy that went green without taking. Which is the rule the whole platform now runs on, and it is four words long: **a green run is not proof.** Engine steps warn and continue, so every cutover needs a positive signal, a row count or a resolved-source log or a message id or a token count, and no ticket closes on a green tick alone. Its companion costs more to keep: every failure path must tell the driver something. I have kept the first rule everywhere. On the second I am two short, and the one that bothers me is the one I cannot yet measure. --- https://ryanlindsey.me/work/delivery-forecasting/ --- title: "Replacing guessed delivery dates with calibrated forecasts" description: "An agent-native forecasting tool for engineering managers that grew from a set of Claude Skills into an internal MCP server, and ended up inside the org's roadmapping process." publishedAt: "2026-09-05" canonical: "https://ryanlindsey.me/work/delivery-forecasting/" --- Every engineering manager I know has had the same bad afternoon. A leader asks when a project will land. You open the epic, squint at the story points, do some arithmetic that is really just vibes with a calculator, and produce a date. You commit to it. You are not confident in it and neither is anyone else, but it is a date, and the meeting needed one. I spent a few years having that afternoon before I built something to replace it. ## Context I am one of several senior managers on the management team of a largely flat engineering department at a cannabis-tech marketplace, and I own delivery for a set of teams whose work shows up on an org-wide roadmap. The expectation of the role is that I solve problems not just for my own teams, but for the betterment of the whole organization. Forecasting was a recurring obligation: leadership plans around dates, so dates have to exist, and somebody has to produce them. Every manager in the department was producing them the same way, which made the method an org-level problem rather than a local one. That method was an educated guess extrapolated from Agile story-point estimates and back-of-napkin math. It had two problems, and only one of them was accuracy. The first was that the number had no traceable basis. A date arrived and nobody, including the manager who produced it, could reconstruct how. That made the number difficult to defend, which meant every forecast opened a negotiation. Leaders pushed on dates because there was little to push against except the manager's confidence, and confidence is not evidence. The second was that producing one was stressful in a way that was out of proportion to the arithmetic. It took a few hours at most. But you were committing your team to something you had thin grounds for, and you knew it, and you would be held to it anyway. ## Constraint Whatever replaced it had to satisfy three things at once. It had to be **objective enough to survive a challenge**, not more confident but more inspectable, so that a disagreement became a conversation about inputs rather than a contest of conviction. It had to be **usable by managers who were not going to learn a modeling tool**. Anything requiring a spreadsheet discipline or a statistics vocabulary would be adopted by three people and abandoned. And it had to **fit where the work already happened**. Engineering managers at this point were living in agentic sessions for a growing share of their day. A dashboard would have been one more tab nobody opened. ## Intervention I built a forecasting toolchain that a manager drives in natural language, inside a session they are already in. They say what project they want a forecast for. They get back a distribution of likely completion dates, and dates drawn from that distribution at stated confidence intervals. It shipped first as a set of Claude Skills, because that was the fastest way to put something in front of colleagues and find out whether the idea survived contact. It did. Then it outgrew that form, which I will come back to. The output is deliberately not a single number. Alongside the distribution it returns **p50, p70 and p85 targets**: the dates by which the project lands with 50%, 70% and 85% likelihood. The change that mattered was not moving from a wrong date to a right one. It was moving from false precision to a date with a stated confidence attached, which is a different kind of claim and a much more defensible one. Because the forecast needs only a reasonable idea of _who_ will work on the project, it can run at planning time rather than after commitment. That shift-left is most of the practical value: a forecast that arrives before the date is promised is an input to the decision instead of a report on it. ## Mechanism Underneath, it is a Monte Carlo simulation over empirical cycle time. That technique is not mine and is not new. Forecasting delivery from observed throughput rather than estimates has been well-documented practice for years. What was worth building was everything around it. **Real cycle time from real people.** The simulation draws on how the specific humans on the project have actually completed work, not on an idealized team velocity. It accounts for holidays and PTO, because a forecast that assumes a full team in late December is wrong in a way everyone can see, and one visibly wrong output is enough to discredit a tool. **Scenario projection.** Scope growth is not a static fudge factor. A manager can project scope changes and re-run, which turns the tool from a reporting instrument into a planning one. _What happens if we add this, or if we lose this person for three weeks_ is a question you can now answer in the session rather than in a follow-up meeting. **Calibration as a regression test.** Calibration was performed against historical data, and the results were frozen into fixtures feeding a test suite. The intent was that calibration would not be a one-time exercise that silently rots, and that changes to the model would have to answer to how it had actually performed. That intent was sound. The execution had a hole in it, which is the most interesting part of this story and is below. **Disclosure control.** This is the design decision I would defend hardest. In the interactive session, the manager sees everything the model knows, including which people on a project look like bottlenecks or risks. That is genuinely useful and genuinely sensitive. So the manager, and only the manager, decides what gets published from the forecast onto the epic. The private view is complete; the shared artifact is curated by a human who is accountable for it. No individual-level inference reaches an org-visible surface without a person choosing to put it there. **Skills to MCP.** The Skills version was outgrown within months. Agentic adoption across the org was climbing fast, project planning practice was changing underneath us, and the Skills packaging imposed operational and distribution problems that got worse with every new user. Moving into our internal MCP server solved them. That migration and a port from Python to TypeScript were run as one coordinated project, with testing, rollout and org communications planned together, rather than something done quietly and announced afterwards. ## Outcome **Adoption above 80% of engineering managers**, and the part I care about more: it was absorbed into the org-wide roadmapping and reporting process. It stopped being my tool and became how the work is done. There are managers using it who have never had a conversation with me about it, which is the only adoption signal I actually trust. **Forecasts land inside the p85 target better than 75% of the time**, and around 80% since the port. Roughly one project in ten delivers ahead of the forecast entirely. **Forecast production went from a few hours to minutes**, but that is the smaller number. The real saving is that the negotiation mostly stopped. When the basis is inspectable, a disagreement becomes a question about inputs, which is a conversation worth having, instead of a contest about whose instinct is better, which is not. Forecasts are reviewed with engineering managers weekly and targets are updated on a regular cadence. Nobody treats them as etched in stone, and some margin of error is expected. That turned out to matter more than I anticipated. ## What I'd do differently **My test suite certified a bug rather than catching it.** The first version had a critical defect in the empirical cycle-time calculation, the input everything else rests on. It survived for some time, through a test suite I considered robust. Here is why the tests could not have caught it. Calibration was run against historical data and frozen into fixtures. Those fixtures were generated _before_ the bug was found, which means they were generated by the buggy calculation. The suite was not failing to detect the defect; it had encoded it as the expected result. Every run confirmed that the system behaved exactly as the system behaved. It surfaced when the tool was ported from Python to TypeScript. Re-deriving the logic in another language broke the shared assumption, because the port could not inherit the misunderstanding the original had been written with. The fixtures were regenerated afterwards against the corrected calculation. The lesson generalizes past forecasting: **a test suite cannot validate the assumption it was derived from.** If your expected values are produced by the system under test, your tests measure self-consistency, not correctness. I knew that in the abstract. I did not notice I had done it, on the input everything else rests on, in a tool the whole engineering org had adopted. The blast radius was smaller than it could have been, and not because of anything clever I did. Because forecasts were reviewed weekly and targets were expected to move, a correction propagated through the normal cadence instead of becoming an incident. A forecasting tool that published immutable dates would have turned the same bug into a much worse week. Building the expectation of revision into the process was accidental insurance, and I would now design for it deliberately. **The second thing I would change: the baseline is not stationary.** The model forecasts from historical cycle time: how the team has performed. But agentic tooling was making those same engineers faster over the period the model was learning from. A simulation trained on the team that existed will systematically under-predict the team that exists now. That is very likely part of why a tenth of projects finish early, and part of the residual gap between an 85% target and an 80% hit rate. I do not think the answer is a correction factor bolted on top. The honest fix is for the model to account for drift in its own baseline, weighting recent throughput more heavily or modeling the trend rather than the average. I have not built that yet. It is the most interesting problem the tool has left.