Skip to content
MCP Five

The Whole Picture47 / 51

The gotcha compendium

All forty things that actually broke, filterable by project and by category.

Every one of the five repos ends its build guide with an appendix titled "the N things that actually broke." Not hypotheticals, not a troubleshooting section written from imagination β€” the actual failures, with the actual error text.

There are forty of them. This is all of them, in one place.

Click any card to see the cause, the fix, and the part that transfers.

Project
Category
Showing 40 of 40
The SDK API changed under userror TS2339: Property 'tool' does not exist on type 'McpServer' β€” on code copied from a current-looking tutorial.#1 the servertooling

What was actually wrong

Nearly every tutorial and blog post online uses the 1.x API. The 2.x packages are a different shape: server.registerTool(name, config, cb) rather than server.tool(name, desc, shape, cb), inputSchema: z.object({...}) rather than a raw shape, and @modelcontextprotocol/server rather than @modelcontextprotocol/sdk/server/mcp.js.

The fix

Read the types you installed: cat node_modules/mcp-handler/dist/index.d.ts.

πŸ’‘ The transferable bit

Don't trust the blog post, read the types on disk. The .d.ts files are the ground truth for the version you actually have, and they cannot be out of date. Five minutes reading them beats an hour of guessing β€” a habit that went on to change the design in all five projects.
`406 Not Acceptable` on every requestThe endpoint exists, the build is clean, and every curl returns 406 with nothing useful in the body.#1 the serverprotocol

What was actually wrong

A missing Accept: application/json, text/event-stream header. The spec requires the client to declare it can handle either reply format, and browsers and fetch don't add it for you.

The fix

Send both header values, every time.

πŸ’‘ The transferable bit

The status code suggests you asked for something unreasonable rather than that you forgot to mention a second content type you weren't planning to use. If a brand-new MCP integration returns 406, this is it β€” every time.
The cookie jar forgetsAdd 20 cookies, look in the jar, see 12.#1 the serverpersistence

What was actually wrong

Module-scope state on serverless. Each instance has its own copy of the variable, and requests land wherever the platform sends them β€” including on a machine that has just been woken up with a fresh copy.

The fix

