Commit graph inspector/scripts
Author SHA1 Message Date
cliffhall
80a013c319 feat: watch the MCP SDK nightly and file an upgrade issue (#1063)
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>
2026-09-06 08:58:47 -04:00
Cliff Hall
5eb350f5db
Merge pull request #2261 from modelcontextprotocol/v2/fix/2247-testing-handoff
Reshape the testing → test-servers pointer into an imperative step
2026-09-05 18:24:51 -04:00
cliffhall
0d947ad569 Review round 8: using a fixture, not connecting, is the trigger
Copilot, all three accepted.

1. Connecting still is not the condition. A few integration tests
   deliberately hand-roll a JSON-RPC server because the composable
   fixture *cannot* produce what they assert on —
   `inspectorClient-malformed-list.test.ts` says so in its own header:
   "the SDK's own server cannot emit these shapes: they are exactly what
   its types forbid". Same for `listSalvage-era.test.ts`. Real
   transport, real client, zero `test-servers/` dependency.

   Round 4 already stated the right condition at the top — "does this
   test depend on a fixture from `test-servers/`" — and then three
   downstream sites went on saying "connects". Those are now scoped to
   fixture use, and the hand-rolled exception is stated where the
   trigger is defined rather than left to be discovered, with both files
   named. Connecting is kept as a hint, since it usually does imply a
   fixture, with the instruction to check whether one can express the
   case first.

2. "description-strength measurement" was the wrong surface, and this
   PR is what made it wrong: a chained prompt is deliberately written so
   the target's *description* cannot fire on it, so the number measures
   the pointer in the first skill's body and nothing else. Left as-is it
   would send the next person tuning a red hand-off at the description.

3. "an 0.8 bar" -> "a 0.8 bar".

Re-measured: 100% / 100% hand-off, 63/63 first-move at 100%, RUNS=5.

Staged by explicit path, not `git add -A` — see ec6895a5.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EQVdemwgUhh45yzeaRSLhW
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-05 18:03:37 -04:00
cliffhall
03af5b966f Review round 6: 80% does not clear a strictly-compared 0.8
Copilot, accepted — and the sharpest catch of the six, because it is
the rationale contradicting the operator two paragraphs below it.

Round 1 rewrote the CHAIN_THRESHOLD comment to say a well-shaped
pointer "does clear 0.8" and cited 100%/80% as the evidence. This
threshold is compared with `>`, so 80% would FAIL an 0.8 bar; the
example refuted the sentence it was offered in support of. It was also
stale: 100%/80% was an intermediate build, and the shipped state
measures 100%/100%.

Both copies now cite 100%/100% as the clearing evidence and keep
100%/80% only as the residual-noise figure, saying explicitly that it
is not an example of clearing the bar.

While checking the claim I found the docs copy needed the same care for
a different reason: `passesThreshold` is strict for the chain and
INCLUSIVE for the first move (`rate > t` vs `rate >= t`, skill-eval.mjs
:558), and 0.8 is the constant for the inclusive one. So "0.8 is
compared strictly" is false as literally read. The docs now say a
*chain* bar of 0.8 would be the strict one, name the operator for both,
and spell out the consequence at RUNS=5 — only 5/5 passes, 4/5 does not.

No functional change; CHAIN_THRESHOLD is still 0.5, still strict.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EQVdemwgUhh45yzeaRSLhW
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-05 17:39:11 -04:00
cliffhall
95633a2283 chore: address Copilot review round 3 (#2226)
Two factual corrections, both suppressed comments.

- `.claude/skills/local-dev/SKILL.md`: the react/react-dom exception cited
  "19.2.8 today, against the root's 19.2.7" — a skew this PR removes. Dropped
  the version pair; the point being made is about which install resolves the
  pair, and it stands without a number that goes stale on the next bump.
- `verify-dep-lockstep.mjs`: the `devDependencies` bullet claimed all four
  surfaced skews were a devDependency somewhere. `react` is a root runtime
  dependency whose client copies are peer-installed, so it is three of four —
  which is a better illustration of why the union spans fields as well as
  installs, and the bullet now says so.

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>
2026-09-05 17:14:44 -04:00
cliffhall
5daa3f5060 chore: address Copilot review round 2 (#2226)
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>
2026-09-05 16:27:46 -04:00
cliffhall
02f094f001 Review round 1: gate the hand-off on connecting, not on tier depth
Copilot, both findings accepted.

1. The trigger was over-broad. "a smoke" and "everything from web
   integration rightwards" routed work to `test-servers` that needs no
   fixture at all: `smoke:launcher` only checks `--help`, `smoke:web`
   asserts the SPA is served, `smoke:web:browser` stops at first paint,
   and Storybook play functions render from fixture props. The condition
   is now "does this exercise MCP behaviour over a transport?", stated
   as such, with both sides named — the smokes that connect
   (`smoke:cli`, `smoke:web:app`, `smoke:web:elicit`, `smoke:web:tabs`)
   and `pack:verify` on one side, the boot-only ones and Storybook on
   the other. Depth in the tier list is explicitly not the boundary.

2. The threshold rationale contradicted the new result. It said an 0.8
   bar would mark a hand-off red "regardless of how strongly the first
   skill points at the second" — which the 100% measurement refutes.
   Both copies (docs and `skill-eval.mjs`) now say what 0.5 actually
   buys: a floor on useful reliability for a noisier second-hop load, so
   the column still separates an adequate pointer from a broken one. The
   value is unchanged.

Re-measured after the narrowing, since a tighter trigger could have
weakened the pointer: 100% / 100% on the hand-off and 63/63 first-move
at 100%, full suite at RUNS=5. Recorded figure updated from the
intermediate 100% / 80%.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EQVdemwgUhh45yzeaRSLhW
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-05 16:17:31 -04:00
cliffhall
8e2c4d49fa chore: address Copilot review round 1 (#2226)
- Include `optionalDependencies` in the declared tier's candidate union. A
  peer range constrains the consumer's host; an optional range is a direct
  declaration npm attempts to install, so omitting it left an optional-only
  package free to skew unseen. `peerDependencies` stays excluded, with the
  reason stated.
- Make the end-to-end fixture's client copy UNDECLARED — only the root
  manifest names `solo`, while `clients/web` merely holds a top-level copy.
  That is #2226's defining shape, and it is what makes the candidate set a
  union across installs rather than a per-install intersection; the old
  fixture would have passed a per-install implementation. Adds the mirror
  case (declared only by a client, held by the root) so the union is pinned
  in both directions.
- Update the two docs that still described the single-tier guard:
  `.claude/skills/local-dev/SKILL.md` said "nothing gates either of those,
  and `verify:dep-lockstep` is not it" and cited the live `@types/node`
  drift in the present tense; `docs/quality-gate.md` described only the
  program tier. Both now state what each tier covers and the two limits
  that remain.

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>
2026-09-05 16:09:44 -04:00
cliffhall
7d0ae9e9ec Reshape the testing → test-servers pointer into an imperative step
The hand-off measured 33% / 33% at RUNS=3 (#2204's two committed chain
cases). `testing` opened with a one-sentence ⚠️ *classifying* which work
belongs to `test-servers` — a fact, which the model skims — and said it
nowhere else except a bare cross-reference in the last section.

Rewrite it as an action with a trigger ("load the `test-servers` skill
now — that is step one"), enumerate the situations that qualify, and
repeat the pointer at the two later points where the run actually
decides it is writing an integration test: the `integration` project
bullet under "Where the test file goes", and the tier list. The top of a
body is read before the model knows it needs the second skill, so a
pointer that lives only there has already been scrolled past by the time
it matters.

Measured at RUNS=5 on the full suite: the two hand-off cases go to
100% / 80% (100% / 100% on a focused `-- test-servers` run), and all
63 first-move cases stay at 100% — no displacement, as expected, since
neither description changed.

Also update the two places that recorded the old number as the standing
state: the worked example in `docs/skill-authoring.md`, and the
`CHAIN_THRESHOLD` rationale in `scripts/skill-eval.mjs`, which cites the
33% measurement as the reason the bar is 0.5 rather than 0.8. The bar is
unchanged — a reshaped pointer raises the ceiling those two cases reach,
not the floor a new hand-off case should be judged against.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EQVdemwgUhh45yzeaRSLhW
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-05 14:59:05 -04:00
cliffhall
1e6bf6425a chore: widen verify:dep-lockstep to declared cross-install copies (#2226)
`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>
2026-09-05 14:10:36 -04:00
cliffhall
313a580982 feat(skills): address Copilot review round 3 on #2204
One finding, and a sharp one: position in the stream is not causation.

`collectSkillInvocations` flattened every `tool_use` block across every
assistant event into one ordered array, and `chainHit` read position
alone. But the model can emit several `Skill` blocks in a SINGLE assistant
message, and it has not seen the first skill's body when it does — those
are parallel guesses, not a hand-off. A run that emitted `testing` and
`test-servers` together in one message scored as `A` led to `B`, and would
have gone on scoring after the pointer in `testing`'s body was deleted:
exactly the false pass this feature exists to rule out, arriving by a
second route (Copilot).

Each recorded invocation now carries the assistant event it came from —
`{payload, turn}` — and `chainHit` requires every link after the first to
land in a STRICTLY later turn. The turn count is threaded through
`collectSkillInvocations`'s `turnOffset`/`nextTurn` so a stream read in
chunks stays monotonic rather than restarting per read; without that, a
chunk boundary would reset the numbering and reintroduce the same bug on
long runs.

The scan stays greedy, which is still correct under the constraint: taking
the earliest occurrence of a link can only leave more room for the rest,
so no later starting point succeeds where the greedy one fails.

`sampleHit` is deliberately unaffected — a first-move case asks only
whether a skill fired at all, so it reads names and ignores turns.

Four new assertions in one case: a same-turn pair scores false; the same
two skills a turn apart score true; a same-turn pair does not poison a
later genuine hand-off; and only the chain's first link is unconstrained.
Plus a `collectSkillInvocations` case pinning the turn tagging and the
cross-chunk offset.

`docs/skill-authoring.md` now lists this as the second way a chained case
can false-pass, alongside a prompt carrying the target's own trigger.

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>
2026-09-04 20:28:37 -04:00
cliffhall
3ec125725a feat(skills): address Copilot review round 2 on #2204
Two findings, both correct, and the first one means round 1's containment
fix was only half a fix.

**`--allowedTools` is not the availability filter.** It grants permission;
it does not restrict which tools exist. So a tool a user's or a plugin's
settings already permit stayed reachable across all 14 turns, and the
bound still rested on the deny list being complete — the exact property
round 1 set out to stop depending on. `--tools` is the real restriction
(it selects from the built-in set), and both flags now carry the same
four names: `--tools` to bound, `--allowedTools` so those four never need
a permission prompt no headless run can answer. Verified against the real
CLI — the full flag set runs to a clean `end_turn`. The test asserts both
flags, and both probe snippets in `docs/skill-authoring.md` carry
`--tools` with a ⚠️ saying which one does the bounding.

**The threshold guard let `NaN` and negatives through.**
`CHAIN_THRESHOLD=abc` is `NaN`, which fails every comparison and prints an
`above NaN%` summary; a negative bar passes every chain unconditionally.
Both turn an advertised env knob into a measurement that quietly means
nothing. `CHAIN_THRESHOLD` must now be in `[0, 1)` — half-open because the
chain bar is strict — and `THRESHOLD` in `[0, 1]`, inclusive because a
first-move floor of 1.0 is meetable. The message names the offending
value. A new case spawns the script for each bad value rather than
asserting on the predicate, since these guards live in `main`.

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>
2026-09-04 20:03:11 -04:00
cliffhall
f20bbac474 feat(skills): address Copilot review round 1 on #2204
Five findings, all real.

**The chain prompts carried the target skill's own trigger.** `test-servers`
claims "a change needs a real server to exercise it", and both cases said
"against a real/live server" — so the model could load `testing`, then pick
`test-servers` from the ORIGINAL prompt, in that order, and score a hit that
would have survived deleting the pointer from `testing` entirely. Rewritten to
carry no server cue ("end to end"), and the measured rate fell from 100% / 67%
to 33% / 33%. That is the size of the artefact, and 33% is what the pointer
alone is worth. Both cases are now red against the 0.5 bar and stay that way:
`skills:eval` is not a gate, the number IS the finding, and lowering the bar to
turn the column green would discard the only signal this feature adds. Filed as
#2247 with the trap written down so a fix cannot re-measure the artefact.

**A deny list cannot bound a 14-turn run.** It only names tools known when it
was written, and this checkout configures an HTTP `mcp-docs` server in
`.mcp.json` while a contributor's own MCP servers and plugins add more. Now
`--allowedTools Read,Glob,Grep,Skill` (what the harness actually needs) plus
`--strict-mcp-config` with no `--mcp-config`, which drops every configured
server. The deny list stays alongside, since a deny is unconditional while an
allow list governs pre-approval. What remains outside all three is a
contributor's plugin tools; `--bare` would remove those and skills with them,
which would measure nothing — so that residual is stated rather than papered
over.

**The chain threshold compared inclusively.** "More often than not" is `> 0.5`,
and `>=` passes exactly half whenever RUNS is even — 2/4 would report a result
the criterion does not license. `passesThreshold` is now strict for chains and a
floor for first moves, and a strict bound of 1.0 is rejected up front rather
than failing every case while looking like a trigger problem.

**The doc's probe command still denied only four tools**, contradicting the
section's own promise that a probe and a scored case see the same policy.
Updated, with a note that it must move with the harness, plus a hand-off probe
form.

**The reporting had no automated coverage** — the one thing that could silently
merge the two measurements lived inside `main`. Extracted as `formatReport` and
tested: both headings, per-group thresholds, the two separate summaries, the
combined exit, and both single-kind selections.

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>
2026-09-04 19:33:51 -04:00
cliffhall
75940976f8 feat(skills): measure a skill reached from another skill (#2204)
`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>
2026-09-04 19:07:26 -04:00
cliffhall
0bf8fbd36b chore(deps): address Copilot review round 15 on #2243
Record each advisory's disposition during the loop instead of deriving
"can this be filed?" from the groups beforehand.

Membership in a group only means an alert exists. Whether an issue
tracks it is decided by the ecosystem check, the manifest read and the
range probe that follow — so if GitHub revised first_patched_version
while that manifest happened to be unparseable, the replacement group
was skipped and the old-key issue was still cleared as "superseded",
claiming an issue that was never filed.

Each advisory is now noted as tracked, not-exposed or indeterminate as
the loop reaches it, and reconciliation refuses to clear while anything
still open is indeterminate or absent. That also splits the clear reason
honestly: a bump whose copies simply left range now says so, rather than
claiming a replacement issue exists.

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>
2026-09-04 09:26:20 -04:00
cliffhall
e846dec491 chore(deps): address Copilot review round 14 on #2243
- 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>
2026-09-04 09:09:24 -04:00
cliffhall
c0d1d56782
chore(deps): address Copilot review round 13 on #2243
Three findings, all of them the same invariant reached by new routes: a
still-open advisory must never stand itself down, and a cleared issue
must be written once.

- Never clear an advisory that is open but unpatched. groupAlerts drops
  an alert with no first_patched_version, so it has no replacement
  group and no replacement issue — calling it "superseded" was false
  and clearing it removed the only thing tracking a live exposure. Such
  an issue is now left exactly as it is, and said so in the log.
- Stop re-editing cleared issues daily. A cleared issue stays open, so
  the next run regenerated its body with a new date and edited it
  again, forever. The date is now read back off the existing body, so
  only a real change writes — and a real change takes today's date.
- Distinguish an absent manifest from an unparseable one. Both returned
  null, so a malformed lockfile was treated as "the manifest is gone"
  and cleared the issue. A read error is evidence of nothing.

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>
2026-09-04 09:06:32 -04:00
cliffhall
bc47fcbcf0
chore(deps): address Copilot review round 12 on #2243
Build the open-GHSA set from the raw alert feed, not from the grouped
alerts. groupAlerts deliberately drops an alert with no
first_patched_version, so an advisory that stays OPEN but loses its
patched version vanished from both the keys and that set — and
reconciliation would have called it "fixed or dismissed".

Same wrong direction as the superseded case fixed last round, reached a
different way: a still-open advisory must never stand itself down.

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>
2026-09-04 09:06:32 -04:00
cliffhall
233647163f
chore(deps): address Copilot review round 11 on #2243
- 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>
2026-09-04 09:06:32 -04:00
cliffhall
9e516ae0a1
chore(deps): address Copilot review rounds 8-10 on #2243
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>
2026-09-04 09:06:32 -04:00
cliffhall
1d6ea1a8f6
chore(deps): address Copilot review round 7 on #2243
Both findings are consequences of round 6's staleness fix, in the two
places it did not reach.

- Resolve the matching open issue BEFORE the skip paths, and rewrite it
  to a cleared state when the exposure is gone. Yesterday's issue is
  still open today, so a manifest that has since been removed or copies
  that have moved out of range left its body asserting a vulnerability
  that no longer exists and its Todo/High card live indefinitely. The
  marker is retained so the issue is reused if the advisory returns; the
  issue is not auto-closed, because whether the card belongs in Done or
  should be deleted depends on why the exposure went away.
- The body's prose counted the marker's monotonic history rather than
  the advisories that apply, so it could claim two open alerts while the
  title and table correctly showed one.

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>
2026-09-04 09:06:32 -04:00
cliffhall
ff339dc7c3
chore(deps): address Copilot review round 6 on #2243
Both were suppressed "previously missed" findings, and both were real.

- isPermissionDenied matched on status alone, so a rate limit (also a
  403) and a bad token (401) were waved through as "missing scope" —
  contradicting the comment right above it, which said those must stop
  the sweep. It now excludes rate-limit wording and drops 401.
- The no-op path asked "were advisories ADDED?" when the question is
  "did the issue CHANGE?". An issue filed for A+B whose branch moved so
  only B applies has nothing added, yet its table, severity, affected
  copies and remediation are all stale. The rendered title and body are
  now compared against the issue, and a comment stays reserved for
  genuinely new advisories.
- The title counts the advisories that APPLY, matching the body, so it
  tracks shrinking exposure as well as growth. The marker's GHSA list
  stays monotonic — its job is to remember what has been announced.

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>
2026-09-04 09:06:32 -04:00
cliffhall
a17eace0bb
chore(deps): address Copilot review round 5 on #2243
Both findings were "previously missed" suppressed ones; the two against
scripts/dependency-refresh.mjs and its workflow are relayed to #2239.

- Serialize the sweep with a fixed concurrency group and
  cancel-in-progress: false. The marker check is a read-before-write and
  a workflow_dispatch can land on top of the scheduled run, so two runs
  could both see no open issue and both file one — the duplicate the
  whole idempotency design exists to prevent. The queued run must wait
  and re-read, never be cancelled.
- Pick the milestone in JS, not in jq. jq sorts null before every
  string, so sort_by(.due_on) | .[0] returns an UNDATED open milestone
  in preference to every dated one. An undated bucket has no due date
  and so cannot be the nearest; pickMilestone drops it, and files the
  issue unmilestoned if nothing dated is open.

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>
2026-09-04 09:06:32 -04:00
cliffhall
1e58d2fe8a
chore(deps): address Copilot review round 4 on #2243
- Narrow each group to the advisories the installed copies are actually
  in range of. Two advisories can share a package, manifest and patched
  version while having different vulnerable ranges, so a group-level
  "does any match?" left the marker, title, severity and table all
  claiming an advisory that does not apply on this branch.
- Escape every free-form Markdown cell, the vulnerable range included.
  A semver range may contain `||`, which is also the column separator.
- Paginate the open-issue lookup instead of capping it at 100. The
  marker lookup is what makes the sweep idempotent, so a truncated list
  would file a duplicate for every issue it could not see — at the same
  scale the alert fetch is built to handle. Pull requests, which the
  issues endpoint also returns, are dropped.

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>
2026-09-04 09:06:32 -04:00
cliffhall
5c65bd2bfa
chore(deps): address Copilot review round 3 on #2243
Derive the remediation from WHICH copies are vulnerable, not from
whether the package is declared. A manifest can declare a safe `pkg@4`
while a dependency drags a vulnerable `pkg@3` into a nested folder; the
old boolean called that "direct" and asked for a range bump that would
have changed nothing, omitting the override the nested copy needs.

- `lockfileEntries` keeps each copy's tree path and whether it is the
  hoisted one; `lockfileVersions` is now derived from it.
- `remediation(affected, declared)` returns both flags, so the issue can
  ask for a range bump, an overrides pin, or explicitly both. The body
  lists the vulnerable copies and their paths.
- An undeclared hoisted copy counts as transitive: it got there the same
  way any other transitive copy did, and no declared range reaches it.
  (Found by a test written for this change.)

The `vulnerability-alerts: read` finding is declined — verified against
a real runner, which reports `VulnerabilityAlerts: read` and reads the
alerts successfully. See the PR comment for the log.

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>
2026-09-04 09:06:32 -04:00
cliffhall
761bdd128d
chore(deps): address Copilot review round 2 on #2243
- Remove the literal NUL bytes that were separating the grouping key's
  fields; they classified the whole source file as binary, so repo
  searches skipped it. The key is a JSON array now, with no separator
  left to justify.
- De-duplicate advisory comments per GHSA rather than per whole set. A
  run that comments and then fails before the marker edit left the next
  run computing a different set, which matched nothing and announced the
  same advisory twice.
- Refresh the issue title on edit; it carries the advisory count, so it
  went stale as soon as an issue grew past what it was filed with.
- Correct the test file's header, which still claimed main() was left
  to workflow_dispatch.

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>
2026-09-04 09:06:32 -04:00
cliffhall
b53266bc72
chore(deps): address Copilot review round 1 on #2243
- `--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>
2026-09-04 09:06:32 -04:00
cliffhall
66a3332f1d
chore(deps): sweep Dependabot alerts into board-tracked issues
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>
2026-09-04 09:06:32 -04:00
cliffhall
0cb12a19ca
chore(deps): paginate the release list so the highest version is actual
Addresses Copilot review round 7 on #2239.

The lookup read one page of 100 and the doc comment justified it with an
assumption rather than a guarantee: "the greatest version is within it
for any real action". The list is ordered by release DATE, not version,
so an action that keeps cutting maintenance releases on lower majors
pushes the genuine maximum off page 1. Ranking what remained would
report a lower major as highest — the exact false-current result that
dropping `releases/latest` in round 3 was meant to prevent, arrived at
by a different route.

Now passes `--paginate`. Cost is unchanged for every action this repo
uses (57 and 64 releases are the largest, so still one request each);
it only costs more where correctness actually required it.

The command-shape test now requires `--paginate` as well as the list
endpoint. It has to: pagination is invisible in the result — one page
and every page return an identical-looking tag list until the day they
do not — so nothing about the returned value can detect its absence.
Verified by mutation: removing `--paginate` fails exactly one test.

Adding the flag also moved the URL out of args[1], which the test's own
filter had hardcoded — it caught that itself. Both the filter and
fakeSpawn's matcher are now argument-position agnostic.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NGtzPg3UxMLszysQXXfqax
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-04 09:06:31 -04:00
cliffhall
8414685ad9
docs(scripts): correct the release-lookup contract left stale by round 4
Addresses Copilot review round 5 on #2239.

Round 4 made every non-zero release-list response fatal and deleted
`isMissingRelease`, but `latestReleaseTag`'s JSDoc still promised
`@throws when the lookup fails for any reason other than a 404` — the
exact behavior that change removed. The PR description carried the same
stale claim.

A contract that documents the opposite of the code is worse than none:
the next reader reasonably trusts it, and here it would tell them a
missing action repo is silently tolerated when it now fails the sweep.

The contract now reserves `null` for a successful response carrying no
usable release — an action that has never cut one, or whose tags are all
unparseable — and states that a failed lookup is never `null`.

Documentation only; no behavior change, and the 29 tests are untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NGtzPg3UxMLszysQXXfqax
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-04 09:06:31 -04:00
cliffhall
dde1736702
chore(deps): treat every failed release-list response as fatal
Addresses Copilot review round 4 on #2239. Both findings are regressions
I introduced in round 3 and both are correct.

Round 3 moved the lookup from `releases/latest` to the release LIST, to
rank by version rather than by GitHub's designated latest — but kept the
404 suppression that only made sense for the old endpoint. The list
endpoint answers "this action cuts no releases" with a successful empty
array, so a 404 there does not mean that at all: it means the repository
is missing or inaccessible, i.e. a `uses:` ref the sweep cannot check.
Converting it to null silently dropped a broken or renamed action from
the flow that replaced Dependabot — the same silent-success shape this
PR has been closing everywhere else, reintroduced one layer down.

Every non-zero status is now fatal and `isMissingRelease` is gone
entirely; the benign no-releases case is the successful empty array,
covered by its own test.

`buildClearedBody` also overstated its all-clear. Refs pinned to a commit
SHA or a branch return null from `parseVersionRef` and are never ranked,
so "no workflow `uses:` ref is behind" asserted a check that had not
happened for them. It now says version-pinned refs and names the
exclusion explicitly.

Both guards are mutation-checked: restoring the 404 suppression, and
dropping the SHA/branch qualification, each fail exactly one test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NGtzPg3UxMLszysQXXfqax
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-04 09:06:31 -04:00
cliffhall
6af74424d8
chore(deps): remove dependabot.yml, fold action bumps into the sweep
Closes #2235.

Switches the old dependency flow off now that #2232 landed the
replacement. Removes the five npm ecosystem entries (root plus each
client under clients/*) and, resolving the question #2229 left open, the
github-actions entry as well — so .github/dependabot.yml goes away
outright rather than being emptied, which its schema does not allow.

The github-actions entry had exactly the property #2229 exists to
remove: it opened a grouped monthly PR carrying no `Closes #N` and no
board card, the one standing exception to "every PR references an
issue". Deleting it unreplaced would have left 9 actions unwatched, and
`npm outdated` says nothing about actions, so the monthly sweep now also
checks every `uses:` ref under .github/workflows and renders the stale
ones as one more section of the same tracking issue.

Ranking comes from the release LIST, not `releases/latest`. That
endpoint returns the release GitHub designates most recent, not the
greatest version, so an action publishing a maintenance release for an
older major (a v6.9.1 cut after v8.0.0) would make a workflow pinned to
v7 compare against v6 and read as current — silently missing a whole
major upgrade, the one thing this check exists to catch.

Staleness is compared only to the precision the ref specifies. `v7` is a
moving major tag that GitHub repoints at every v7.x release, so `v7`
against a highest of `v7.0.1` is current and only `v8` makes it stale;
an exactly-pinned `v7.0.0` is behind `v7.0.1`; a SHA pin is deliberately
immovable and is never reported.

A release lookup suppresses only a 404 — the legitimate "this action has
never cut a release" answer — and throws on anything else. Treating a
rate limit or an expired token as "no release" is indistinguishable from
"not stale", and since every action here already sits on its latest
major, the resulting empty section is byte-identical to a healthy run.

`buildClearedBody` now speaks for both halves: once actions are in scope,
its npm-only wording would assert a clean bill of health the sweep never
checked.

Dependabot security updates are unaffected — they are configured in repo
settings, not in this file, and kept working while it was missing
entirely (#1833, #1840). That note moves into the workflow header rather
than dying with the file; #2233 is where they are turned off
deliberately. The script header, the workflow header and the generated
issue body all say version-update PRs rather than claiming Dependabot is
replaced wholesale.

Also renames the workflow's npm-outdated job to dependency-sweep now
that the sweep is no longer npm-only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NGtzPg3UxMLszysQXXfqax
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-04 09:06:31 -04:00
cliffhall
ddf2307c4c
test(scripts): stop the repo-guard test depending on the ambient env
`main refuses to run without a repo` called `main(undefined, …)` so the default
parameter would fire, then asserted it throws. The default reads
`process.env.GITHUB_REPOSITORY` — which GitHub Actions sets on every run — so
the assertion held only where the variable happened to be unset. It passed
locally and failed CI, the one environment where the default is always
populated.

The test now clears the variable and restores it, and a second test covers the
other half of the default: with it set, `main(undefined, …)` uses it instead of
throwing. Between them the default's behavior is pinned in both environments
rather than inherited from whichever one is running.

Verified both ways: 14 pass with the variable unset and with it set, and the
full `test:scripts` suite is 411/411 under the CI environment shape.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RWMJENjs1mExXE5XPJTEei
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-04 09:06:30 -04:00
cliffhall
07bd987129
chore(deps): address Copilot review round 1 on #2232
Five findings, all valid.

`runOutdated` accepted every exit status and returned `result.stdout ?? ""`.
A failed `npm outdated` (exit 2 on a registry or config error) also prints
nothing, so the empty string parsed to an empty package list — and across five
installs a total outage produced output byte-identical to a clean sweep. It now
throws on any status other than the documented 0/1.

`main()` was untested, which is how that reached review: the helper-only suite
passed while the silent-success path was live. It now takes its spawn function
as a parameter and is driven by a fake, covering npm failure, create vs. edit,
milestone handling, both no-op paths and the missing-repo guard.

The no-op path returned before looking for the marker issue, so once every
install caught up, a still-open tracking issue kept its obsolete package table
indefinitely — contradicting the body's own promise to update in place. It now
rewrites through `buildClearedBody()`. It deliberately does not close the
issue: the sweep takes no board actions, and closing one whose card a
maintainer may have moved would make the board assert work shipped that this
script cannot verify shipped.

The release step mandated `npm audit fix`, which AGENTS.md forbids. The reason
is not `--force`: plain `audit fix` resolves an advisory with no upward escape
in range by silently downgrading, as it did to esbuild across three installs in
 #2058, and `local:gate` has no version-regression check to catch it. The step
is now `npm audit` report-only, with fixes applied deliberately via a direct
bump or an `overrides` entry.

An unmilestoned issue is swept into Incoming, not Todo — Todo asserts a
maintainer signed off. Message corrected and pinned by a test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RWMJENjs1mExXE5XPJTEei
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-04 09:06:30 -04:00
cliffhall
ee14a9fc1c
chore(deps): move the dependabot.yml change out to #2235
The npm-entry removal in `.github/dependabot.yml` is split out of this PR so
that every change to that one file lands in a single reviewable piece —
alongside the `github-actions` entry, which #2229 had declared out of scope but
which opens the same issue-less, board-cardless PR the decision exists to
remove.

Reverts `.github/dependabot.yml` to its `v2/main` state, leaving this PR with
the release-time `npm audit fix` and the monthly `npm outdated` sweep: the
replacement flow, not the switch-off.

Both new files' header comments claimed the switch-off had already happened,
which is false until #2235 lands. They now say #2235 does it, and that until
then Dependabot's npm PRs and this sweep overlap — duplicate signal rather than
conflicting action.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RWMJENjs1mExXE5XPJTEei
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-04 09:06:30 -04:00
cliffhall
7dd9f47ca2
chore(deps): npm audit fix before release bump; monthly npm-outdated issue
Adds npm audit fix (root + every client) as the first sub-step of the
release skill's bump step, before npm version, so a release is never
gated on remembering to check separately. Never --force; anything
audit fix can't resolve is left to the dependabot-alert pipeline
(#2229) or a follow-up issue.

Disables Dependabot npm version updates in dependabot.yml (a
version-update PR carries no issue and no board card) and replaces
them with a monthly scheduled sweep that runs npm outdated across the
root install and every client, filing or updating one idempotent
tracking issue instead of an auto-generated PR.

Closes #2231
Part of #2229

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017N9ha27Qg6LHpBQP7G7zPY
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-04 09:06:30 -04:00
cliffhall
afca8faaea docs: address Copilot review round 9 on #2203
Restrict the truncation guard to PLAIN scalars. `yaml` attaches a legitimate
trailing comment to quoted and block scalars too, so keying on `.comment` alone
rejected valid frontmatter — `description: "Complete value" # note` was reported
as truncated, telling the author to quote a value that was already quoted. The
regex this replaced tested the same thing by reading the first character; moving
to the node dropped the check rather than encoding it. Verified all three
shapes (double-quoted, single-quoted, block) against the parser, and added the
regression cases.

Also correct the `--max-turns 1` bullet in docs/skill-authoring.md, which
claimed the skill must be the model's first tool CALL. `collectSkillInvocations`
scans every tool_use block in the assistant event without regard to order, so a
turn calling Read and Skill together scores a hit. What actually loses the
sample is needing a tool RESULT first — that decision lands in turn two, which
never runs. The practical advice is unchanged; the mechanism now matches the
collector.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SyBPs7caoMcyvnp9ArMBhV
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-01 13:02:14 -04:00
cliffhall
c66793bfd8 docs: address Copilot review round 8 on #2203
Ask the YAML parser what it discarded instead of re-deriving it from the raw
frontmatter text. `yaml` hangs the comment it stripped on the scalar node, so
`parseDocument(...).get(field, true).comment` IS the truncation signal: a plain
scalar that lost text has one, a quoted or block scalar never does, and
`Issue#28` (no preceding space, so no comment) keeps its full value.

That closes both findings at once. The line-locating regex only ever matched
the canonical `description:` key, so `"description":` and `description :`
populated the mapping while the guard skipped them and left verify:skills green
on a truncated listing; and the continuation scan stopped at the first blank
line, inspecting only the opening paragraph of a plain scalar that YAML happily
continues past one.

It also retires the scanning itself — three rounds of regex here each fixed one
shape and missed the next (tags and anchors, then indented continuations, now
key forms and blank lines). The node carries all of them for free, so 65 lines
of hand-rolled parsing come out. Parsing once with `parseDocument` keeps the
node view and the value view from ever disagreeing.

Tests cover the three noncanonical key forms, the blank-line continuation, and
the standalone comments that must NOT read as truncation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SyBPs7caoMcyvnp9ArMBhV
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-01 12:46:22 -04:00
cliffhall
0febaa0f6f docs: address Copilot review round 7 on #2203
Strip YAML properties (tags, anchors, in either order) before deciding
whether a description scalar is quoted — testing the first character alone
read `&copy "Counts # safely"` as unquoted and rejected a `#` the quotes
make harmless.

Count the positive-case floor by distinct prompt rather than by entry: the
floor exists for breadth, so five copies of one prompt cleared it while
exercising exactly the single trigger it is meant to look past. Compare on
collapsed, case-folded text so trivial edits do not read as a new situation.

Re-aim the two positive prompts that measured the wrong thing — the
project-structure case now poses a concrete "which piece handles what"
question, and the test-servers case names running a real MCP server.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SyBPs7caoMcyvnp9ArMBhV
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-01 12:21:09 -04:00
cliffhall
5d3571597a docs: address Copilot review round 6 on #2203
The truncation guard rejected valid frontmatter. It assumed the only way
a raw scalar can exceed its parsed value is a comment, but YAML also
strips presentation syntax, so `description: &copy Some text` and
`description: !!str Some text` were both reported as truncated — naming
an unquoted `#` the value does not contain.

The length check is now gated on an actual comment marker: `#` at the
start of the scalar or preceded by whitespace, which is where YAML begins
a comment. Anchors and tags fall out before the length is consulted.
Regression cases cover both, and assert the genuine `#` case still fails
so the fix cannot pass by disarming the guard.

Also corrects the checklist, which still said an unquoted `#` lets every
check pass. That predated the verifier; verify:skills rejects it now, and
the sentence was telling authors the gate is blind to something it
catches.

No skill description or eval case changed, so skills:eval is unchanged
from round 4's 63/63. local:gate passes, 34/34. 391/391 script tests.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-01 11:44:57 -04:00
cliffhall
ec2ef5656d docs: address Copilot review round 5 on #2203
The truncation guard read only the first physical line of the scalar, so
a multiline plain scalar slipped past it:

    description: Covers boards
      #28 and #11

YAML parses that as `Covers boards`, and the first-line-only comparison
saw `Covers boards` on both sides and reported no loss — passing the
exact silent truncation the guard exists to reject.

It now walks the whole plain scalar: the value line plus every indented
continuation, whitespace-collapsed to match YAML's folding of newlines to
spaces. Quoted and block scalars are still skipped, since neither loses
text to a comment.

Two regression cases: the multiline truncation, and a multiline scalar
with no `#`, which must stay clean — the fix cannot be "reject anything
multiline".

No skill description or eval case changed, so skills:eval is unchanged
from round 4's 63/63. local:gate passes, 34/34. 390/390 script tests.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-01 11:31:22 -04:00
cliffhall
4acab97e63 docs: address Copilot review round 4 on #2203
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>
2026-09-01 11:14:06 -04:00
cliffhall
104a05dd7f docs: address Copilot review round 3 on #2203
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>
2026-09-01 10:53:56 -04:00
cliffhall
880be3acdd docs: address Copilot review round 2 on #2203
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>
2026-09-01 10:32:41 -04:00
cliffhall
50f3360d65 docs: address Copilot review round 1 on #2203
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>
2026-09-01 10:10:06 -04:00
cliffhall
a344225e23 docs: accept several skill names in skills:eval and re-aim overlapping cases
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>
2026-09-01 00:18:43 -04:00
cliffhall
a2ddece7a5 chore: address Copilot review round 20
All three findings are the same shape: a check that accepts a command which
exists but cannot gate.

- Splitting on `|` and `&` conflated `||`, so both failure-masking forms counted:
  `npm run X || true` swallows a rejection and `true || npm run X` never runs
  the validator. Both `runsCommand` and the chain walk now split on `\n`, `;`
  and `&&` only, which refuses each without special-casing either.
- A CI step (or job) carrying `continue-on-error` asserts nothing, since a
  rejected skill leaves the workflow green. Both are now skipped, treating a
  string `"true"` and an unevaluable `${{ }}` expression as masking on the same
  conservative principle as the `if:` checks; an explicit `false` still counts.
- `rootReachesScript` was substring-based, so a `validate` reading `echo npm run
  verify:skills` satisfied the sibling vouch while the guard never executed —
  the cycle protecting nothing. It now uses the exact chain walk, which is
  therefore shared in `lib/npm-scripts.mjs` and fixes the same hole in all four
  vouches, not just this PR's. `reachableScripts` keeps its substring behavior
  for the harvesting callers, where over-inclusion is safe.

The chain walk also follows npm's implicit pre/post hooks, with a test — a guard
invoked from a `prevalidate` is genuinely run, and reporting it missing would be
a false failure introduced by the fix.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-29 05:32:12 -04:00
cliffhall
b91b746867 chore: address Copilot review round 19
The `local:gate` half of the wiring vouch used `reachableScripts`, which
extracts any `npm run …` substring — so `echo npm run verify:skills:cli && npm
run coverage` satisfied it while the validator never ran. Same over-match the CI
half had in round 15, from the other side.

`scriptChainRuns` walks the script graph instead, splitting each body on the
separators that begin a new command and requiring an exact invocation of the
target. A segment naming some other script is followed with or without trailing
flags, since `npm run coverage --silent` still runs it, and `seen` makes a
cyclic graph terminate. Regression cases for the mention, a longer script name,
and the cycle; verified by mutation.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-29 05:18:18 -04:00
cliffhall
1203246537 chore: address Copilot review round 17
- ciRunsUnconditionally never looked at the workflow-level `on:` trigger, so a
  workflow switched to `on: release` still contained the step and reported as
  wired while no PR ever ran the validator. It now requires `push` or
  `pull_request`, handling all three shapes `on:` takes. The positive fixtures
  had no trigger at all, so they could not assert the "on every push" half of
  the contract; they carry one now, alongside a release-only negative. Verified
  by mutation.
- Stale JSDoc from the round-16 version change: `probeClaudeVersion` is now
  generic in its parser's result rather than hard-coding `number[]`,
  `validatorCommand` documents `{ parts, prerelease }`, and `runValidator`
  documents the `platform` option it already accepted. Annotations that lie make
  valid call sites look type-invalid.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-29 04:54:04 -04:00
cliffhall
c26995bf82 chore: address Copilot review round 16
`parseClaudeVersion` dropped SemVer suffixes, so an installed `2.1.250-beta.1`
parsed as `[2, 1, 250]` and compared EQUAL to the stable pin — a different
validator schema running locally while the log claimed an exact match, which is
the cross-machine disagreement the pin exists to prevent.

It now returns `{ parts, prerelease }`, because the two callers want different
things: `isPinnedVersion` rejects any prerelease (build metadata still matches,
per SemVer precedence), while the eval's availability probe only needs some
usable CLI. `formatClaudeVersion` keeps the suffix in the messages, so a
prerelease is reported as what it is rather than as the pin.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-29 04:40:48 -04:00