statenour-verify
Use before committing or pushing changes to the statenour app (apps/statenour/) — the real local verification sequence and the two gates that silently pass when run naively.
SKILL.md.claude/skills/statenour-verify/SKILL.md
GitHubstatenour-verify
Verify a statenour change is safe to commit/push. The gate names are non-obvious and two of them silently lie if run carelessly.
Run, in order (from apps/statenour/)
pnpm typecheck—tsc --noEmit. Must be 0 errors.pnpm lint—eslint .(2026-07 consolidation; the oldlint:sourcescript no longer exists — if you see it referenced, that doc is stale).pnpm test— vitest. Must endTest Files N passed (N), exit 0.- (full gate)
pnpm verify:hard— typecheck:raw + lint + test + check:raw-sql + check:crons + check:stale-docs (strict) + prompt:size-check + prisma validate.
Traps
vitest.setup.tsis not wired, so no test runs in a fixed timezone.apps/statenour/vitest.setup.ts:1setsTZto America/New_York, butvitest.config.ts:27lists onlytests/setup/auth-guard-mock.tsinsetupFiles, so that file never loads. Never assume a timezone in a test: run any date test once withTZ=UTCfrom Git Bash before trusting a local green (this laptop runs Eastern, CI runs UTC). Witnessed 2026-10-22: a due-date test passed locally and failed in CI on PR 3239.- A script that loads env in its body must not statically import
lib/prisma(directly or transitively). ES imports are hoisted above aprocess.loadEnvFile(...)call, andlib/prismareads onlyapps/statenour/.env*, so the client is built with noDATABASE_URL("No database host"). Import Prisma inside the function that needs it and pin it with a test that the module loads without loading Prisma (tests/lib/obsidian-engine-status-lazy-prisma.test.ts). Witnessed 2026-10-02: the Obsidian engine never wrote a status row to prod. - Windows paths in
.envuse forward slashes. Inside double quotes Node'sloadEnvFileexpands\n, so"C:\Users\nourd\..."loads asC:\Usersplus a newline (witnessed 2026-10-02 onOBSIDIAN_VAULT_PATH). - A typecheck error inside
.next/types/**is a STALE LOCAL ARTIFACT, not your diff. Symptom:tsc --noEmitfails on.next/types/validator.ts—Cannot find module '../../app/api/<x>/ route.js'— for a route that does not exist. Cause:maindeleted the route after your worktree's lastnext build; the generated validator still enumerates it. Fix: overwrite the offending generated file (e.g. write a one-line comment into it); the next build regenerates it. Do NOT recursively delete.next/types—Remove-Item -Recurseon a worktree path is policy-blocked (the junctions walk into the primary checkout). Witnessed 2026-08-07 onapp/api/system/prompt-compare. pnpm test | tailmasks the exit code. A piped command's exit status istail's (0), not vitest's. Read the actualTest Files … failedsummary line, or runpnpm test; echo "EXIT=$?"with no pipe.- PowerShell
Select-Stringis case-insensitive by default. A vitest summary capture with a bare'Tests'pattern also matchestests 14msinside the Duration line — witnessed 2026-08-04: four mutation probes printed Duration lines as their "results" and verified nothing. Use-CaseSensitivewith an anchored pattern ('^\s*Tests\s+\d'), and read one captured line before trusting a batch of them. typecheckdoes NOT covertests/. A greentsc --noEmitsays nothing about test-fixture correctness — only a test run does. Proven 2026-07-30 (#1238): a test fixture omitted a newly-required field on an interface, typecheck exited 0, and vitest was the only thing that caught it. Never report "verified" off typecheck alone.pnpm typechecknever compilesscripts/either — bothscripts/andtests/are tsconfig-excluded. Verify a new or changed script with a scoped tsconfig extending the app's, with the script underfiles:{"extends":"./tsconfig.json","files":["scripts/<file>","next-env.d.ts"], "include":[]}, thentsc --noEmit -pthat file. Checktsc --listFilesOnly -p <config>names the script before trusting its exit 0: a childincludedoes not undo the baseexclude, so the oldincludeform compiled nothing and exited 0 (measured 2026-11-05, a planted type error went unseen). Tests have no such workaround — they're verified by execution only, never by typecheck.- A green
tsccan still ship a brokennext build— a different failure mode from thetests//scripts/exclusion above, same "green tsc lied" shape. Before importing betweenlib/observability/*(or anything reachable from*.client.config.ts/instrumentation-client.ts), ask which bundle the IMPORTER lands in — a Node-only dependency reachable from the browser graph failsnext build, nevertsc --noEmit. Run the real build when you change an import edge, not just typecheck. - A comment asserting "covered by
<other file>" is a coverage CLAIM, not a fact — grep the named file for the symbol/category before writing it, exactly like any other receipt. If the coverage doesn't exist yet, write the test first, or write "NOT yet covered" instead. - TS2307 "cannot find module" in a file OUTSIDE your diff = stale-junction
phantom, not your regression. Worktree node_modules are junctions to the
PRIMARY checkout; if the primary sits behind main, packages added since
(witnessed 2026-08-27:
edge-tts-universalfrom #1930) do not resolve. Confirm withgit show HEAD:apps/statenour/package.json | grep <pkg>vs what is installed - the fix is refreshing the PRIMARY checkout, never patching the app. - lefthook's parallel pre-commit jobs can flake red under memory pressure and pass unchanged on retry (witnessed twice 2026-08-27: statenour-typecheck 149s red -> green, agent-os-verify red while the same command passed standalone). Rerun ONCE before diagnosing; twice red at identical duration = real.
- A green CI job is not a green sweep; read the TASK count.
paths-filter(.github/workflows/test.yml) decides whether thenodeJOB runs;turbo run --affecteddecides which PACKAGES run inside it. A workflow-only diff satisfies the first and is empty to the second, so the job goes green having compiled nothing (witnessed 2026-09-16: #2362 was cited as the heaviest case and it passed; it ran zero turbo tasks). Before citing a CI run as proof a build-level change worked, open the sweep step's log:Tasks: 0 successful, 0 totaland a full sweep are the same colour. - Any build-resource number must be taken COLD. CI always is (fresh
checkout, remote caching disabled); a local repeat is not.
Compiled successfully in <10sis the tell: that run skipped the compile and type-check phases that hold the peak. Delete.nextbetween arms, and never write a locally measured MB figure into a workflow comment without naming the cache state (witnessed 2026-09-16: warm 4962 MB vs cold 6778 MB; #2368 existed only to retract the figures). - The full suite runs from a shell with
bashon PATH.tests/repo/anti-slop-gate.test.tsandtests/redteam/repo-canary-gates.redteam.test.tsspawnbash; from the PowerShell tool they fail (39 tests, exit 1, empty output on 2026-10-15) and pass from Git Bash (195 tests). Before diagnosing either with exit 1 and no output, checkGet-Command bash/which bashin the shell that ran the suite; both helpers fold every spawn error into exit 1, so that signature alone does not prove a missing bash. If those two files are the only reds in PowerShell, rerun them from Git Bash before calling them real. - A partial directory list is not a sweep. Before RETURN on any copy or
class sweep, run the whole statenour vitest suite once, from Git Bash, alone.
Witnessed 2026-10-16: a lane ran six directories and reported green; CI then
failed on
tests/home/waiting-line.test.tsx:59, a casing pin outside that list (about 45 runner-minutes). - ~405 eslint
anywarnings are pre-existing and non-blocking — onlyerror-severity problems fail the gate. - Vitest can abort with a native
failed to delete napi refcrash on Windows (exit 0xC0000409) right after other heavy node processes — that's infra flake, not a test failure. Re-run standalone. - Pushing
maindeploys statenour to Railway; the repo-level pre-push hook runsturbo build, NOT the test suite — confirming a greenpnpm testlocally is on you. git pushitself runsbuild:affectedinside pre-push — give it a 600s timeout and run it in the background (the default 120s times out as exit 143 with nothing pushed). The local SHA is not the receipt that the push landed; prove it withgit ls-remote origin refs/heads/<branch>.- Never invoke
cross-envbare in PowerShell, and never trust aFINAL_EXIT=$LASTEXITCODEsentinel after a&&chain. Witnessed: chaining gates through... && cross-env STALE_DOCS_STRICT=1 pnpm check:stale-docs && ...broke at barecross-env— CommandNotFound is a resolution error, not an exit code, so the trailing sentinel printed 0 while three gates had silently never run. Use$env:VAR='1'; pnpm <script>instead, and count the gate outputs rather than reading the sentinel — a green sentinel after a broken chain reports the last RESOLVED command. check:lint-baselinereporting NEW FILE on a file outside your diff is dep-bump archaeology, not your regression. Confirm withgit diff origin/main -- <file>(empty = not yours), then compare the baseline snapshot's last commit against the last eslint bump — a bump mints new warning classes in files nobody touched. Witnessed 2026-08-06:app/(mastery)/system/fleet/page.tsxwas byte-identical tomainwhile #1357's eslint dev-minor bump minted a fresh react-hooks class. RootAGENTS.mdclassifies this check non-blocking-red: disclose it, chip the fix, do not absorb it into your PR.- A prompt section joins the budget/trim economy ONLY with a
##title.trimPromptToBudgetsplits on\n##and nothing else, so###sub-blocks FUSE into the preceding##section and the trimmer can then only keep or drop the fused blob wholesale. Root cause of #1450: the Master Content Engine's ~30###sub-blocks fused into one atomic ~80k section, so the 65k runtime slice dropped the ENTIRE engine on the primary lane — every non-anthropic content turn served with no content engine, behind aprompt:size-checkthat had been red since #687 and that everyone had learned to ignore. - A fire-and-forget write sharing a mocked model with the code under test
spills calls ACROSS tests.
vi.clearAllMockscannot fence an unawaited promise — the Phase-1 memory gateway's shadow receipts ride the SAME mockedbrainMemory.createthe tests spy on, and receipts from earlier tests land in later tests' spy windows (5 visible in one window; red on main for a day, #1532). Assert with a discriminator filter (e.g.category !== "memory_gateway_shadow"), never raw call counts, and treatcalls[0]reads as the same hazard. - N concurrent FIRST-TIME dynamic imports of a mocked module can race the
mock registry — some callers get the real module instead of the mock.
Import the mocked module once before the fan-out and pass the binding
down; don't let each concurrent caller
import()it cold. A "mocked" test emitting the real module's log lines, or taking seconds instead of milliseconds, is this bug. - State shared between
instrumentation.tsand app code must live onglobalThis(or another process-global), never module scope — the two load as separate bundles, so a module-scoped variable set in one is invisible in the other. Canary shape:vi.resetModules()plus a fresh import must still read the settled state; reading the pre-reset default instead means the state was module-scoped. - Before deleting a file,
grep -rn '<basename>' tests/— not just its import. A source-reading guard (readSource/readFileSync) references a file by string and survives an import-based dead-code sweep untouched, then breaks when the file it names is gone. When the guard's subject is legitimately retired, retarget the contract at the replacement surface — don't delete the canary. - Deleting the last file of a directory (a patch, a fixture, anything)
can delete a directory a Dockerfile
COPYs — git does not track empty directories, so the next checkout simply lacks the dir theCOPYexpects. Runtests/repo/dockerfile-copy-sources.test.tsand read its message before pushing any change that deletes files under aCOPYsource; it exists in the suite specifically to catch this.
Before declaring a prompt-injection / fencing fix done
One fixed renderer is not a fixed class. Witnessed: a fencing fix landed for
one of five assemblers that splice stored text into the chat system prompt
(contextual recall) while the cross-session thread, hybrid recall,
anticipatory recall, chat recall, and tool results (searchColdMemory,
searchConversations, customer-360 notes) all still reached the prompt bare
— found only by a hostile review, and again by a self-review after the "fix"
was declared closed.
Before declaring any such fix done, enumerate every assembler that
renders stored text into a prompt or a tool result — brain-context.ts
block producers, system-prompt.ts sections, augment-final-prompt.ts,
context-hints.ts, lib/ai/tools/* — and name each one as fenced,
allowlisted-with-reason, or not applicable. The durable gate shape is
tests/ai/prompt-block-fencing-gate.test.ts.
Before shipping any report / diagnostic section
Prove the source receives rows from the path it claims to observe. Run the query against prod — code inspection is not enough. A filter whose discriminator value has no writer renders a fabricated all-clear, and it is invisible to tests, typecheck, and lint.
This shape landed three times in one day (2026-07-30):
| Read | Reality |
|---|---|
apiRequestLog where path starts /api/ai/chat |
chat route never passes through the request logger — 0 rows ever |
ai_generations where status='failed' |
writers only ever emit complete / error |
error_logs where level='fatal' |
writers only ever emit error / warn |
Corollary: a failed read must never render as good news. Distinguish
"query failed" from "genuinely empty" — reference implementation is
lib/observability/fleet-truth.ts, where a thrown probe becomes
unknown and unknown never counts as healthy.
When a finding rests on what a structure does NOT contain, read the
WHOLE structure by its delimiters — never a line-number window.
sed -n '/const typeMap/,/};/p', not sed -n '140,165p'. A truncated
read of a map is how orphaned-subject false positives are manufactured:
witnessed 2026-08-26, an audit reported nickstire's typeMap as having
"no mapping that yields booking" — delivered to the operator as a
finding — because the window cut off the map's head, whose first line is
"nickstire:booking": "booking". Refuted only when fix work forced a
full read. Absence claims inherit the blast radius of "not in the repo
is a fact about your search": prove the search saw the whole subject.
Before declaring a producer-side fix done on any rendered flag or
badge, grep the RENDER expression for every producer it ORs or aggregates
and check each one — fixing one producer while a sibling still emits
the old value leaves the badge unchanged and the fix unproven. For chat
quality verdicts specifically, proof is reload + read the persisted
tokenUsage.critic / tokenUsage.gate blob via trpc chat.conversation
— a live-stream screenshot is a blind instrument for this surface; it
shows the stream, not what got persisted.
Confirming a merge is DEPLOYED
"Merged" and "deployed" are different claims. Confirm via the unauthenticated deploy surface, and compare by ANCESTRY, never equality:
DEPLOYED=$(curl -s https://bdnick.info/api/version | jq -r .data.build.commit)
git merge-base --is-ancestor <your-merge-sha> "$DEPLOYED" && echo LIVE
jq may be absent in git-bash — fall back to
grep -o '"commit":"[a-f0-9]*"' on the raw response body. Treat an EMPTY
$DEPLOYED as "the check is broken," never as "not deployed yet." Prefer
a per-turn one-shot check over a long-running background poll loop — a
turn boundary kills the loop silently and leaves you waiting on nothing.
Two waiter failure modes witnessed 2026-08-26, same session:
- SHA equality breaks the moment a sibling merges — the deployed SHA legitimately overtakes the one you await (watched prod run two later commits containing nothing of mine, then one containing everything; an equality waiter would call all three "not deployed").
- A prefix longer than
commitShortnever matches — the endpoint'scommitShortissha.slice(0, 7); a 9-char comparison printed "still not deployed" forever over a build that was already live. Compare full SHAs via ancestry, or exactly 7 chars, nothing between.
Promote this to a hard step before any "shipped" wording, not an
optional spot-check. Run BOTH railway status (read Deploy failed
per service) AND the ancestry check above; a merge with only one of the
two is MERGED, never SHIPPED. Trap: railway logs --build with no
deployment id shows the latest SUCCESSFUL build, not the current one —
list deployments first and pass the FAILED deployment id explicitly, or
you'll read a stale green build log and call a broken deploy healthy.
When the operator DECLINES a gate finding
A gate that always fails is a gate everyone learns to ignore — the exact rot this repo keeps removing. So when the operator waives a finding:
- Waive by signature, not by filename. Put a marker on the offending line with a dated reason. Excluding the whole file blinds the check to every future violation in it.
- Red-green the waiver: green with it in place, and a fresh violation in the same file must still fail. Verify both; don't assume.
- Precedent:
check:anti-slop+ the stats-page gradient (#1239).
Extra checks worth running (not in verify:hard)
pnpm check:anti-slop— UI slop signatures (Inter/Roboto/Arial fonts, purple SaaS gradients). Line-levelanti-slop-allowwaivers.pnpm check:policy-coverage— every active cron has an AutomationPolicy row. Needs prodDATABASE_URL, so it can never be a CI/pre-push gate; run it manually.- Skill-registry freshness — if
data/skills-registry.jsongeneratedAtis stale orscripts/audit-skill-embeddings.tsreports missing embeddings, the recall layer is blind to recently-installed skills. Refresh withbuild-skill-registry.ts --merge(never--force— it drops entries belonging to your other machines), then embed. Target state isverdict: IN SYNC ✓.
When no statenour toolchain exists on the box
Witnessed 2026-09-02 (#2068): the primary checkout's apps/** had been wiped,
every worktree's apps/statenour/node_modules junction dangled, and
pnpm install inside a worktree is policy-blocked — so typecheck, lint
and the full vitest could not run anywhere. What still verifies, in order:
- Pure helpers run under the SIBLING app's vitest. Put an ad-hoc config
INSIDE
apps/nickstire/(a config in the scratchpad cannot resolvevitest/config):root= the statenour dir,resolve.alias["@"]= that dir,test.include= your test files; runapps/nickstire/node_modules/.bin/vitest run --config <it>; delete the config after. Proves the helper, not the app. - Static
check:*scripts run under nickstire'stsx. These ran without statenour deps:anti-slop,et-clock,stale-docs(STRICT),raw-sql,get-auth,mutations:strict,soft-delete,crons,runbooks. These could NOT load:prompt-injection(needsglob),policy-coverage(needs@prisma/clientand prodDATABASE_URL). - Commit + push from a hookless sparse scratch clone, because the
lefthook pre-commit typecheck and pre-push build would fail on the missing
toolchain:
git clone --no-checkout <url> <dir>·git sparse-checkout init --cone·git sparse-checkout set apps/statenour· branch fromorigin/main·git applythe patch from your worktree · commit · push. No install, so no hooks. Disclose it in the PR body ("commit pushed from a hookless scratch clone; CI is the gate") and merge only on fully green.
The same hookless-clone technique applies to a MULTI-APP wave sharing
one worktree, for a different reason: turbo build --affected reads
the working tree, so another app's uncommitted edits make ITS build a
gate for your push too. Either use one worktree per app, or export
per-app patches (git add -N for new files, then git diff HEAD --binary --output=<file> -- <paths>) and commit each from its own hookless
scratch clone. Run the per-app pre-commit gates by hand on the STAGED
files first — a brand-voice or lint scan reports 0 files scanned, not a
pass, unless you staged them.
Before calling a wave done
The author's own hostile pass is necessary, not sufficient. On the 2026-09-01 nickstire wave it found 5 defects; three parallel independent reviewers over the full diff (silent-failure, schema + cross-app contracts, client/docs) found 24 more, including two P0 regressions the wave introduced. Dispatch reviewers per subsystem with a file:line brief and a "verified OK / not checked" answer shape, verify their top findings yourself, then declare done.
Running an operator script that needs real credentials
.env.local is not the full credential set — several keys live only
on Railway. A provider-failure cascade is not proof of a billing problem
until you check which lanes actually had keys.
A key's PRESENCE — an env-check boolean, a .env line, a doc's claim —
is never evidence of VALIDITY. When any provider lane misbehaves, the
only real check is one live, cheap call against the provider from the
runtime that actually holds the key. Reading the key's existence proves
nothing about whether it still authenticates.
Prefer
railway run --service statenour-web <cmd>over hand-exporting vars. It injects the real environment, so the preferred provider lane actually gets tried.
Proven 2026-07-30: embed-skills.ts failed every provider (HuggingFace
402 credits-depleted, OpenAI 429 quota) and looked billing-blocked. The
real cause was that Cohere — first in the chain and the only lane pinned
to 1024 dimensions — has no key locally but does on Railway. Under
railway run it embedded 8 skills in one second.
Dimension trap in the same chain: Cohere pins output_dimension: 1024
and rejects anything else, but the OpenAI fallback returns 1536 with
no length guard. "Succeeding" on that fallback would silently poison a
1024-dim corpus — check embedding_dim after any embedding backfill.
When every scenario in a run errors with the provider's sentinel value in seconds, check the provider breaker or cooldown FIRST — a recent stray call or a recent failed call can trip it, and the next real attempt inherits the trip. Re-run the identical command once before touching any code. Never grade or trust a run whose Nick calls came back as sentinels; it measured the breaker, not your change.
Credentials in fixtures and diffs
Fixtures use synthetic values with the same shape, never a real
credential — not even in a test that proves the value gets redacted.
Before committing anything touching credentials, grep the diff for
fragments of every value the operator has shown you this session,
screenshots included. A green gitleaks scan is shape-dependent, not
value-dependent: the same real key passes as an inline argument and
fails only as const secret = "..." — don't trust it as the sole check.
Reading an ambiguous CI or hook result
Two of every three "failures" in the 2026-09-02 wave needed no code change. Triage before fixing:
##[error]The operation was canceled.is INCONCLUSIVE, not a pass. A cancel can be a sibling's merge superseding the run, a manual cancel, or a timeout — and in every case the checks did not finish, so an absence ofFAILlines proves nothing about whether they would have failed. Read the cancellation cause, then rerun; rootAGENTS.mdis explicit: "If CI is cancelled by a sibling's merge, rerun; don't debug." Never treat a cancelled run as green evidence.- The completion-authority steps of the agent-policy job evaluate review threads at run time.
Resolving threads afterwards leaves a STALE red. Re-run it; do not
re-litigate the threads. It compiles four sub-gates — know the one-line
fix for each: rewrite the per-diff evidence entry (never reuse a prior
diff's); resolve review threads via GraphQL
resolveReviewThreadAFTER replying to each, not before; obey the ledger ladder — fix the LABEL, never inflate the state; always runscripts/render-reality-ledger.mjsand commit the regenerated.mdbeside the.json. - List review threads (
gh api graphql … reviewThreads) and READ them before resolving, every time. Reply with evidence, then resolve — but a thread is sometimes right about a real bug the tests missed. Never resolve a thread just to clear the gate; that converts a correct reviewer finding into a shipped defect. - Trace reachability before assuming authorship. An e2e failure
looked like the wave's until traced:
/api/intelhas no import path to the changed code, the error was a heartbeat timeout (curl rc=28), neighbouring runs were cancelled, and a re-run passed. - ⚠ A pre-commit eslint OOM (exit 134) aborts the commit while the
subsequent push prints
Everything up-to-date— which reads exactly like success and leaves the work uncommitted. Checkgit logafter any commit whose hook output looks odd. - ⚠
echo $?aftercmd | headcaptures head's status, not the command's. A subagent reported a false-green typecheck this way. Do not pipe a gate you intend to read the exit code of — run it unpiped and read the summary line.${PIPESTATUS[0]}recovers it in Bash only; this repo's primary shell is PowerShell, where that array does not exist and$LASTEXITCODEafter a pipeline is likewise the LAST command's. Unpiped is the portable answer. - Check the CI database version, not just prod. CI is
pgvector/pgvector:pg16, prod is PG17 —IS JSON OBJECTneeds PG16+. Both fine here, but the gap is real and untested by default. - A gate that reports a failure COUNT with no LOCATION needs the
instrument fixed before the finding. Witnessed: gitleaks printed
leaks found: 1with no rule/file/line; guessing (agoogle-site-verificationmeta tag) shipped a wrong, mis-scoped allowlist while the red stayed red.--report-format json --report-pathgave the real hit (generic-api-keyon a minified analytics key). Never scope an allowlist to a LINE on minified HTML — one line can be the whole 15KB document, and a real secret sharing it would be exempted too. Scope to the matched text. gh pr checksreporting zero rows is not "settled," and a pushed SHA with zero check-runs while Actions shows operational is a stalled event delivery, not a billing question. Require a positive row count (pass > 0, not justpending==0 && fail==0) before trusting a settle loop. If check-runs never appear on a pushed SHA, push the next real commit — closing/reopening the PR does not re-fire them.
After MODIFYING a verified query, re-run it
A receipt for the old version is not a receipt for the new one. Recorded after a wave modified a SQL statement post-verification and shipped it unre-run.
A negative result from a third-party API is not evidence yet
Before recording "the record isn't there", clear two failure modes that both return a clean, believable empty result:
- The vendor's naming convention. Langfuse stores an observation as
<functionId>:<span>— searchingobservability-probefinds nothing whileobservability-probe:ai.generateTextis sitting right there. - List endpoints are PROJECTIONS. On
GET /api/public/v2/observationsa field comes back only if its group is requested withfields=(defaultcore,basic), so an unrequested field reads as absent. The legacyGET /api/public/traces[/<id>]detail route is removed from Langfuse Cloud on 2026-11-16. Querying without the group returns "absent" for a field that is present.
Both fired in one run, and nearly recorded a WORKING pipeline as dead — in a session whose whole subject was a health badge that lied. Prove the reader works by matching one record you know exists, then trust its zeroes. Same discipline as empty-vs-error: absent and broken are different answers, and a thin projection makes them look identical.
Naming collisions in a spread-order barrel
nourTools spreads multiple domain files in order — the LAST one to declare a
given key wins, silently. Adding a tool whose name already exists elsewhere in
the barrel overwrites a working tool with no error, and the catalog's total
count stays flat (one key replaced the other), so a count-only sanity check
won't catch it. Witnessed: a new scheduleFollowUp silently shadowed an
existing customer-facing follow-up tool of the same name; caught only by
catalog-integrity's stronger check.
Before adding a tool: git grep -n '<name>: tool(' across lib/ai/tools/.
Register in BOTH catalog.ts and tool-families.ts — their category/cost
unions differ, and only the pre-commit typecheck catches a mismatch between
the two.
Registration is not reachability for the chat pruner either. A tool
correctly present in all three registries can still be invisible to
pruneTools for all but one of five realistic phrasings — the trigger
regex and the attach-pattern regex are different, and a tool can match one
without the other. After adding a chat tool, run pruneTools against 5
phrasings a real user would type and assert the tool actually surfaces; a
registered-but-unreachable tool measured this way on a real wave got
attached for only 1 of 5 tries.
When NOT to use
Non-statenour work, or nickstire (it has its own pnpm run verify).