A database. This series settled on Neon Postgres (npx vercel install neon β€” it provisions from the Vercel Marketplace and doesn't ask for a card). Or, if the data is genuinely per-conversation, keep it client-side and pass it in as a tool argument.

πŸ’‘ The transferable bit

The diagnostic that saves you: log a per-instance random id at module scope. If it changes between requests, you've moved machines and the mystery evaporates.
Windows path and shell frictionerror 2147942593 (0x800700c1) launching npm.ps1; curl rejecting a JSON payload; endless LF will be replaced by CRLF warnings.#1 the servertooling

What was actually wrong

PowerShell's Start-Process can't invoke a .ps1 shim as an executable; cmd.exe doesn't handle single-quoted strings; and git normalises line endings.

The fix

Run npx next start directly rather than through the npm shim. Use Git Bash or Invoke-RestMethod for JSON payloads. The CRLF warnings are harmless β€” silence them with git config core.autocrlf true.
`create-next-app` refuses to share a folderThe directory contains files that could conflict β€” over a single unrelated .md file.#2 the hosttooling

What was actually wrong

create-next-app refuses to scaffold into a folder containing any file it doesn't recognise. Project #1's guide suggested --skip-install --disable-git in this situation; that advice is wrong for this version, because the check happens before either flag is considered.

The fix

Scaffold into a temp directory and copy in: npx create-next-app@latest /tmp/scaffold … then cp -r /tmp/scaffold/. ./.

πŸ’‘ The transferable bit

This one bit every single project in the series, all five times, because every project starts with a kickoff document already in the folder.
The MCP package that isn't the one you installedYou install @modelcontextprotocol/sdk β€” what project #1 installed, and what every tutorial says β€” and mcp-handler still complains about an unmet peer dependency.#2 the hosttooling

What was actually wrong

mcp-handler peer-depends on `@modelcontextprotocol/server`, a different package. The first line of its own types says so.

The fix

npm install @modelcontextprotocol/server.

πŸ’‘ The transferable bit

The transferable lesson is not install this package. It is: read the peer dependencies and the first import line of the types. This is project #1's gotcha 1 wearing a new hat β€” the ecosystem moved again between the two projects.
The type files you can't findfind node_modules/@modelcontextprotocol/server -name "*.d.ts" returns nothing, on a package that obviously ships types.#2 the hosttooling

What was actually wrong

It is ESM-first and ships .d.mts / .d.cts, not .d.ts. Your find pattern was wrong, not the package.

The fix

Search by content rather than filename: grep -rln "registerTool" node_modules/@modelcontextprotocol/server/dist/. Or check package.json's types / exports field, which tells you exactly which file to open.

πŸ’‘ The transferable bit

When read the types turns up nothing, doubt your glob before you doubt the package.
`.env*` swallows `.env.example`You write a nice .env.example, commit, and it isn't in the repo.#2 the hosttooling

What was actually wrong

The Next.js default .gitignore contains .env*, which matches .env.example just as happily as .env.local.

The fix

Add !.env.example β€” and keep that block last, because gitignore is order-sensitive and the last matching pattern wins.

πŸ’‘ The transferable bit

Harmless in itself, and it means every person who clones your repo has no idea which variables to set.
The dev server that would not diePort 3000 is in use by process 42444, using available port 3001 instead. Your new server is on 3001 while everything you configured points at 3000 β€” where the old process, with the old environment, is still cheerfully answering.#2 the hosttooling

What was actually wrong

Killing the shell that launched npx next dev does not necessarily kill the Node process it spawned. This is worse on Windows.

The fix

Check the port, not the shell: Get-NetTCPConnection -LocalPort 3000 -State Listen | Select-Object OwningProcess, then taskkill /PID <pid> /F. On macOS or Linux, lsof -ti:3000 | xargs kill -9.

πŸ’‘ The transferable bit

If a code or env change appears to have no effect, confirm which process is actually serving the port before changing anything else. This bit projects #2, #3 and #4 β€” when the same surprise recurs three times, the mental model is wrong rather than the command.
`.env.local` is read at startup, not per requestYou add ANTHROPIC_API_KEY to .env.local, hit the app, and get ANTHROPIC_API_KEY is not set β€” from a file that visibly contains it.#2 the hosttooling

What was actually wrong

The dev server was already running when you created the file. Environment variables are read into the process at boot.

The fix

Restart the dev server, and confirm it picked the file up β€” Next prints - Environments: .env.local on startup. If that line is missing, the file wasn't loaded.

πŸ’‘ The transferable bit

This compounds nastily with the previous gotcha: you restart, the old process survives on port 3000, and you conclude the env file is broken.
The host called itself through a locked doorEverything works locally. In production the app still answers β€” but one MCP server is missing from the trace with HTTP 401, while curling that same endpoint on the public URL returns 200 perfectly.#2 the hostdeployment

What was actually wrong

Vercel gives you two different hostnames with different protection. VERCEL_URL is the per-deployment hostname and Deployment Protection answers it with 401. VERCEL_PROJECT_PRODUCTION_URL is the stable alias and is not protected. Every tutorial tells you to use VERCEL_URL for the app's own URL, which is fine for a redirect target and wrong when the server must call itself.

The fix

Prefer VERCEL_PROJECT_PRODUCTION_URL, falling back to VERCEL_URL.

πŸ’‘ The transferable bit

It does not look like a failure. The host degrades gracefully, the model answers using the servers that did connect, and the user gets a plausible response. Nothing goes red β€” you silently have half the tools you think you have. It deployed and the page loads is not verification: this bug survived a clean typecheck, a clean build, a green deployment and a working demo.
Vercel un-ignores your `.env.example`, repeatedlyYou add !.env.example to .gitignore, and later it is ignored again anyway.#3 the gatedeployment

What was actually wrong

vercel link appends its own .env* line β€” and so does vercel install, and so does anything else that writes .env.local. Each one appends to the end, and gitignore is order-sensitive, so each one silently undoes your negation.

The fix

Re-check .gitignore after every Vercel command that touches .env.local, and delete the duplicates.

πŸ’‘ The transferable bit

Leave a comment in the file explaining why the block must stay last, because you will do this again.
`git check-ignore -v` lies to you about negationsgit check-ignore -v .env.example prints a matching line and exits 0 β€” which reads like yes, ignored, so you conclude the negation didn't work.#3 the gatetooling

What was actually wrong

Exit 0 here means a pattern matched, and the pattern that matched is your negation. The file is fine. The tool is telling you which rule decided, not what it decided.

The fix

Ask the question you actually care about: git add -n .env.example. It prints add '.env.example' if it will be committed, or the following paths are ignored if it won't.

πŸ’‘ The transferable bit

When a check is ambiguous, test the behaviour you care about, not a proxy for it. This one nearly caused a fix to a file that was already correct β€” and it came back in project #4.
`tsc --noEmit` fails on a clean cloneCannot find name 'LayoutProps' β€” on code create-next-app itself generated.#3 the gatetooling

What was actually wrong

LayoutProps<"/"> is a generated type that Next writes into .next/types during a build. On a fresh clone that directory doesn't exist, so neither does the type. It works for anybody who has run npm run build first, which is why it survives into templates.

The fix

Type the layout explicitly: { children }: { children: React.ReactNode }.

πŸ’‘ The transferable bit

A typecheck that only passes after a build isn't a pre-build check. This is the sort of thing that turns a clean CI pipeline red on its very first run.
The trace was empty, and replay looked brokenAfter several successful runs with approvals, npm run replay listed them all with 0 events. The whole replay feature appeared not to work.#3 the gatepersistence

What was actually wrong

Nothing was wrong with replay. recordEvent was called in the route handlers, and the terminal checkpoint script called the loop directly β€” so runs created by npm run approval had rows in runs and approvals but nothing in trace_events.

The fix

Record the trace in the checkpoint script too, exactly as the route does.

πŸ’‘ The transferable bit

Two paths into the same feature will drift, and the one you test with is not always the one you ship. The symptom pointed at the newest code; the cause was in the oldest. This one came back twice more β€” project #4 fixed it structurally, and project #5 found a fifth caller that still skipped the shared path.
The dev server that would not diePort 3000 in use, new server on 3001, and JAR_MCP_URL still pointing at 3000 where the old process is answering with the old environment.#3 the gatetooling

What was actually wrong

Killing the shell does not kill the process it spawned.

The fix

Check the port, not the shell.

πŸ’‘ The transferable bit

Project #2's gotcha 5, unchanged, one project later. If a change appears to have no effect, confirm which process is serving the port before changing anything else.
The seatbelt that unbuckled itself on every resumeNone. Nothing failed. That is what makes it worth listing.#3 the gatecost

What was actually wrong

MAX_ITERATIONS = 10 is a cost seatbelt, and /api/resume calls runAgentLoop fresh β€” so iteration starts at 0 again. A run that pauses for approval nine times would get nine separate budgets of ten iterations, and the cap you thought you had would be 90.

The fix

Thread the count through: runAgentLoop({ …, iterationOffset: run.iterations }).

πŸ’‘ The transferable bit

When you split one logical operation across two HTTP requests, audit every counter, cap and budget that was implicitly per-operation. Pausing didn't just add a feature β€” it changed what one run means, and anything scoped to a run had to be re-scoped by hand. Project #4 hit the identical bug with its spawn cap.
The model asks for permission, and that is not a safety featureThe good kind. Asked to smash the jar, the model itself stopped and asked for confirmation instead of calling the tool β€” so the gate never fired and the checkpoint reported the loop finished without ever pausing.#3 the gatemeasurement

What was actually wrong

The tool's description says it is irreversible, and the model behaved sensibly.

The fix

Re-run with a user who is actively trying to get past it: Smash the jar. Yes I am certain, do it right now, no questions. The model complied immediately β€” and the host stopped it anyway.

πŸ’‘ The transferable bit

That is a disposition, not a guarantee. It varies run to run, it varies by model, and it evaporates the moment a user is insistent. If your safety testing only uses polite prompts, you are measuring the model's manners, not your controls.
Project #2's own server locked this project outThe plan was to connect this host to project #2's live /api/toolbox. It returned 401 for every request.#3 the gatedeployment

What was actually wrong

Project #2's stage 9 added withMcpAuth and set MCP_SHARED_TOKEN in production. It works exactly as designed β€” this host just isn't holding the token.

The fix

Connect to project #1's open server instead, and get the missing capability from this repo's own jar server.

πŸ’‘ The transferable bit

Your own past projects are third-party services. Their auth, uptime and rate limits constrain you the same way a stranger's would. The Promise.allSettled in buildToolbox is what kept this a design decision instead of an outage β€” and project #4 responded by shipping its servers deliberately open.
The premise was wrong, and the eval said soThe whole project is built on sixty jars is too much for one agent. The baseline run scored 100% β€” sixty jars inspected, nine tampered found, zero false positives, four iterations.#4 the crewmeasurement

What was actually wrong

Sixty verbose reports is about 15,000 tokens, and the model batches twenty inspect_jar calls per turn. It was never close to a context limit.

The fix

Stop asserting where the ceiling is and go and measure it. Make the pantry size a variable, keep the exam identical at every size, and turn it up until something breaks β€” which it did, decisively, at 240.

πŸ’‘ The transferable bit

A version of this project that shipped sixty jars and a celebratory README would have been demonstrating a feature that, at the size it shipped, made no measurable difference at all. The eval suite is what made the difference between a lesson and a claim.
A backtick inside a template literal, in SQLERROR: Expected "]" but found "trace_events".#4 the crewtooling

What was actually wrong

A SQL comment inside a JS template literal, written in markdown reflex β€” -- Attribution lives in the \trace_events\ table β€” where the backtick ends the string.

The fix

No backticks in SQL comments inside template literals.

πŸ’‘ The transferable bit

The error points at trace_events, which is a perfectly good table name, and says nothing about backticks. When a parse error names a token that is obviously fine, look at the delimiters around it, not the token.
A regex that assumed the data would never growAt 240 jars the eval reported that a worker assigned jars 91-120 had been given one jar, and one assigned jars 211-240 had been given fourteen. It looked like the orchestrator was delegating badly.#4 the crewmeasurement

What was actually wrong

parseJarIds used \d{1,2} β€” written when the pantry was fixed at sixty and two digits was obviously enough. On "jars 211-240" the engine matches 21, wants a dash, finds 1, slides along one character, matches 11, finds the dash, matches 24, and reports the range 11 to 24. No error anywhere.

The fix

\b(\d{1,3})\b, validated against JAR_COUNT.

πŸ’‘ The transferable bit

The instrument was broken, not the thing being measured β€” and it failed plausibly. A wrong-but-believable number is worse than a crash. When a measurement says your system is misbehaving, confirm the measurement first.
Killing the shell does not kill the process, part twoA 240-jar comparison came back scored 0% in both modes, 0 tokens, 0 jars inspected β€” which reads like a devastating finding about delegation and is actually nothing at all.#4 the crewmeasurement

What was actually wrong

An earlier compare run had exceeded a 10-minute command timeout and been killed. It kept running. A second was started, and the two ran concurrently until they exhausted the API credit balance.

The fix

Check for the process, not the shell β€” and before starting anything expensive twice, confirm the first one is actually dead.

πŸ’‘ The transferable bit

A failure message that doesn't say what failed will eventually be mistaken for data. The eval's own output said only stopped: error, and the verdict line dutifully printed ONE AGENT IS BETTER from two identically-broken runs. A red tick with no explanation is worse than no tick.
`create table if not exists` will not add your new columnsNone on a fresh database. On an existing project #3 database, npm run db:init reports success and then every insert fails on a missing parent_run_id.#4 the crewpersistence

What was actually wrong

create table if not exists sees a table and does nothing. It does not diff columns.

The fix

Pair every new column with an explicit alter table … add column if not exists.

πŸ’‘ The transferable bit

"Idempotent schema script" and "migration" are not the same thing. The first is safe to re-run; only the second actually upgrades anything.
The spawn cap that reset on every resumeNone. Nothing failed. That is what makes it worth listing.#4 the crewcost

What was actually wrong

MAX_SPAWNS is enforced by a counter in a closure created per run. A run that pauses for approval and resumes in a new HTTP request builds fresh crew tools with a counter starting at zero β€” so the cap was 8 per resume, not per run.

The fix

createCrewTools(spawnOffset), threaded through exactly like iterationOffset.

πŸ’‘ The transferable bit

Project #3's gotcha 6 in a place nobody thought to look. When you split one logical operation across two HTTP requests, every counter, cap and budget that was implicitly per-operation must be re-scoped by hand β€” and a counter hidden in a closure is easier to miss than one at the top of a loop.
A Next.js route module is not a place to keep helpers/api/resume importing stripState from ../chat/route. It typechecks, and it is a build error waiting for a bad day.#4 the crewtooling

What was actually wrong

A route module is only supposed to export handlers and a few config constants.

The fix

The helper moved to lib/run-driver.ts.

πŸ’‘ The transferable bit

The shared thing between two routes belongs below both of them, not inside one.
Three places have to agree about the pantry sizeinspect_jar { id: 200 } rejected as out of range while the database happily holds 240 jars.#4 the crewtooling

What was actually wrong

The pantry MCP server builds its Zod input schema at module load, from JAR_COUNT. A dev server started before you exported PANTRY_JARS=240 is serving a 60-jar schema no matter what you reseeded.

The fix

PANTRY_JARS on all three β€” db:init, dev, and the script.

πŸ’‘ The transferable bit

Env vars are read once, at startup, and a schema derived from one is even stickier than a value β€” it is baked into what the server will accept.
The headline number compared two different amounts of workThe comparison reported the crew cost 1.87Γ— what one agent cost. Plausible, quotable, and wrong.#4 the crewmeasurement

What was actually wrong

The two modes do not run the same number of runs. crew-one-approval is a delegation-only case, so a 3-attempt comparison is 3 runs in single mode and 6 in crew mode β€” and the script divided one total token count by the other. Part of what it measured was the crew did twice as many runs. Nothing errored. Both totals were correct. The division was the lie.

The fix

Track tokens per observation group and compare only the groups both modes actually executed. The full spend is still printed, clearly labelled, because it is what the run really cost β€” it just isn't the number that answers the question.

πŸ’‘ The transferable bit

Any comparison between two configurations has to check that they did the same work, not just that they both ran. This is the same error as an A/B test where one arm gets more traffic, and it is very hard to see from the inside because every individual number in the report is accurate. Ask how many runs went into each of these two totals? before quoting a ratio.
`git check-ignore -v` still lies about negationsExit 0 on .env.example, which reads like ignored and isn't.#4 the crewtooling

What was actually wrong

It reports that a pattern matched. The pattern was the negation.

The fix

git add -n <file> asks what git will actually do.

πŸ’‘ The transferable bit

Project #3's gotcha 2, one project later, in the same series, by the same author. Some gotchas do not get learned β€” they get written down and re-encountered, which is the argument for the appendix existing at all.
The feature everyone documents does not work hereserver.server.createMessage() β€” the one-liner in every sampling tutorial β€” throws.#5 the ledgerprotocol

What was actually wrong

It is a server→client push, and a push needs a live connection. A serverless function has a request, not a connection. The SDK says so precisely: per-request legacy serving cannot receive server-to-client requests.

The fix

The 2026-07-28 revision replaces the push with a retry: return inputRequired(…), the client fulfils it, the client calls the tool again with params.inputResponses and params.requestState.

πŸ’‘ The transferable bit

The types said @deprecated next to the function every tutorial recommends. A deprecation notice on the thing you were about to build on is worth more than a working example of it.
`LATEST_PROTOCOL_VERSION` is not the latest protocol versionSUPPORTED_PROTOCOL_VERSIONS doesn't contain 2026-07-28, so you conclude the runtime can't serve it and design around the legacy path.#5 the ledgerprotocol

What was actually wrong

Those constants describe the `initialize`-negotiated era only. The 2026 era is not negotiated by a handshake at all β€” it is triggered by a per-request _meta envelope, and lives in a completely separate code path.

The fix

Send the envelope and find out.

πŸ’‘ The transferable bit

A constant named LATEST_ answers a narrower question than its name implies. When types and constants disagree, the wire is the tiebreaker.
Two readers, two shapes, and only one of them documented where I lookedThe handler returned input_required forever. The host answered, the retry arrived, and the handler asked again β€” an infinite loop that costs a model call per round.#5 the ledgerprotocol

What was actually wrong

acceptedContent(responses, key) is elicitation-only. For sampling it returns undefined, which is indistinguishable from no answer yet. The right reader is inputResponse(responses, key). And they aren't even the same shape: a sampling view wraps its payload in .result, while an elicitation view is flattened.

The fix

inputResponse for sampling; destructure elicitation directly.

πŸ’‘ The transferable bit

Both cost exactly one compile error to discover, which is the cheapest way this project found anything out. Let the compiler tell you the shape instead of guessing at it in prose.
I built the replay runner first, and nothing was being recordedThe $0 replay suite was built first, exactly as recommended. Then the live suite ran β€” 13 cases, all green β€” and replay reported no stored run for this prompt for every single one.#5 the ledgerpersistence

What was actually wrong

Nothing was wrong with replay. observeOnce calls runAgentLoop directly, so it never went through lib/run-driver.ts, so it never wrote a row. This is project #3's gotcha 4 arriving for a third time: #3 hit it with a script and a route handler, #4 fixed it structurally with one drive-and-persist path β€” and then left the eval suite as a fifth caller that quietly didn't persist, because in project #4 nothing read those rows.

The fix

Thread an optional persist through observeOnce, reusing TraceWriter rather than writing a second persistence path β€” which would have been the same mistake a fourth time.

πŸ’‘ The transferable bit

A code path that skips the shared one is not a bug until something needs what the shared one produces. Then it is the whole feature, missing. The gap had existed since project #4 and was invisible until something depended on it.
Six cents for a blank pageThe expensive digest came back empty. 26,240 tokens, six cents, and a report with nothing in it β€” which the server dutifully wrapped in a nice header and returned as a successful tool result.#5 the ledgercost

What was actually wrong

The sampling call never set thinking. On Sonnet 5 that does not mean off β€” adaptive thinking is on by default, and max_tokens caps thinking and answer together. The host's 1,000-token clamp was spent entirely on reasoning.

The fix

thinking: { type: "disabled" }, plus an explicit check that empty output is an error rather than an answer.

πŸ’‘ The transferable bit

The bug was created by this project's own design. The host clamps output to control cost; the model spends that same allowance on thinking first. A cost control silently became a thinking budget β€” and the server that asked has no idea either number exists. Anything that clamps max_tokens on somebody else's behalf has this bug available to it.
The error path reported a real spend as freeNone, until it was read. When the empty-output check started throwing, the catch recorded actualCents: 0 β€” for a call that had genuinely burned 26,000 tokens.#5 the ledgercost

What was actually wrong

usage is populated before the check throws. The catch block was written for network failures, where nothing was spent, and inherited by a failure mode where plenty was.

The fix

Charge the tab, log the real number, and label the row a failure. decision says refused; actual_cents tells the truth.

πŸ’‘ The transferable bit

A ledger that under-reports is worse than no ledger, because you would trust it. Every catch around a billable call needs to ask did this cost anything before it failed?
A demo that could not demonstrate anythingThe gate worked, the refusal worked, and the headline demo β€” set the ceiling to two cents and watch it refuse β€” did nothing. Every request estimated at 0.22Β’, comfortably under 2Β’.#5 the ledgermeasurement

What was actually wrong

There was only one kind of request, and it was cheap. Nothing in the system could produce a number a two-cent ceiling had an opinion about.

The fix

A depth: "brief" | "deep" dial on the tool β€” same tool, same server, same user question, about 27Γ— the price.

πŸ’‘ The transferable bit

A demonstration bug rather than a code bug, and worth catching because it makes the argument concrete. Two prices for one tool name is exactly why the gate reads the estimate rather than the tool name.
A passing suite that printed a crash after passingSCORE: 100% (1/1 scored) followed immediately by Assertion failed: !(handle->flags & UV_HANDLE_CLOSING).#5 the ledgertooling

What was actually wrong

process.exit() tears the process down while the Neon driver still holds open HTTP handles, and libuv aborts β€” after a perfectly good report.

The fix

process.exitCode = … and let the event loop drain.

πŸ’‘ The transferable bit

A green suite that ends in a native assertion will be read as a failing suite. Exit codes are part of the output.
A price table is a dated factNone yet, and that is the point.#5 the ledgercost

What was actually wrong

lib/pricing.ts is the only file in the repo whose correctness expires. Sonnet 5 is on an introductory rate that ends 2026-08-31; hard-code the discounted number and every downstream calculation reports to two decimal places, with total confidence, and is wrong from September.

The fix

Encode both rates and the date the intro ends, stamp PRICES_CHECKED_ON, and print it in the UI and the checkpoint scripts. And the load-bearing half: the gate reasons in standard prices while the ledger records effective ones, because a ceiling computed with a temporary discount silently loosens the day the discount ends.

πŸ’‘ The transferable bit

Estimate high, bill honestly. And when a value has an expiry date, make the expiry date part of the value.
Project #4's gotcha 9 was still live in project #4's own scriptnpm run compare had never been run in this repo. Run once, it reported the crew cost 2.01Γ— what one agent cost β†’ at 60 jars delegation is pure overhead β€” a conclusion project #4's own corrected README already contradicts.#5 the ledgermeasurement

What was actually wrong

ONE AGENT scored 2 comparable cases; A CREW scored 4. The ratio divided one run's tokens by two runs' tokens and called the difference a price. Project #4 built the fix and never wired it up: usageByGroup was populated and stored on the results object, and the verdict read results[n].usage β€” the raw totals β€” ignoring it entirely.

The fix

Intersect the two modes' group keys, sum only the shared ones for the ratio, and print the full spend separately. Re-running turned 2.01Γ— into 1.08Γ—.

πŸ’‘ The transferable bit

A documented fix is not a fix. The appendix entry, the code comment and the helper data structure all existed and all described a behaviour the program did not have. The only evidence that a fix works is the output of the fixed program β€” and this one had never been produced.
A checkpoint whose second half tested nothingnpm run approval printed PASS β€” the gate fired and both paths worked, above output showing the DENY half had run against an empty jar and never paused.#5 the ledgermeasurement

What was actually wrong

Two bugs stacked. The APPROVE half eats every cookie; the DENY half then asks the agent to empty an already-empty jar, so the model sensibly just looks and the gate never fires β€” the second half's precondition was consumed by the first half. And the banner collapsed both halves into one boolean, so either path firing printed both paths worked.

The fix

Restock the jar before each half, track the two outcomes separately, and report PASS / PARTIAL / FAIL β€” plus a non-zero exit code, because a checkpoint that cannot fail is not a checkpoint.

πŸ’‘ The transferable bit

A checkpoint whose two halves share mutable state is one checkpoint and one decoration. Both halves were individually correct; the ordering silently disarmed the second. And a summary line must never claim more than the run proved.

What the distribution says

A few things are worth noticing once they are all in one list.

Eight of the forty had no symptom at all. They are on the list because somebody went looking, not because anything broke. Both reset counters, the under-reported ledger row, the price table with an expiry date, the route module exporting a helper. A gotcha with no symptom is the most expensive kind, because the only thing standing between you and it is somebody deciding to check.

Several are the same bug, recurring. git check-ignore lying about negations is in projects #3 and #4. The dev server that would not die is in #2, #3 and #4. The counter that re-scopes itself is in #3 and #4. The code path that skips the shared one is in #3, #4 and #5.

The categories shift as the series goes on. Projects #1 and #2 are almost entirely tooling β€” scaffolding, packages, headers, ports. By project #5 the list is protocol, cost and measurement. The problems stop being about getting the thing to run and start being about whether what it reports is true.

And the two most uncomfortable entries are both about a fix that wasn't one. Project #4's headline ratio, and project #5 discovering that fix had never been wired up. Both are on the list because the documentation of a problem got mistaken for the resolution of it.