Three conflicts, all in the files this PR refactored. The code from #1955 and
#2161 (core/react's useSyncExternalStore conversion) merged cleanly.
- `.github/copilot-instructions.md` — modify/delete. This PR deletes it, and
the four lines v2/main added there are a distillation of AGENTS.md rules that
arrive in AGENTS.md anyway. Kept deleted.
- `AGENTS.md` — the incoming change expanded the `core/react/` entry in the
project tree by eleven lines; this PR compacted that tree to one line per
entry on purpose. Took the compact tree and carried the substance onto its
one line. The other incoming hunk — the store-subscription rules under React
instructions — merged cleanly, which is the split working: it is a RULE, so
it lands in the section that stayed.
- `README.md` — same shape, one tree line. This README no longer enumerates
`core/`'s subdirectories at all, so the substance went where that content now
lives: `docs/architecture.md` and the `project-structure` skill's core table.
Nothing from v2/main was dropped; every incoming statement is now in exactly one
place rather than the three it used to occupy.
Signed-off-by: cliffhall <cliff@futurescale.com>
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>
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>
- 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>
- 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>
`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>
- The CI wiring guard matched a substring, and last round's justification for
that was wrong: it accepts a longer script name
(`npm run verify:skills:cli:disabled`) and a mere mention (`echo npm run
verify:skills:cli`), either of which lets the real validator be removed while
the guard stays green. `runsCommand` now splits the script on the separators
that begin a new command and requires a segment to equal the invocation
exactly, with both counterexamples as regression cases.
- `local-dev`'s own description and its AGENTS.md index row both still promised
"where a new dependency must be declared" — the rules round 13 moved out of it
entirely. Both now describe what the skill actually carries: the reasoning
behind those rules. Recorded listing share updated to 1,199/4,000.
Signed-off-by: cliffhall <cliff@futurescale.com>
- The CI wiring vouch accepted any syntactically-executable `run:`, so a step
moved into a release-only job or put behind `if: false` still reported as
wired while PR CI validated nothing. It now requires an UNCONDITIONAL step:
any `if:` on the job or the step disqualifies it, since this guard cannot
evaluate an Actions expression and "runs sometimes" is not the property being
asserted. Seven predicate cases, including the two conditional shapes and a
multi-line `run:` that must still count.
- The `local:gate` stage list now has one owner, `docs/quality-gate.md`. It had
three copies, and they drifted apart across rounds 5, 6 and 8 of this review —
which is the argument the PR itself is making, applied to itself.
Signed-off-by: cliffhall <cliff@futurescale.com>
- skill-manifest: an eval file may only expect its own skill. A foreign
`expect: "other-skill"` passed whenever that other skill fired, so the file
reported a measurement of something it does not describe — and it satisfied
the positive-case requirement while doing so, masking the absence of a real
positive. Both halves covered by a regression case.
- local-dev: stop restating the dependency-placement rules. They are normative
and belong only in AGENTS.md; the skill now points there and carries what a
rule cannot — what each one is defending against, what it looked like when it
was violated (#1970, #2082, #2067, #1952, #1896, #2058), and how to tell you
have hit it. Including the two traps that make this class hard to see: the
ordinary test tiers cannot catch a bundling miss, and probing one by hand with
`node -e` falsely succeeds.
Signed-off-by: cliffhall <cliff@futurescale.com>
The board audit checked Done from one side only — a card parked there for work
that never shipped — and so could not see an issue still OPEN under a Done card.
That is the normal way this breaks here: closing keywords do not fire on
`v2/main`, so every v2 issue is closed by hand after its PR merges, and
forgetting that step leaves exactly this state. No other check could catch it;
they either gate on `isopen` or skip `Done`. Predicate verified against a
fixture holding one open and one closed Done card.
Signed-off-by: cliffhall <cliff@futurescale.com>
- skill-manifest: accept CRLF on the opening fence. A Windows checkout with
`core.autocrlf` has `---\r\n`, the fence is still the first line, and the
authoritative validator accepts it — so `npm run validate` was failing a valid
manifest, and failing it *before* that validator ever ran. The closing fence
now strips a preceding CR too, so the YAML handed to the parser is clean.
Regression cases for CRLF, mixed endings, and CRLF-with-no-body, plus cases
proving the first-line rule and the whole-line terminator rule still hold with
CRLF. Verified by mutation.
- docs/quality-gate: `verify:skills` requires eval cases only from model-invoked
skills; the seven name-only ones intentionally have none, and the summary read
as an instruction to add them everywhere.
- docs/quality-gate: the `test:scripts` inventory said four new suites;
`claude-cli.test.mjs` makes five.
Signed-off-by: cliffhall <cliff@futurescale.com>
The CI half of the wiring vouch matched the workflow's raw text, so a YAML
comment, a step `name:`, or an unused anchor mentioning `npm run
verify:skills:cli` satisfied it — including this guard's own docblock wording.
Deleting the real step would then have left `verify:skills` green, which is the
vacuous-guard failure the vouch exists to prevent.
It now reads the workflow's executable regions via `extractExecutableRegions`,
the same helper the #2146 workflow gate is built on, and matches only a `run:`.
Covered by three should-not-count cases (comment, step name, workflow input)
plus a multi-line `run:` that must count; verified by mutation — restoring the
substring check fails them.
Signed-off-by: cliffhall <cliff@futurescale.com>
Copilot round 6. `handleCommandScopedAuthRecovery` wrote
`pendingStepUpRetryRef` before calling `trySetPendingStepUp`, which
REFUSES a second prompt while one is already open — so the refused
command's operation replaced the open prompt's, and authorizing that
prompt ran the command the user had just been told could not start.
The retry now goes in only on a prompt that actually opened, which makes
the pairing hold in both directions: the prompt and its retry are
installed together, and the EMA branch takes them out together.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Quk1hBUtyhkXowwMXzAnAY
Signed-off-by: cliffhall <cliff@futurescale.com>
- Enrol both skill gates in the repo's gate-integrity vouch cycle. A gate that
stops being invoked fails silently with every fixture test green, which is the
failure the existing cycle exists to prevent. `verify:format-coverage` now
vouches for `verify:skills`, `verify:skills` vouches back, and it additionally
asserts the two links nothing else could see: `verify:skills:cli` present in
`local:gate` and in the workflow — neither is reachable from `validate` or
from any test. `checkWiring` is pure and unit-tested, with a live case against
the real package.json and workflow so the fixtures cannot drift. All four
links verified by mutation.
- The existing sibling-vouch fixture is now a table over every sibling, so
adding a fourth guard later does not need a new test.
- release skill: stop hard-coding `2.3.0`, which has already shipped — the tag
command aborted on an existing tag and the branch template named the wrong
release. Both now read the version from `package.json` (the tag from
`origin/main`'s), with a warning never to copy a version out of the file.
Signed-off-by: cliffhall <cliff@futurescale.com>
Copilot round 5. `pendingStepUpRetryRef` is shared, and the EMA branch
dismisses the prompt *before* its awaits — so a newer command can open
its own step-up and install its own retry while the first authorization
is in flight. Every arm that read or cleared the ref afterwards was
acting on that newer operation: running it under the wrong
authorization and reporting its failure to the wrong source, or
deleting it outright.
The attempt now takes its retry out of the ref before any await, and no
arm touches the ref again. That covers all three sites at once and
removed the interactive-arm clear added last round — the value is
already out by then. The standard step-up path redirects and clears
synchronously, so it has no such window.
Also rewrites the `writeOAuthResumeSnapshot` docstring, which still
described the byte-comparison token it no longer returns.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Quk1hBUtyhkXowwMXzAnAY
Signed-off-by: cliffhall <cliff@futurescale.com>
Copilot review round 3.
"stops listening on unmount" asserted that the rendered value stopped
changing after unmount, which proves nothing: React drops a store
notification for an unmounted component whether or not the listener is
still attached, so the test passed with the leak it existed to catch.
Spy the store's add/removeEventListener and assert the exact function
attached is the one detached. Verified it discriminates by deleting the
cleanup, watching this test fail, and restoring.
Signed-off-by: cliffhall <cliff@futurescale.com>
Copilot round 4:
- the EMA `interactive` arm kept `pendingStepUpRetryRef`, on a comment
claiming it was "still on its way to a redirect". That is wrong twice:
`prepareOAuthRedirect` returns before the navigation is resolved, so a
pre-navigation failure leaves the operation for a later, unrelated
step-up to re-run; and the full-page navigation destroys the closure on
the success path anyway. It now clears the ref, matching the standard
step-up below.
- the two surviving `void (async () => …)()` sites now state why the
caller cannot await, as AGENTS.md requires — the comments had described
only what the catch handles.
- the `randomUUID` fallback test restored an own descriptor that never
existed (it is inherited from `Crypto.prototype`), leaving an own
`randomUUID: undefined` that would push every later test in the file
onto the fallback path. It deletes the temporary property instead, and
asserts the global is a function again afterwards.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Quk1hBUtyhkXowwMXzAnAY
Signed-off-by: cliffhall <cliff@futurescale.com>
- skill-eval: `only` no longer narrows `ours`. A negative case means "no skill
of this repo fired", so filtering the repo's other model-invoked skills out of
that set let `skills:eval -- testing` score a `local-dev` invocation as
somebody else's skill and pass a negative case it should fail. Every
model-invoked skill is now inspected; `only` applies when enqueueing.
`collectCases` takes a skills directory so the focused-mode regression is
actually testable — verified by mutation: restoring the early filter fails it.
- The workflow's `validate` step comment said two coverage guards; it runs four.
Signed-off-by: cliffhall <cliff@futurescale.com>
Copilot review round 2.
The contract claimed an unstable `read`/`whenAbsent` trips React's "The
result of getSnapshot should be cached" error. It does not. Measured on
React 19: an inline `read` closure throws nothing, logs nothing, and forces
no extra render pass. React's dev check calls getSnapshot twice within a
single render, where the closure is unchanged, so the cache answers both
calls identically and the warning never fires.
The real cost is quiet — a fresh value every render, defeating downstream
useMemo/React.memo/effect deps — which is a better reason to state the
contract than an error that never arrives.
Also stop requiring module scope for every fallback. A primitive
`whenAbsent` (`false`, `undefined`, `"disconnected"`) is stable under
Object.is and is passed inline throughout these hooks, so the rule
contradicted the code it was describing. Only object/array fallbacks need a
constant, which is what NO_TOOLS and NO_PAGINATION are.
Signed-off-by: cliffhall <cliff@futurescale.com>
Node has refused shell-free `.cmd`/`.bat` spawns since CVE-2024-27980, and an
npm-installed `claude` is a `.cmd` shim on Windows — so every shell-free spawn
of it reported ENOENT there, each in a quiet way: `verify:skills` skipped its
authoritative hand-off, `verify:skills:cli` downloaded the pinned package it
already had (and failed offline), and `skills:eval` said "not on PATH".
- New `scripts/lib/claude-cli.mjs` is the one place that decides how `claude` is
spawned: `shell` on win32, arguments through the existing `winShellArgs`
quoting so `cmd.exe` cannot re-parse them as syntax (#1939). Unit-tested with
the platform injected, since a win32-only bug no runner here executes is
exactly the kind that ships.
- `verify-skills`, `verify-skills-cli` and `skill-eval` all route their probes
and spawns through it.
- skill-eval sends the prompt on **stdin** rather than in argv. `claude -p` reads
a piped prompt (verified end to end), which sidesteps `cmd.exe` metacharacter
re-parsing entirely rather than relying on quoting a model-visible string, and
keeps the prompt out of the process table.
Signed-off-by: cliffhall <cliff@futurescale.com>
Copilot round 3, four items:
- the snapshot "attempt token" was the stored serialization, which is not
unique: the snapshot records neither the authorization URL nor its
`state`, so two concurrent redirects for one server with unchanged UI
serialize identically. `OAuthResumeSnapshot` now carries an optional
`attemptId`, stamped per write and matched on clear. Optional because
an older build's snapshot, mid-redirect across an upgrade, has none —
and treating that as a match would strand a live callback.
- the resume-failure toast promised a retry even when the session had
ended and nothing was restored. Copy is now chosen by whether anything
is left pending; `authRecoveryAbandonedMessage` covers the other case.
- a connect-scoped redirect that failed left the connection at
"connecting" with the active-server lock held, since the redirect was
what would have ended the attempt. It now disconnects first, as the
sibling arm in `useConnectionLifecycle` does — scoped to that context,
because an ambient or step-up failure does not mean the session is dead.
- both terminal EMA step-up failure arms now drop the stored retry. A
later step-up does not overwrite the ref, so a retained operation could
be re-run by an unrelated authorization. The `interactive` arm keeps
it: that one is still on its way to a redirect.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Quk1hBUtyhkXowwMXzAnAY
Signed-off-by: cliffhall <cliff@futurescale.com>
Copilot review round 1.
The cache was keyed on the store and its revision alone, so a changed
`read` was answered with the previous reader's output, and with a null
store — where the revision is pinned at 0 — a changed `whenAbsent` was
ignored outright. Key on `event`, `read` and `whenAbsent` too.
This makes an unstable argument defeat the cache rather than silently
serve a value derived from the previous one, so React fails it with "The
result of getSnapshot should be cached" at first render. That is the
intended failure mode and is now documented as such: a loud error beats a
wrong list nobody can account for.
Also correct the docs, which described the helper as universal. That
misread useListError as a violation: its snapshot is the stored Error
instance, already referentially stable, so it subscribes with
useSyncExternalStore directly and needs no cache. The rule is
read-during-render; the helper is for getters that return a fresh value
per read.
Signed-off-by: cliffhall <cliff@futurescale.com>
- verify-skills: the best-effort `plugin validate` hand-off now also requires
the EXACT pinned CLI. `local:gate` runs `validate` before the pinned
`verify:skills:cli` step, so a newer local CLI could reject skills that CI's
pinned validator accepts — and the gate would exit there, never reaching the
reproducible check. One schema everywhere, or none.
- Dropped `PLUGIN_VALIDATE_MIN_VERSION`: with both validators on the exact pin
it was read only by its own test, and a constant nothing enforces is a claim
nothing enforces. Its test now pins what actually matters — that equality is
distinguishable from newer as well as older.
- The CI step's comment described the old floor behavior.
- issue-create: the duplicate check searched only open issues, so a completed or
rejected match was invisible to a procedure that says never to duplicate.
Searches all states and reports state/stateReason.
Signed-off-by: cliffhall <cliff@futurescale.com>
Copilot round 2, both findings valid:
- `resumePendingReauth`'s restore could resurrect a challenge whose
session ended during the awaits — a server switch or disconnect clears
the slot on purpose. It is now gated on the attempt still targeting
the live session, client identity included (reconnecting to the same
server builds a replacement client an id-only check would admit), and
uses a functional update so a newer deferral queued in the same batch
wins rather than being read past.
- `prepareOAuthRedirect` cleared whichever resume snapshot was current,
not the one it wrote. Nothing single-flights that callback, so a
redirect failing before navigation could delete a later attempt's
callback-routing state. `writeOAuthResumeSnapshot` now returns the
serialization it stored, and `clearOwnOAuthResumeSnapshot` removes it
only while it is still the current one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Quk1hBUtyhkXowwMXzAnAY
Signed-off-by: cliffhall <cliff@futurescale.com>
Every state-subscription hook in core/react/ seeded local React state from
its store prop and re-synced it inside a useEffect. That shape paints one
frame of the previous store's data when the prop changes (switching
servers), and loses any event dispatched between the render that took the
snapshot and the effect that attached the listener.
Convert all 16 to useSyncExternalStore through a new shared
useStoreSnapshot, following useManagedListError (#1954).
useSyncExternalStore needs a referentially stable snapshot, and these
getters hand back a defensive copy, so TypedEventTarget now keeps a
per-event-type dispatch counter (getEventRevision) that the snapshot is
cached against. A contents comparison would not do: MessageLogState folds
a response into its request entry by mutating that entry in place, which a
shallow compare reports as unchanged. The bump lives on the dispatchEvent
override so no dispatch — including a hand-rolled CustomEvent — can bypass
it.
useInspectorClient is included though the issue did not list it; its nine
subscribed slices made the stale frame most visible. Its lastError stays
useState (accumulated from events, no getter to read back) but resets on
client swap during render rather than in an effect.
The settings-draft and async fetch hooks are deliberately untouched: the
former hold genuinely local editable state, the latter are I/O rather than
store subscriptions.
Also answers the issue's open question. react-hooks/set-state-in-effect
never fired here because eslint-plugin-react-hooks has never linted
core/react/ at all — it is a clients/web devDependency and the root config
loads no React plugin. Corrected the config comment that gave "no JSX" as
the reason; the gap itself is #2192, measured at 15 findings, none of them
in the hooks converted here.
Closes#1955
Signed-off-by: cliffhall <cliff@futurescale.com>
- 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>
Three findings, all valid:
- `beginInteractiveAuthorization` navigated *before* awaiting provider
creation and flow-state recording, so a rejection could arrive after
the browser had been handed over — and the web catch clears the resume
snapshot on one. Fixed at the root: the navigation now resolves last,
after every fallible step, which makes every rejection the method can
produce a pre-navigation rejection. It is also better on its own
terms — a provider that cannot be built means the callback could not
have completed, so navigating first only bought a dead-end round trip.
- `resumePendingReauth` restored the pending slot unconditionally, which
could overwrite a newer challenge deferred while it was awaiting.
Restores only into an empty slot, so latest wins.
- the `authChallengeInteractive` catch re-read the active server rather
than the one the challenge arrived for. `serverId` is now captured
synchronously in the listener and used by both the body and the catch.
Three new tests, one per finding, each driving the interleaving rather
than the happy path.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Quk1hBUtyhkXowwMXzAnAY
Signed-off-by: cliffhall <cliff@futurescale.com>
- 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>
#2153 dropped App.tsx below the React Compiler's bail-out, so the
compiler-powered rules in eslint-plugin-react-hooks v6 began reporting
nine latent findings. Each was suppressed with a directive pointing at
this issue; the later phases moved them into useConnectionLifecycle and
useMcpApps. Eight remained. Five are now fixed and three re-justified on
their own terms, so no suppression points at an open issue any more.
Fixed:
- `connectedServerId` was a useState mirrored from `connectionStatus` and
`activeServerId` by an effect. It is a pure function of both, so it is
derived. A state copy could only lag the status it copies by one
committed render, which is a frame of the wrong highlight for no gain.
- The two latest-ref writes (`sandboxUrlRef`, `listedResourcesRef`) move
from render into a layout effect, the same fix review applied to
`setupClientForServerRef` in #2153. Both refs are read by an event
handler or an async handler that runs after the commit, so neither
reader observes a different value.
- `appElicitationController` was a ref seeded during render with `??=`.
The instance is never reassigned, so `useState`'s lazy initializer
gives the same create-once semantics without a render-time ref read —
which also retires the two directives on the `useSyncExternalStore`
arguments it fed.
Kept, with the argument in place of the pointer:
- The deep-link `onToggleConnection` call. It opens a transport and
drives a handshake; the state it sets along the way is a consequence of
that I/O, not something derivable from props. An effect is the right
tool, and the one-shot `deepLinkConnectRef` guard is what bounds it.
- The two `createAppBridgeFactory` calls. `react-hooks/refs` cannot see
through the factory and warns that a ref-reading dep it is handed
(`getListedResourceMeta`) may be called during render. It is not: the
factory only stores its deps, and the sole call site is inside the
async `sandboxready` handler.
No behavior change beyond `connectedServerId` landing in the same
committed render as the status it derives from, rather than the next one.
Signed-off-by: Cliff Hall <cliff@futurescale.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hzwzS8UvBsr4yZm7o7sev
Signed-off-by: cliffhall <cliff@futurescale.com>
Each of the five sites in `useOAuthRecovery` discarded a promise with
`void`, satisfying `@typescript-eslint/no-floating-promises` but not the
narrower rule AGENTS.md states — `void` only where the callee owns its
failures or the caller cannot await, with a comment saying which.
Each now picks a user-visible outcome:
- `prepareOAuthRedirect` clears the resume snapshot (so the next load
cannot read a redirect that never started as an *abandoned* one),
flags the server and raises the re-auth banner.
- the `authChallengeInteractive` handler flags and banners, where before
a rejection drew no UI response at all.
- `resumePendingReauth` restores the pending slot and toasts, so the
next trigger retries the deferred recovery instead of losing it.
- the `/oauth/callback` effect flags and banners what falls between its
existing arms — the client rebuild and the post-resume scope check.
- `handleStepUpAuthorize`'s EMA branch reports exactly as its `failed`
outcome arm does; the stored retry gets its own catch, since by then
the permissions leg succeeded and the failure is the command's.
The two surviving `void`s carry the justification AGENTS.md asks for.
Closes#2165
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Quk1hBUtyhkXowwMXzAnAY
Signed-off-by: cliffhall <cliff@futurescale.com>
- 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>
- 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>
`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>
- 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>
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>
The CI coverage job failed with `ReferenceError: sessionStorage is not defined`.
`revocation-e2e.test.ts` used `BrowserOAuthStorage`, but the `integration`
vitest project runs in the node environment, not happy-dom. Node 22 gates Web
Storage behind a flag while newer Node exposes it by default, so it passed
locally and failed in CI. It uses `NodeOAuthStorage` with a temp dir now,
matching the other OAuth integration tests, with a comment naming the trap.
Also from Copilot review round 21: `listIssuers` and `getIssuerTokens` became
dead production API when `takeRevocationSnapshot` took over — a grep finds them
only in their own implementations and tests. Keeping them would widen the
exported `OAuthStorage` contract and force every implementation and mock to
carry methods nothing calls. Removed, along with their tests and the mock
entries in four suites.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177mPHAdECD18nLLCTwR5rh
Signed-off-by: cliffhall <cliff@futurescale.com>