Skip to content

chore: extract AGENTS.md recipes into agent skills - #2189

Open
cliffhall wants to merge 22 commits into
v2/mainfrom
v2/chore/2163-agent-skills
Open

chore: extract AGENTS.md recipes into agent skills#2189
cliffhall wants to merge 22 commits into
v2/mainfrom
v2/chore/2163-agent-skills

Conversation

@cliffhall

@cliffhallcliffhall commented Aug 29, 2026

Copy link
Copy Markdown
Member

Closes#2163

AGENTS.md was a single 1,223-line contract loaded in full on every turn — the issue/board workflow, the priority rubric, the release procedure, the Mantine conventions — whether the task was a one-line CSS fix or a release cut. This splits it along the line the issue drew: rules stay resident in AGENTS.md; procedures move to .claude/skills/.

Nothing is duplicated. Every recipe that moved was removed from where it was.

The skills

Ten, under .claude/skills/. Seven are disable-model-invocation: true — procedures with side effects, invoked by name — so there is no trigger question for them at all and their descriptions never enter the listing budget.

SkillCoversInvocation
issue-createThe five-step create flow/issue-create
issue-triageThe two-pass sweep, the priority rubric, the board audit/issue-triage
board-opsgh project recipes + the field/option IDs for #28 and #11, the option-deletion hazard/board-ops
pr-flowBranch naming, DCO, screenshots, Copilot review, closing out/pr-flow
pre-push-gateRunning local:gate and diagnosing a failing stage/pre-push-gate
releaseCutting a release/release
test-serversPicking and running a showcase server/test-servers
local-devInstall/run + dependency placement reasoningmodel-invoked
testingWhere a test goes, which command runs it, clearing the gatemodel-invoked
project-structureWhere things livemodel-invoked, user-invocable: false

What shrank

FileBeforeAfter
AGENTS.md1,223380
README.md811105

README.md is split into docs/{architecture,quality-gate,test-servers,publishing,docker}.md and is no longer auto-loaded by CLAUDE.md. The project tree in AGENTS.md is one line per entry — the thirty-line commentary on app-origin-controller.ts and friends is gone, with a note that those files carry their own header comments and the source is the place to read them.

.github/copilot-instructions.md and the rule to mirror review-relevant changes into it are deleted. Copilot reads AGENTS.md.

Reliability

The issue's central concern: AGENTS.md is unconditional, a skill is not, and a skill that stops firing loses behavior silently. Four things address that.

npm run verify:skills runs inside validate, so it is in local:gateand in GitHub CI. It parses each SKILL.md the way Claude Code does and fails on anything that would strip the metadata — the whole-class failure being malformed frontmatter YAML, which loads the body with an empty description, so /skill-name still works and a manual spot check passes while the skill can never auto-fire again. It also requires an explicit invocation mode on every skill, requires eval cases (positives and negatives) for model-invoked ones, and budgets the listing. It runs claude plugin validate too when that CLI is on PATH — but the check does not depend on it, because the CLI is absent in CI and a guard that skips itself wherever it actually runs is worse than one that reimplements the check. Its pure parsers are unit-tested via test:scripts (13 cases).

That guard earned its keep during this PR: tuning the testing description introduced description: Testing procedures for this repo: which npm command…, and the unquoted colon was caught immediately.

Listing budget: 1,173 / 4,000 characters, printed on every run and failed when exceeded. I used a computed measure rather than a /doctor reading because it is enforceable and regresses visibly when a skill is added; the acceptance criterion asked for the number to be recorded, and it is now recorded somewhere that checks itself.

npm run skills:eval runs the committed trigger cases headless (claude -p … --output-format stream-json), N samples per case, asserting on a Skill tool_use. It is deliberately not in the gate — it needs the CLI and real model calls.

Two measured findings, both recorded in AGENTS.md

paths is not a free win. The issue recommended it for testing as "far closer to deterministic than description matching". It does gate loading to matching files — but measured against that skill's own cases, adding it roughly halved the rate at which the same skill fired from a conversational prompt (0–50% with paths, 33–100% without). It also cannot be measured at all: a prompt-only eval can never exercise a path trigger, so shipping it means shipping an untestable claim. I dropped it and wrote down why, so it does not get helpfully re-added.

A case whose answer is already in AGENTS.md is not a trigger case. Two early local-dev cases sat at 0% because the model answered them correctly and directly from the resident rules — which is the design working, not a miss. Likewise a prompt naming a concrete file ("how does the @inspector/core alias resolve?") invites a Read, which is a better answer than a skill. Good cases are "how do I / where does this go" procedure questions.

Current hit rates at RUNS=3: project-structure 5/5, local-dev 5/6, testing 3/5, negatives 100% across the board. The two below threshold are borderline-by-nature rather than broken, and are committed as-is so the next tuning pass has them.

Gate

npm run local:gate passes end to end (GATE_EXIT=0), including the Firefox engine pass and Storybook. npm run test:scripts is 305/305. All relative Markdown links across the repo were verified to resolve.

