docs: rule that a wait is a notification, never a tight poll loop (#2253)

Agents routinely spend turns tight-polling for a condition that was already
going to notify them — ~80 consecutive no-op turns waiting out one local:gate
run on #2250, with a completion notifier for that very process already armed.
The convention existed only in a per-user memory note, which is invisible to
anyone else and cannot be relied on mid-session.

- AGENTS.md gains a "Waiting on long-running work" section: arm a notifier and
  stop; one notification is a backgrounded command or a single `until` loop, many
  is a Monitor. It states the one genuine exception — state the harness cannot
  observe — and says where the poll belongs in that case, inside one backgrounded
  loop rather than spread across turns.
- pre-push-gate points at it where the gate is actually run.
- pr-flow §7 points at it where the Copilot review is actually waited on, with
  the loop written out. The count-not-existence trap is called out: on round two
  the first round's review is still there, so `-ge 1` returns immediately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017nLLLTGc37hQiC8s3mNyhq
Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-09-06 12:13:47 -04:00 • committed by GitHub
commit 27bf55e0e5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 105 additions and 64 deletions

View file

@ -240,7 +240,23 @@ gh api graphql -f query='
```
Poll for the review with a `startswith` match — the review login carries a
`[bot]` suffix.
`[bot]` suffix. **Put that poll in one backgrounded loop that exits when the
round lands, and wait for its notification** rather than re-fetching once per
turn; a review is remote state the harness cannot observe, which is exactly the
exception described in [Waiting on long-running
work](../../../AGENTS.md#waiting-on-long-running-work) — and exactly where the
poll belongs when one is needed.
```sh
until [ "$(gh api --paginate repos/modelcontextprotocol/inspector/pulls/<N>/reviews \
--jq '[.[]|select(.user.login|startswith("copilot-pull-request-reviewer"))]|length' \
| awk '{s+=$1} END{print s+0}')" -ge "$EXPECTED" ]; do sleep 20; done
```
`EXPECTED` is the review **count** you are waiting to reach, not `1` — on round
two the first round's review is still there, so an existence check returns
immediately. Give the inline comments a further ~60s after the body lands; they
arrive late (see step 8).
## 8. Respond to the review

View file

@ -37,7 +37,7 @@ not.
⚠️ **There is no `npm run ci`.** The gate was renamed to `local:gate` (#2146)
precisely because `npm ci` is a built-in that clean-installs from the lockfile
and does *not* run this script. `npm run ci` now fails with npm's missing-script
and does _not_ run this script. `npm run ci` now fails with npm's missing-script
error.
## Verify by exit code, not by grepping output
@ -50,7 +50,14 @@ npm run local:gate; echo "EXIT=$?"
```
⚠️ If you run it as a background task, the harness's "exit code 0" notification
describes the *wrapper*, not the gate — read the `EXIT=` line.
describes the _wrapper_, not the gate — read the `EXIT=` line.
**Background it and then wait for that notification** — do not spend turns
watching it. The gate takes several minutes, and re-running `tail` or an
`echo ok` once per turn until it lands tells you nothing the completion
notification would not have; see [Waiting on long-running
work](../../../AGENTS.md#waiting-on-long-running-work). Waiting out one run this
way cost ~80 consecutive no-op turns on #2250.
## Diagnosing a failing stage
@ -105,7 +112,7 @@ a whitelist, so a module placed outside it falls out of the gate silently.
### `smoke:web*`
⚠️ **An orphaned prod web server from a previous run fakes a rejection.** It
answers the readiness probe with *its* token, and the deep link comes back
answers the readiness probe with _its_ token, and the deep link comes back
`data-deeplink="rejected"` with no error. Assert the port is free before
blaming the change.
@ -118,7 +125,7 @@ Vite's `fs.allow`. Do a real `npm install` in the worktree.
⚠️ Two concurrent `npm run local:gate` runs starve each other — ~326 tests time
out at 5s. Run one at a time. (A `pgrep -f "npm run local:gate"` wait loop
matches *itself* and never exits.)
matches _itself_ and never exits.)
## Local-only steps
@ -128,7 +135,7 @@ Two stages have no GitHub CI counterpart, each deliberately:
Firefox. Trialled as a CI job and removed (#2086): across a dozen runs it never
disagreed with Chromium, and `playwright install --with-deps` carries a real
flake surface. Kept in front of a human about to push instead.
- **`smoke:tui`** — needs a real TTY. It *is* invoked in CI via `npm run smoke`
- **`smoke:tui`** — needs a real TTY. It _is_ invoked in CI via `npm run smoke`
and self-skips there on `process.env.CI`, so it needs no guarding.
A guard (`scripts/lib/workflow-gate.mjs`, run by `npm run test:scripts`) fails

134
AGENTS.md
View file

@ -14,18 +14,18 @@ users invoke them by name.
## Skills index
| Skill | Covers | How it loads |
| --- | --- | --- |
| [`local-dev`](.claude/skills/local-dev/SKILL.md) | Install and run each client; the `@inspector/core` alias; and the **reasoning** behind Dependency placement below — what each rule defends against and how to tell you have hit one (the rules themselves stay here) | Model-invoked, or `/local-dev` |
| [`project-structure`](.claude/skills/project-structure/SKILL.md) | Which client owns which surface, what is in `core/`, where a new file belongs | Model-invoked only |
| [`testing`](.claude/skills/testing/SKILL.md) | Where a test file goes, which command runs it, the tiers, clearing the coverage gate, `renderWithMantine` | Model-invoked, or `/testing` |
| [`issue-create`](.claude/skills/issue-create/SKILL.md) | The five-step create flow: version label, type label, milestone, board card, Status + Priority | Model-invoked, or `/issue-create` |
| [`issue-triage`](.claude/skills/issue-triage/SKILL.md) | The two-pass sweep of unboarded issues, the priority rubric and its score comment, the board audit | Model-invoked, or `/issue-triage` |
| [`board-ops`](.claude/skills/board-ops/SKILL.md) | `gh project` recipes and the field/option IDs for boards #28 and #11; the option-deletion hazard and its recovery | Model-invoked, or `/board-ops` |
| [`pr-flow`](.claude/skills/pr-flow/SKILL.md) | Branch naming, DCO signoff, screenshots, opening the PR, requesting a Copilot review, responding, closing out | Model-invoked, or `/pr-flow` |
| [`pre-push-gate`](.claude/skills/pre-push-gate/SKILL.md) | Running `npm run local:gate` and diagnosing a failing stage | Model-invoked, or `/pre-push-gate` |
| [`release`](.claude/skills/release/SKILL.md) | Cutting a release: bump on `v2/main`, milestone merge, tag `origin/main`, publish | `/release` |
| [`test-servers`](.claude/skills/test-servers/SKILL.md) | Picking and running a showcase test server; the stale-build hazard | Model-invoked, or `/test-servers` |
| Skill | Covers | How it loads |
| ---------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------- |
| [`local-dev`](.claude/skills/local-dev/SKILL.md) | Install and run each client; the `@inspector/core` alias; and the **reasoning** behind Dependency placement below — what each rule defends against and how to tell you have hit one (the rules themselves stay here) | Model-invoked, or `/local-dev` |
| [`project-structure`](.claude/skills/project-structure/SKILL.md) | Which client owns which surface, what is in `core/`, where a new file belongs | Model-invoked only |
| [`testing`](.claude/skills/testing/SKILL.md) | Where a test file goes, which command runs it, the tiers, clearing the coverage gate, `renderWithMantine` | Model-invoked, or `/testing` |
| [`issue-create`](.claude/skills/issue-create/SKILL.md) | The five-step create flow: version label, type label, milestone, board card, Status + Priority | Model-invoked, or `/issue-create` |
| [`issue-triage`](.claude/skills/issue-triage/SKILL.md) | The two-pass sweep of unboarded issues, the priority rubric and its score comment, the board audit | Model-invoked, or `/issue-triage` |
| [`board-ops`](.claude/skills/board-ops/SKILL.md) | `gh project` recipes and the field/option IDs for boards #28 and #11; the option-deletion hazard and its recovery | Model-invoked, or `/board-ops` |
| [`pr-flow`](.claude/skills/pr-flow/SKILL.md) | Branch naming, DCO signoff, screenshots, opening the PR, requesting a Copilot review, responding, closing out | Model-invoked, or `/pr-flow` |
| [`pre-push-gate`](.claude/skills/pre-push-gate/SKILL.md) | Running `npm run local:gate` and diagnosing a failing stage | Model-invoked, or `/pre-push-gate` |
| [`release`](.claude/skills/release/SKILL.md) | Cutting a release: bump on `v2/main`, milestone merge, tag `origin/main`, publish | `/release` |
| [`test-servers`](.claude/skills/test-servers/SKILL.md) | Picking and running a showcase test server; the stale-build hazard | Model-invoked, or `/test-servers` |
Longer-form human documentation lives in [`docs/`](./docs) — see the table in the
[README](./README.md#documentation).
@ -65,6 +65,7 @@ that reasoning in this file — a duplicated rationale is one that goes stale
silently. For the fuller map (what each `core/` area owns, what each
`clients/web/server/` file does, where a new file belongs), the
`project-structure` skill.
## Development setup
v2 is **not** an npm workspace — each client under `clients/*` keeps its own
@ -87,12 +88,12 @@ The reasoning behind each of these, and what breaks when it is ignored, is the
`local-dev` skill. The rules themselves:
- **Every runtime dependency `core/` imports is declared in the repo-root `package.json` and nowhere else.** That is the MCP SDK packages (`@modelcontextprotocol/client`, `core`, `server`, `server-legacy`, `ext-apps`) and, since #2195, the rest of what `core/` reaches: `ajv`, `atomically`, `chokidar`, `hono`, `@napi-rs/keyring`, `pino`, `proper-lockfile`, `react`, `undici`, `zod`. So is anything reached only through root-owned code with no manifest of its own (`test-servers/src`, `core/`). The v1 SDK (`@modelcontextprotocol/sdk`) is **not** a dependency of this repo and must not become one.
- **A root declaration is not by itself a claim that `core/` imports it.** `commander`, `open`, `@hono/node-server`, `vite` and `@vitejs/plugin-react` are root `dependencies` reached only from *client* code, for the runtime-consumption reason below: a published install resolves every externalized import from the root manifest, so a client's runtime import has to be declared there whether or not `core/` also reaches it. Those need naming only in the `external` list of the client that actually imports them, not in all three.
- **A root declaration is not by itself a claim that `core/` imports it.** `commander`, `open`, `@hono/node-server`, `vite` and `@vitejs/plugin-react` are root `dependencies` reached only from _client_ code, for the runtime-consumption reason below: a published install resolves every externalized import from the root manifest, so a client's runtime import has to be declared there whether or not `core/` also reaches it. Those need naming only in the `external` list of the client that actually imports them, not in all three.
- **A client declares only what that client alone consumes** — its own UI stack, its bundler-inlined packages, its dev tooling. `clients/cli` and `clients/launcher` therefore declare **no** runtime dependencies at all, and that is the expected steady state, not an omission: everything they run on is root-declared and resolves by walk-up from the client directory. Re-adding a root-declared package to a client manifest re-creates the second copy this rule exists to make impossible (#1896), so a missing module at runtime is a signal to check the **root** manifest and the client's `external` list, never to add it back.
- **A package that moves to the root moves its `vitest.shared.mts` pin with it.** Left pointing at `<client>/node_modules` a pin resolves to a directory that no longer exists — or, where a transitive copy happens to sit there (`chokidar` under `vite`, `react` as a peer of `react-dom` and `ink`), to the very duplicate the pin list exists to prevent. **`react` and `react-dom` are the deliberate exception** and stay pinned per client, so a client's renderer and the React it calls into come from one install; every other root-owned pin resolves from the repo root.
- **`dependencies` vs `devDependencies` follows from who consumes it at runtime**, not from where it is declared. Anything `core/` imports at runtime must be a root **`dependency`** — the client builds externalize npm packages and a published install resolves them from the root manifest, where devDependencies are absent.
- **The shared toolchain is declared once, at the repo root, and in no client manifest.** `eslint`, `@eslint/js`, `typescript-eslint`, `globals`, `prettier`, `typescript`, `vitest`, `@vitest/coverage-v8` and `@types/node` are used by every client's own scripts, and a client that declares none of them still resolves the root copy by walk-up — `npm run` puts each ancestor `node_modules/.bin` on `PATH`, and Node and TypeScript walk parent `node_modules` / `node_modules/@types` the same way. `clients/launcher` declares no `devDependencies` at all and its `validate` is unchanged. A client-side declaration buys nothing and installs a second copy free to drift, as `globals` (`^17.7.0` root / `^17.4.0` clients) and `typescript-eslint` (`^8.65.0` / `^8.56.1`) had before #2196. These stay **`devDependencies`** — none is consumed at runtime and the tarball ships only each client's `build/`. The boundary is **used by every client**, not "used by one": anything narrower stays where it is, whether one client declares it (`tsx`, `playwright`, `storybook`, `happy-dom`, `ink-testing-library`, `vite-node`, each client's own `@types/*`) or several do — `tsup` is declared in web, cli and tui, and `vite` in web and tui on top of the root **runtime** `dependency` that `--web --dev` needs. Those are out of scope here; consolidating them is a different call with a different rationale.
- ⚠️ **Deleting the declaration does not always delete the copy, and the local copy still wins.** npm auto-installs an unmet **peer** into the install that needs it, and it has no visibility into the root's tree — so a client-only ESLint plugin drags a client-local `eslint` in (`eslint-plugin-react-refresh`/`-storybook` in web, `eslint-plugin-react-hooks` in tui), and web's Storybook/Vitest stack drags in a local `typescript` and `vitest`. A hoisted transitive does the same: `@types/express` puts an `@types/node` in web and cli. Those copies sit *nearer* than the root's and take precedence. The consolidation is therefore about **one declaration and one place to bump**, not about a single copy on disk. ⚠️ **Nothing keeps the surviving copies aligned automatically — but since #2226 the guard rejects the drift.** A **peer** copy is at least constrained by its holder's peer range — tightly for `vitest` (an exact peer, hence the pin below), loosely for `eslint` (`^9 || ^10`), where the copies agree only because npm resolves the same latest in both installs. A **transitive** copy is constrained by nothing of ours at all, and cli's `@types/node` (`24.13.1` against the root's `24.13.3`) diverged on exactly that. **That is detection, not alignment: `verify:dep-lockstep` fails on this class since #2226, and you still do the bump by hand.** Its second tier compares every package any install *declares* (`dependencies`, `devDependencies`, `optionalDependencies`; not peers) against every top-level copy across all five installs, independent of what a `tsc` program loads, so a transitive drift and a peer shadow (`eslint`, `typescript`, `vitest`) are both in scope now. Two limits remain: the tier reads lockfiles, so a tool binary you installed by hand and never committed is still invisible; and it only compares names some manifest declares, so a purely transitive package no manifest names is out of scope in both tiers unless a `tsc` program loads both copies. Aligning a stale install is `npm update <pkg>` there; a transitive copy that will not move takes an `overrides` entry in that install (`clients/cli` pins `@types/node` this way).
- ⚠️ **Deleting the declaration does not always delete the copy, and the local copy still wins.** npm auto-installs an unmet **peer** into the install that needs it, and it has no visibility into the root's tree — so a client-only ESLint plugin drags a client-local `eslint` in (`eslint-plugin-react-refresh`/`-storybook` in web, `eslint-plugin-react-hooks` in tui), and web's Storybook/Vitest stack drags in a local `typescript` and `vitest`. A hoisted transitive does the same: `@types/express` puts an `@types/node` in web and cli. Those copies sit _nearer_ than the root's and take precedence. The consolidation is therefore about **one declaration and one place to bump**, not about a single copy on disk. ⚠️ **Nothing keeps the surviving copies aligned automatically — but since #2226 the guard rejects the drift.** A **peer** copy is at least constrained by its holder's peer range — tightly for `vitest` (an exact peer, hence the pin below), loosely for `eslint` (`^9 || ^10`), where the copies agree only because npm resolves the same latest in both installs. A **transitive** copy is constrained by nothing of ours at all, and cli's `@types/node` (`24.13.1` against the root's `24.13.3`) diverged on exactly that. **That is detection, not alignment: `verify:dep-lockstep` fails on this class since #2226, and you still do the bump by hand.** Its second tier compares every package any install _declares_ (`dependencies`, `devDependencies`, `optionalDependencies`; not peers) against every top-level copy across all five installs, independent of what a `tsc` program loads, so a transitive drift and a peer shadow (`eslint`, `typescript`, `vitest`) are both in scope now. Two limits remain: the tier reads lockfiles, so a tool binary you installed by hand and never committed is still invisible; and it only compares names some manifest declares, so a purely transitive package no manifest names is out of scope in both tiers unless a `tsc` program loads both copies. Aligning a stale install is `npm update <pkg>` there; a transitive copy that will not move takes an `overrides` entry in that install (`clients/cli` pins `@types/node` this way).
- ⚠️ **`vitest`, `@vitest/coverage-v8` and web's `@vitest/browser-playwright` are pinned exactly, and move together.** `@vitest/browser-playwright` declares an **exact** peer on `vitest`, so it — not the root range — decides which `vitest` web installs. Left to float, the root resolves a newer patch and web's tests then run on one `vitest` while loading a coverage provider built against another. Bumping means editing all three in one change, the same discipline the exact `prettier` pin (#1790) exists for.
- **A root-declared package that `core/` imports at runtime must also be named in all three bundler `external` lists** (`clients/{cli,tui}/tsup.config.ts`, `clients/web/tsup.runner.config.ts`), since which client reaches it is a function of what `core/` imports rather than of what the client's own code names. `npm run verify:bundle-externals` enforces this against the **built output**.
- **A dependency that renders React components must be bundled** into the client that uses it (`noExternal`) and declared only there — an externalized one resolves its own `react` and splits the tree. `ink` is the single exemption, on cost, and it is only safe while the root `react` range stays open to the whole major (`^19.0.0`).
@ -103,26 +104,26 @@ The reasoning behind each of these, and what breaks when it is ignored, is the
**Dependabot opens no pull requests against this repo — neither version updates nor security updates.** A Dependabot PR carries no `Closes #N` and no board card, so it was the one standing exception to [Issue-driven Work Style](#issue-driven-work-style), enforced by nothing. Both halves are now replaced by scheduled workflows that file **issues**, and a maintainer writes the fix by hand against `v2/main`.
| Half | Switched off by | Replaced by | Cadence |
| --- | --- | --- | --- |
| Version updates | Deleting `.github/dependabot.yml` outright (#2235) — an empty `updates:` list is not valid config | `.github/workflows/dependency-refresh.yml` → `scripts/dependency-refresh.mjs`: `npm outdated` across every install, plus a `uses:` check against each action's latest release, folded into **one** tracking issue | Monthly |
| Security updates | `DELETE /repos/{owner}/{repo}/automated-security-fixes` — a **repo setting**, not a file | `.github/workflows/dependabot-alerts.yml` → `scripts/dependabot-alerts.mjs`: reads the alerts and files one issue **per bump** | Daily |
| Half | Switched off by | Replaced by | Cadence |
| ---------------- | ------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
| Version updates | Deleting `.github/dependabot.yml` outright (#2235) — an empty `updates:` list is not valid config | `.github/workflows/dependency-refresh.yml` → `scripts/dependency-refresh.mjs`: `npm outdated` across every install, plus a `uses:` check against each action's latest release, folded into **one** tracking issue | Monthly |
| Security updates | `DELETE /repos/{owner}/{repo}/automated-security-fixes` — a **repo setting**, not a file | `.github/workflows/dependabot-alerts.yml` → `scripts/dependabot-alerts.mjs`: reads the alerts and files one issue **per bump** | Daily |
Four things about this that are not obvious from the code:
- **Dependabot *alerts* stay on.** Alerts and security-update PRs are independent settings; only the PRs are off. Turning alerts off would blind the sweep that replaced them.
- **Dependabot _alerts_ stay on.** Alerts and security-update PRs are independent settings; only the PRs are off. Turning alerts off would blind the sweep that replaced them.
- **The security half is a schedule, not an event handler**, because there is no `dependabot_alert` workflow trigger — it is a webhook event only.
- **Alerts are computed from the default branch (`main`), and we ship from `v2/main`.** So the sweep re-checks each alert's vulnerable range against `v2/main`'s own lockfile before filing, and skips one that is already fixed there. The converse is a real blind spot with no fix on this path: a vulnerable dependency introduced on `v2/main` and not yet merged to `main` produces **no alert at all**. The release-time `npm audit --audit-level=high` report (#2231) is the partial second signal — and only at release time.
- **`automated-security-fixes` can be re-enabled from the UI without a commit**, so nothing in the repo would record it. The sweep reads it back and **fails loudly on an explicit `enabled: true`**. ⚠️ It is a *conditional* guard, not an invariant: the endpoint needs `administration: read`, which `GITHUB_TOKEN` cannot be granted (`permissions:` has no such key), so under the default token the sweep logs **UNVERIFIED** and carries on rather than going red every day for an unrelated reason. Only a token carrying that scope makes it a real assertion.
- **`automated-security-fixes` can be re-enabled from the UI without a commit**, so nothing in the repo would record it. The sweep reads it back and **fails loudly on an explicit `enabled: true`**. ⚠️ It is a _conditional_ guard, not an invariant: the endpoint needs `administration: read`, which `GITHUB_TOKEN` cannot be granted (`permissions:` has no such key), so under the default token the sweep logs **UNVERIFIED** and carries on rather than going red every day for an unrelated reason. Only a token carrying that scope makes it a real assertion.
An issue filed by either sweep is an ordinary board item — `v2` + `chore` + `dependabot`, the current milestone, and a card on #28. **How it gets its card differs, and the two sweeps are not interchangeable here:**
| | files the card itself? |
| --- | --- |
| | files the card itself? |
| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Monthly version sweep | **No, never.** It does not attempt a board write at all and has no `PROJECT_TOKEN`; the issue arrives labeled and milestoned, and `/issue-triage` places it. |
| Daily security sweep | **Only when it can.** With an org-project PAT it places the card directly at **Todo / High**; without one it degrades to the same triage hand-off. |
| Daily security sweep | **Only when it can.** With an org-project PAT it places the card directly at **Todo / High**; without one it degrades to the same triage hand-off. |
The board write needs `organization projects: write`, which `GITHUB_TOKEN` cannot have — hence "only when it can", and hence a filed-but-unboarded issue is a normal outcome rather than a failure. **Todo, not Incoming**, when the security sweep does place it: arriving through this pipeline *is* the approval. **`High` is a standing override** of the [priority rubric](.claude/skills/issue-triage/SKILL.md), which would otherwise score a routine bump Medium; the issue body records the override so it does not read as a mis-score. ⚠️ A milestone is a precondition for placing a card — `Incoming` ⇔ no milestone — so the security sweep leaves an issue **unboarded** rather than parked at Todo when no dated milestone is open. It picks the open milestone with the nearest **due date**, ignoring undated buckets; the monthly sweep's own selection does not yet filter those out (raised on #2239), so don't read this as a guarantee both scripts already implement.
The board write needs `organization projects: write`, which `GITHUB_TOKEN` cannot have — hence "only when it can", and hence a filed-but-unboarded issue is a normal outcome rather than a failure. **Todo, not Incoming**, when the security sweep does place it: arriving through this pipeline _is_ the approval. **`High` is a standing override** of the [priority rubric](.claude/skills/issue-triage/SKILL.md), which would otherwise score a routine bump Medium; the issue body records the override so it does not read as a mis-score. ⚠️ A milestone is a precondition for placing a card — `Incoming` ⇔ no milestone — so the security sweep leaves an issue **unboarded** rather than parked at Todo when no dated milestone is open. It picks the open milestone with the nearest **due date**, ignoring undated buckets; the monthly sweep's own selection does not yet filter those out (raised on #2239), so don't read this as a guarantee both scripts already implement.
### The SDK watch is the third sweep
@ -130,7 +131,7 @@ The board write needs `organization projects: write`, which `GITHUB_TOKEN` canno
- **Two upstreams, two issues.** `client`/`core`/`server`/`server-legacy` ship from `modelcontextprotocol/typescript-sdk` in lockstep and share one issue; `ext-apps` ships from its own repo and gets its own. A fifth `@modelcontextprotocol/*` package added to the root manifest and not added to `SDK_GROUPS` **fails the sweep loudly** rather than going unwatched — that guard is the point, since a hardcoded group table is otherwise a silent blind spot.
- **It compares the INSTALLED version, not the declared range.** The four SDK packages are pinned exactly, so the two agree for them; `ext-apps` is a caret range whose lockfile already resolves higher, and comparing the declared string would file an issue for a bump `npm install` has already taken.
- **The target is the LOWEST `latest` across a group — the version the whole group has reached — not the highest.** npm publishes a lockstep release one package at a time, so a sweep landing mid-publish sees one package ahead of its three siblings. Targeting the highest would name a version three of them do not have *and* write a marker that suppresses the real filing once the publication completes, so the release would never be tracked at all. Taking the minimum keeps the issue actionable and lets the completed release file its own.
- **The target is the LOWEST `latest` across a group — the version the whole group has reached — not the highest.** npm publishes a lockstep release one package at a time, so a sweep landing mid-publish sees one package ahead of its three siblings. Targeting the highest would name a version three of them do not have _and_ write a marker that suppresses the real filing once the publication completes, so the release would never be tracked at all. Taking the minimum keeps the issue actionable and lets the completed release file its own.
- **It never boards, like the monthly sweep** — no `PROJECT_TOKEN` exists in this org — so the issue arrives labeled and milestoned and `/issue-triage` places it.
- **It never closes an issue either.** A further release files its own issue and leaves a **supersession comment** on the older one; closing is a maintainer act, since the card may already have moved. An issue closed for the same target keeps suppressing it, so a maintainer's "not planned" is not re-argued nightly.
@ -152,11 +153,11 @@ The board write needs `organization projects: write`, which `GITHUB_TOKEN` canno
⚠️ **The model gets no `Bash` at all**, and that is the resolution of the whole class above rather than a fourth patch to it. Every command grant turned out to have a wider flag surface than the grant looked, and a `Bash(...)` rule can match a **compound** command (`gh release view … && curl …`) besides. So the upstream release notes are **prefetched by a deterministic step** into `upstream-release-notes.md` and the model only reads files. When a grant keeps needing narrowing, take the capability away instead.
⚠️ **A marker is not evidence — this repo is public.** Anyone can open an issue or write a comment whose body starts with any string, and every marker here drives automation. Untrusted, an outsider could file (and close) an issue carrying the current target's marker to suppress the real upgrade issue indefinitely, post `ANALYSIS_MARKER` to suppress analysis retries forever, or forge a supersession note so the genuine one is never posted. So the sweep trusts a marker **only** on something the automation wrote: an issue must be authored by `github-actions` *and* carry the `chore` + `dependencies` labels an outsider cannot set, and a comment must come from `github-actions[bot]`. Marker versions are validated with `semver.valid` too, since a malformed target would otherwise throw in `semver.lt` and fail the sweep every run.
⚠️ **A marker is not evidence — this repo is public.** Anyone can open an issue or write a comment whose body starts with any string, and every marker here drives automation. Untrusted, an outsider could file (and close) an issue carrying the current target's marker to suppress the real upgrade issue indefinitely, post `ANALYSIS_MARKER` to suppress analysis retries forever, or forge a supersession note so the genuine one is never posted. So the sweep trusts a marker **only** on something the automation wrote: an issue must be authored by `github-actions` _and_ carry the `chore` + `dependencies` labels an outsider cannot set, and a comment must come from `github-actions[bot]`. Marker versions are validated with `semver.valid` too, since a malformed target would otherwise throw in `semver.lt` and fail the sweep every run.
So the model returns its write-up as structured output (`--json-schema`) and never posts anything itself. The write-up crosses **two** boundaries before it becomes a comment, and each one is a property to preserve:
1. **Out of the model's job.** A step in `analyze` scans the text and, only if it passes, writes `analysis.md` and uploads it as an artifact. ⚠️ **The scan gates the write, in the same step, and the upload is gated on that file existing** — scanning later would be no protection at all, since this repo is public and an artifact holding a verbatim credential is downloadable for as long as it is retained. Refusing to *post* text that has already left the job refuses nothing.
1. **Out of the model's job.** A step in `analyze` scans the text and, only if it passes, writes `analysis.md` and uploads it as an artifact. ⚠️ **The scan gates the write, in the same step, and the upload is gated on that file existing** — scanning later would be no protection at all, since this repo is public and an artifact holding a verbatim credential is downloadable for as long as it is retained. Refusing to _post_ text that has already left the job refuses nothing.
2. **Into the comment.** `post` downloads the artifact, scans it again (defense in depth — the artifact is the boundary, so each side checks what it handles), and streams it to `gh issue comment --body-file -` over **stdin**, so nothing in the body can be read as a flag or a path.
`WebFetch` is denied and the model has no shell, so it controls no outbound channel; release notes are prefetched with `gh api` by a deterministic step. **Keep every one of those properties when editing this job**, and note that a test pins the scan-before-upload ordering specifically, because ordering is the whole control.
@ -181,11 +182,11 @@ If you've already built a change locally, share the **prompt** you used and scre
Three branches, three distinct roles. Target the one matching the work; **never
open a PR against `main`**.
| Branch | Role | PRs target it? | Publishes to |
| --- | --- | --- | --- |
| `v2/main` | **Develop.** All active v2 work lands here. | **Yes** — every v2 PR | nothing directly; reaches npm via `main` |
| `main` | **Release.** The repo's default branch; holds the latest released v2. Not a development branch. | **No** — it only receives milestone merges from `v2/main` | `latest` |
| `v1/main` | **Maintenance.** The deprecated v1 line, security fixes only, no active development. | **Yes** — every v1 PR, directly | `v1-latest`, published straight from this branch |
| Branch | Role | PRs target it? | Publishes to |
| --------- | ----------------------------------------------------------------------------------------------- | --------------------------------------------------------- | ------------------------------------------------ |
| `v2/main` | **Develop.** All active v2 work lands here. | **Yes** — every v2 PR | nothing directly; reaches npm via `main` |
| `main` | **Release.** The repo's default branch; holds the latest released v2. Not a development branch. | **No** — it only receives milestone merges from `v2/main` | `latest` |
| `v1/main` | **Maintenance.** The deprecated v1 line, security fixes only, no active development. | **Yes** — every v1 PR, directly | `v1-latest`, published straight from this branch |
So v2 flows `feature branch → v2/main → (milestone) main → npm latest`, while v1
is flat: `feature branch → v1/main → npm v1-latest`, with no merge into `main` at
@ -204,7 +205,7 @@ show up in your PR's diff. The version bump rides the same flow and is made on
### Keep documentation files up to date
- When adding, removing, renaming, or changing the purpose of any file or folder, update the corresponding entry in the main README.md and/or the related clients/*/README.md
- When adding, removing, renaming, or changing the purpose of any file or folder, update the corresponding entry in the main README.md and/or the related clients/\*/README.md
- When the structure of the project, the tech stack, or the developer setup changes, update the appropriate README.md files with the details.
- When adding new commands, dependencies, or architectural patterns, update the relevant sections of the appropriate README.md files as well.
- When rules for implementation and testing change, update this file, AGENTS.md.
@ -219,13 +220,13 @@ that from happening:
1. **`npm run verify:skills` must pass.** It runs inside `validate` (and so in
`local:gate` and in CI). It parses each `SKILL.md`'s frontmatter the way Claude
Code does and fails on anything that would strip the metadata — most importantly
**malformed YAML**, which loads the body with an *empty* description, so
**malformed 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. An unquoted colon in a description is enough — and so
is an unquoted **`#`**, which YAML reads as a comment and which truncates the
description *silently* from that point on rather than emptying it. `board-ops`
description _silently_ from that point on rather than emptying it. `board-ops`
shipped that way: `Covers board #28 (v2) and board #11 (v1), their
node/field/option IDs, and the option-deletion hazard` was cut at `#28`, so 90
node/field/option IDs, and the option-deletion hazard` was cut at `#28`, so 90
characters — including both board numbers — were absent from the listing while
every check stayed green, because a truncated description is still a non-empty
one. **Quote any description containing `#` or `:`.** It also
@ -236,7 +237,7 @@ that from happening:
step of its own**, `npm run verify:skills:cli`, in `local:gate` and in CI.
It resolves the CLI rather than hoping for one: an installed CLI **only when
it matches the pin exactly**, otherwise the pinned package via `npx -y`.
Exact rather than a floor, because a newer local CLI is a *different* schema
Exact rather than a floor, because a newer local CLI is a _different_ schema
from CI's — accepting it would let the same `local:gate` disagree across
machines, which is what a pin exists to prevent. Both tiers run the same
script, so they cannot drift either.
@ -248,7 +249,7 @@ that from happening:
knowledge rather than an action also sets `user-invocable: false`.
**Default to `false`.** `true` was the original default here, on the argument
that a procedure with side effects would be typed as `/name` anyway — but
*invoking* a skill has no side effects, it loads instructions, and the premise
_invoking_ a skill has no side effects, it loads instructions, and the premise
is false for anything a user asks for in prose. "Create a PR for #2163" is how
that work actually starts, and under `true` the model **cannot** reach the
skill at all: it is absent from the listing and the Skill tool refuses it. The
@ -261,19 +262,19 @@ that from happening:
model-invocable skill says "see `/board-ops`", that pointer is a dead end for
the model unless `board-ops` is model-invocable too.
⚠️ **Flipping is not free, and the listing budget is not what costs.** Going
from three model-invoked skills to nine measurably *lowered* the trigger rate
from three model-invoked skills to nine measurably _lowered_ the trigger rate
of the ones already there: `project-structure` fell from 100% to 0% on two
cases (n=4) and `testing` from 3/5 to 2/5, while the six new skills all
measured 100% and every negative case stayed clean. So the ceiling is
attention, not characters — we were at 2.8k of a 4k budget throughout *that
experiment* (it is ~3.2k now; the point is that nothing was near the cap). Adding
a skill therefore has a cost paid by the *existing* ones, which only
attention, not characters — we were at 2.8k of a 4k budget throughout _that
experiment_ (it is ~3.2k now; the point is that nothing was near the cap). Adding
a skill therefore has a cost paid by the _existing_ ones, which only
`skills:eval` can see. **Re-run the full eval after any flip _or description
edit_**, not just the changed skill's own cases.
**How to write a description that fires, and cases that measure it, is
[`docs/skill-authoring.md`](./docs/skill-authoring.md)** — the case shapes
that work, the ones that can never pass, and the probe-then-measure loop.
The lever that works is the description's *shape*. Leading with the actions
The lever that works is the description's _shape_. Leading with the actions
and then enumerating concrete situations ("Use when … ; when … ; when …")
beats a noun-phrase list of contents: it took `pre-push-gate` from 3/5 to 5/5
and `testing`'s three cases from 25/50/25% to 100% each (n=4), displacing
@ -301,11 +302,11 @@ that from happening:
trigger case — the model answers correctly without the skill, and the case
reads as a miss; and a prompt naming a concrete file or mechanism
("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"
a _better_ answer than a skill. Good cases are "how do I / where does this go"
questions whose answer is a procedure.
**A pointer from one skill's body to another is measured by a `chain` case,
not an `expect` one.** A first-move case can only observe the model's opening
tool call, so a skill reached only *through* another scores a clean 100% on
tool call, so a skill reached only _through_ another scores a clean 100% on
its direct cases while the hand-off silently never fires (#2204). A chained
case names the ordered skills one run should load, **ending with the skill
whose file it lives in** — so the file that goes red is the one belonging to
@ -345,7 +346,7 @@ let `paths` do the scoping.
## Issue-driven Work Style
All work is driven by items on the project board. The *recipes* for the flows
All work is driven by items on the project board. The _recipes_ for the flows
below are in the `issue-create`, `issue-triage`, `board-ops` and `pr-flow`
skills; the rules are here.
@ -354,9 +355,9 @@ skills; the rules are here.
- **Only issues go on a board — never PRs.** A PR gets the `v2` label but is tracked through its linked issue's card (via `Closes #N`), not its own board item.
- **Label by version — every issue and every PR, no exceptions.** Exactly one of `v1` (work targeting `v1/main`, the deprecated security-fix-only line) or `v2` (active development; the default for anything new). There is no unlabeled state and no "decide later": an issue with neither label belongs to no version line and is invisible to every version-filtered query. Set it at **create time** (`gh issue create --label v2 …`), never by backfilling. **If the target version isn't obvious, it's `v2`.**
- **Label by type — exactly one of `bug` / `enhancement` / `documentation` / `chore` / `question`** on every issue you create or triage. The version label says which line the work belongs to; the type label says what kind of work it is, and the two are independent. Don't force the binary: pressing a docs task or a dependency pin into `enhancement` degrades it to "not a bug", at which point filtering by it stops telling you anything. A **PR** needs no type label — it is classified through the issue it closes.
- **Every v2 issue you create gets a milestone.** Milestones are *release* buckets, so pick by when the work ships. Never leave a v2 issue you filed unmilestoned pending a decision. Two exceptions, both deliberate: an issue that arrives **unboarded** stays unmilestoned in `Incoming` until a maintainer approves it — there, the *absence* of a milestone is the signal; and **every milestone is a v2 release bucket, so a `v1` issue has none to take**. Say so when filing one rather than dropping it in a v2.x bucket.
- **Every v2 issue you create gets a milestone.** Milestones are _release_ buckets, so pick by when the work ships. Never leave a v2 issue you filed unmilestoned pending a decision. Two exceptions, both deliberate: an issue that arrives **unboarded** stays unmilestoned in `Incoming` until a maintainer approves it — there, the _absence_ of a milestone is the signal; and **every milestone is a v2 release bucket, so a `v1` issue has none to take**. Say so when filing one rather than dropping it in a v2.x bucket.
- **Every v2 board item has a Priority.** Priority is a **board field**, not a label, so an unboarded issue has nowhere to store it. Derive it with the rubric in the `issue-triage` skill rather than asserting it. Board #11 has no Priority field; a v1 issue gets a Status and nothing else.
- **`Incoming` ⇔ no milestone; everything past it ⇔ milestoned — on board #28.** Board #11 is exempt for the reason above: a v1 issue has no bucket to take, so its Status is set on its own and the audit's milestone checks do not apply to it. The rest of the invariant is unchanged: assigning the milestone *is* the approval act, so the two always go together. `Todo` asserts a maintainer signed off, so never park an unreviewed issue there — that erases the distinction and quietly promotes unreviewed work into the queue. An issue created through the documented flow skips `Incoming` entirely, because filing it *was* the approval.
- **`Incoming` ⇔ no milestone; everything past it ⇔ milestoned — on board #28.** Board #11 is exempt for the reason above: a v1 issue has no bucket to take, so its Status is set on its own and the audit's milestone checks do not apply to it. The rest of the invariant is unchanged: assigning the milestone _is_ the approval act, so the two always go together. `Todo` asserts a maintainer signed off, so never park an unreviewed issue there — that erases the distinction and quietly promotes unreviewed work into the queue. An issue created through the documented flow skips `Incoming` entirely, because filing it _was_ the approval.
- **`Done` means the work shipped.** Exactly two things earn a card a place in Done: its **PR merged**, or it is a **parent whose last sub-issue closed**. Anything else — duplicate, won't fix, not planned, obsolete, superseded — means nothing shipped, so the card is **deleted**. Done is read as the record of what a milestone actually delivered; a duplicate sitting there makes that record wrong in a way nobody can detect later. Deleting a card touches the board only — the issue keeps its labels and comments and stays searchable forever.
- **When work begins**, create a feature branch and set Status to **In Progress**. **Branch names start with the target version segment** — `v2/fix/2071-oauth-resource-metadata`, `v1/fix/proxy-ssrf-pin` — matching the base branches themselves.
- **When work is complete**, run `npm run format` then `npm run local:gate`, **sign off every commit** (`git commit -s` — the DCO check is a hard merge gate with no partial credit), open a PR against the matching base branch with **`Closes #<ISSUE_NUMBER>` as the body's first line**, and set Status to **In Review**.
@ -371,18 +372,19 @@ When asked to respond to a code review of a PR:
- it is not necessary to implement all suggestions
- you are free to implement suggestions in a different way, or to ignore one if there is a good reason
- after making the changes, respond to each review comment with what was done (or why it was ignored)
## Always test new or modified code
The *procedure* — where a given test file goes, which command runs it, how to
The _procedure_ — where a given test file goes, which command runs it, how to
diagnose a failing gate — is the `testing` skill. These are the rules.
- **Ensure all code has corresponding tests.** New code must clear **≥ 90 on all four dimensions** — lines, statements, functions, and branches — per file. This gate is enforced by each client's `test:coverage` across `clients/web`, `clients/cli`, `clients/tui` and `clients/launcher`, and **CI enforces it**: a PR that drops any file below 90 on any dimension fails.
- **A genuinely-unreachable branch is annotated at the source, never waved through by lowering the gate.** Use a justified `/* v8 ignore … -- <reason> */`. Acceptable reasons: happy-dom-inherent paths (Mantine portal mount points, `useMediaQuery` fallbacks, `typeof window` SSR guards); React StrictMode effect-replay blocks; and provably-dead defensive guards (a `?? fallback` for a value the types guarantee non-null, a `Select.onChange` receiving a value outside the allowed list). Reach for it only when the branch is genuinely impossible to exercise.
- **In unit tests that expect error output, suppress it from the console.**
- **Test placement — side-by-side by default, `src/test/` only for what can't be co-located, and the Node clients are different.**
- **`clients/web`**: `<Name>.test.tsx` **next to the source** — components, hooks, `lib/`, `utils/`. A web-owned test living under `src/test/` instead is a bug. `src/test/` is for the three things that cannot be co-located: tests of the repo-root **`core/`** package (`src/test/core/…`, mirroring the `core/` layout — it lives outside `clients/web/` and has no harness of its own); the **`integration`** project (`src/test/integration/…` — *placement is the manifest*, picked up by a folder glob, with no enumeration to keep in sync); and **shared test infrastructure** (`renderWithMantine.tsx`, `setup.ts`, `fixtures/`).
- **`clients/web`**: `<Name>.test.tsx` **next to the source** — components, hooks, `lib/`, `utils/`. A web-owned test living under `src/test/` instead is a bug. `src/test/` is for the three things that cannot be co-located: tests of the repo-root **`core/`** package (`src/test/core/…`, mirroring the `core/` layout — it lives outside `clients/web/` and has no harness of its own); the **`integration`** project (`src/test/integration/…` — _placement is the manifest_, picked up by a folder glob, with no enumeration to keep in sync); and **shared test infrastructure** (`renderWithMantine.tsx`, `setup.ts`, `fixtures/`).
- **`clients/cli`, `clients/tui`, `clients/launcher`**: **all** tests in a top-level **`__tests__/`**, not beside their source. Their `tsconfig.json` excludes `**/*.test.*`, so a co-located test lands in **no** tsconfig project and fails `npm run verify:typecheck-coverage`.
- **Root tooling**: a `scripts/*.mjs` helper with pure logic gets a sibling `*.test.mjs`. Keep that exact filename — `node --test` silently *skips* a file its glob misses and still exits 0.
- **Root tooling**: a `scripts/*.mjs` helper with pure logic gets a sibling `*.test.mjs`. Keep that exact filename — `node --test` silently _skips_ a file its glob misses and still exits 0.
- **Render React components through `renderWithMantine`** (`src/test/renderWithMantine.tsx`); do not hand-roll a bare `MantineProvider`, which skips the project theme and the helper's options and drifts from every other test. Pass the `colorScheme` option to exercise a forced scheme rather than hand-rolling `defaultColorScheme`. Use `renderWithMantineTransitions` **only** when a test must assert mid-flight transition state, and read the long comment on the helper before changing anything about it.
- **The web coverage `include` is a whitelist.** It names `components`/`hooks`/`theme`/`lib`/`utils`/`server` plus the browser-consumed `core/*` runtime, so a module placed **outside** those directories falls out of the gate entirely, silently. Place new modules inside a gated directory. The documented exceptions — `src/App.tsx` and the `src/main.tsx` / `src/index.ts` bootstraps — are called out in a comment on the `include` array itself.
@ -394,18 +396,31 @@ diagnose a failing gate — is the `testing` skill. These are the rules.
- There is deliberately **no `npm run ci`** — that name collided with the `npm ci` built-in, which clean-installs from the lockfile and does not run this script.
- What each stage covers, and why two of them are local-only, is [`docs/quality-gate.md`](./docs/quality-gate.md); how to diagnose a failing stage is the `pre-push-gate` skill.
## Waiting on long-running work
**When you are waiting for something to finish, arm a notifier and stop. Never spend turns polling.** The harness re-invokes you when a backgrounded task exits, so a per-turn `echo ok`, or a per-turn `tail`/`grep` of a log, delivers nothing the notification would not have delivered anyway. It burns turns and tokens, it pushes the actual work further from the top of the context window in exactly the long sessions where that hurts most, and it buries the verdict under dozens of identical no-op turns so nobody reading back can find where the run actually landed. Waiting out one `local:gate` run cost ~80 consecutive no-op turns on #2250 while a completion notifier for that very process was already armed.
Pick by how many notifications the wait needs:
- **One — "tell me when this finishes."** Background the command itself, or a single `until` loop that exits on the condition. The `local:gate` run and a Copilot review round are both this shape.
- **Many — "tell me on each occurrence."** A `Monitor` over a stream that emits one line per event.
**The one genuine exception is state the harness cannot observe** — a CI run, a remote review queue, an external job. That does need polling. But the poll belongs **inside a single backgrounded loop that exits when the condition holds**, not spread one call per turn across the conversation. Set its interval from how fast the state actually changes: 30s or more for a remote API, and one check for an eight-minute CI run rather than eight.
⚠️ **If a notifier is already armed, that settles it — wait.** Re-checking by hand alongside a watcher that is watching the same condition is the polling this rule forbids, dressed as diligence.
## Build output is never a gate target
**No gate — lint, format, or typecheck — may read generated output.** The gated surface is first-party source only: `clients/*/src`, `clients/*/__tests__`, `clients/web/{server,.storybook}`, each client's top-level configs, `core/`, `test-servers/src`, `scripts/`, and the root shared files. Everything a build writes is out of scope: `clients/*/build` (the tsup/tsc bundles), `clients/web/dist` (the Vite SPA), `clients/web/storybook-static`, `clients/*/coverage`, `test-servers/build`, `core/**/{build,dist}`, and any `*.tsbuildinfo`. Each scope states this itself — `globalIgnores([...])` in every `eslint.config.js`, the `format`/`format:check` globs in each `package.json`, and a tsconfig `include` that names source directories rather than the package root.
Why it matters, given that these paths are all gitignored and the findings are usually warnings:
- **It reports defects nobody can fix.** A bundle vendors third-party code, so a rule that fires inside it names a problem in someone else's source. `clients/web` shipped this for a while: `build` was missing from its `globalIgnores` while its three sibling clients had it, so the client's *only* lint output was an unused-`eslint-disable` warning from inside the vendored `undici` (#2043).
- **It reports defects nobody can fix.** A bundle vendors third-party code, so a rule that fires inside it names a problem in someone else's source. `clients/web` shipped this for a while: `build` was missing from its `globalIgnores` while its three sibling clients had it, so the client's _only_ lint output was an unused-`eslint-disable` warning from inside the vendored `undici` (#2043).
- **A warning becomes a gate failure without warning.** `reportUnusedDisableDirectives` is a warning by default and a rule promotion — or any new rule a bundled dependency happens to trip — turns it into a `validate` failure on a file nobody wrote. #1959 (enabling `no-floating-promises` across all five scopes) is exactly that kind of change.
- **It trains people to skim the channel.** A scope whose lint is never clean has no signal left in it, and the real warning added later lands where everyone has learned to look past.
- **It is wasted work on every run.** `lint` runs inside `validate`, the fast inner-loop check, and the web runner bundle alone is ~1.2MB of generated JS.
The two coverage guards do **not** catch this, and adding a third is not the fix. `verify:format-coverage` and `verify:typecheck-coverage` assert that first-party source is *covered*; neither asserts that generated output is *excluded* — an asymmetry that is deliberate, since a guard can't distinguish "generated" from "source" without being told, and the ignore lists are already that statement. So this class drifts silently and the check is a human one: **when a build starts writing to a new location, add it to that scope's ignore list in the same change.** The reverse of the guards' rule also holds — never widen an ignore to silence a finding in first-party code, and never add a build directory to a tsconfig `include` to make a generated `.d.ts` resolve (import the source, or fix the build's types).
The two coverage guards do **not** catch this, and adding a third is not the fix. `verify:format-coverage` and `verify:typecheck-coverage` assert that first-party source is _covered_; neither asserts that generated output is _excluded_ — an asymmetry that is deliberate, since a guard can't distinguish "generated" from "source" without being told, and the ignore lists are already that statement. So this class drifts silently and the check is a human one: **when a build starts writing to a new location, add it to that scope's ignore list in the same change.** The reverse of the guards' rule also holds — never widen an ignore to silence a finding in first-party code, and never add a build directory to a tsconfig `include` to make a generated `.d.ts` resolve (import the source, or fix the build's types).
## Lint has no warning tier
@ -416,7 +431,7 @@ This exists because the gate's promise — that passing `npm run local:gate` loc
Two consequences worth stating:
- **Do not silence a finding to satisfy the gate.** A warning is now a defect to fix. If a rule genuinely must be waived on a line, use its inline disable comment **with a one-line justification** — the same standard this document sets for `v8 ignore` and for `void` on a floating promise. Widening a `globalIgnores` or dropping a rule to make `lint` pass is not an acceptable fix.
- **A rule left at `warn` still reads wrong in an editor.** The flag makes severity irrelevant to the *gate*, not to the developer looking at a squiggle. `react-hooks/exhaustive-deps` is therefore set to **`error`** in every React scope (`clients/web`, `clients/tui`, and the root's `core/react/**` block) rather than relying on the CLI flag alone. Prefer `error` for any rule you actually intend to enforce.
- **A rule left at `warn` still reads wrong in an editor.** The flag makes severity irrelevant to the _gate_, not to the developer looking at a squiggle. `react-hooks/exhaustive-deps` is therefore set to **`error`** in every React scope (`clients/web`, `clients/tui`, and the root's `core/react/**` block) rather than relying on the CLI flag alone. Prefer `error` for any rule you actually intend to enforce.
## Typescript instructions
@ -432,9 +447,9 @@ Two consequences worth stating:
- Regularly review and refactor TypeScript code to ensure it remains well-structured and adheres to evolving best practices
- **NEVER leave a promise floating.** `@typescript-eslint/no-floating-promises` is enabled at `error` in **all five** ESLint scopes — `clients/{web,cli,tui,launcher}` and the root `core/` + shared gate (#1959). Every promise must be awaited, returned, terminated with `.catch(…)`, or explicitly discarded with the `void` operator.
- **The class it catches is invisible at review time.** A floated call reads like an awaited one minus four characters, and the unhandled rejection it produces surfaces somewhere else entirely — a different test, a different file, a stack pointing at SDK internals. Two un-held `client.callTool(...)` promises made `npm run local:gate` unpassable in #1947: `disconnect()` rejected them with `Connection closed`, the unhandled rejection failed the whole vitest run, and the chain aborted at `coverage`, silently skipping `verify:build-gate`, `smoke`, and Storybook. Attributing it took a full investigation; the fix was two lines.
- **Prefer holding and settling the promise.** `void` is an escape hatch, not a fix — it is visible at review time (strictly better than nothing) but still discards the rejection. Reach for it only when the callee already owns its failures (it ends in a `catch` that surfaces the message) or the caller genuinely cannot await — a synchronous `useEffect` body, an Ink `useInput` key handler, a Hono `stream.onAbort` listener. Say **which** of those it is in a one-line comment; an unexplained `void` is a review finding. Where the callee does *not* own its failures, give it a `catch` rather than voiding the call (see `handleDisconnect` in `clients/tui/src/App.tsx`), or terminate with `.catch(…)` at the call site (see `open(url)` in `clients/web/server/{server,vite-hono-plugin}.ts`).
- **Prefer holding and settling the promise.** `void` is an escape hatch, not a fix — it is visible at review time (strictly better than nothing) but still discards the rejection. Reach for it only when the callee already owns its failures (it ends in a `catch` that surfaces the message) or the caller genuinely cannot await — a synchronous `useEffect` body, an Ink `useInput` key handler, a Hono `stream.onAbort` listener. Say **which** of those it is in a one-line comment; an unexplained `void` is a review finding. Where the callee does _not_ own its failures, give it a `catch` rather than voiding the call (see `handleDisconnect` in `clients/tui/src/App.tsx`), or terminate with `.catch(…)` at the call site (see `open(url)` in `clients/web/server/{server,vite-hono-plugin}.ts`).
- **In Storybook play functions, `expect(...)` from `storybook/test` returns a promise.** Storybook instruments it so the interactions panel can trace each assertion, so every `expect` in a play function is awaited — as is any shared helper that wraps one (`src/test/scrollAreaStoryAssertions.ts` is `async` for this reason).
- **The rule is type-aware, so each scope's ESLint config carries a parser project.** Each client's config must name **every leaf project covering its lint surface**, since the parser needs a program that literally *contains* the file — for cli, tui, and launcher that is the two they already typecheck (`tsconfig.json` + `tsconfig.test.json`, `src` in the first and `__tests__` only in the second), and for **web it is four** (`tsconfig.app.json`, `tsconfig.node.json`, `tsconfig.storybook.json`, `tsconfig.test.json`) — web's `tsconfig.json` is a solution file with `files: []`, so naming it alone would contain nothing. Adding a leaf project to a client means adding it here too. The root config instead points at **`tsconfig.lint.json`**, which exists solely to give the parser a program covering `core/**`, `test-servers/src/**`, and `vitest.shared.mts` — none of which is rooted in a tsconfig of its own. That file emits nothing and gates nothing: type *checking* for those sources stays where it was (`core/` through `clients/web`'s `tsc -b`, `test-servers/src` through `clients/cli`'s test project). It sets `moduleResolution: bundler` deliberately — `core/` uses extensionless relative imports, which NodeNext fails to resolve, degrading every import to `any` so the rule silently stops seeing promises at all. **Its `include` must stay a superset of the root config's type-aware `files` globs** — a file the lint block matches but the project omits fails outright with "was not found in any of the provided project(s)" rather than being checked, so widening one means widening the other (that is why the `include` carries `test-servers/src/**/*.tsx`, which nothing has produced yet). Note also that a tsconfig `include` does **not** expand braces — `core/**/*.{ts,tsx}` matches nothing; list the extensions separately.
- **The rule is type-aware, so each scope's ESLint config carries a parser project.** Each client's config must name **every leaf project covering its lint surface**, since the parser needs a program that literally _contains_ the file — for cli, tui, and launcher that is the two they already typecheck (`tsconfig.json` + `tsconfig.test.json`, `src` in the first and `__tests__` only in the second), and for **web it is four** (`tsconfig.app.json`, `tsconfig.node.json`, `tsconfig.storybook.json`, `tsconfig.test.json`) — web's `tsconfig.json` is a solution file with `files: []`, so naming it alone would contain nothing. Adding a leaf project to a client means adding it here too. The root config instead points at **`tsconfig.lint.json`**, which exists solely to give the parser a program covering `core/**`, `test-servers/src/**`, and `vitest.shared.mts` — none of which is rooted in a tsconfig of its own. That file emits nothing and gates nothing: type _checking_ for those sources stays where it was (`core/` through `clients/web`'s `tsc -b`, `test-servers/src` through `clients/cli`'s test project). It sets `moduleResolution: bundler` deliberately — `core/` uses extensionless relative imports, which NodeNext fails to resolve, degrading every import to `any` so the rule silently stops seeing promises at all. **Its `include` must stay a superset of the root config's type-aware `files` globs** — a file the lint block matches but the project omits fails outright with "was not found in any of the provided project(s)" rather than being checked, so widening one means widening the other (that is why the `include` carries `test-servers/src/**/*.tsx`, which nothing has produced yet). Note also that a tsconfig `include` does **not** expand braces — `core/**/*.{ts,tsx}` matches nothing; list the extensions separately.
- Type-aware linting costs real time: `clients/web`'s `eslint .` went from ~8s to ~19s, and `lint` runs inside `validate`, the fast inner-loop check. That is the price of the guarantee; if it needs reducing later, narrowing the projects each scope loads is the lever, not dropping the rule.
## Web source layout: `src/lib` vs `src/utils`
@ -477,6 +492,7 @@ Nothing **enforces** the boundary: no path alias keys off it, and the coverage `
- CSS classes are ONLY acceptable on subcomponents for styles that cannot be expressed as flat CSS-in-JS properties in the theme — specifically: pseudo-selectors (`:hover`, `:focus`), cross-component hover relationships (`.parent:hover .child`), nested child-element selectors (`.wrapper p`, `.wrapper code`), `@keyframes` definitions, and native HTML elements (`img`, `iframe`) that are not Mantine components.
- When a theme variant needs a CSS class for nested/pseudo selectors, use `classNames` in the theme extension to auto-assign it — never add `className` manually in JSX for theme-styled components.
- Example — subcomponent constant with `withProps`:
```tsx
const CardContent = Group.withProps({
flex: 1,
@ -487,6 +503,7 @@ Nothing **enforces** the boundary: no path alias keys off it, and the coverage `
return <CardContent> ... </CardContent>;
```
- Example — theme variant with auto-assigned className for nested selectors:
```tsx
// src/theme/Paper.ts
export const ThemePaper = Paper.extend({
@ -505,15 +522,16 @@ Nothing **enforces** the boundary: no path alias keys off it, and the coverage `
// Component.tsx
const MessageContainer = Paper.withProps({ variant: "message" });
```
- State and effects
- **NEVER reset or re-sync local state from a prop inside a `useEffect`.** `useEffect(() => setX(prop), [prop])` renders once with the stale value, paints it, and only then corrects itself — the user sees the wrong frame and React renders twice. It is an error under `react-hooks/set-state-in-effect`, which the `eslint-plugin-react-hooks` recommended set enforces for the web client and — since #2192 — for `core/react/` too. (`clients/tui` registers the plugin but deliberately enables only `rules-of-hooks` and `exhaustive-deps`, for the reason its own config states.)
- Use **`useValueChange(value, onChange)`** (`clients/web/src/hooks/useValueChange.ts`) instead. It is React's documented ["adjusting state during render"](https://react.dev/reference/react/useState#storing-information-from-previous-renders) pattern: it compares `value` against the previous render's with `Object.is` and calls `onChange(next)` during render, so React discards the in-progress output and re-runs the component before anything reaches the DOM. It does **not** fire on the first render — seed the dependent state with `useState` instead. Because the comparison is `Object.is`, the value you pass **must be referentially stable** across renders that mean "no change": prefer a primitive key derived from the data (an id, a name, a URI), and otherwise a memoized value. A fresh object/array literal would compare unequal every render and loop.
- The `onChange` you pass runs **during render**, so it must be pure — `setState` calls and nothing else. No fetches, DOM writes, logging, ref mutation, or parent callbacks: a render can be replayed (StrictMode) or abandoned (concurrent React), so external work would run an unpredictable number of times.
- An effect is still the right tool for genuine synchronization with an external system (DOM measurement, `requestAnimationFrame`, subscriptions, timers). The rule is about deriving React state from React props, not about effects in general. `NetworkEntry` shows the split: the reveal's force-open is a state update and uses `useValueChange`, while its `requestAnimationFrame` scroll stays a `useEffect`.
- **Subscribing to an `@inspector/core` state store is `useSyncExternalStore`, never `useState` + a subscribing `useEffect`.** That second shape looks like the legitimate "synchronize with an external system" case above and is not, because it also *seeds and re-seeds local state from the store prop* — so it carries the same stale frame (switching servers paints the previous server's tools for one frame) plus a window where an event dispatched between the render and the effect is lost outright. Every hook in `core/react/` was converted away from it in #1955.
- **Subscribing to an `@inspector/core` state store is `useSyncExternalStore`, never `useState` + a subscribing `useEffect`.** That second shape looks like the legitimate "synchronize with an external system" case above and is not, because it also _seeds and re-seeds local state from the store prop_ — so it carries the same stale frame (switching servers paints the previous server's tools for one frame) plus a window where an event dispatched between the render and the effect is lost outright. Every hook in `core/react/` was converted away from it in #1955.
- Reach for **`useStoreSnapshot(store, event, read, whenAbsent)`** (`core/react/useStoreSnapshot.ts`) when the getter returns a **fresh value per read** — a defensive copy (`getTools()` is `[...this.items]`) or a freshly built object (`getPagination()`). That is nearly all of them, and the caching it adds is what keeps `useSyncExternalStore` from looping. Call it once per value.
- When a getter's value is **already referentially stable**, subscribe with `useSyncExternalStore` directly and skip the helper — `useListError` does, because its snapshot is the stored `Error` instance itself (or `null`). The rule is read-during-render, not "always use the helper".
- Either way `useValueChange` is *not* the tool here — it lives in `clients/web/src`, and `core/react/` is consumed by the CLI and TUI too.
- Either way `useValueChange` is _not_ the tool here — it lives in `clients/web/src`, and `core/react/` is consumed by the CLI and TUI too.
- `read` and `whenAbsent` must be **referentially stable across renders** — they are part of the snapshot's cache key. `read` is a function, so declare it at module scope. `whenAbsent` only needs a module-scope constant when it is an **object or array** (`NO_TOOLS`, `NO_PAGINATION`); a primitive fallback (`false`, `undefined`, `"disconnected"`) is already stable under `Object.is` and is passed inline throughout these hooks.
- An unstable one **fails quietly, so don't expect to be told**: measured on React 19, an inline `read` throws nothing, logs nothing — not even React's "getSnapshot should be cached" dev warning, whose double-call happens within a single render where the closure is unchanged — and forces no extra render. It simply returns a fresh value every render, defeating every downstream `useMemo` / `React.memo` / effect dep that keys on it.
- The snapshot is cached against the store's **per-event dispatch counter** (`TypedEventTarget.getEventRevision`), which every dispatch advances automatically — not against the snapshot's contents. That is deliberate and load-bearing: these getters return a defensive copy, so contents are the only alternative, and a contents comparison cannot see a dispatch that mutated an entry the list already holds (`MessageLogState` folding a response into its request entry does exactly that). Don't "optimize" it into a shallow compare.