Deliberate calls worth reviewing

  • .gitignore now un-ignores .claude/skills/ (.claude/* + !.claude/skills/), so the skills ship with the checkout while Claude Code's local state stays out.
  • The dependency-placement rules are back in AGENTS.md as six bullets, with the reasoning in local-dev. The issue's table assigned them to the skill, but they are exactly what a reviewer cites against a diff, and the issue's own constraint says rules stay resident. The skill carries the war stories.
  • The authoritative validator runs in both tiers, and fetches a pinned CLI if it has to.npm run verify:skills:cli is a step in local:gateand in CI. It uses an installed claude only when it matches PINNED_CLI_VERSION exactly, and otherwise runs npx -y @anthropic-ai/claude-code@2.1.250 — so the gate can reach the network, and CI installs that package on every push. That is a deliberate supply-chain trade for making "authoritative validation runs in CI" true rather than aspirational; the pin is what bounds it, and the version is a one-line change. verify:skills (inside validate) stays offline and best-effort, so the fast inner loop needs no CLI and no network.

Closes#2163
`AGENTS.md` was one 1,223-line contract loaded in full on every turn, whether
the task was a one-line CSS fix or a release cut. This splits it: rules stay
resident, procedures move to `.claude/skills/`.
- Ten skills under `.claude/skills/`, seven `disable-model-invocation: true`
(invoked by name only, so they cost no listing context and have no trigger
question) and three model-invoked with committed eval cases.
- `AGENTS.md` 1,223 → 380 lines: rules, a skills index, and a project tree
reduced to one line per entry. The removed recipes are not duplicated.
- `README.md` 811 → 105 lines, split into `docs/{architecture,quality-gate,
test-servers,publishing,docker}.md`, and dropped from `CLAUDE.md`'s auto-load.
- `.github/copilot-instructions.md` and its mirroring rule removed — Copilot
reads `AGENTS.md`.
- `npm run verify:skills` (in `validate`, so in `local:gate` and CI) parses each
SKILL.md the way Claude Code does, requires an explicit invocation mode and
eval cases for model-invoked skills, and budgets the listing.
- `npm run skills:eval` runs the committed trigger cases headless.
Signed-off-by: cliffhall <cliff@futurescale.com>
@cliffhallcliffhall added the v2 Issues and PRs for v2 label Aug 29, 2026
@cliffhall
cliffhall requested a balanced review from CopilotAugust 29, 2026 04:37

CopilotAI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The skill guard, evaluator, board workflows, and extracted documentation contain correctness and consistency issues.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Extracts procedural guidance from resident instructions into scoped agent skills, supported by validation tooling and reorganized documentation.

Changes:

  • Adds ten agent skills with trigger evaluations.
  • Adds skill validation/evaluation scripts to the development workflow.
  • Splits the root documentation into focused guides.
File summaries
FileDescription
AGENTS.mdRetains rules and indexes skills.
README.mdCondenses project and documentation overview.
CLAUDE.mdStops automatically loading the README.
.gitignoreAllows committed skills while ignoring local state.
.github/copilot-instructions.mdRemoves duplicated review guidance.
package.jsonAdds skill validation and evaluation commands.
scripts/verify-skills.mjsValidates committed skill manifests and evaluations.
scripts/skill-eval.mjsMeasures model-triggered skill invocation.
scripts/lib/skill-manifest.mjsParses and validates skill metadata.
scripts/lib/skill-manifest.test.mjsTests skill manifest rules.
docs/architecture.mdDocuments shared architecture.
docs/quality-gate.mdExtracts quality-gate reference material.
docs/test-servers.mdExtracts the test-server catalogue.
docs/publishing.mdExtracts packaging and release documentation.
docs/docker.mdExtracts container guidance.
clients/launcher/README.mdRedirects publishing documentation.
.claude/skills/board-ops/SKILL.mdAdds project-board procedures.
.claude/skills/issue-create/SKILL.mdAdds issue-creation procedures.
.claude/skills/issue-triage/SKILL.mdAdds triage and audit procedures.
.claude/skills/local-dev/SKILL.mdAdds local-development guidance.
.claude/skills/local-dev/evals/evals.jsonAdds local-development trigger cases.
.claude/skills/pre-push-gate/SKILL.mdAdds gate-diagnosis procedures.
.claude/skills/pr-flow/SKILL.mdAdds pull-request workflow procedures.
.claude/skills/project-structure/SKILL.mdAdds repository-placement guidance.
.claude/skills/project-structure/evals/evals.jsonAdds structure trigger cases.
.claude/skills/release/SKILL.mdAdds release procedures.
.claude/skills/test-servers/SKILL.mdAdds test-server execution guidance.
.claude/skills/testing/SKILL.mdAdds testing procedures.
.claude/skills/testing/evals/evals.jsonAdds testing trigger cases.
Review details

Suppressed comments (2)

.claude/skills/issue-triage/SKILL.md:244

  • Likewise, this only detects issues with no type label; an issue carrying multiple type labels passes even though AGENTS.md requires exactly one. Count labels from the five-type set and flag counts other than one.
 "open, no type label": [$o[0][] | select(.state=="OPEN")
| select(([.labels[].name] | index("bug") or index("enhancement")
or index("documentation") or index("chore")
or index("question"))|not) | .number],

docs/quality-gate.md:38

  • This supposedly comprehensive test:scripts reference omits the newly added skill-manifest.test.mjs suite. Add it to the parser-test list so the extracted quality-gate documentation remains accurate.
| `npm run test:scripts` | Table-driven unit tests (`node --test`) for the guard's own pure parsers (`scripts/lib/npm-scripts.mjs`, `scripts/lib/tsc-program.mjs` + the exported helpers of `verify-typecheck-coverage.mjs` and `verify-dep-lockstep.mjs`), one case per rule they encode, plus two suites over shared `scripts/lib` helpers that no smoke can check itself: `resolve-node-bin.test.mjs` — the cross-platform bin resolver (#1939), pinned against the real `bin`/`exports` shapes of the packages the scripts actually spawn — and `announced-child.test.mjs` — the spawn/readiness ownership helper (#2000), which drives real `node -e` children to prove a child that never announces is still published to the caller before the timeout throws, and so is reachable by teardown rather than orphaned. Four more do the same: `mcp-app-flow.test.mjs` covers the shared MCP Apps flow (#2003) — the deep link's two CSRF gates and `appArgs` encoding, plus `driveAppFlow`'s failure branches against a stand-in page, all of which are dead code from the happy-path smokes' point of view and would otherwise surface only as opaque timeouts; `render-smoke.test.mjs` and `pty.test.mjs` cover the TUI boot harness ([#2147](https://github.com/modelcontextprotocol/inspector/issues/2147)) — the former driving real `node -e` stubs to prove that a child which paints the marker and *then* dies is a **failure**, which the old harness reported as OK and which no fixed TUI can reproduce; the latter pinning the three mutually-incompatible `script(1)` invocations, where a wrong guess stops the pseudoterminal from being allocated at all; and `ensure-test-servers.test.mjs` pins the [#2111](https://github.com/modelcontextprotocol/inspector/issues/2111) invariant — that `test-servers/build` is rebuilt **even when it already exists** — which no smoke can assert about itself, since one driving a stale fixture reports a product failure rather than a staleness one. `workflow-gate.test.mjs` is a different shape again ([#2146](https://github.com/modelcontextprotocol/inspector/issues/2146)): besides the table of parser cases it runs the parser over the repo's real `.github/workflows/**`, so a workflow that invokes the local-only gate or a non-Chromium engine pass fails here rather than in a CI run nobody expected to be red. Runs in `validate` — and `verify:typecheck-coverage` guards *this* gate in turn (reachable from `validate`, non-empty test set, every test file matched by the `test:scripts` glob), since `node --test` silently skips a file its glob misses and still exits 0. |
  • Files reviewed: 26/29 changed files
  • Comments generated: 13
  • Review effort level: Balanced

Comment threadscripts/lib/skill-manifest.mjs Outdated
};
}
const rest = text.slice(FRONTMATTER_FENCE.length + 1);
const end = rest.indexOf("\n" + FRONTMATTER_FENCE);
Comment threadscripts/skill-eval.mjs Outdated
}
});
p.on("error", reject);
p.on("close", () => resolve(invoked));
Comment threaddocs/test-servers.md Outdated

## Showcase configs

Each config below is a ready-made server for exercising one feature by hand. Load one with `--config`, and unless noted, connect with **Protocol Era = Modern**.
Comment on lines +71 to +73
⚠️ **Every milestone is a v2 release bucket.** There is no v1 bucket, so a `v1`
issue cannot satisfy the milestone rule — don't drop it in a v2.x one. Leave it
unmilestoned and say so.
Comment thread.claude/skills/issue-triage/SKILL.md Outdated
Comment on lines +239 to +240
"open, no version label":[$o[0][] | select(.state=="OPEN")
| select(([.labels[].name]|index("v1") or index("v2"))|not) | .number],
Comment thread.claude/skills/test-servers/SKILL.md Outdated
Comment on lines +47 to +49
# From the repo root, with a built launcher:
node clients/launcher/build/index.js --web
# then in the Inspector, add the server the config announces
Comment thread.claude/skills/issue-create/SKILL.md Outdated
Comment on lines +92 to +100
```sh
ITEM_ID=$(gh project item-add 28 --owner modelcontextprotocol --url <issue-url> --format json --jq '.id')
# Status → Todo
gh project item-edit --project-id PVT_kwDOCt2Azc4BJVxt --id "$ITEM_ID" \
--field-id PVTSSF_lADOCt2Azc4BJVxtzg5iI8c --single-select-option-id fbdaf21e
# Priority → Medium (score it with the rubric in /issue-triage; don't eyeball it)
gh project item-edit --project-id PVT_kwDOCt2Azc4BJVxt --id "$ITEM_ID" \
--field-id PVTSSF_lADOCt2Azc4BJVxtzg5iJE4 --single-select-option-id da944a9c
```
Comment thread.claude/skills/pr-flow/SKILL.md Outdated
Comment on lines +175 to +176
**On merge of a v2 PR, manually close its issue and move the board item to
Done** (`259d6aab`), since auto-close won't fire on `v2/main`.
Comment threadscripts/skill-eval.mjs Outdated
Comment on lines +14 to +16
// NOT part of `validate`, `local:gate`, or CI: it needs the `claude` CLI and
// real model calls, so it is metered and non-deterministic. Run it when adding
// a skill or editing a model-invoked skill's description.
Comment threadscripts/skill-eval.mjs Outdated
Comment on lines +58 to +60
function runOnce(prompt) {
return new Promise((resolve, reject) => {
const p = spawn(
- skill-manifest: the closing `---` fence must occupy a whole line. `---oops`
was accepted as a terminator, silently truncating the frontmatter and passing
validation — the exact failure the guard exists to catch. Regression cases
added (prefix, trailing whitespace, EOF, CRLF).
- skill-eval: reject a nonzero `claude -p` exit instead of resolving it as an
empty observation. An auth error or rate limit was scoring as "no skill
invoked", which passes every negative case and reads as a trigger miss on
every positive one.
- skill-eval: extract `collectSkillInvocations` / `sampleHit` behind a `main()`
guard and cover them in `skill-eval.test.mjs` — the stream parsing and the
process-outcome handling are unreachable from an eval run's happy path.
- docs/test-servers: annotate the era on every catalogue row. Six legacy rows
carried no annotation under a "unless noted, Modern" default, so the doc sent
readers to the wrong era for the feature they were trying to see.
- docs/quality-gate: `validate` runs four guards, not three; add the
`verify:skills` row and the two new `test:scripts` suites; fix the `#publishing`
fragment left dangling by the split.
- issue-create / pr-flow: delegate the card steps to `/board-ops` rather than
restating its option IDs, which are explicitly unstable.
- issue-triage: the audit counted presence, not cardinality — an issue carrying
both `v1` and `v2` passed a check that claims "exactly one". Same for the five
type labels. Also scope the two milestone checks to board #28.
- AGENTS.md: codify the v1 milestone exception. Every milestone is a v2 release
bucket, so a v1 issue has none to take and the Incoming-milestone invariant
cannot apply to board #11.
- clients/launcher/README: its pointer promised an explanation of why the cli/tui
manifests ship, which the extracted guide does not make.
Signed-off-by: cliffhall <cliff@futurescale.com>
@cliffhall

Copy link
Copy Markdown
MemberAuthor

Copilot review round 1 — responses

All 13 comments read; 12 acted on, 1 declined with reasoning. Pushed in a51e62d. npm run validate passes (exit 0), test:scripts 312/312.

Mirroring at PR level because inline replies go hidden once the fix is pushed.

Fixed

scripts/lib/skill-manifest.mjs:52 — closing fence accepted any line starting ---. Correct, and the sharpest catch in the round: ---oops terminated the block, silently truncating the frontmatter and reporting the file valid — which is precisely the failure the guard exists to catch. Now /\n---[ \t]*(\r?\n|$)/, with regression cases for the prefix, trailing whitespace, EOF and CRLF.

scripts/skill-eval.mjs:101 — child exit status ignored. Correct and worse than it looks: a failed CLI observed nothing, so an auth error or rate limit passed every negative case and read as a trigger miss on every positive one — a plausible-looking hit rate for a run that never happened. Nonzero exit now rejects.

scripts/skill-eval.mjs:60 — no coverage for the stream parsing / outcome handling. Agreed, and the bug above is the argument. Extracted collectSkillInvocations and sampleHit, put main() behind the standard import.meta.url guard, and added scripts/skill-eval.test.mjs (6 cases: Skill payloads, other tools, malformed and blank lines, a held-back partial line, a tool_use with no input, and positive/negative scoring).

docs/test-servers.md:29 — "unless noted, Modern" is wrong. Verified against test-servers/configs/*.json: six rows (pagination, structured-output, duplicate-tool-names, nullable-fields, rfc6570-templates, advertised-extensions) carry no transport.modern and their sections say legacy. Rather than fix six rows and keep a default that will rot again, every one of the 23 rows now names its era and the default sentence is gone.

docs/quality-gate.md:32/38validate runs four guards now, and the test:scripts list is short. Both fixed, plus a verify:skills row in the script table and the two new suites described in the test:scripts row.

docs/quality-gate.md:42 — dangling #publishing fragment. Points at ./publishing.md.

clients/launcher/README.md:123 — pointer disagrees with the extracted guide. Right: the guide says no client package.json is read at runtime, so none needs to ship. Swapped that clause for the invariant the guide actually makes (clients/web/static must ship at that exact path).

.claude/skills/test-servers/SKILL.md:49 — the recipe never starts the server. Correct, the recipe was simply wrong. Now two processes, with the real command (node test-servers/build/server-composable.js --config …), plus the two mechanics that bite: the URL is announced on stderr, and the bound port is not necessarily the config's because findAvailablePort() walks upward.

.claude/skills/issue-create/SKILL.md:100 and pr-flow/SKILL.md:176 — board IDs duplicated out of board-ops. A fair hit against this PR's own central constraint, and the consequence you name is the real one: an option ID is regenerated whenever its field's option list is edited, so the copy goes stale silently. Both now delegate to /board-ops and name columns rather than IDs. grep confirms every live project/field/option ID appears in exactly one file.

**.claude/skills/issue-triage/SKILL.md:240/241 (+ suppressed :244)** — the audit tests presence, not cardinality.** Correct: an issue carrying **both** v1andv2 passed a check whose stated invariant is "exactly one", and the same for the five type labels. Both predicates now count (map(select(IN(…))) | length != 1`), verified against a hand-built fixture. The rows are renamed to say "not exactly one".

.claude/skills/issue-create/SKILL.md:73 — v1 milestone divergence. The best structural catch of the round: the skill quietly carried an exception the resident rules contradicted, which is exactly the drift this PR is meant to prevent. Codified in AGENTS.md instead — every milestone in this repo is a v2 release bucket, so a v1 issue has none it could take, and the Incoming ⇔ no milestone invariant therefore cannot apply to board #11. The audit's two milestone checks are now scoped to #28, with a note saying why (unscoped, they would flag every card on #11 for a state it cannot reach).

Declined, with reasoning

scripts/skill-eval.mjs:16 — "#2163 asks for headless skill smoke testing in CI and the pre-push gate, but this excludes the only check that observes whether a skill fires."

Keeping it out, and the issue supports the split rather than contradicting it. Its acceptance criteria list claude plugin validate .claude/skills as the thing that runs in local:gate and CI, and describe the trigger eval separately as the higher-effort option whose cases should be committed — which is what shipped.

The substantive reason is that a trigger eval cannot be a gate. It costs metered model calls on every push, it is non-deterministic by construction (the measurement is a hit rate over samples), and it fails on a rate limit or an expired token — so as a gate it would be red for reasons unrelated to the diff, and the first response to that is to stop trusting it.

You are right that verify:skills cannot detect a valid-but-never-triggering description, and I would rather say so than imply otherwise: that gap is now stated in the script header and in AGENTS.md, together with the measured hit rates and the two findings about what makes a case measure the wrong thing. A deterministic gated smoke would need a stable trigger oracle, which is the thing that does not exist here.

Two findings from the eval worth flagging

  • paths is not a free win. Adding it to testing roughly halved the rate at which the same skill fired from a conversational prompt (0–50% with, 33–100% without), and it cannot be measured at all by a prompt-only eval. Dropped, with the measurement recorded in AGENTS.md so it does not get helpfully re-added.
  • A case whose answer is already in AGENTS.md is not a trigger case. Two early local-dev cases sat at 0% because the model answered correctly from the resident rules — the design working, not a miss.

`verify:skills` checks that a skill is well-formed and that its cases exist;
only `skills:eval` observes whether it actually fires, and that cannot be
gated. Say so in the script header and in AGENTS.md rather than leaving the
split to be inferred.
Signed-off-by: cliffhall <cliff@futurescale.com>

CopilotAI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Board procedures, v1 branching, evaluator matching, and validator compatibility contain unresolved correctness issues.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details

Suppressed comments (2)

scripts/skill-eval.mjs:149

  • The nonzero-exit behavior is still not covered: skill-eval.test.mjs imports only collectSkillInvocations and sampleHit, so changing this branch back to resolve an empty set would leave every test green despite the test header and quality-gate documentation saying this failure is pinned. Expose an injectable process/completion seam and assert that a nonzero close rejects.
 if (code !== 0) {
reject(new Error(`\`claude -p\` exited ${code} for prompt: ${prompt}`));
return;

.claude/skills/board-ops/SKILL.md:147

  • This delete lookup has the same repository-local issue-number ambiguity as the move recipe. On an organization board containing another repository's issue with the same number, it can delete the wrong card or pass multiple IDs; include the repository in the selector.
ITEM_ID=$(gh project item-list 28 --owner modelcontextprotocol --format json --limit 500 \
--jq '.items[] | select(.content.number==<ISSUE_NUMBER>) | .id')
  • Files reviewed: 27/30 changed files
  • Comments generated: 5
  • Review effort level: Balanced

Comment threadscripts/skill-eval.mjs Outdated
Comment on lines +108 to +111
export function sampleHit(expect, invoked) {
if (expect === null) return invoked.size === 0;
return [...invoked].some((payload) => payload.includes(expect));
}
Comment thread.claude/skills/board-ops/SKILL.md Outdated
Comment on lines +132 to +133
ITEM_ID=$(gh project item-list 28 --owner modelcontextprotocol --format json --limit 500 \
--jq '.items[] | select(.content.number==<ISSUE_NUMBER>) | .id')
Comment thread.claude/skills/pr-flow/SKILL.md Outdated
Comment on lines +41 to +42
**Cut the branch from `v2/main`**, never from a milestone-merge branch — the
latter carries release-only commits that will show up in your PR's diff.
Comment thread.claude/skills/issue-triage/SKILL.md Outdated
jq -nr --slurpfile o "$D/i.json" --slurpfile a "$D/b28.json" --slurpfile b "$D/b11.json" --arg R "$R" '
($o[0] | map({key:(.number|tostring), value:{st:.state, sr:(.stateReason // ""),
lab:[.labels[].name], ms:(.milestone.title // null)}}) | from_entries) as $M
| def own($s): [$s[].items[] | select(.content.repository==$R)];
Comment threadscripts/verify-skills.mjs Outdated
Comment on lines +124 to +126
// The authoritative parse, when it is available.
const probe = spawnSync("claude", ["--version"], { stdio: "ignore" });
if (probe.error) {
- verify-skills: version-gate the optional `claude plugin validate`. Probing
only for the executable failed the MANDATORY gate for anyone on a CLI older
than 2.1.233, which has no such subcommand and exits nonzero on it. Skips
below the floor; a failure now means a supported validator rejected the skills.
- skill-eval: match the invoked skill structurally. A substring test counted
`{"skill":"not-testing"}` as a hit for `testing` and inflated the rate.
- skill-eval: `runOnce` → exported `runPrompt` with an injectable `spawnFn`, so
the nonzero-exit branch is actually pinned. Reverting it now fails a test
(verified by mutation) rather than leaving the suite green.
- board-ops: filter the item lookups by repository. Issue numbers are repo-local
and these are org boards — #11 really does carry a `servers` card — so an
unfiltered `select` can move or delete the wrong card, or pass two ids.
- issue-triage: the audit's `own()` filter dropped draft cards, which carry no
`.content.repository`, so the "non-Issue on a board" check reported 0 while
the invariant was being violated. Verified against a fixture.
- pr-flow: cut the branch from the base it will target. The blanket "cut from
v2/main" was wrong for v1, whose history is unrelated.
Signed-off-by: cliffhall <cliff@futurescale.com>
@cliffhall

Copy link
Copy Markdown
MemberAuthor

Copilot review round 2 — responses

All five new findings (including both suppressed) are real and are fixed in ca68289. npm run validate passes (exit 0); test:scripts is now 319/319.

scripts/verify-skills.mjs:126 — the probe fails the mandatory gate on an older CLI. Correct, and the worst of the five, because it breaks the gate for someone whose only sin is not having upgraded: claude --version succeeds on a pre-2.1.233 binary, plugin validate then exits nonzero as an unknown command, and verify:skills reports the skills as rejected. Now version-gated — parseClaudeVersion + compareVersions against a PLUGIN_VALIDATE_MIN_VERSION of 2.1.233, skipping below it with a message naming the version it found. A failure once again means a supported validator actually rejected something. Both helpers are unit-tested (boundary at .232/.233/.250, plus a shorter and a longer triple).

scripts/skill-eval.mjs:111 — substring matching. Correct: {"skill":"not-testing"} scored as a hit for testing, inflating the rate with invocations of a different skill. Now invokedSkillNames parses the payload and compares string values by equality — still without assuming the field's name, since every string value is a candidate. Cases cover the not-testing false positive, an alternative field name, and the four unreadable payload shapes.

Suppressed, scripts/skill-eval.mjs:149 — the nonzero-exit branch is still not covered. A fair and sharp follow-up: I extracted the parsing last round but not the completion handling, so the test header and the quality-gate row both claimed something the suite did not check. runOnce is now the exported runPrompt(prompt, { spawnFn }), and the tests drive it through a stand-in child (an EventEmitter pair) covering a chunk-boundary split, a nonzero close, and a spawn error. Verified by mutation: flipping if (code !== 0) to if (false) now fails a test, where before it left the suite green.

.claude/skills/board-ops/SKILL.md:133 and :146 (suppressed) — item lookups are ambiguous on an org board. Correct, and the skill contradicts itself on this point — a few lines away it says #11 carries a modelcontextprotocol/servers card. Both lookups (move and delete) now filter on .content.repository, with a ⚠️ note giving the reason: issue numbers are repo-local, so an unfiltered select can hit the wrong card or return two ids at once — and the delete path makes that destructive.

.claude/skills/issue-triage/SKILL.md:222 — the repo filter removes drafts before the non-Issue check can see them. The subtlest catch of the two rounds: a draft card has no .content.repository, so own() dropped it, and the check whose whole purpose is finding drafts reported 0 while the invariant was being violated — a silently vacuous audit row. own() now admits an item with no repository and excludes only cards naming a different one. Verified against a hand-built fixture holding a draft, a foreign-repo issue and a local one: the draft is reported, the foreign issue is not.

.claude/skills/pr-flow/SKILL.md:42 — "cut from v2/main" is unconditional. Correct, and specifically bad here because the two lines have unrelated histories: a v1/fix/… branch cut from v2/main and opened against v1/main arrives carrying the whole v2 tree. Now "cut the branch from the base it will target", keeping the milestone-merge-branch warning.

CopilotAI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The evaluator can silently omit broken skills, and the required authoritative CI validation is currently skipped.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details

Suppressed comments (2)

Previously missed (2) — in code that hasn't changed since the last review.

scripts/skill-eval.mjs:59

  • Malformed model-invoked skills and missing eval files are silently skipped here. Running npm run skills:eval directly can therefore execute the remaining cases and exit successfully while omitting the very skill that was just broken. Fail collection on these conditions (and on invalid cases), or make this command run the verifier before evaluating.
 if (skill.errors.length > 0 || !skill.modelInvoked) continue;
const evalsFile = path.join(SKILLS_DIR, dir, "evals", "evals.json");
if (!existsSync(evalsFile)) continue;

.claude/skills/issue-create/SKILL.md:109

  • This contradicts the repository's issue forms: blank issues are disabled, the bug form applies bug, and the feature form applies enhancement plus v2 (.github/ISSUE_TEMPLATE/*.yml). Describing all incoming issues as unlabeled can make the triage procedure reason from the wrong initial state.
An issue opened by hand in the GitHub UI — by an outside reporter *or* by a
maintainer — arrives with no label, no milestone, and no card. That is normal on
  • Files reviewed: 27/30 changed files
  • Comments generated: 2
  • Review effort level: Balanced

Comment threadscripts/verify-skills.mjs Outdated
.sort();
}

function main() {
Comment threadscripts/verify-skills.mjs Outdated
Comment on lines +133 to +138
const probe = spawnSync("claude", ["--version"], { encoding: "utf8" });
if (probe.error || probe.status !== 0) {
console.log(
"verify:skills — `claude` CLI not usable here; skipped `claude plugin validate`.",
);
return;
- CI now installs a pinned `claude` CLI and runs `claude plugin validate` as its
own step. The guard skips the authoritative validator when the CLI is absent,
which without this meant "always, in CI" — so the acceptance criterion was
aspirational. Verified it needs no authentication (clean HOME).
- skill-eval: classify a run from its terminal `result` event, not its exit
code. With `--max-turns 1` a run in which a skill FIRES necessarily hits the
limit and the CLI exits 1 (`error_max_turns`, `num_turns: 2`), so last round's
blanket nonzero rejection threw away exactly the observations the eval counts.
A run with no terminal `result`, or an unusable subtype, still rejects.
- skill-eval: fail collection on a skill that does not parse, a model-invoked
skill with no eval file, unreadable JSON, or invalid cases. Skipping them let
`skills:eval` exit 0 while silently omitting the skill just broken.
- verify-skills: `main()` takes an optional skills directory, and
`verify-skills.main.test.mjs` drives it over fixtures — missing SKILL.md,
malformed frontmatter, undeclared invocation mode, missing/invalid evals,
budget overflow (and that name-only skills do not count toward it), and
multi-offender reporting. Matches the sibling guards' *.main.test.mjs
precedent; without it the gate could stop enforcing while the parser tests
stayed green.
- issue-create: the note claimed an issue arrives unlabeled, contradicting
.github/ISSUE_TEMPLATE — blank issues are disabled, the bug form applies
`bug`, the feature form applies `enhancement` + `v2`. Now a table of what each
form sets and what triage still has to add.
Signed-off-by: cliffhall <cliff@futurescale.com>
@cliffhall

Copy link
Copy Markdown
MemberAuthor

Copilot review round 3 — responses

All four (two inline, two suppressed) fixed in 7e0bb8b. npm run validate passes (exit 0); test:scripts is 332/332.

scripts/verify-skills.mjs:138 — making the authoritative validator optional guarantees it is skipped in CI. Taking this one. You are right that the previous state made the acceptance criterion aspirational: the guard skips claude plugin validate when the CLI is absent, and the CLI is always absent in CI, so "runs in CI" was never true. CI now installs a pinned @anthropic-ai/claude-code@2.1.250 and runs claude plugin validate .claude/skills as its own step. Pinned rather than @latest because an unpinned validator can start failing a PR that changed nothing, which is how a gate loses its credibility. Verified it needs no authentication — it passes with a scrubbed environment and a clean HOME. This is separate from the trigger eval I declined last round, and I have narrowed that note accordingly: the honest statement is that neither structural validator can detect a well-formed skill whose description never matches anything.

scripts/verify-skills.mjs:57 — no fixture-level test for main(). Agreed, and it is the right consistency argument: the sibling guards all carry *.main.test.mjs, and without one this gate could stop enforcing its checks with every parser test green. main() now takes an optional skills directory, and verify-skills.main.test.mjs drives the real script over throwaway fixtures: missing SKILL.md, malformed frontmatter, undeclared invocation mode, a model-invoked skill with no evals, unreadable/all-positive/all-negative/malformed cases, budget overflow — including that the same text behind disable-model-invocation: true does not count toward the budget — an empty directory, and multi-offender reporting in one pass. The directory argument also suppresses the CLI hand-off, so a fixture run stays deterministic.

Suppressed, scripts/skill-eval.mjs:59 — broken skills and missing eval files are silently skipped. Correct, and the consequence you name is the bad one: npm run skills:eval could exit 0 having quietly dropped the skill that was just broken — a green measurement of a set that shrank. Collection now throws on a skill that does not parse, on a model-invoked skill with no eval file, on unreadable JSON, and on cases that fail validateEvalCases. A well-formed disable-model-invocation: true skill is still skipped, which is the one intended omission.

Suppressed, .claude/skills/issue-create/SKILL.md:109 — contradicts the repository's issue forms. Correct: blank issues are disabled, the bug form applies bug, and the feature form applies enhancementandv2, so "arrives with no label" describes a state that cannot occur and would have triage reasoning from the wrong start. Replaced with a table of what each form sets and what triage still has to add — the bug form needs its version label derived from the version-line dropdown (a labels: list is static, so GitHub cannot map an answer to a label), and the feature form usually needs no label work at all. Kept the point that the milestone and the card are genuinely absent, since that is what makes it a triage case.

One correction to a round-2 fix, found while testing this round

Last round I implemented "reject nonzero exits" literally. That was wrong for this CLI, and the eval stopped working. With --max-turns 1, a run in which a skill fires necessarily hits the turn limit, and the CLI exits 1 — I verified the terminal event is {subtype: "error_max_turns", num_turns: 2}. So a blanket nonzero rejection discards exactly the observations the eval exists to count; every positive case errored out.

The concern behind the comment is still right, so it is now implemented at the correct layer: runRejection classifies on the terminal result event. success and error_max_turns are usable observations; no terminal result (a CLI that never started, an auth failure, a rate limit) or any other subtype rejects with the subtype and exit code named. That is a strictly better discriminator than the exit code — it distinguishes "ran and observed nothing" from "ran to a conclusion" — and it is covered by unit cases plus the fake-child tests. Re-ran the real eval afterward to confirm the harness works end to end.

CopilotAI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Skill-validation guarantees remain incomplete, and several extracted procedures contain contradictory or inaccurate guidance.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details

Suppressed comments (9)

Previously missed (5) — in code that hasn't changed since the last review.

scripts/lib/skill-manifest.mjs:104

  • The linked reliability criterion defines the 1,536-character cap over description + when_to_use, but this checks description alone and parseSkill does not retain when_to_use. A future skill can therefore pass the local guard with an over-cap listing entry. Parse that field and validate the combined length, with a boundary test.
 } else if (description.length > DESCRIPTION_CAP) {

docs/architecture.md:12

  • The Node backend runner does not use Vite's alias; its build defines @inspector/core in clients/web/tsup.runner.config.ts:55-57. Pointing both surfaces at vite.config.ts makes the architecture guide direct maintainers to the wrong configuration.
- **Web:** the same alias in `clients/web/vite.config.ts` for the browser app and the Node backend runner.

.claude/skills/local-dev/SKILL.md:33

  • The tarball does not ship only each client's build/: the root files allowlist also includes clients/web/dist, clients/web/static, and the install helper. Describe the relevant invariant—consumers use prebuilt artifacts and do not run the client-install cascade—without contradicting the packaging guide.

This issue also appears on line 73 of the same file.

The cascade is dev-only — it exits early when the package is installed under
`node_modules`, and the published tarball ships only each client's `build/`. Set
`INSPECTOR_SKIP_CLIENT_INSTALL=1` to skip it.

.claude/skills/issue-triage/SKILL.md:16

  • The actual sweep below selects every unboarded issue, including the explicitly handled case that already has a milestone. Defining the test as “no card and no milestone” would cause that approved-but-unboarded case to be skipped; the criterion should be absence of a board card, with milestone presence deciding Todo versus Incoming.

This issue also appears on line 279 of the same file.

**An issue needs triage when it arrives with no board card and no milestone —
regardless of who filed it.** That is the whole test, and it is deliberately

scripts/verify-skills.mjs:115

  • listingCost(parsed) counts only repository skills, but the 4,000-character listing budget is shared with bundled and ~/.claude/skills entries, as the linked reliability requirements note. This guard can therefore pass while the real listing is already truncating entries, and it permits repo skills alone to consume the entire shared budget. Reserve a measured baseline for non-repo entries or measure the complete listing with the pinned CLI; at minimum the reported value must be identified as only the repo contribution.

scripts/verify-skills.mjs:144

  • This lets local:gate pass without running the authoritative validator whenever the CLI is absent, while issue #2163 explicitly requires claude plugin validate to run in both the local gate and CI. Make the pinned validator a guaranteed local-gate step, or fail here with installation remediation instead of treating the missing validator as success.
    .claude/skills/local-dev/SKILL.md:74
  • The Node backend runner is built by tsup and gets this alias from clients/web/tsup.runner.config.ts:55-57, not from Vite. The current instruction sends dependency/debugging work to the wrong config.
- **Web** — the same alias in `clients/web/vite.config.ts`, for both the browser
app and the Node backend runner.

.claude/skills/issue-create/SKILL.md:10

  • The “all five” invariant cannot hold for v1: this skill later says v1 must have neither a milestone nor Priority, matching AGENTS.md. Define completion in terms of fields applicable to the version and mark both v2-only items in this list, otherwise the procedure starts with a rule it tells users to violate later.
An issue **you** create is not "created" until all five of these are true. A
label is a repo tag, the milestone is a release bucket, and the board is a

.claude/skills/issue-triage/SKILL.md:281

  • $I is built from gh issue list --state all, so closed issues do have entries in $M. This explanation describes an older open-only query and obscures why isopen is required; update it to explain that open-only invariants must exclude valid closed/Done cards.
- **Only open issues have a `$I` entry.** A closed issue still has a card
(correctly, in `Done`), so a check that treats "no milestone found" as a
violation must gate on `isopen(.n)` or it flags every closed card.
  • Files reviewed: 29/32 changed files
  • Comments generated: 1
  • Review effort level: Balanced

Comment threadscripts/skill-eval.mjs Outdated
Comment on lines +190 to +193
export function sampleHit(expect, invoked) {
if (expect === null) return invoked.size === 0;
return [...invoked].some((payload) =>
invokedSkillNames(payload).includes(expect),
- verify:skills:cli — the authoritative validator is now a GUARANTEED step in
`local:gate` and CI, not a best-effort hand-off. It resolves the CLI itself
(an installed one at 2.1.233+, else a pinned `npx -y`), so both tiers run the
same script and cannot drift. `verify:skills` keeps skipping when the CLI is
absent, which is right for `validate`: fast, offline, no Claude Code required.
- skill-manifest: the 1,536-char listing-entry cap covers `description` +
`when_to_use` together; checking the description alone passed an over-cap
entry. `when_to_use` is now parsed, validated and counted, with boundary tests
at 1,536/1,537.
- verify-skills: report the budget as *this repo's contribution*. The real
listing budget is shared with bundled skills and the contributor's own
~/.claude/skills, none of which is visible from here, so the number was
claiming more than it measured.
- skill-eval: a negative case now means "no skill OF THIS REPO'S fired". It
failed on any Skill invocation, so a contributor with an unrelated global
skill got a false failure about their environment rather than these skills.
- docs/architecture + local-dev: the Node backend runner gets its
`@inspector/core` alias from `clients/web/tsup.runner.config.ts`, not from
Vite. The old text sent runner work to the wrong config.
- local-dev: the tarball does not ship "only each client's build/" — the
allowlist also carries `clients/web/dist`, `clients/web/static` and the
install helper. States the invariant that actually matters instead.
- issue-triage: the test for needing triage is "no board card". Adding "and no
milestone" would skip the approved-but-unboarded case the same section then
handles. Also corrected the `$M` note: the lookup is `--state all`, so closed
issues DO have entries — `isopen` is about the invariants, not the lookup.
- issue-create: replaced "all five" with a per-version table. Steps 3 and 6
(milestone, Priority) do not exist for v1, so the old list opened with a rule
the same skill later tells you to violate.
Signed-off-by: cliffhall <cliff@futurescale.com>
@cliffhall

Copy link
Copy Markdown
MemberAuthor

Copilot review round 4 — responses

All ten (one inline, nine suppressed) fixed in d05e76d. npm run validate passes (exit 0); test:scripts is 339/339; verify:skills:cli passes.

The two that changed the design

verify-skills.mjs:144/145 — the local gate can still skip the authoritative validator. Conceding this one properly. Last round I covered the CI half and left the local half as "skips when the CLI is absent" — which, combined with "usually absent", means never runs, exactly the objection. There is now a guaranteed step, npm run verify:skills:cli, in local:gate and in CI, which resolves the CLI rather than hoping for one: an installed CLI at 2.1.233+, otherwise a pinnednpx -y @anthropic-ai/claude-code@2.1.250. Both tiers invoke the same script, so they cannot drift, and the CI step is now a one-liner rather than a second copy of the install.

verify:skills keeps its best-effort hand-off, deliberately: it runs inside validate, which is the fast inner loop and must work offline, and a Node contributor should not need Claude Code installed to run it. The guaranteed step is the one that can reach the network, so it sits in local:gate and not in validate. Resolution logic is unit-tested (installed-and-new-enough, exactly at the floor, no CLI, and a CLI predating the subcommand) — branches an actual run on my machine never takes.

skill-eval.mjs:193 — a negative case fails on any Skill invocation. Correct and a genuinely nasty one, because the false failure would be about the contributor's environment rather than about these skills: claude -p also loads bundled and ~/.claude/skills entries, so someone with an unrelated personal skill would see negatives fail. sampleHit now takes the repo's model-invoked skill names, and a negative case asserts that none of those fired. A foreign skill firing is ignored; a repo skill firing still fails. Positives are unaffected.

Correctness fixes

skill-manifest.mjs:104 — the 1,536 cap is over description + when_to_use. Right, and parseSkill was not even retaining the field. It now parses and type-checks when_to_use, validates the combined length, and counts it in listingCost — with boundary cases at exactly 1,536 (passes) and 1,537 (fails).

verify-skills.mjs:115 — the budget counts only repo skills, but the real listing is shared. Fair. The measurement cannot see bundled skills or a contributor's ~/.claude/skills, so it was claiming more than it measured. It now reports and fails as this repo's contribution — "this repo's listing share 1,173/4,000" — and the failure text says the real budget is shared and that keeping our share well under the cap is what leaves room. I did not try to reserve a modelled baseline for non-repo entries: any number I picked would be invented, and an invented reservation is worse than an honestly-labelled partial measurement.

docs/architecture.md:12 and local-dev/SKILL.md:74 — the runner's alias comes from tsup, not Vite. Confirmed at clients/web/tsup.runner.config.ts:56. Both now say the web client is two builds with two configs, and that runner work belongs in the tsup one.

local-dev/SKILL.md:33 — "the tarball ships only each client's build/". Wrong: the allowlist is clients/{launcher,web,cli,tui}/build, clients/web/dist, clients/web/static, and scripts/install-clients.mjs. Replaced with the invariant that actually explains why the cascade is dev-only — consumers get prebuilt artifacts rather than sources to compile — and pointed at docs/publishing.md for the allowlist itself.

issue-triage/SKILL.md:16 — the test should be "no board card". Right, and self-contradictory as written, since the same section then handles the milestoned-but-unboarded case explicitly. The test is now the absence of a card; the milestone decides only where the card lands (milestoned → Todo, unmilestoned → Incoming).

issue-triage/SKILL.md:281 — the $M note describes an older open-only query. Correct: it is built from --state all, which it must be, because the last check reads closed issues' state reasons. Rewritten to say what isopen is actually for — the invariants are about open work, and a closed issue legitimately sits in Done with whatever milestone it had.

issue-create/SKILL.md:10 — "all five" cannot hold for v1. Right, and it opened the skill with a rule the same skill later tells you to violate. Now a per-version table: steps 3 (milestone) and 6 (Priority) are marked ❌ for v1 with the reason inline — every milestone is a v2 release bucket, and board #11 has no Priority field — noting these are absent fields rather than oversights, and that AGENTS.md states both exceptions.

CopilotAI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Validator reproducibility, credential exposure, missing orchestration coverage, and documentation drift remain unresolved.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details
  • Files reviewed: 31/34 changed files
  • Comments generated: 7
  • Review effort level: Balanced

Comment threadscripts/verify-skills-cli.mjs Outdated
Comment on lines +41 to +44
if (
version !== null &&
compareVersions(version, PLUGIN_VALIDATE_MIN_VERSION) >= 0
) {
Comment threadscripts/verify-skills-cli.mjs Outdated
Comment on lines +67 to +68
function main() {
const probe = spawnSync("claude", ["--version"], { encoding: "utf8" });
Comment thread.claude/skills/pr-flow/SKILL.md Outdated
Comment on lines +115 to +117
curl -sX POST -H "Authorization: token $(gh auth token)" \
--data-binary @pr-screenshots/tools-tab-after.png \
"https://uploads.github.com/user-attachments/assets?repository_id=<REPO_ID>&name=tools-tab-after.png&content_type=image/png"
Comment threaddocs/quality-gate.md Outdated
Comment threaddocs/quality-gate.md Outdated
| `npm run test:scripts` | Table-driven unit tests (`node --test`) for the guard's own pure parsers (`scripts/lib/npm-scripts.mjs`, `scripts/lib/tsc-program.mjs` + the exported helpers of `verify-typecheck-coverage.mjs` and `verify-dep-lockstep.mjs`), one case per rule they encode, plus two suites over shared `scripts/lib` helpers that no smoke can check itself: `resolve-node-bin.test.mjs` — the cross-platform bin resolver (#1939), pinned against the real `bin`/`exports` shapes of the packages the scripts actually spawn — and `announced-child.test.mjs` — the spawn/readiness ownership helper (#2000), which drives real `node -e` children to prove a child that never announces is still published to the caller before the timeout throws, and so is reachable by teardown rather than orphaned. Four more do the same: `mcp-app-flow.test.mjs` covers the shared MCP Apps flow (#2003) — the deep link's two CSRF gates and `appArgs` encoding, plus `driveAppFlow`'s failure branches against a stand-in page, all of which are dead code from the happy-path smokes' point of view and would otherwise surface only as opaque timeouts; `render-smoke.test.mjs` and `pty.test.mjs` cover the TUI boot harness ([#2147](https://github.com/modelcontextprotocol/inspector/issues/2147)) — the former driving real `node -e` stubs to prove that a child which paints the marker and *then* dies is a **failure**, which the old harness reported as OK and which no fixed TUI can reproduce; the latter pinning the three mutually-incompatible `script(1)` invocations, where a wrong guess stops the pseudoterminal from being allocated at all; and `ensure-test-servers.test.mjs` pins the [#2111](https://github.com/modelcontextprotocol/inspector/issues/2111) invariant — that `test-servers/build` is rebuilt **even when it already exists** — which no smoke can assert about itself, since one driving a stale fixture reports a product failure rather than a staleness one. `workflow-gate.test.mjs` is a different shape again ([#2146](https://github.com/modelcontextprotocol/inspector/issues/2146)): besides the table of parser cases it runs the parser over the repo's real `.github/workflows/**`, so a workflow that invokes the local-only gate or a non-Chromium engine pass fails here rather than in a CI run nobody expected to be red. Two more arrive with the skills (#2163): `skill-manifest.test.mjs` pins the frontmatter contract one case per rule — most importantly that a fence which is not a whole line is not a terminator, and that malformed YAML is an error rather than an empty description — and `skill-eval.test.mjs` covers the trigger eval's stream parsing and its process-outcome handling, which an eval run's happy path never reaches and which would otherwise report a plausible hit rate for a CLI that failed to run. Runs in `validate` — and `verify:typecheck-coverage` guards *this* gate in turn (reachable from `validate`, non-empty test set, every test file matched by the `test:scripts` glob), since `node --test` silently skips a file its glob misses and still exits 0. |
| `npm run verify:typecheck-coverage` | The typecheck-coverage analog of the above (#1791): for each Node client (auto-discovered from disk — enrolled via its `typecheck` script's projects, or for a `tsc -b` client like `clients/web` via its `tsconfig.json` `references`) it runs those projects with `tsc --listFilesOnly`, unions them, and **fails** listing any tracked `.ts`/`.tsx`/`.mts`/`.cts` under the client that lands in no project (so a new top-level config/helper can't silently go untypechecked). It also requires, deny-by-default, the first-party TS no client owns (`test-servers/src`, the root `vitest.shared.mts`, all of `core/`, and any new top-level location) to land in some client project's tsc pass — so a `core` `*.tsx` web's projects don't reach is caught too. Also asserts the gate is wired (each client's typecheck pass — its `typecheck` script, or web's `tsc -b` — is reachable from its `validate`, and the root chain runs each client's `validate`). Runs in `validate`. |
| `npm run verify:dep-lockstep` | Guards the "one version per install-crossing dependency" invariant (#1896). v2 is not a workspace, so a client's test project compiles the shared first-party TypeScript — `core/`, `test-servers/src`, and the root-owned `vitest.shared.mts`, all of which resolve their dependencies from the **root** install — alongside the client's own sources, putting the same package in one `tsc` program twice. At the same version that's harmless; skewed, TypeScript must relate two structurally-distinct copies of every type, which for a recursive-generic surface is exponential (zod `4.3.6` vs `4.4.3` exhausted the 4GB tsc heap in `clients/web`). Derives its candidate set from **what actually enters each program** (#1965) — every client tsconfig project listed with `tsc --listFilesOnly` via the shared `scripts/lib/tsc-program.mjs`, each resolved `node_modules` file mapped to its owning install, keeping the packages that reach one program from two installs (a package whose declarations arrive only through another package's `.d.ts`, as `@modelcontextprotocol/sdk`'s do, is invisible to a scan of first-party imports). Prices each copy from the lockfile entry for the exact install path the program resolved, compares only the installs that met in one program, and **fails deny-by-default** on any disagreement not in the annotated `TOLERATED_SKEW` allowlist — empty today — with an allowlisted package tolerated only *within a major version*. Runs in `validate`.
| `npm run local:gate` | **Mandatory pre-push command.** `validate` → `coverage` → `verify:build-gate` → `verify:bundle-externals` → `smoke` → `smoke:web:firefox` → `local:storybook`. A strict superset of GitHub CI — see [Two tiers](#two-tiers-github-ci-and-the-local-gate). Named `local:` rather than `ci` on purpose (#2146); there is no `npm run ci` alias. |
# jobs, but that's a larger restructure and deliberately out of scope.
run: npm run validate

- name: Validate the skills with the authoritative CLI (#2163)
Comment threaddocs/quality-gate.md Outdated
| `npm run verify:format-coverage` | Parses the `format:check` globs out of every `package.json` (only those reachable from `validate`), enumerates all tracked source files, and **fails** listing any not covered by a glob — the durable guard for the "every first-party source file is format-gated" invariant (#1792). Runs first in `validate`. |
| `npm run verify:skills` | Guards `.claude/skills` ([#2163](https://github.com/modelcontextprotocol/inspector/issues/2163)). A skill's body loads only when it is invoked, so one that stops being reachable loses behavior **silently** — and the way that happens is frontmatter Claude Code cannot parse, which loads the body with an *empty* description: `/skill-name` still works, so a manual spot check passes, while nothing is left for the model to match against. This parses each `SKILL.md` the same way, requires an explicit invocation mode on every skill and committed eval cases (positives **and** negatives) on every model-invoked one, and fails when the skill listing exceeds its recorded character budget. Also runs `claude plugin validate` — the authoritative schema — when that CLI is present and at least 2.1.233 (below that the subcommand does not exist and exits nonzero, which must not fail the gate for an un-upgraded contributor). Runs in `validate`; because that hand-off is best-effort, the authoritative validator also gets a guaranteed step of its own — see `verify:skills:cli` below. |
| `npm run verify:skills:cli` | Runs `claude plugin validate` — the **authoritative** skill schema — as a guaranteed step, in `local:gate` and in CI. `verify:skills` reimplements the parse and *skips* when the CLI is absent, which is right for `validate` (fast, offline, no Claude Code required) but adds up to "never runs". This one resolves the CLI instead: an installed one when it is new enough (2.1.233+), otherwise a **pinned** `npx -y @anthropic-ai/claude-code@…`. Pinned because a validator that moves on its own can start failing a PR that changed nothing. Needs no authentication. Not in `validate` — it can reach the network. |
| `npm run test:scripts` | Table-driven unit tests (`node --test`) for the guard's own pure parsers (`scripts/lib/npm-scripts.mjs`, `scripts/lib/tsc-program.mjs` + the exported helpers of `verify-typecheck-coverage.mjs` and `verify-dep-lockstep.mjs`), one case per rule they encode, plus two suites over shared `scripts/lib` helpers that no smoke can check itself: `resolve-node-bin.test.mjs` — the cross-platform bin resolver (#1939), pinned against the real `bin`/`exports` shapes of the packages the scripts actually spawn — and `announced-child.test.mjs` — the spawn/readiness ownership helper (#2000), which drives real `node -e` children to prove a child that never announces is still published to the caller before the timeout throws, and so is reachable by teardown rather than orphaned. Four more do the same: `mcp-app-flow.test.mjs` covers the shared MCP Apps flow (#2003) — the deep link's two CSRF gates and `appArgs` encoding, plus `driveAppFlow`'s failure branches against a stand-in page, all of which are dead code from the happy-path smokes' point of view and would otherwise surface only as opaque timeouts; `render-smoke.test.mjs` and `pty.test.mjs` cover the TUI boot harness ([#2147](https://github.com/modelcontextprotocol/inspector/issues/2147)) — the former driving real `node -e` stubs to prove that a child which paints the marker and *then* dies is a **failure**, which the old harness reported as OK and which no fixed TUI can reproduce; the latter pinning the three mutually-incompatible `script(1)` invocations, where a wrong guess stops the pseudoterminal from being allocated at all; and `ensure-test-servers.test.mjs` pins the [#2111](https://github.com/modelcontextprotocol/inspector/issues/2111) invariant — that `test-servers/build` is rebuilt **even when it already exists** — which no smoke can assert about itself, since one driving a stale fixture reports a product failure rather than a staleness one. `workflow-gate.test.mjs` is a different shape again ([#2146](https://github.com/modelcontextprotocol/inspector/issues/2146)): besides the table of parser cases it runs the parser over the repo's real `.github/workflows/**`, so a workflow that invokes the local-only gate or a non-Chromium engine pass fails here rather than in a CI run nobody expected to be red. Two more arrive with the skills (#2163): `skill-manifest.test.mjs` pins the frontmatter contract one case per rule — most importantly that a fence which is not a whole line is not a terminator, and that malformed YAML is an error rather than an empty description — and `skill-eval.test.mjs` covers the trigger eval's stream parsing and its process-outcome handling, which an eval run's happy path never reaches and which would otherwise report a plausible hit rate for a CLI that failed to run. Runs in `validate` — and `verify:typecheck-coverage` guards *this* gate in turn (reachable from `validate`, non-empty test set, every test file matched by the `test:scripts` glob), since `node --test` silently skips a file its glob misses and still exits 0. |
- verify-skills-cli: use a local CLI only when it matches the pin EXACTLY. A
floor let a maintainer on a newer CLI validate against a different schema than
CI's, so the same `local:gate` could disagree across machines — the failure a
pin exists to prevent.
- verify-skills-cli: extract `runValidator({probe, spawn, log, error})` and
cover the orchestration — validator accepts, validator rejects, spawn fails,
and the three unusable-probe shapes. On a machine with a working CLI an actual
run only ever walks the happy path.
- pr-flow: stop putting the `gh` token in curl's argv, where any local process
can read it off the process table. Feed the header through `curl --config -`.
- docs/quality-gate: three places had drifted — the CI tier row, the
`local:gate` chain, and the `test:scripts` inventory (which now names all four
suites this PR adds, not two).
Signed-off-by: cliffhall <cliff@futurescale.com>
@cliffhall

Copy link
Copy Markdown
MemberAuthor

Copilot review round 5 — responses

All seven fixed in 75ab1b0, plus the PR description updated. npm run validate passes (exit 0); test:scripts is 342/342.

scripts/verify-skills-cli.mjs:44 — a floor defeats the pinning guarantee. The best catch of this round, and it undoes something I got wrong yesterday: I pinned the fetched CLI and then accepted any local one at or above the floor, so a maintainer on a newer CLI validated against a different schema than CI's and the same local:gate could disagree across machines — precisely what a pin is for. The local binary is now used only when it matches PINNED_CLI_VERSION exactly; everything else, newer included, runs the pinned package. Tested across newer, older, next-major, and pre-floor versions.

scripts/verify-skills-cli.mjs:68 — the orchestration is untested. Agreed, and consistent with the same ask you made about verify-skills and skill-eval. runValidator({probe, spawn, log, error}) is now injectable and covered: validator accepts, validator rejects (exit 1 with the reason), spawn fails outright, and the three unusable-probe shapes (ENOENT, nonzero --version, unparseable output) each falling back to the pinned package. On a machine with a working CLI, an actual run walks none of those.

.claude/skills/pr-flow/SKILL.md:117 — the token is in curl's argv. Right, and worth fixing regardless of how "local" the machine is: /proc/<pid>/cmdline is world-readable on Linux, so any process on the box can read the credential for the life of the upload. Now printf 'header = "Authorization: token %s"\n' "$(gh auth token)" | curl -sS --config -, which passes the header on stdin so it never becomes an argument. I also said plainly what that does not solve — the token is still in the shell's environment — rather than implying it is now secret.

docs/quality-gate.md:14, :40, :43 — three places drifted. All correct and all mine, from adding verify:skills:cli without walking the canonical reference. The CI tier row and the local:gate chain now include it, and the test:scripts inventory names all four suites this PR adds (skill-manifest, verify-skills.main, skill-eval, verify-skills-cli) rather than two. The verify:skills:cli row also had to change with the pin fix — it described the floor.

.github/workflows/main.yml:55 — the PR description describes a superseded design. Fair, and the reason given is the right one: reviewers should be evaluating the current network and supply-chain behavior. The description said no CI step installs the CLI, which was true two rounds ago. Rewritten to state what actually happens now — CI and local:gate run the same script, it fetches @anthropic-ai/claude-code@2.1.250 via npx -y when no exactly-pinned CLI is present, so the gate can reach the network and CI installs that package on every push — and to name that as the deliberate trade it is, bounded by the pin. Also kept the note that verify:skills inside validate stays offline, so the fast inner loop needs neither CLI nor network.

@cliffhall

Copy link
Copy Markdown
MemberAuthor

Copilot review round 15 — responses

Both points fixed in 3905cee (the round reported 0 new inline comments). npm run validate passes (exit 0); test:scripts 372/372.

scripts/verify-skills.mjs:96 — the substring check accepts a different script. Correct, and it directly refutes the justification I wrote for it one round ago ("a plain substring cannot over-match"). It can, in two ways you name: npm run verify:skills:cli:disabled is a longer script name that contains the command, and echo npm run verify:skills:cli merely mentions it. Either would let the real validator be deleted while the wiring guard stayed green — the exact failure the last three rounds have each narrowed.

runsCommand now splits the run: block on the separators that begin a new command (\n, ;, |, &) and requires a segment to equal the invocation. Regression cases cover both of your counterexamples plus npm run verify:skills (the shorter neighbour), a trailing-flag form, and the empty script; the positive side keeps the chained (&&) and multi-line shapes working, and ciRunsUnconditionally inherits the rule.

I noted in the source why exact equality is right here rather than in general: the commands this guard checks take no arguments, so a command that did would need prefix matching with an argument boundary instead. That is the assumption a future editor would otherwise have to rediscover.

"Resident guidance contradicts the shipped manifests." Checked this by diffing AGENTS.md's skills index against the actual frontmatter of all ten manifests. The invocation modes all match — but the real contradiction is one level in, and it is mine: local-dev's own description: and its index row both still promised "where a new dependency must be declared", which is precisely what round 13 removed from that skill. So the resident index advertised a skill that would not answer the question it claimed to, and the description the model matches on was recruiting it for exactly the queries it no longer serves.

Both now describe what the skill actually carries — the reasoning behind the placement rules, with the rules staying in AGENTS.md — and the index row says so explicitly ("the rules themselves stay here"). Re-ran the trigger eval afterwards, since a description change is the one edit that can silently break triggering: local-dev still scores 5/6 with negatives clean. The recorded listing share moved 1,173 → 1,199 and is updated in AGENTS.md.

CopilotAI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Prerelease CLI versions currently satisfy the stable exact-version pin.

Review details

Suppressed comments (1)

Previously missed (1) — in code that hasn't changed since the last review.

scripts/lib/skill-manifest.mjs:262

  • The “exact pin” check drops SemVer suffixes: an installed 2.1.250-beta.1 is parsed as [2, 1, 250] and therefore compares equal to the stable 2.1.250 pin. That can run a different validator schema locally while the logs claim it matches exactly. Parse the complete version banner (rejecting prerelease/build suffixes for this stable pin) and add a prerelease test case.
 const m = /(\d+)\.(\d+)\.(\d+)/.exec(text);
  • Files reviewed: 35/38 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced

`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>
@cliffhall

Copy link
Copy Markdown
MemberAuthor

Copilot review round 16 — responses

The suppressed finding is fixed in c26995b (the round reported 0 new inline comments). npm run validate passes (exit 0); test:scripts 373/373.

scripts/lib/skill-manifest.mjs:262 — a prerelease satisfies the stable pin. Correct. /(\d+)\.(\d+)\.(\d+)/ matches the first three numbers it finds and ignores everything after, so an installed 2.1.250-beta.1 parsed as [2, 1, 250] and compared equal to the pin. The failure is the one the exact pin was introduced (round 5) to prevent, reappearing one layer down: a different validator schema running locally while the log said matches the pin, so local:gate could disagree with CI while claiming reproducibility.

parseClaudeVersion now returns { parts, prerelease } rather than a bare triple, because the two callers genuinely want different things and collapsing that distinction is what caused this:

  • isPinnedVersion — used by both validators — requires the same triple and no prerelease.
  • skill-eval's availability probe only needs some usable CLI, so a contributor on a prerelease can still run the eval. It does not need the pin, and rejecting them there would have been a gratuitous second bug in the same fix.

Build metadata still matches (2.1.250+build.7), deliberately: SemVer excludes it from precedence, so it denotes the same release. That is a judgement, so it is asserted rather than implied.

formatClaudeVersion keeps the suffix in the messages, so a prerelease is now reported as local claude 2.1.250-beta.1, not the pinned 2.1.250 instead of appearing to be the pin. And with both validators on isPinnedVersion, compareVersions is down to one caller — it stays because that caller is the pin check itself, and its ordering test is what pins the equality semantics.

CopilotAI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The CI wiring guard can pass even when the workflow no longer runs on pushes, and several updated JSDoc contracts remain stale.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details

Suppressed comments (3)

Previously missed (2) — in code that hasn't changed since the last review.

scripts/verify-skills.mjs:98

  • ciRunsUnconditionally never checks the workflow-level on trigger. A workflow containing this step but changed to on: release would still return true, leaving the wiring guard green even though pushes and PR updates never run the validator. Validate that the workflow is triggered for pushes and add a release-only workflow fixture.
    scripts/verify-skills.main.test.mjs:298
  • The positive fixture has no on: push trigger, so it cannot verify the function's stated “on every push” contract. Add the trigger here and a negative case showing that the same step under a release-only workflow is rejected; that test should fail until the workflow-level check is implemented.

scripts/verify-skills-cli.mjs:85

  • The injected probe now returns the parsed version object rather than number[], and platform is also accepted by the implementation but absent here. Keeping this annotation stale makes valid test harnesses appear type-invalid.
  • Files reviewed: 35/38 changed files
  • Comments generated: 2
  • Review effort level: Balanced

Comment threadscripts/verify-skills-cli.mjs Outdated
Comment threadscripts/lib/claude-cli.mjs Outdated
Comment on lines +46 to +48
* @param {(text: string) => number[] | null} parseVersion
* @param {{ spawn?: typeof spawnSync, platform?: string }} [io]
* @returns {number[] | null}
- 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>
@cliffhall

Copy link
Copy Markdown
MemberAuthor

Copilot review round 17 — responses

All three fixed in 1203246. npm run validate passes (exit 0); test:scripts 374/374.

scripts/verify-skills.mjs:98 — the guard never checks the workflow-level on: trigger. Correct, and it is the fourth and outermost layer of the same question: round 9 asked whether the command appears, round 10 whether it appears in executable position, round 14 whether the job and step are unconditional — and none of them asked whether the workflow runs. A file switched to on: release still contains an unconditional step in an unconditional job, so the guard reported the wiring intact while no PR ever validated a skill.

It now requires push or pull_request before looking at jobs, handling all three shapes on: takes (bare string, list, map). I noted the YAML 1.1 ontrue pitfall in the source and why it does not bite here: the yaml package parses to the 1.2 core schema, so the key stays the string on — worth stating, because a reader who knows that pitfall would otherwise assume this code is broken.

scripts/verify-skills.main.test.mjs:298 — the positive fixture has no trigger, so it could not verify the stated contract. Right, and this is the sharper half of the finding: the test asserted "on every push" against a workflow with no on: at all. Every fixture now carries one, and there is a release-only negative — which, as you predicted, fails against the old implementation. Verified by mutation: removing the trigger check drops the suite to 24/25.

Stale JSDoc after the round-16 version change (verify-skills-cli.mjs:46, :83, claude-cli.mjs:48). All three fixed. probeClaudeVersion is now @template T — generic in whatever its parser returns — rather than hard-coding number[], which is the honest contract since the helper only distinguishes "parsed" from "did not". validatorCommand documents { parts, prerelease }, and runValidator documents the platform option it had silently accepted since round 7.

Worth conceding the pattern: an annotation that lies is worse than none, because it makes a correct call site look type-invalid — and I changed the parser's return type in round 16 without walking its consumers' docs. That is the same drift this PR keeps finding in prose, in a place I was not looking for it.

CopilotAI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The release recipe fails on fresh clones, and board snapshots can expose private project data through accidental commits.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details

Suppressed comments (1)

Previously missed (1) — in code that hasn't changed since the last review.

.claude/skills/release/SKILL.md:38

  • This fails or reads the wrong starting version on a fresh/default clone: only main is checked out locally, so package.json is read from main and the local v2/main ref may not exist. Fetch first, create the bump branch from origin/v2/main, and only then read the version.
# The branch name carries the version you are bumping TO, so read it first.
node -p "require('./package.json').version" # what is on v2/main now
git checkout -b v2/chore/<ISSUE>-bump-<X-Y-Z> v2/main
  • Files reviewed: 35/38 changed files
  • Comments generated: 1
  • Review effort level: Balanced

Comment thread.claude/skills/board-ops/SKILL.md Outdated
Comment on lines +216 to +217
```sh
gh project item-list 28 --owner modelcontextprotocol --format json --limit 600 > board-snapshot.json
- board-ops: write board dumps to a temp dir, not the repo root. The boards are
private, so a snapshot is a full export of item IDs and every card's Status
and Priority — and the recipe left three such files in the worktree, one
`git add -A` from being published in a PR. All three now go to `$BOARD_TMP`,
and the snapshot step prints the path, since recovery needs it.
- release: branch from `origin/v2/main` after fetching, and read the version
once you are on it. A default clone has only `main` checked out, so the local
ref may not exist and `package.json` was `main`'s — the released version
rather than the one being bumped from. Added the one-liner for reading it
before branching, since the branch name needs it.
Signed-off-by: cliffhall <cliff@futurescale.com>
@cliffhall

Copy link
Copy Markdown
MemberAuthor

Copilot review round 18 — responses

Both fixed in d29bbbb. npm run validate passes (exit 0); test:scripts 374/374.

.claude/skills/board-ops/SKILL.md:217 — snapshots land in the repo root and are not ignored. The most consequential finding in a while, because the failure is a disclosure rather than a broken command. The boards are private — the skill says so two sections down — so board-snapshot.json is a complete export of item IDs and every card's Status and Priority, and the recipe wrote three such files into the worktree of a repo whose documented flow is git add → PR. Nothing would have failed; the data would simply have been published.

All three now go to $BOARD_TMP=$(mktemp -d), with the snapshot step echoing the path because recovery needs it a session later, and the recovery block reusing the same dir (${BOARD_TMP:-$(mktemp -d)}) so the two halves cannot diverge. The before/after diff step alongside the option-editing hazard sends its dumps there too.

I chose a temp dir over .gitignore entries deliberately: an ignore rule keeps the data out of a commit but still leaves a private board export sitting in the working tree indefinitely, and it only protects the three filenames someone thought of. Ran the rewritten pipeline against fixture JSON to confirm it still works end to end and leaves the worktree clean.

.claude/skills/release/SKILL.md:38 — the bump recipe fails or reads the wrong version on a fresh clone. Correct, and quietly wrong in the worse of the two ways: git checkout -b … v2/main fails loudly if the local ref is absent, but the node -pbefore it succeeds and prints main's version — the already-released one — so the reader names the branch after the wrong bump. Now git fetch origin v2/main, branch from origin/v2/main, and read the version only once on it. Since the branch name needs the target version before that, I added the git show origin/v2/main:package.json | node -p … one-liner for reading it without checking anything out — the same shape the tag step already uses.

CopilotAI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The local-gate wiring check can be satisfied by a non-executed textual command reference.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details
  • Files reviewed: 35/38 changed files
  • Comments generated: 1
  • Review effort level: Balanced

Comment threadscripts/verify-skills.mjs Outdated
"the root `validate` no longer runs `verify:format-coverage` (a sibling guard). Restore it.",
);
}
if (!reachableScripts(rootScripts, "local:gate").has("verify:skills:cli")) {
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>
@cliffhall

Copy link
Copy Markdown
MemberAuthor

Copilot review round 19 — responses

Fixed in b91b746. npm run validate passes (exit 0); test:scripts 378/378.

scripts/verify-skills.mjs:185reachableScripts matches a substring. Correct, and it is the round-15 finding arriving from the other side: I tightened the CI half to require an exact invocation and left the local:gate half on a helper that extracts every npm run … substring it can see. So echo npm run verify:skills:cli && npm run coverage satisfied it, as would a longer script name, while the validator never ran locally.

scriptChainRuns replaces it — a walk of the script graph rather than a substring scan. Each script body is split on the separators that begin a new command, a segment must equalnpm run <target> to count, and a segment naming some other script is followed so the chain still works through validate and friends. Two details worth stating:

  • Recursion tolerates trailing flags, matching does not.npm run coverage --silent still runs coverage, so it is followed; but the target itself must be an exact segment, consistent with runsCommand and justified the same way — these gate scripts take no arguments.
  • A cyclic graph terminates.seen guards it, with a test, because the walk is over user-editable config and a typo could make a → b → a hang the gate rather than fail it.

Regression cases cover the mention, the longer name, the absent case, and the cycle, plus a checkWiring case using your exact echo … example. Verified by mutation: restoring reachableScripts fails it.

reachableScripts is still the right tool for its other caller (rootReachesScript, the sibling-guard vouch), where the question really is "does validate mention this guard at all" — so I narrowed the call site rather than the shared helper.

CopilotAI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The gate-integrity checks can report success when validation is skipped or its failures are masked.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details

Suppressed comments (1)

scripts/verify-skills.mjs:182

  • This accepts a step even when its failure cannot gate CI. For example, run: npm run verify:skills:cli || true passes runsCommand, and a step with continue-on-error: true also passes this loop, but either lets rejected skills leave the workflow green. Reject failure-masking shell forms and continue-on-error so the asserted authoritative gate remains mandatory.
  • Files reviewed: 35/38 changed files
  • Comments generated: 2
  • Review effort level: Balanced

for (const sibling of [
"verify:typecheck-coverage",
"verify:dep-lockstep",
"verify:skills",
Comment threadscripts/verify-skills.mjs Outdated
Comment on lines +112 to +116
for (const segment of body.split(/[\n;|&]+/).map((s) => s.trim())) {
if (segment === `npm run ${target}`) return true;
const tokens = segment.split(/\s+/);
if (tokens[0] === "npm" && tokens[1] === "run" && tokens[2]) {
queue.push(tokens[2]);
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>
@cliffhall

Copy link
Copy Markdown
MemberAuthor

Copilot review round 20 — responses

All three fixed in a2ddece. npm run validate passes (exit 0); test:scripts 383/383.

All three are the same shape — a check that accepts a command which exists but cannot gate — so they are worth reading together.

scripts/verify-skills.mjs:116 and :178 — splitting on | conflates ||. Correct, and both directions are wrong in different ways: npm run verify:skills:cli || true runs the validator and swallows its rejection, so CI stays green on skills that were refused; true || npm run verify:skills:cli never runs it at all. Splitting on | turned both into a bare segment equal to the command.

Both the shell matcher and the script-chain walk now split on \n, ; and && only. That is a smaller change than special-casing ||, and strictly better: leaving || inside the segment means the segment simply does not equal the invocation, so both shapes are refused by the same rule, along with a pipe (… | tee log) whose exit status is the pipeline's.

Suppressed, :182continue-on-error masks the failure. Same defect at the workflow layer, and the one I would least have found: the step runs, the validator rejects, and the job is green anyway. Now skipped on the step and on the job. A string "true" counts as masking (YAML lets you write either) and so does an unevaluable ${{ … }} expression, on the same conservative principle the if: checks already use; an explicit false is the ordinary case and still counts.

scripts/verify-format-coverage.mjs:180rootReachesScript is substring-based. The most valuable of the three, because it means round 19's fix was incomplete in a way I asserted it was not: I said reachableScripts was "still the right tool for the vouch", and it is not — validate: "echo npm run verify:skills" satisfied it while the guard never executed, so the cycle protected nothing.

Rather than narrow one call site, the exact walk moved into lib/npm-scripts.mjs and rootReachesScript now uses it — which fixes the same hole in all four vouches (verify-format-coverage, verify-dep-lockstep, verify-typecheck-coverage ×2), not only the two this PR added. reachableScripts keeps its substring behavior for the harvesting callers (tsc-program, the glob collectors), where over-inclusion is safe and under-inclusion would be the bug.

Two things I added while there, both to avoid trading one false result for another: the walk follows npm's implicit pre/post hooks, since a guard invoked from a prevalidate really does run and reporting it missing would be a fresh false failure; and a cyclic script graph terminates. Both have tests, as does your echo npm run verify:skills fixture in the sibling guard's own suite.

CopilotAI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@cliffhall

Copy link
Copy Markdown
MemberAuthor

Review loop status

Copilot's 21st review came back Copilot was unable to review this pull request because the user who requested the review has reached their quota limit — so the loop ends on quota rather than on a clean pass. Rounds 1–20 are all addressed, each with a pushed fix and a PR-level response; nothing is outstanding from any of them.

CI is green on the current head: build ✅ 11m2s, coverage ✅ 6m12s. That includes the new verify:skills:cli step, which is the first CI run to exercise the pinned-CLI fallback (npx -y @anthropic-ai/claude-code@2.1.250 plugin validate) on a runner with no Claude Code installed — the path that could only be verified locally until now.

npm run validate passes locally and test:scripts is 383/383, up from 305 when the PR opened.

What the review changed

The 20 rounds produced ~35 findings. The ones that changed behavior rather than prose:

ClassExamples
Guards that could not failThe CI wiring vouch accepted, in turn: a comment (r10), a step in a release-only job or behind if: false (r14), a longer script name or an echo (r15), a workflow with no push trigger (r17), a local:gate mention (r19), and || true / continue-on-error (r20)
Measurements that liedThe eval scored a failed CLI run as "no skill" (r2), matched skills by substring (r5), counted a foreign skill's invocation (r13), and — in focused mode — treated the repo's other skills as somebody else's (r8)
PortabilityWindows .cmd shims made every claude spawn read as missing (r7); CRLF manifests failed validate on Windows checkouts (r11)
Correctness of the recipesThe board audit could not see an open issue carded Done (r12) — this repo's most likely board defect, since v2 issues close by hand
DisclosureThe board recovery recipe wrote private project exports into the repo root (r18)

Two are worth flagging because they were my errors that a later round caught: round 2's "reject nonzero exits" broke the eval outright (a firing skill hits --max-turns 1 and exits 1), and round 19's claim that reachableScripts was "still the right tool for the vouch" was wrong — the substring hole was in all four sibling vouches, so round 20's fix now covers guards this PR didn't add.

Ready for maintainer review.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

v2Issues and PRs for v2

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@cliffhall