# specguard-mcp

> The MCP bridge to [SpecGuard](https://github.com/yatfa-ai/specguard) — gives an AI agent the suite
> intelligence behind a very large test suite as tools.

`specguard-mcp` is a [Model Context Protocol](https://modelcontextprotocol.io) server that connects
an MCP-capable agent (Claude Code, Claude Desktop, …) to SpecGuard, so the agent can ask what a
suite covers, what it costs to run and where the gaps are — without a line of HTTP or auth code in
its prompt.

SpecGuard is built [primarily for AI coding agents](https://github.com/yatfa-ai/specguard); this
bridge is how an agent reaches it without scraping a web UI.

> **Status: bootstrap.** A small set of tools ships today, each wrapping a capability that already
> exists — see [The tools](#the-tools) for what is in it. The toolset **grows gradually** — see
> [Adding a tool](#adding-a-tool). It is not published to npm yet; install from a checkout.

## Install

```bash
git clone https://github.com/yatfa-ai/specguard-mcp.git
cd specguard-mcp && npm install && npm run build
```

Requires Node.js 20 or newer.

## Configure

Nothing is required to *start* the server. Each tool asks for what it needs when it is called, so a
missing variable comes back as one readable sentence in a tool result — never as a server that
refuses to boot and takes the tools that needed no configuration down with it.

| Variable | Needed by | Default | What it is |
| --- | --- | --- | --- |
| `SPECGUARD_ENDPOINT` | `get_repository_overview`, `get_intent_schema`, `get_server_version`, `list_repositories`, `add_repository`, `registrable_repositories` | — | your SpecGuard instance's root URL, **including the scheme** — e.g. `https://specguard.example.com`, or `http://localhost:3000`. A value with no scheme is refused by name (`SPECGUARD_ENDPOINT is not a usable URL: "sg.example.com"`) rather than surfacing later as an opaque failure. `SPECGUARD_URL` is accepted as an alias, and is the name every message uses when it is the one you set. A blank value counts as unset, so leaving `SPECGUARD_ENDPOINT` empty in a templated config falls through to `SPECGUARD_URL` instead of suppressing it. `get_server_version` and `get_intent_schema` need only this variable — they send no API key, because the routes they read are unauthenticated by design |
| `SPECGUARD_API_KEY` | `get_repository_overview`, `near_duplicate_clusters`, `find_tests_near_behavior` (default calls) | — | an agent/CI API key (`sgk_…`) issued by that deployment — a **per-repository** key, which is the single repository those tools answer about by default |
| `SPECGUARD_USER_API_KEY` | `list_repositories` (fallback), `add_repository`, `registrable_repositories`, `remove_repository` (fallback), `create_repository_api_key` (fallback), `revoke_repository_api_key` (fallback), `list_repository_api_keys` (fallback), `list_repository_agent_keys` (fallback), `revoke_repository_agent_key` (fallback), `list_repository_agent_keys_presented_revoked` (fallback), `list_repository_members` (fallback), `add_repository_member` (fallback), `update_repository_member_permissions` (fallback), `remove_repository_member` (fallback), `get_repository_overview` / `near_duplicate_clusters` / `find_tests_near_behavior` **with** `repository` (fallback), `rename_repository` | — | a **user** API key (`sgu_…`), minted from that deployment's account page. A different credential from the one above, not a second place to put the same value: SpecGuard decides which of them a request may use from the token's prefix, before it reads anything, and answers `401` for the other one. Set whichever your tools need — both, if you use both |
| `SPECGUARD_AGENT_API_KEY` | `list_repositories` (preferred), `remove_repository` (preferred), `create_repository_api_key` (preferred), `revoke_repository_api_key` (preferred), `list_repository_api_keys` (preferred), `list_repository_agent_keys` (preferred), `revoke_repository_agent_key` (preferred), `list_repository_agent_keys_presented_revoked` (preferred), `list_repository_members` (preferred), `add_repository_member` (preferred), `update_repository_member_permissions` (preferred), `remove_repository_member` (preferred), `get_repository_overview` / `near_duplicate_clusters` / `find_tests_near_behavior` **with** `repository` (preferred) | — | an **agent** API key (`sga_…`), minted from that deployment's account page (Agent keys panel) with an explicit set of repositories and permissions. It speaks for nobody: its reach is exactly the set granted onto it, fixed at mint time, and every read is bounded by that set server-side. This is the credential to give an automated agent — one key, many repositories, none of a person's rights. When it and `SPECGUARD_USER_API_KEY` are both set, every tool that answers either key — `list_repositories`, `remove_repository`, the members tools, the key-lifecycle tools, and `get_repository_overview` / `near_duplicate_clusters` / `find_tests_near_behavior` **with** `repository` — uses **this** one, so discovery stays inside the set the other tools can reach |
| `SPECGUARD_LINT_COMMAND` | `lint_intent_annotations` | `specguard-lint` | the command that runs the linter — the one switch between the Ruby and the JS/TS client. Most Ruby projects need `bundle exec specguard-lint`; a JS/TS project sets `npx -p @yatfa/specguard specguard lint` (and needs the validator backend: a `validate-intent` binary whose path `SPECGUARD_VALIDATE_INTENT` names — today the only way to supply it) |
| `SPECGUARD_TIMEOUT_MS` | HTTP tools | `30000` | how long a call to SpecGuard may take |

`SPECGUARD_ENDPOINT` and `SPECGUARD_API_KEY` are the same variables
[`specguard-ruby`](https://github.com/yatfa-ai/specguard-ruby) uses to ship a run, so a repository
that already posts telemetry to SpecGuard already has them. `SPECGUARD_USER_API_KEY` is **not** one
of them — the gem has no notion of a user key — so that one is minted and set here for the first
time.

Register it with your MCP client — for Claude Code:

```json
{
  "mcpServers": {
    "specguard": {
      "command": "node",
      "args": ["/path/to/specguard-mcp/dist/bin/specguard-mcp.js"],
      "env": {
        "SPECGUARD_ENDPOINT": "https://specguard.example.com",
        "SPECGUARD_API_KEY": "sgk_…",
        "SPECGUARD_USER_API_KEY": "sgu_…",
        "SPECGUARD_AGENT_API_KEY": "sga_…",
        "SPECGUARD_LINT_COMMAND": "bundle exec specguard-lint"
      }
    }
  }
}
```

## The tools

### `lint_intent_annotations`

Validates the `@intent:` annotations in a project's test files against the
[OpenTestIntent](https://github.com/yatfa-ai/open-test-intent) schema, by running that project's own
linter with `--json` — `specguard-lint` (Ruby/RSpec, `*_spec.rb`) or `specguard lint` (JS/TS,
`@yatfa/specguard`; `.ts`/`.tsx`/`.js`/`.jsx`/`.mjs`/`.cjs`). `SPECGUARD_LINT_COMMAND` selects which;
the server does not auto-detect it. A JS/TS run needs the validator backend: a `validate-intent` binary
whose path `SPECGUARD_VALIDATE_INTENT` names (install it with open-test-intent's `scripts/install.sh`). That variable is
today the only way to supply it — the npm package's auto-resolution is a seam that resolves nothing yet — and without a
binary the linter exits `2` and says why (see the specguard-ts README, "The validate-intent binary"). Findings come back as data — file, line, failure kind, every violated rule
— rather than as a prose report to regex.

| argument | |
| --- | --- |
| `project_dir` | the project to lint; defaults to the server's working directory. A path that does not exist, or is not a directory, is refused by name — never reported as a missing linter |
| `paths` | specific test files, relative to `project_dir`; omit to check every spec file outside dependency/build directories. An empty list is an error rather than a synonym for "everything", because a run that selected nothing must not come back clean |
| `changed` | check what the branch changed since the merge base with the default branch — CI's mode; **needs a linter version that ships `--changed`**: the Ruby gem and `@yatfa/specguard` main do, `@yatfa/specguard` 0.1.1 on npm does not (exit `2`, `invalid option: --changed`); untracked spec files count too (no `git add` needed); `--exclude-standard` keeps `.gitignore`d paths out of the untracked leg alone, and a tracked file is never subject to `.gitignore`, so the dependency/build directory fence holds ignored tracked paths out |
| `base` | diff `changed` against this ref instead |

The report is the linter's own document, echoed untouched, so its `summary` keys and finding kinds
differ per client — Ruby reports `files`/`annotations`/`failed`; JS/TS reports
`files`/`annotations`/`malformed`/`unreadable`. Both clients can emit an `unreachable` finding kind (an
annotation extraction can never claim: stacked, separated, shadowed, or trailing on a group line); JS/TS
additionally emits `no-match`, which Ruby folds into `read` — read the report rather than assuming one shape.

Needs no SpecGuard deployment and no API key. A **missing** annotation is never a failure: adoption
is gradual by design, so a suite with no annotations lints clean.

**The exit code is a verdict, and the mapping matters.** Either linter exits `0` clean, `1` on a
malformed annotation, and `2` when it could not do its job. Exit `1` comes back as a **successful**
tool call carrying findings — an agent told "the tool failed" retries the tool, where an agent handed
a finding fixes the annotation. Only exit `2` is a tool error, and it carries the linter's stderr,
because the gem deliberately emits no document on that path.

### `get_intent_schema`

Serves the **OpenTestIntent schema document itself** — the contract an `@intent:` annotation is
validated against — read live from the deployment's own unauthenticated root-level schema mirror,
which hands back the canonical document's bytes verbatim.

Call it **before** writing or editing an annotation. This is the *reference*; `lint_intent_annotations`
is the *judge*, and a judge can only tell you that something you already wrote is wrong.

**Reading the contract is the only way to learn parts of it.** A refusal names the legal values of a
field you got *wrong*, and says nothing at all about a field you *omitted* — so the fastest way to
learn the rules from refusals alone is to submit values you believe are invalid. And an **optional**
property can never appear in any refusal: `required` does not name it, and a closed-object check
only ever reports the key you did send, so nothing you submit at any value causes the linter to
mention it. That part of the contract is reachable here and nowhere else in this toolset.

The answer is an **envelope** that carries the whole document in both shapes, derived from one
fetched body so they cannot disagree — plus the enforcement identity beside it:

| field | |
| --- | --- |
| `structured.schema` | the document **parsed** — the shape to read the field rules and any enumerated values off |
| `structured.enforced` | the identity the deployment itself serves from its `GET /version`: `schema_sha256`, the digest of the contract it actually enforces, and `schema_origin`, where those bytes come from on the server — or `null` when the deployment could not say |
| `text` | the served bytes **unchanged** — no re-encoding, no reformatting, so a digest taken over this text matches the canonical document's, which is the mirror's whole promise |

**The end-to-end check is derivable from this one tool.** Take a SHA-256 digest over `text` and
compare it with `enforced.schema_sha256`: a **match** means the reference you read is the contract
that will judge you; a **mismatch** means the bytes you received are *not* what the deployment
enforces — do not author annotations against them, and read `enforced.schema_origin` to see which
half of the disagreement is the server's. The tool serves both facts side by side and never
judges: a mismatch is never refused or warned here, because the judgment is yours.

**`enforced: null` means "this deployment could not say"** — its `/version` omits the identity
keys (a build predating the identity channel), answers non-2xx, or the identity fetch fails
entirely — and the document answer still stands in full. It is not the same null as a served
`schema_sha256: null` *inside* a present `enforced`: that is the deployment's own honest "I
enforce from this origin and cannot currently read it", passed through exactly as served.

Nothing in this bridge restates the schema's contents — not here, not in a tool description, not in
a test. The answer *is* the contract; any prose copy of it would be one release behind the document
this tool serves, and this repo deliberately vendors no copy of its own.

**A 404 means the deployment predates the mirror route: the endpoint is right and the build is old.**
The error text names the endpoint as possibly misconfigured, because a non-contract 404 body names
no cause — when the other tools work against the same endpoint, read it as *"the deployment needs
upgrading"*, not as a wrong URL. A **2xx** whose body will not parse is reported as a malformed
mirror instead, naming the route rather than your configuration: the response arrived, so the
endpoint is not the thing to go and fix.

**Needs no API key of any kind** — both routes it reads (the schema mirror and `GET /version`) are
unauthenticated by design, because a contract is not a secret — so it works with
`SPECGUARD_ENDPOINT` alone and sends no `Authorization` header. Takes no arguments.

### `get_repository_overview`

Asks SpecGuard what a repository's suite looks like **without running it** — the cold-start question
from Project Goals. One call returns the latest CI run (spec counts, annotated ratio, wall-clock and
per-shard cost), where that run spent its time (heaviest files, heaviest directories, slowest
individual examples with file and line), which descriptions are repeated across the suite (the
overcoverage ranking — one description carried by many examples, and which files it is spread over),
which areas grew or shrank and which got slower or faster since the previous run on the same branch
(the per-area comparisons, at both the example-count grain and the runtime grain), the recent run
history, and the branches that have runs. Pass `branch` for two more: which tests fail intermittently
rather than consistently (the cross-run flakiness ranking) and how the areas moved across the whole
branch window rather than between the last two runs.

Everything above is answered about **one repository**, and which one has two spellings. By default
it is the repository the configured `sgk_…` key resolves to — the key *is* the subject. Pass
`repository` (the numeric id `list_repositories` reports) and the call goes to the **plural**
endpoint, `GET /api/v1/repositories/:id`, which answers **either member credential**, whichever is
set: `SPECGUARD_AGENT_API_KEY` (`sga_…`) — preferred, and the set of repositories granted onto it
at mint time is the boundary ids are looked up inside (`list_repositories` under the same key lists
exactly the set that is reachable) — or, when no agent key is set, `SPECGUARD_USER_API_KEY`
(`sgu_…`), a **person** key whose accessible set is the boundary instead. With both set the agent
key wins, so the answer stays inside the set that key can act on. Same overview body, same ladder,
every parameter below honoured identically — with one
deliberate omission this path owns: `api_key` is **absent** from the plural body rather than nulled,
because that block describes the credential that made the request and no member credential is a
repository key (an absent key there is the surface's shape, never a dropped block). Other
than that, only the subject moves. That is what makes the *other* repositories visible: an `sgk_`
key is one repository
by design, so a credential holding only that could never ask about a second one. The server owns
every refusal on the plural path: a repository outside the presented credential's grant answers
404, indistinguishable from one that does not exist, person and agent alike. SpecGuard refuses each
credential kind in the other's place before it reads anything, so the pairing is not mixable: the
default call wants the `sgk_` key, the `repository` call wants a member key — and a call made with
the wrong kind, or with neither member key set, is refused *here*, naming the variable(s) to set,
before any request is made.

| argument | |
| --- | --- |
| `repository` | ask about THIS repository (its numeric id from `list_repositories`) under the **agent key** (`SPECGUARD_AGENT_API_KEY`, `sga_…`), or — when no agent key is set — the **user key** (`SPECGUARD_USER_API_KEY`, `sgu_…`), instead of the one the `sgk_…` key resolves to — omit it for the default, `sgk_`-keyed call |
| `branch` | narrow the run **history** to one branch, for a real growth series — and unlock `unstable_tests`, `slowest_tests` (with `slowest_tests_window`), `directory_growth`, `layer_growth` (with `layer_growth_window`), `layer_runtime_window_growth` (with `layer_runtime_window_growth_window`) and `directory_runtime_window_growth` (with `directory_runtime_window_growth_window`), which read the same window (`layer` narrows `slowest_tests`, `unstable_tests`, `directory_growth` and `directory_runtime_window_growth` there; `layer_runtime_window_growth` is not narrowed) — and, together with `spec_directory`, the window file drill-ins `directory_file_growth` (with `directory_file_growth_window`) and `directory_runtime_window_file_growth` (with `directory_runtime_window_file_growth_window`), both `null` without `branch` and also without `spec_directory`, and both narrowed by `layer` |
| `spec_directory` | open ONE of the heaviest directories and list the spec files inside it |
| `spec_file` | open ONE of the heaviest spec files and list the individual examples inside it |
| `repeated_description` | open ONE repeated description and list the examples that all share it |
| `unstable_test` | open ONE flaky test and list its outcome run by run across the window, newest run first (needs `branch`) |
| `commit_sha` | anchor the answer on ONE named run instead of the repository's newest one — every run-grain block moves with it, `history` does not |
| `layer` | narrow the run-grain blocks (`slowest_examples`, `spec_files`, `spec_directories`, `repeated_descriptions`, `repeated_description_examples`, `spec_file_examples`, `spec_directory_files`, the previous-run area-growth blocks `directory_run_growth` and `directory_runtime_growth`, and — with `spec_directory` — `directory_run_file_growth` and `directory_runtime_file_growth`) and — when `branch` is also asked — the window-grain `slowest_tests`, `unstable_tests`, `directory_growth` and `directory_runtime_window_growth` (plus, with `spec_directory`, `directory_file_growth` and `directory_runtime_window_file_growth`) to one declared layer (`unit`, `integration`, `request`, `system` or `undeclared`); `unannotated_examples` is not narrowed — see below |
| `unannotated_examples` | `true` to list the individual tests carrying no `@intent` — the examples behind the annotated ratio, each labelled with what SpecGuard reads of it — and, in the same answer, which areas carry the most of them |

`branch` narrows `history` only — `latest_run` always names the repository's newest run, which on a
busy repo may be on another branch. That is a property of the endpoint, not of this bridge — and
`commit_sha` is the remedy: it names WHICH RUN to describe, where `branch` asks about a series. Every
run-grain block re-anchors together (`latest_run` and its rollups, the five run-grain drill-ins —
`spec_directory_files`, `spec_file_examples`, `repeated_description_examples`, `unannotated_examples`,
`slowest_examples` — `shards`, both growth windows, `previous_test_run`); `history` does not, so the
`history[0] == latest_run` identity holds
on a default call and is **not** expected to hold under an explicit ask. Nor does `unstable_test_runs`,
which is read over the branch window rather than off the anchored run. (`unannotated_directories`,
below, re-anchors too and is *not* a fifth drill-in: this roster carries one representative key per
drill-in **parameter**, not one entry per response key — `spec_directory` opens three blocks and only
`spec_directory_files` is listed, with `directory_run_file_growth` and `directory_runtime_file_growth`
absent for the same reason. `unannotated_directories` is a second block of an existing parameter's ask
and adds no parameter, so it is covered above by *`latest_run` and its rollups* and the roster stays at
four.) A sha with no run — a stale
bookmark, a pruned run, a commit whose CI never reported — does not error: the endpoint
falls back to the newest run and says so, so read `run_anchor.resolved` rather than trusting that a
successful response is about the commit you named.

`branch` is also the gate on the three blocks read over that same window, and they are `null` without
it: `unstable_tests` (which tests failed intermittently across the window rather than consistently),
`slowest_tests` with its `slowest_tests_window` (which durable tests cost the most wall clock across
the window) and `directory_growth` (how each area moved between the two **endpoints** of the window).
`layer_growth`, with its contract block `layer_growth_window` (how the declared-layer mix moved between the
same two endpoints), is gated by `branch` in the same way and is `null` without it. The
per-area comparisons against the **previous run** — `directory_run_growth` at the example-count
grain, `directory_runtime_growth` at the runtime grain, and `layer_run_growth` for the declared-layer
mix, and `layer_runtime_growth` for the time each declared layer accounts for — are a different question and take no branch at all: they scope to the latest run's own branch by construction, so a plain unparameterised
call already carries them. The two grains are independent, which is why both ship: making an
existing spec slow adds zero examples and shows up only in the runtime pair, and splitting one slow
spec into four fast ones is `+3` examples and *less* time.

`spec_directories` ranks the heaviest areas but stops at the area grain, so it says *where* the time
went and not *which files* spent it. `spec_directory` is the next question: pass a path exactly as
served in `latest_run.spec_directories.rows[].path` and `latest_run.spec_directory_files` opens with
the files in that one directory (`total_seconds`, `recorded_count`, `timed_count`, `layer_counts`
each), plus the **area's** own `layer_counts`, `file_count`/`recorded_count`/`timed_count` and the `limit` the row list was cut at —
those totals describe the whole area, not the returned page, so don't re-derive them from `rows`.
Omit the argument and the key is `null`, meaning *you did not ask*; an area the run recorded nothing
for answers `rows: []` rather than an error, so a renamed or deleted directory is an empty result and
not a failure.

That one ask opens **three** blocks at run grain, each in its own grain: `latest_run.spec_directory_files` for
which files carry the area's wall clock, `directory_run_file_growth` for which of them changed size
since the previous run, and `directory_runtime_file_growth` for which of them changed time. The last
two are the answer to the question the area-grain comparisons dead-end on — `spec/models 412 → 459
(+47)`, but *which files did that* — so they need no second parameter. When `branch` is also asked it
opens **two more**, at window grain: `directory_file_growth` (which files gained or lost examples across the
`branch` window) and `directory_runtime_window_file_growth` (which got slower or faster across it), each with its
`_window` contract block; both bodies are `null` without `branch`.

`spec_files` ranks the heaviest files (each row: `path`, `total_seconds`, `recorded_count`,
`timed_count`, `layer_counts`) but stops at the file grain, so it says *which files* cost the
most and not *which examples* inside them spent it. `spec_file` is the next question: pass a path
exactly as served in `latest_run.spec_files.rows[].path` and `latest_run.spec_file_examples` opens
with up to 50 of that file's individual examples, cut by **duration** (`name`, `file_path`,
`line_number`, `spec_file_path`, `duration_seconds`, `outcome`, `intent_layer`, `declared_intent`
each — eight fields), plus the **file's** own
`recorded_count`/`timed_count` and the `limit` the row list was cut at — those totals describe the
whole file, not the returned page, so don't re-derive them from `rows`. Omit the argument and the
key is `null`, meaning *you did not ask*; a path that matched nothing answers `rows: []` rather than
an error, so a renamed or deleted spec file and a stale bookmark are empty results and not failures.

`repeated_descriptions` ranks the descriptions carried by the most examples — the overcoverage
ranking; each row: `name`, `total_seconds`, `recorded_count`, `timed_count`, `files_seen`,
`layer_counts` — but names the description and the files it was seen in, not *which* examples say the same
thing. `repeated_description` is the next question: pass a description exactly as served in
`latest_run.repeated_descriptions.rows[].name` and `latest_run.repeated_description_examples` opens
with up to 25 of that group's members (the same eight fields), plus the **group's** own
`recorded_count`/`timed_count` and the `limit` the row list was cut at — again totals for the whole
group and not for the returned page. This is the **only** way to reach a group's members:
`slowest_examples` is the run-wide top ten and rarely contains them, and walking `spec_file` over
each path in the row's `files_seen` is N unrelated lists each cut by duration, with no guarantee the
group's members survive the cut in any of them. Omit the argument and the key is `null`; a
description that matched nothing answers `rows: []`, so a test renamed since and an edited
description are empty results and not failures.

`unstable_tests` ranks the tests that failed intermittently across the branch window, but a row
carries `run_count`, `failed_run_count` and `outcome_words` — and those three figures are
*identical* for four failures in runs 27–30 and four failures in runs 3, 11, 19 and 26. The first is
a **regression** and the work is to find the commit; the second is genuine **flakiness** and the work
is quarantine or shared state. `unstable_test` is the next question: pass a description exactly as
served in `unstable_tests.rows[].name` and `unstable_tests.unstable_test_runs` opens with that
description's rows run by run in window order, **newest run first**, up to 200 (`test_run_id`,
`commit_sha`, `branch`, `ingested_at`, `outcome`, `duration_seconds`, `spec_file_path`,
`line_number` each), plus the **description's** own
`recorded_count`/`reported_outcome_count`/`unreported_outcome_count`, the window's `run_count` and
the `limit` the row list was cut at.

Every row of `unstable_tests.rows[]` and of `unstable_tests.shared_description_rows[]` also carries
`declared_layers` — the sorted distinct set of `@intent layer:` values that test's examples declared
anywhere in the window. `[]` means no run in the window declared one: it is not `unreadable` and not
a measured `unit`. A partially annotated test lists only what was declared, and more than one entry
means the test changed layer mid-window. It is declared-only — the stored column, never inferred from
the spec path or `files_seen`. The `unstable_test_runs` drill-in rows do **not** carry a layer, so
read `declared_layers` off the ranking row.

Note the two ways it differs from the drill-ins above. The answer lands **inside** the flakiness
block — `unstable_tests.unstable_test_runs`, not under `latest_run.*` — and `branch` is a hard
prerequisite rather than a suggestion: `unstable_tests` is `null` without it, so `unstable_test` sent
alone leaves no block to drill into at all, and not an empty `rows: []` either. Omit the argument and
the key is `null`; a description the window recorded nothing for answers `rows: []`, so a renamed
test — which starts a new history under the project's semantic identity rule — is an empty result and
not a failure.

**Mind the direction.** The rows are newest run first: element 0 is the most recent run in the
window, so the run a failure *started* at is the **last** row of the leading failed block, not the
first. Read front-to-back as run 1 onwards and the regression above reads as four failures at the
start of the window that have passed since — a fixed flake, the exact inversion of the truth, and
nothing errors to signal it. The 200-row cap drops the **oldest** rows for the same reason, so a
truncated sequence is still the recent runs. Read the run off each row's `commit_sha`/`test_run_id`
and never off its index: a run that recorded nothing under the description contributes no row, and a
description carried by two examples in one run contributes two, so `rows` is not one entry per run
and its length is not the window's `run_count`.

`slowest_tests_window` + `slowest_tests` are the cross-run runtime ranking, a top-level pair beside
`unstable_tests`: one row per **durable test** (a moved or renamed test keeps its history), ranked by
its window `total_seconds`, nulls last. It is a different grain from `latest_run.slowest_examples`,
which is one run at coordinate grain (file and line). Both keys are `null` without `branch`
(`slowest_tests_window.grouped` is true exactly when `slowest_tests` is non-null), so `branch` is a
hard prerequisite here as it is for `unstable_test`. Add `layer` to narrow it to one declared layer (echoed as `slowest_tests_window.layer`).

`slowest_tests.state` is four-way and only the last value is a result: `no_runs` (the window is
empty), `unrecorded` (the anchor run wrote no per-example rows), `unresolved` (it wrote rows and none
has been matched to a durable test yet — the ordinary state for the seconds after an ingest, since
identity resolution is asynchronous) and `ranked`. Only `ranked` with `rows: []` means nothing in the
suite is slow. `anchor_run` (`test_run_id`, `commit_sha`, `branch`, `ingested_at`; `null` only in
`no_runs`) names the run that decided membership: candidates are the **newest** run's slowest tests
and the window only supplies their history, so a test that ran earlier in the window but not in the
newest run is not listed. `truncated`, `unexamined_count` and `limit` disclose the candidate cap. `recorded`, `resolved`,
`excluded_unresolved_rows`, `untimed_count`, `complete` and `run_count` are the booleans and operands
beside them.
`recorded_count`, `unresolved_count`, `candidate_count`, `resolved_count` and `timed_count` are
`null` — not `0` — in every state that returned before the read that would have produced them; a `0`
is a measured zero.

Each `slowest_tests.rows[]` row carries `spec_identity_id`, `total_seconds` / `slowest_seconds`
(`null` when untimed, never a zero), `moved` / `renamed` with their operands `files_seen` /
`descriptions`, and `declared_layers` — the sorted distinct set of `@intent layer:` values the test
declared in the window. `[]` means none declared; it is declared-only, never inferred from the spec
path.

`annotated_ratio` is the product's adoption metric and it was the one population on this endpoint
you could not walk down: the dashboard printed *"SpecGuard cannot see the other N tests"* and could
not name one of them either, so an agent told to raise annotation coverage learned how far it had to
go and not a single test to annotate. `unannotated_examples` is that rung.

**Unannotated is not the same as unreadable, and the difference is on every response.** A test
called `Invoice#total sums the line items` has an entity, an action and a behavior in its own
description, so SpecGuard reads it whether or not anybody annotated it.
`latest_run.intent_readings` splits the run's examples into `authored` (an `@intent` a human wrote),
`derived` (read from the description) and `unreadable` (neither), with the `recorded` population
they were counted from — no flag to pass. **`unreadable` is the only figure on this endpoint that
means tests SpecGuard can say nothing about.** `total_specs - annotated_specs` is annotation debt,
which on a suite that has never been annotated is the whole suite and almost all of it readable;
never render that subtraction as blindness. A derived reading is genuinely weaker than an authored
one — no preconditions, a behavior written for a test runner's output rather than declared, and a
layer inferred from the directory — so report it as inferred and never as equivalent. And
`authored` never replaces `annotated_ratio`: "how much of this suite has a human-written intent" is
still that figure, off the run's own counters.

**Which layer the suite declares is a third, separate question**, answered on every response by
`latest_run.layer_counts`, a sibling of `intent_readings`: five operands — `unit`, `integration`,
`request`, `system` and `undeclared` — that sum to `intent_readings.recorded`. It is `null`, **not
five zeros**, when the run stored no per-example rows, because a zero would read as a measured none.
The layers are **declared only** — an `@intent` annotation said so — and **never inferred from the
directory path**: a test under `spec/models` that declared nothing is `undeclared`, and `undeclared`
is **not** `unreadable` (a test can be perfectly readable and declare no layer). The operands carry no
pyramid verdict; reading the mix is yours. **Where the machine time went by layer** is
`latest_run.layer_durations`, a sibling of `layer_counts` with the same five keys, each
`{total_seconds, timed_count}`. `total_seconds` is `null`, **not 0**, for a layer with no timed example,
so an untimed layer is not a free one; `timed_count` (at most that layer's `layer_counts` entry) is the
real denominator for any average. The whole key is `null` when the run stored no per-example rows. It is
a **sum** of example durations — machine time and **not wall clock** — so it can exceed
`duration_seconds` on a parallel or sharded run. The layer is **declared only**, never inferred from the
path, and the operands carry no pyramid verdict. **Which tests those are**: pass `layer`
(`unit`, `integration`, `request`, `system` or `undeclared`) to narrow a closed list of blocks, each of which
echoes `layer` on its own block (the key is absent when a layer was not asked or is malformed). **Run
grain** (moves with `commit_sha`): `latest_run.slowest_examples` (echoed as `slowest_examples.layer`),
`spec_files` and `spec_directories` (ranked by that layer's time), `repeated_descriptions` and
`repeated_description_examples` (repeated *within* the layer), `spec_file_examples` and
`spec_directory_files` (which adds a `layer-empty` state), and the previous-run area-growth blocks
`directory_run_growth` and `directory_runtime_growth` plus, when `spec_directory` is also asked,
`directory_run_file_growth` and `directory_runtime_file_growth` (each echoed as `<block>.layer` and
`<block>_window.layer`; they need no `branch`, and every area count and time sum in them is that layer's;
a layer empty in both runs stays comparable with no rows, empty in only one run reads as a new or removed
area); a layer with no rows answers `rows: []` with the block still present. **Window grain** (needs `branch`, does **not** move with `commit_sha`): the cross-run
`slowest_tests` (echoed as `slowest_tests_window.layer`) and `unstable_tests` (echoed as
`unstable_tests_window.layer`; the candidate step is the tests that *failed* in a layer example while each
row's history stays whole) and `directory_growth` (echoed as `directory_growth.layer` and
`directory_growth_window.layer`) and `directory_runtime_window_growth` (the per-area **time** movement between the
same two endpoints; echoed as `directory_runtime_window_growth.layer` and
`directory_runtime_window_growth_window.layer`) and, when `spec_directory` is also asked, the window file drill-ins
`directory_file_growth` (echoed as `directory_file_growth.layer` and `directory_file_growth_window.layer`) and
`directory_runtime_window_file_growth` (echoed as `directory_runtime_window_file_growth.layer` and
`directory_runtime_window_file_growth_window.layer`); `layer_runtime_window_growth` is **not** narrowed. All the window echoes are
absent when `branch` is not asked or the value is malformed.
On `slowest_tests` the candidate step is narrowed (at least one anchor-run example in the
layer) while row totals stay the whole durable test's, so `declared_layers` may list several layers;
`resolved_count`, `candidate_count`, `timed_count` and `untimed_count` become the layer's anchor-run
figures while `recorded_count` and `unresolved_count` stay whole-run; a layer the anchor never declared
answers `state: "ranked"` with `rows: []` — not "nothing is slow". `unannotated_examples` and
`unannotated_directories` are **not** narrowed. On `slowest_examples` with a layer asked,
`recorded_count`/`timed_count`/`reported_outcome_count`
become that layer's own counts, any other value is read by the server as no ask (so it is forwarded
unvalidated — verify the **per-block echoes**), and `rows: []` with `recorded_count: 0` means that layer has no
examples while `slowest_examples: null` means the run
recorded nothing. **How the declared-layer mix moved since the previous
run** is `layer_run_growth`, with its contract block `layer_run_growth_window` — both **top-level
siblings of `directory_run_growth`**, not inside `latest_run` (the delta is not in
`latest_run.layer_counts`). They compare the latest run with the previous run **on the latest run's own
branch**, so no `branch` is needed, and `commit_sha` re-anchors them with the rest of the run-grain
blocks. `layer_run_growth` is `null` in **every** non-comparable state — **never a block of zeros**, so
`null` is not "no change" — and `layer_run_growth_window.state` names why: `latest_unmeasured`,
`previous_unmeasured`, `assembled_differently`, `neither_recorded`, `previous_unrecorded`,
`latest_unrecorded`, or `comparable`, plus `no_latest_run` (CI never reported) and `no_previous_run`
(the first run on the branch). `layer_run_growth_window.comparable` is true exactly when
`layer_run_growth` is non-null; the window also carries `basis`, `branch`, `anchor_commit_sha` and
`baseline_commit_sha`. When comparable, `layer_run_growth` is
`{layers, baseline_recorded_count, anchor_recorded_count}`, where `layers` holds `unit`, `integration`,
`request`, `system` and `undeclared`, each `{baseline_count, anchor_count, change}`. A layer that did not
move reads `change: 0` — a **measured zero**, not an absence — and the five `change` values sum to
`anchor_recorded_count` minus `baseline_recorded_count`. The layers are **declared only**, never inferred
from the path, `undeclared` is **not** `unreadable`, and the operands carry no pyramid verdict.
**How the time each declared layer accounts for moved since the previous run** is `layer_runtime_growth`,
with its contract block `layer_runtime_growth_window` — both **top-level siblings of
`directory_run_growth`**, not inside `latest_run`, exactly as the count pair is. It answers "did my push
make the request layer N seconds slower?", which `layer_run_growth` (counts) and
`directory_runtime_growth` (per path) cannot. They compare the latest run with the previous run **on the
latest run's own branch**, so no `branch` is needed, and `commit_sha` re-anchors them.
`layer_runtime_growth` is `null` in **every** non-comparable state — **never a block of zeros** — and
`layer_runtime_growth_window.state` names why: the `layer_run_growth` states (`latest_unmeasured`,
`previous_unmeasured`, `assembled_differently`, `neither_recorded`, `previous_unrecorded`,
`latest_unrecorded`), plus `neither_timed`, `previous_untimed` and `latest_untimed` (a run recorded
example rows but timed none of them), `comparable`, `no_latest_run` and `no_previous_run`.
`layer_runtime_growth_window.comparable` is true exactly when `layer_runtime_growth` is non-null; the
window also carries `basis`, `branch`, `anchor_commit_sha` and `baseline_commit_sha`. When comparable,
`layer_runtime_growth` is `{layers}`, where `layers` holds `unit`, `integration`, `request`, `system` and
`undeclared`, each `{baseline_seconds, anchor_seconds, baseline_timed_count, anchor_timed_count,
baseline_count, anchor_count, change}`. `change` is anchor minus baseline in seconds and is `null` —
**not** `0` — when either side's layer was untimed; a layer timed on both sides that did not move reads
`change: 0`. It is a **sum of machine time**, not wall clock; layers are **declared only**, a test
changing its declared `@intent` layer reads as one layer gaining time and another losing it, and no
verdict is given.
**How the declared-layer mix moved across the `branch` window** is `layer_growth`, with its contract block
`layer_growth_window` — both **top-level siblings of `directory_growth`**, not inside `latest_run`, and
**branch-gated** exactly as `directory_growth` is: without `branch`, `layer_growth` is `null` and
`layer_growth_window` reads `grouped: false` with `state: null` (no query ran, and there is no
all-branches fallback). It is the window grain of `layer_run_growth`, which compares only the latest run
with the previous one and needs no branch: use `layer_run_growth` for "what did my push change?" and
`layer_growth` for "how has the pyramid drifted over the window?" — an example added and another removed
on every push reads ±1 per push there and is never flagged. It compares the **two endpoints** of the
window (`layer_growth_window.basis` is `"two_endpoints"`) and is **not a series**: an example added in
run 12 and removed in run 25 reads `change: 0`. The baseline is found by the same walk as
`directory_growth`, so for one window both carry the same `baseline_commit_sha` and `runs_back`. The window
block carries `basis`, `branch_scope`, `branch`, `grouped`, `state`, `comparable`, `anchor_commit_sha`,
`baseline_commit_sha` and `runs_back`. `layer_growth` is `null` in **every** non-comparable state —
**never a block of zeros** — and `layer_growth_window.state` names why, one of eight: `anchor_unmeasured`,
`no_earlier_run`, `no_measured_baseline`, `no_comparable_composition`, `neither_recorded`,
`baseline_unrecorded`, `anchor_unrecorded` or `comparable`. `layer_growth_window.comparable` is true
exactly when `layer_growth` is non-null. When comparable, `layer_growth` is
`{layers, baseline_recorded_count, anchor_recorded_count}`, where `layers` holds `unit`, `integration`,
`request`, `system` and `undeclared`, each `{baseline_count, anchor_count, change}`; a layer that did not
move reads `change: 0` — a **measured zero**, not an absence — and the five `change` values sum to
`anchor_recorded_count` minus `baseline_recorded_count`. Layers are **declared only**, never inferred from
the path, `undeclared` is **not** `unreadable`, and no pyramid verdict is given.
**How the machine time moved across the `branch` window** is two more pairs, the time siblings of
`directory_growth` and `layer_growth`: `layer_runtime_window_growth` with its contract block
`layer_runtime_window_growth_window` (declared-layer time) and `directory_runtime_window_growth` with
`directory_runtime_window_growth_window` (per-area time). All four are **top-level siblings of
`directory_growth`**, not inside `latest_run`, and **branch-gated** exactly as it is: without `branch` both growth
keys are `null` and both window blocks read `grouped: false` with `state: null` (no query ran, and there is no
all-branches fallback). They are the window grain of `layer_runtime_growth` and `directory_runtime_growth`, which
compare only the latest run with the previous one and need no branch. They compare the **two endpoints** of the
window (`basis` is `"two_endpoints"`) and are **not a series**: a layer or area slowed in run 12 and sped up in run
25 reads `change: 0`. The baseline is found by the same walk as `directory_growth`, so for one window all of them
carry the same `baseline_commit_sha` and `runs_back`. `directory_runtime_window_growth_window` also carries `order`
(`abs_change_desc_nulls_last,path_asc`), `tie_break_served` and `limit`, and echoes `layer` only when `layer` was
asked (that pair is narrowed by `layer`; `layer_runtime_window_growth` is not). Each growth key is `null` in
**every** non-comparable state — **never a block of zeros** — and the window block's `state` names why, one of
eleven: `anchor_unmeasured`, `no_earlier_run`, `no_measured_baseline`, `no_comparable_composition`,
`neither_recorded`, `baseline_unrecorded`, `anchor_unrecorded`, `neither_timed`, `baseline_untimed`,
`anchor_untimed` or `comparable`. `comparable` is true exactly when the growth key is non-null. When comparable,
`layer_runtime_window_growth` is `{layers}` with `unit`, `integration`, `request`, `system` and `undeclared`, each
`{baseline_seconds, anchor_seconds, baseline_timed_count, anchor_timed_count, baseline_count, anchor_count, change}`;
`directory_runtime_window_growth` is `{rows, directory_count, truncated, baseline_recorded_count,
anchor_recorded_count, baseline_timed_count, anchor_timed_count, limit}`. `change` is anchor minus baseline in
seconds and is `null` — **not** `0` — when either end was untimed; one timed on both ends that did not move reads
`change: 0`. It is a **sum of machine time**, not wall clock, layers are **declared only**, and no verdict is given.
**Which files moved across the `branch` window** is two more pairs, the file grain of `directory_growth` and
`directory_runtime_window_growth`: `directory_file_growth` with `directory_file_growth_window` (which spec files of
one area gained or lost examples) and `directory_runtime_window_file_growth` with
`directory_runtime_window_file_growth_window` (which got slower or faster). All four are **top-level siblings**, not
inside `latest_run`; the two bodies are `null` unless `branch` **and** `spec_directory` are both asked (the window
blocks are always present: otherwise they read `grouped: false` with `state: null`, and `path` restates the ask),
and `layer` narrows both. They compare the **two endpoints** of the window (`basis` is `"two_endpoints"`) and are
**not a series**: a file that changed in run 12 and changed back in run 25 reads `change: 0`, and a *moved* file reads
as one removed file beside one new file. Each window block's `state` and `comparable` are the **parent area block's**
verdict, not re-derived from the file rows — the eight states of `directory_growth_window` for
`directory_file_growth`, the eleven of `directory_runtime_window_growth_window` for
`directory_runtime_window_file_growth` — and each carries `path`, `order`, `tie_break_served`, `limit`, `basis`,
`branch_scope`, `branch`, `grouped`, `anchor_commit_sha`, `baseline_commit_sha` and `runs_back`, plus `layer` only when
`layer` was asked. Each body is `null` in every non-comparable state — never a block of zeros. When comparable,
`directory_file_growth` is `{rows, file_count, truncated, baseline_recorded_count, anchor_recorded_count}`, each row
`{path, baseline_count, anchor_count, change, moved, new_file, removed_file}`; `directory_runtime_window_file_growth`
adds `baseline_timed_count` and `anchor_timed_count` (the four denominators are the **area's** own, not the whole
run's) and its rows are `{path, baseline_seconds, anchor_seconds, change, comparable, moved, new_file, removed_file,
timing_gap}`, where `change` is `null` — **not** `0` — for a new, removed or untimed end. `file_count` is counted
**before** the `limit`, so `truncated` is judged against it and never against `rows.length`.
The same
`layer_counts` rides each row of
`spec_directories`, each row of `spec_files` and each row of `repeated_descriptions` (the same five
operands, **declared only** and never inferred from the path, measured zeros, summing to that row's
`recorded_count`), the `spec_directory_files` area block and each of its file rows, and every
per-example row of `slowest_examples`, `spec_file_examples` and `repeated_description_examples`
carries `intent_layer` and `declared_intent` (`{entity, action, behavior}` or `null`, declared only).

The `unannotated_examples` flag is the one argument
here that is a **flag rather than a name** — pass `true`, not a value — because it opens a
*population* rather than a pick: `total_specs` minus `annotated_specs` is a subtraction, and a
subtraction has no line to name. Which population is still yours to choose: sent alone the flag
opens the whole run, and sent **together with** `spec_file` or `spec_directory` it narrows to that
file, that area, or the AND of the two — those two keep opening their own blocks as well, so
narrowing this one is additional rather than instead. `latest_run.unannotated_examples` opens with
up to 100 of the unannotated examples **of whatever you asked for** (`name`, `file_path`,
`line_number`, `spec_file_path`, `reading` and `derived_intent` each — six fields, unlike the
per-example drill-ins' eight: no `duration_seconds`, no `outcome`, no `intent_layer`), plus that
same population's own `recorded_count`, `derived_count` and `unreadable_count`, the `limit` the row
list was cut at, and
`spec_file`/`spec_directory` **echoed back** as the server read them — `null` for each one you did
not send. Read the echo before the count: the **worklist's** `recorded_count` — and only that one,
because the map below deliberately does not narrow — is the figure you would reconcile against
`total_specs - annotated_specs`, and it counts the *narrowed* population whenever either echo is
non-null, so that reconciliation is expected to hold only when both are `null`. Do not re-derive
that count from `rows` either way: un-narrowed, this population is routinely the entire run — a
repository that has just installed the gem has every test in it — so the cap fires as the normal
case here rather than the exotic one, and a narrowed ask is cut at the same 100.

That one ask opens **two** blocks, each in its own grain: `latest_run.unannotated_examples` for
*which tests* to go and annotate, and `latest_run.unannotated_directories` for *where the debt is* —
the run's annotation debt rolled up by code area, which is what you pick the next `spec_directory`
narrowing **from**. Both come from the one flag; there is no second argument to send and no new
value. Each worklist row carries `reading` — `"derived"` or `"unreadable"` — and `derived_intent`,
the `entity`/`action`/`behavior` SpecGuard got from the description or `null`; the **unreadable rows
come first**, so the 100-row cap cannot hide them. The map's rows carry `path`,
`unannotated_count`, the `recorded_count` that area was counted against, and the same three-way
split — `authored_count`, `derived_count` and `unreadable_count`, which sum to `recorded_count`
while the last two sum to `unannotated_count` (the operands, never a fraction), plus
`directory_count` — **every** area the run
touched, not every area with debt, and not `rows.size` — and its **own** `limit`, which is **10 and
not the worklist's 100**. Two caps under one ask, and the difference is the kind of list: 100 caps a
*worklist* to work through, 10 caps a *ranking* to pick from. The orders differ for the same reason —
the worklist is file-navigable within each reading, the map is ranked `unreadable_count` descending,
then `unannotated_count` descending, with `path` as a tiebreak only — the areas SpecGuard cannot
read lead, because a ten-row ranking led by debt on an unannotated suite is a ranking by area size
and the dark corners never surface. A fully-annotated area is a real **row** with `unannotated_count: 0`, never an
omission; those rows sort last *collectively*, so on a run with more areas than the cap they are cut
and never seen, but on a run inside the cap they *are* listed and listed is correct. So `rows.size` is
not a count of areas *with* debt — read each row's `unannotated_count`. Both blocks are at run grain,
so both move with `commit_sha`.

**The two blocks disagree in two places, on purpose — do not reconcile them by arithmetic.**
*Scope:* `spec_file`/`spec_directory` narrow the **worklist** and its `recorded_count`, and the
**map stays whole-run** under both. So under a narrowing `unannotated_examples.recorded_count` is
*not* the sum of `unannotated_directories.rows[].unannotated_count`, and neither figure is wrong:
one counts the area or file you named, the other ranks the whole run. The map is whole-run by design
because it is the thing you choose a narrowing *from* — narrowed to the area you had already picked
it would answer nothing. The sum is short of the run's total whenever `directory_count > rows.size`
besides, narrowing or not. *Null versus empty:* on a run that recorded no per-example rows at all,
with the flag sent, `unannotated_examples` is a **present** block with `rows: []` and
`recorded_count: 0` while `unannotated_directories` is **`null`**. That is a signal rather than an
inconsistency — `recorded_count: 0` on the worklist means *both* "fully annotated" and "recorded
nothing", and the map is how you tell them apart: a present map beside that zero means the zero is
the success state, a `null` map means the run recorded nothing and the zero is an absence of data.

`false` means the same as omitting it and sends nothing at all. That matters more here than
elsewhere: the server reads only whether the parameter was **named**, so `?unannotated_examples=false`
on the wire would open the block for a caller who asked for it not to be — declining is not sending,
which is how every other argument here is declined too. Omit the flag and **both** keys are `null`,
meaning you did not ask. A **fully-annotated run is not an error and not a `null`**: the worklist
answers 200 with `rows: []` and `recorded_count: 0`, because that is the state the metric exists to
reach — walk a repository to completion and the block goes empty rather than vanishing. A narrowing
that matched nothing reads the same way and is never a 404: an unknown or renamed path, an already
fully-annotated file, and a contradictory file-and-area pair all answer `rows: []` with both
narrowings echoed, which is an empty intersection rather than a dropped parameter.

**Two blocks come back on every response, and they take no argument at all.** They answer what every
figure above silently depends on — is SpecGuard still being fed? `delivery_health` is why the data
may be **stale**: `refusing` compares stamps rather than reading a live wire — it is true when the
newest refusal is newer than the newest *accepted* run, and true when nothing has ever been
accepted, so a repository refused once and quiet since still answers `true`. Read it with
`last_rejection_at` and judge recency yourself. Each retained rejection carries the endpoint's own
reasons and, where the client reported one, the client
version that sent it, plus `served_by` — the build of the SpecGuard deployment that refused it,
stamped on the row at write time. That stamp answers a question the deployment's live version read
cannot: `get_server_version` reports which build is answering *now*, while retained refusals
outlive deploys, so for a past row the two routinely disagree — read `served_by` for which build
refused *that row*, never the live route. The row's two nulls mean *different* things and must not
be read as each other: a null `reported_client` means the client sent no `User-Agent` (the row
knows, and the client said nothing); a null `served_by` means the row carries no build identity, so
the platform cannot name which build answered it — the write path can land on that null in more
than one way (for example a row retained from before the stamp existed, a build whose VERSION file
is missing or unreadable, a column stored blank), so the null says only that no build is named,
never which way it got there. The window those rows sit in carries its own **population and client
composition** beside them: `retained_total` counts every rejection the retention window still holds
(up to `retention_rows` rows) and `retained_clients` splits that total by client, largest bucket
first with alphabetical ties. `retained_total` is the **window's** population, never the length of
the `rejections` array beside it — that list is capped at `limit`, and the whole point of serving
both is that they differ: a client whose refusals all sit beyond the page is named in
`retained_clients` and in no `rejections` row, which is how an upgrade-fleet question ("is it the
new gem or the old gem being refused?") gets answered off this body instead of from the page's
newest ten. A null `reported_client` inside `retained_clients` carries the row-level meaning — the
client sent no `User-Agent` — and is not the `served_by` null. A `latest_run` from days ago beside a live rejection stream is a suite
SpecGuard *stopped accepting*, not a suite nobody ran. `credential_health` covers the break that one
structurally **cannot** see: a rejected key resolves no repository and writes no rejection row, so an
authentication-broken pipeline is invisible to every rejection figure — it names any key that was
**rotated** and has not authenticated since, a secret some pipeline has not picked up.
A quiet answer is a **finding, not a gap**: `refusing: false` is "nothing was refused" and
`rotated_and_unused: false` is "no key is stranded", and neither means "SpecGuard does not track
that". Do **not** read `api_key.last_used_at` as evidence anything was *accepted* — it is stamped on
the way in, before the payload is looked at, so a repository having every run thrown away still
reports it seconds ago; the endpoint says so itself in `acceptance_reported_by` and
`rotation_reported_by`, which name these two blocks. Where a bound sits beside a list, the list is
a **page and not the set**: `limit` next to `rows`, or on that list's `*_window` block, which is
also where the *order* the cut was made in is named when the list has one. What announces the cut
varies too — `truncated`, `bounded`, `returned` short of `limit`, or a `recorded_count` larger than
the rows served — so read the bound beside the list in front of you and never take a full-looking
ranking for the whole set. Where **no** bound sits beside a list, it is everything there was:
`credential_health.keys` and both `latest_run.shards` lists are complete by construction, which is
a finding and not a disclosure someone forgot.

Figures are `null` where CI did not report them. A `null` means *not measured*; it is never a zero,
because a zero would read as a measurement that was taken. That rule is about nulls and does not
run backwards: a non-null figure is not thereby a measurement — a run that reported zero tests
serves a real `total_specs: 0` beside `suite_size_measured: false`, so it is that boolean, never
the nullness, that says whether the row measured a suite.

Three more keys ride **every** response, with no parameter to pass, and together they decide whether
two history rows may be *differenced at all* — which is what the history is for. `suite_size_measured`
is served on `latest_run` and on every `history[]` row, from the same predicate at both sites so one
run cannot disagree with itself: `false` means that run reported **no** tests, so its `total_specs`
is a *report*, not a measurement — never difference across a row where it is false. `shard_count`
and `timed_shard_count` ride every `history[]` row and are the two denominators: difference
`total_specs` only across rows of *equal* `shard_count`, because that count is a sum over the shards
**recorded**; difference `duration_seconds` only across rows of *equal* `timed_shard_count`, because
that figure is a max over the shards that **reported**. Ignore the second and a run whose four
shards all reported, differenced against a run whose two slowest were cancelled — identical
`shard_count`, identical `suite_size_measured`, only `timed_shard_count` differing — reads as a 70%
speedup produced entirely by telemetry loss. A half-reported run is the **ordinary** state, not an
exotic one: every sharded run passes through it while its shards are still arriving, and a job
cancelled after two of four shards leaves a half-sized row in the history permanently.

Every `history[]` row also carries `layer_counts` (followed by `layer_durations`) — the same five operands
(`unit`, `integration`, `request`, `system`, `undeclared`) as `latest_run.layer_counts`, declared only and
never inferred from the path, measured zeros, or `null` (**not** five zeros) when that run stored no
per-example rows — so the history is a per-run *series* of the declared-layer mix, which
`layer_run_growth` (latest vs previous) and `layer_growth` (two endpoints) cannot give. Pass `branch` and it
is that one branch's series. It is a sum over the shards recorded, so difference it only across rows of
*equal* `shard_count`; the operands carry no verdict.

Every `history[]` row also carries `layer_durations` as its **last** key — the same five layers as
`latest_run.layer_durations`, each `{total_seconds, timed_count}`, where `total_seconds` is `null`
(**not** 0) for a layer with no timed example and the whole key is `null` when that run stored no
per-example rows — so the history is also a per-run *series* of machine time by declared layer, which
`layer_runtime_growth` (latest vs previous) and `layer_runtime_window_growth` (two endpoints) cannot
give. Pass `branch` and it is that one branch's series. It is a sum of example durations — machine
time and **not** wall clock — so difference it only across rows of *equal* `shard_count` **and** *equal*
`timed_shard_count`; the operands carry no verdict.

### `get_server_version`

Reports **which build of the SpecGuard deployment is answering**, read from the deployment's own
unauthenticated root-level `GET /version` — the figure the release bot's `VERSION` file carries,
served as a JSON object and passed through verbatim. Since SPGD-1316 the body also carries the
enforced contract's identity beside the build (`schema_sha256` + `schema_origin`); this tool
passes the whole answer through without interpreting it — `get_intent_schema` is where the served
contract is read beside that identity and the two are compared.

**This is the server's build, not this bridge's.** The version in the initialize handshake's
`serverInfo` and the `specguard-mcp/<version>` User-Agent describe *this bridge* — a different
component, released on its own cadence — so never read one as the other. Pinning which build's
crafted 404/400 message sentence a contract suite asserts, verifying that a deployment upgrade took
effect, or naming the build that produced a behavior is *this* tool's question; the handshake cannot
answer it.

**`version: null` is an honest answer, not an error.** The deployment is up but cannot say its own
build — its `VERSION` file was missing or unreadable at boot — and the platform serves that
deliberately, because an unverifiable identity beats a 500 from the one endpoint whose whole job is
to be askable. The null is passed through exactly as served, never replaced with a guessed value.

**A 404 means the deployment predates the route: the endpoint is right and the build is old.** The
error text names the endpoint as possibly misconfigured, because a non-contract 404 body names no
cause — when the other tools work against the same endpoint, read that error as *"the deployment
needs upgrading"*, not as a wrong URL.

**Needs no API key of any kind** — the route is unauthenticated by design — so it works with
`SPECGUARD_ENDPOINT` alone, and it sends no `Authorization` header (as does `get_intent_schema`, the
other credential-free read here). Takes no arguments.

### `list_repositories`

Lists the SpecGuard repositories this server's key may open — *what can I ask about*, which is the
one question no other tool here can answer. `get_repository_overview` without `repository` takes no
repository because its `sgk_…` key **is** the repository, so without this an agent can only report
on a repository somebody already named for it.

The credential decides which repositories are *in* the answer — a person's own-plus-shared set
under an `sgu_…` key, the agent key's mint-time granted set under an `sga_…` one; the three
optional asks below narrow **within** whichever set answered — never around it. The endpoint has
served them since SpecGuard's SPGD-940 (`ef6236d`), composed through the same `RepositoryNarrowing`
concern the web grid reads, and this tool forwards them rather than re-deriving them. A repository
the credential does not admit never enters the response, so no ask can filter it *in* either. A
blank value is no ask: passing an empty string and omitting the argument make the identical request.

| argument | |
| --- | --- |
| `q` | keep only repositories whose `full_name` (`org/repo`) contains this substring, case-insensitively. A plain substring, not a pattern — the LIKE wildcards `%`/`_` are escaped server-side, so `org/my_repo` matches itself. A match-less ask is an empty list, not an error |
| `role` | `"owned"` or `"shared"` — one half of the list's mix: the repositories this person owns, or the ones shared with them. Mind the spelling: the *ask* values are `owned`/`shared`, while each entry's `role` field reads `owner`/`member` — `role: "owner"` is not a valid ask and settles to no ask (full list, no error). Under the `sga_…` agent key the ask settles to no ask for every value: ownership is a person fact and the key speaks for nobody, so the full granted list is served |
| `sort` | `"stale"` — re-order stalest-first: repositories CI has never ingested a run for first, then least-recently-ingested, with `full_name` breaking ties so two calls agree element for element. `"annotated"` — re-order least-annotated share first: a suite measured at 0-of-N leads, repositories with no run or an unmeasured suite come last (never ranked as 0%), and `full_name` breaks ties. `"pyramid"` — re-order lowest request+system share first: ascending `(request+system)/(unit+integration+request+system)` of the latest run's `layer_counts`, with `undeclared` excluded from the denominator; a measured 0 leads, repositories with no run, a `null` `layer_counts` or a declared total of 0 come last (never ranked as 0%), and `full_name` breaks ties. The same entries, a different order; the default order stays `full_name` ascending |

The body comes back as SpecGuard serves it — `{"repositories": […]}`, each entry carrying `id`,
`full_name`, `name`, `registered_at`, `role`, `delivery_health` and `latest_run`, ordered by
`full_name` ascending unless `sort` asks otherwise. The identity fields are deliberately the same
fields, under the same names, that `get_repository_overview` serves in its own `repository` block,
so a client that reads one reads the other.

`delivery_health` is the entry's delivery verdict — `refusing` beside `last_rejection_at` — served
on **every** entry, so this one call triages ingest-pipeline health across the whole reachable set
instead of paying one `get_repository_overview` call per repository. It is the coarse sibling of
the overview's fuller per-repository block. A quiet verdict is a finding, not a gap:
`refusing: false` means nothing was refused, never that delivery is untracked.

`latest_run` is the entry's newest run — when CI last reported, on what branch and commit, how big
the suite is, how much of it SpecGuard can read, and the run-level cost scalars. `null` means CI
has **never reported** for that repository — never a run that found an empty suite.

Each list entry's `latest_run` also carries `layer_counts` — five operands, `unit`, `integration`,
`request`, `system` and `undeclared`, that sum to the run's recorded examples. It is `null`, not five
zeros, when the run recorded no per-example rows (a zero would read as a measured none). The layers
are **declared** only — an `@intent` annotation said so — never inferred from a path, and
`undeclared` is not `unreadable`. The operands carry no pyramid verdict; reading the mix is yours.

`role` has one value per
credential kind: under a **user** key (`sgu_…`) it is `owner` or `member` — the list mixes
repositories this person owns with repositories somebody shared with them, and nothing else tells
them apart — while under an **agent** key (`sga_…`) every entry is `agent`, the value that says the
ownership question does not apply because the key speaks for nobody (branching on owner/member
correctly reads false for both). Read it before assuming a repository is one you may administer.

Under an **agent** key the top level also carries `credential.capabilities` — the calling key's own
grant, read through the server's policy, so an agent key learns what it was granted here instead of
discovering its permissions by hitting refusals; under a **user** key the block is absent, not
`null` — a person key has no mint-time permission set, and a null would assert one exists and is
empty.

An empty list means no access, not an error.

**It reads a different key from `get_repository_overview` — either of two, whichever is set.**
`SPECGUARD_AGENT_API_KEY` (`sga_…`) when it is set: the answer is then the repository set granted
onto that key at mint time, the same set every other agent-keyed call answers inside, so discovery
and reach always agree. `SPECGUARD_USER_API_KEY` (`sgu_…`) when no agent key is set: the answer is
what that person may open. With both set the **agent** key wins. Neither is ever a stand-in for
`SPECGUARD_API_KEY` (`sgk_…`) — SpecGuard refuses each credential kind in the other's place, the
prefix deciding which table is consulted before any of them is read — and every message this tool
produces names the variable *it* read, so a `401` here never sends you to check a key this tool
never used.

Registering a repository is `add_repository`, below — it reads `SPECGUARD_USER_API_KEY` (an
`sgu_…` key, which this tool also accepts but does not require) and takes the `full_name` this
tool reports. Removal and the key lifecycle (`remove_repository`,
`create_repository_api_key`, `revoke_repository_api_key`) are below too, on the same
either-credential rule; a tool
here is a promise the agent will act on, so each waits for the capability rather than the other way
round.

### `add_repository`

Registers a GitHub repository with SpecGuard for the person behind `SPECGUARD_USER_API_KEY`, and
returns the repository together with its **first CI API key** — the `sgk_…` key that repository's CI
will use to ingest runs, minted in the same call so a fresh registration is usable without a second
trip through the browser.

| argument | |
| --- | --- |
| `full_name` | the repository to register, as `org/repo` (for example `acme/billing`) — the same handle `list_repositories` reports. Not a URL, not a bare repository name |

The body comes back as SpecGuard serves it: a `repository` block (`id`, `full_name`, `name`,
`registered_at` — deliberately the same four fields `get_repository_overview` serves in its own
`repository` block) and an `api_key` block (`id`, `name`, `token`, `hint`, `created_at`). `id` is
the key's durable handle (the token is reveal-once); every **other** key's id comes from
`list_repository_api_keys`.

> ⚠️ **`api_key.token` is shown once and never again.** Nothing stores it and no endpoint can
> re-serve it. Capture it from this response — an agent should hand it straight to the person it is
> working for. A key that is lost is replaced from SpecGuard's API-keys page in a browser, not from
> here.

> ⚠️ **This tool is not idempotent, and it writes.** If the call exceeds `SPECGUARD_TIMEOUT_MS` the
> bridge gives up, but the registration may still have succeeded on the server — taking its one-time
> token into a response nobody received. The retry is then refused with `has already been taken`,
> which is the honest answer rather than a bug. Do not retry a timeout blindly; check
> `list_repositories`, and recover the key in the browser.

**It needs a current record of your GitHub permissions, and only a browser creates one.** SpecGuard
decides whether you may register a repository from a stored grant, and fails closed when that grant
is missing or stale — which is every person who has not signed in and connected GitHub recently. That
refusal arrives as SpecGuard's own sentence, verbatim, naming the fix: *sign in to SpecGuard in a
browser and reconnect GitHub, then try again*. No argument to this tool substitutes for it. The same
path carries the other refusals — a repository the SpecGuard GitHub App is not installed on, one you
do not administer, one already registered.

**The `org/repo` format is not re-checked here.** This bridge verifies only that you passed a
non-blank string; SpecGuard validates the name and refuses an unusable one in its own words. A second
format rule on this side would be free to drift from the one that actually decides, and would surface
as this bridge rejecting a name the platform would have accepted.

It reads `SPECGUARD_USER_API_KEY` (`sgu_…`) — a **person** key, which `list_repositories` also
accepts but does not require — and a different one from the `sgk_…` key `get_repository_overview`
uses.

### `registrable_repositories`

Lists the GitHub repositories the person behind `SPECGUARD_USER_API_KEY` **could** register with
SpecGuard — the set the registration gate would consult, read out loud in advance, so an agent can
pick a `full_name` for `add_repository` from a real answer rather than by guessing. `list_repositories`
reports what *is* registered; this reports what could be, and the two answer different questions.

The body comes back as SpecGuard serves it: `{"repositories": […]}` with each entry carrying
`full_name` and `registered`, ordered by `full_name` ascending, plus a `grant` block (`captured_at`,
`expires_at`, `stale`) describing the stored record of this person's GitHub permissions.

**`registered` is asked globally, not just of your own repositories.** An entry marked
`registered: true` was registered by *somebody* — possibly someone else — and a POST naming it will
be refused with `has already been taken`. That is exactly why entries are *marked* rather than
excluded: a reading scoped to what you can open would send you at a name nobody can register.

**A name appearing here is not a promise the write will succeed.** This is the set the gate would
consult at the moment of the read; the repository may be registered by someone else between this
call and your POST.

**A missing or stale grant is not an error — it is the modal first answer.** SpecGuard fails closed
when it has no current record of your GitHub permissions, which is every person who has not opened
SpecGuard in a browser recently. The call then answers `403` with SpecGuard's own sentence naming
the fix: *sign in to SpecGuard in a browser and reconnect GitHub, then try again*. On that refusal
the body still carries `grant`, and it distinguishes the two cases: `grant: null` means there never
was one (first-time setup), a populated grant with `stale: true` means an existing connection lapsed
— same remedy, very different urgency. Read it before telling the person what to do.

**This tool takes no arguments** — the credential is the whole of the scope. The endpoint takes no
parameters; which repositories are in the answer is decided by SpecGuard from the person the key
speaks for.

It reads `SPECGUARD_USER_API_KEY` (`sgu_…`) — the same **person** key `add_repository` reads, and
a different one from the `sgk_…` key `get_repository_overview` uses; `list_repositories` also
accepts that key but does not require it (its agent key wins when both are set).

### `near_duplicate_clusters`

Runs SpecGuard's near-duplicate census over a repository's tests — which tests *read alike* (same
body text, whatever file they sit in), clustered by similarity. Answers the refactoring question
the overview's per-run rankings cannot: where the same test is written twice, before you delete or
merge anything.

**This call is served stored** (SPGD-1474): the platform computes the census once per write that
moves its inputs — at each ingest, after identity resolution settles the run's identities, and
again when a run is deleted from the repository (which can move the run the weight figures are
weighed on) — and keeps it — so the answer returns in
milliseconds instead of running the minutes-scale computation that used to sit behind the ask and
never fit this bridge's 30-second deadline. The opt-in ask is unchanged wire contract
(`?near_duplicates=`, shipped by SPGD-703, still opens the block; the plain overview still answers
`near_duplicates: null` without it, and `get_repository_overview` never sends the ask). Calling
this tool **is** the ask.

By default one call returns the **whole** census — the clusters are the repository's, computed over
every run, with every cluster's member list. (The server reads only that the `near_duplicates` key
is *present* — `=false` would open it too — so there is no value for that default ask to carry.)
Two optional arguments bound the view instead (SPGD-1714, over the platform's SPGD-1712), and
`repository` chooses which **repository** is censused:

| argument | |
| --- | --- |
| `repository` | census THIS repository (its numeric id from `list_repositories`) under the **agent key** (`SPECGUARD_AGENT_API_KEY`, `sga_…`), or — when no agent key is set — the **user key** (`SPECGUARD_USER_API_KEY`, `sgu_…`), instead of the one the `sgk_…` key resolves to — omit it for the default, `sgk_`-keyed census |
| `summary` | `true` asks for the bounded **summary** view — the `near_duplicates_summary` block: the ranking with no member lists. `false` or omitted sends nothing (the server treats any value as an ask, so `false` is never sent) |
| `cluster` | an integer ≥ 1: ask for ONE cluster by its `rank` — the `near_duplicate_cluster` block, members listed once. Not range-checked by the bridge; the server answers an out-of-range rank with `cluster: null` |

**Start with `summary`, drill in with `cluster`.** With neither argument the request is exactly the
one this tool has always made (`?near_duplicates=true`). With `summary` and/or `cluster`, **only**
the asks named are sent — the whole-census `near_duplicates` block is not also opened; supplying
both sends both keys, once each. Argument shapes are validated before any request is made
(`cluster` must be an integer of at least 1; `0`, `-1`, `2.5` and `"3"` are refused).

The bounded view's response keys:

- `near_duplicates_summary` — the ranking, **head keys first**, then one entry per cluster carrying
  `rank`, `files_seen`, `file_count`, `declared_layers` and `overlap_kind` — and **no member lists**.
- `near_duplicate_cluster` — one cluster: `requested` (the rank you asked for, echoed), `rank`,
  `cluster_count`, `weighed_run_id`, `computed_at`, `member_listing` (`members` or `layer_groups` —
  which of the two member shapes `cluster` carries) and `cluster` itself.
- **Ranks are positions in a snapshot** — read `computed_at` before reusing a rank: an ingest or a
  run deletion recomputes the census, and the same rank can then name a different cluster. A rank
  past `cluster_count` is not an error: the key is present with `cluster: null` and `requested`
  echoing the ask.
- A deployment that predates the bounded view silently ignores the ask. When an ask was sent and
  its matching key is **absent** from the body (absent, not `null` — a present `null` passes
  through), the tool fails naming the missing key rather than returning a body that lacks it.

The tool stays a pass-through: no reshaping, filtering or ranking.

The credential changes with `repository`, exactly as on `get_repository_overview`: the plural
endpoint answers **either member credential**, whichever is set — the agent key preferred (its
mint-time granted repository set is the boundary the id is looked up inside), the user key when no
agent key is set (its accessible set is the boundary instead), the agent key winning when both are
present — and the credential kinds refuse each other's tokens, so a
`repository` ask with neither member key set is refused *here*, naming both variables, before any
request is made. A repository outside the presented credential's grant answers 404 at the server,
person and agent alike.

Read the response with its own rules in mind:

- `similarity_floor` and `similarity_basis` sit **first** in the block and qualify every figure
  below them — a cluster count without what "similar" meant is a count you cannot act on.
- `ranking_basis` sits in the head of the block (after `layer_source`) and states the order the
  clusters are ranked in: clusters spanning more than one spec file first, then by summed wall
  clock, single-file groups following. So the top row is the costliest **multi-file** cluster, not
  necessarily the costliest overall. Present on current deployments; an absent `ranking_basis`
  means the census was stored under the older cost-only order (costliest first) — not an error.
- `overlap_kind` on each cluster (and on every summary row) is `multi_file` or `single_file`,
  naming which kind of group the cluster is. It describes where the members' last-known paths sit;
  it is not a verdict (the census says "read as similar", never "duplicate"), and it is
  independent of `layer_redundancy` — a two-file group whose members declared different layers is
  both `multi_file` and `cross_layer`. Absent on older stored censuses.
- `computed_at` is when the stored census was taken and `weighed_run_id` is the run its weight
  figures are from — read the stamp before acting on the figures. A census read shortly after an
  ingest **or a run deletion** may be the **previous** artifact, carrying its own stamp, while the
  recompute for the new state is still running; a `near_duplicates` block of `null` on this tool
  means no census has
  been computed for the repository yet (never a live computation, never zeros).
- `member_count` (texts in the repository, across every run) and `example_count` (examples in the
  one run `weighed_run_id` names) are **different grains**: a three-example table-driven loop is
  one member and three examples. Never fold them.
- `truncated: true` means the `clusters` list was cut at the cap while the counts above it
  (`cluster_count`, `layer_redundancy_counts`, `identity_count`, `clustered_*`) describe the
  whole census.
- `unobserved_members: true` on a cluster says a member identity the weighed run did not observe
  (deleted, renamed, deselected) is still listed — do not reconcile the member list against that
  run's examples.
- `similarity_range` is `[strongest, weakest]`: membership is transitive, similarity is not.
- `total_seconds` is `null` where nothing was timed — never a zero that would read as free.
- `layer_source` at the head of the block states where the layer dimension comes from: **declared
  via the intent protocol** when at least one clustered member's examples declared a layer, `null`
  on a suite that declared nothing — absence stated, never an empty fiction. The layers are
  DECLARED, never inferred: no path or directory convention is consulted anywhere in the cut (a
  `request`-declared test under `spec/models/` reports `request`).
- `layer_redundancy` classifies each cluster: `cross_layer` when its members declared two or more
  DISTINCT layers (the same behaviour covered on several levels — the test-pyramid question),
  `same_layer` when confined to one (a plain duplicate), `null` when its members declared nothing
  — neither state, never folded into `same_layer`.
- `layer_redundancy_counts` in the census head is `{cross_layer, same_layer, undeclared}`, counted
  over **every** cluster in the census (not the served page of `clusters`) and summing to
  `cluster_count`. `undeclared` (clusters whose members declared no layer) is neither state and is
  never folded into `same_layer`; a layer-free suite reads `{0, 0, cluster_count}` and an empty
  census all zeros. The key is absent on a census stored before it existed (an older stored census,
  not an error). Use it — not a tally of `layer_redundancy` over `clusters` — to answer how many
  groups span more than one layer. It rides `summary: true` (`near_duplicates_summary`) verbatim.
- `layer_groups` holds the members grouped by declared layer (alphabetically, the undeclared LAST
  as the `layer: null` group), each group's members in the same five-field shape the flat list
  serves. A member whose own examples declared two layers appears in EACH group it declared, so
  the groups' sizes can exceed `member_count` by exactly those members — that is the finding, not
  an arithmetic bug — and a member that declared nothing stays in its cluster's null group, never
  dropped.
- `clusters: []` with a real `identity_count` is the **success** state (nothing reads alike); the
  three silences — nothing ingested, nothing embedded, nothing alike — are kept distinguishable by
  `recorded_count` / `identity_count` / the list itself.

Without `repository`, same credential and endpoint as `get_repository_overview`'s default
(`sgk_…` repository key on `GET /api/v1/repository`); the response is that body with the
asked-for block opened (`near_duplicates` by default), passed through unmodified. With `repository`, the plural endpoint
answers either member credential, exactly as on `get_repository_overview` — and carries that
path's one deliberate omission: `api_key` is **absent** from the plural body rather than nulled,
because the block describes the credential that made the request and no member credential is a
repository key (an absent key there is the surface's shape, never a dropped block).

### `find_tests_near_behavior`

Asks a repository's stored test suite map which tests are **nearest a behavior phrase** you give
it. The server embeds the phrase and ranks the repository's stored test identities by similarity,
returning the top hits in the `near` block (`GET /api/v1/repository?near=<phrase>`, or the plural
`GET /api/v1/repositories/:id?near=<phrase>`) — each hit with its similarity, its `signal_source`,
its last-known path and the weight the latest run measured.

| argument | |
| --- | --- |
| `behavior` | **required** — the behavior phrase, in plain words (e.g. "rejects an expired password reset token"). Trimmed and sent as `near`. Blank or whitespace-only is refused before any request, and so is a phrase containing a NUL (`\u0000`) character: the server would read either as no ask and answer the whole overview with `near: null`, which would look like an answered ask |
| `repository` | ask THIS repository (its numeric id from `list_repositories`) under the **agent key** (`SPECGUARD_AGENT_API_KEY`, `sga_…`), or — when no agent key is set — the **user key** (`SPECGUARD_USER_API_KEY`, `sgu_…`), instead of the one the `sgk_…` key resolves to — omit it for the default, `sgk_`-keyed ask |
| `limit` | optional integer page size, **1–50** — omit it for the default page of 10. Sent as `&limit=<n>` only when supplied, so an ask without it is the same request as before. The server clamps the ask to 50 and reports the value it applied as `limit` in the `near` block; a non-integer, zero or negative value is refused before any request |

**The answer is a page, not the set.** Read `truncated` and `limit` in the returned `near` block:
`limit` is the page size the server applied, and `truncated: true` means **more matches exist than
were returned** — a full page of 10 may be "10 of 37". When `truncated` is true, re-ask the **same
phrase** with a larger `limit` (max 50) before paraphrasing it: a repeated phrase is cache-served and
free, while each paraphrase is a novel phrase and one billed embed.

**What the answer is not.** It ranks *stored* tests nearest the phrase and **never answers "is this
already tested?"** — it never gates a write and never issues a verdict. A hit near the phrase is not
coverage, and an empty answer is not proof of absence. `similarity_floor` is the near-duplicate
census's redundancy bar, **not** the 0.95 matching threshold that decides whether two tests are the
same test. Read `similarity_basis` and `similarity_floor` before any figure.

**The three silences differ** — never collapse them, and `null` is never `[]`:

- `status` of `provider_unconfigured` or `embedding_failed` with `ranked: null` — no ranking was
  attempted, or the provider refused (`error` carries its reason). This says nothing about the suite.
- `identity_count: 0` with `ranked: []` — the repository holds no identities; nothing has been
  ingested.
- `ranked: []` with `identity_count` above zero and `best_below_floor_similarity` — identities exist,
  the search ran, and none is near the phrase; the nearest one's similarity is served so "nothing
  near" is a finding you can check.

`signal_sources` (the composition of the served page) and each hit's `signal_source` (matched on
declared intent, or on name) are different evidence — read the source before leaning on a hit.

**Cost.** Each *novel* phrase costs **one billed embedding call** at the provider; a repeated phrase
is served from the cache and costs nothing (`cache_served` says which happened). The ask is live —
computed on the request — unlike the stored census `near_duplicate_clusters` returns, and it is a
tool of its own so that a `get_repository_overview` call never pays an embed by accident.

Same endpoints and credentials as `get_repository_overview`: without `repository`, an `sgk_…`
repository key on `GET /api/v1/repository`; with `repository`, either member credential on the
plural endpoint — the agent key preferred, the user key when no agent key is set. A repository
outside the presented credential's grant answers 404 at the server. The response is that body with
the `near` block opened, passed through unmodified; on the plural path `api_key` is **absent** from
the body rather than nulled (the surface's shape, never a dropped block).

### `remove_repository`

Removes a repository from SpecGuard — and with it **every key, run and intent on it**. This is the
destructive gesture in this toolset: irreversible, no undo, and a `204` means the repository and its
history are gone short of re-registering from scratch. Confirm with the user before calling.

| argument | |
| --- | --- |
| `repository_id` | the repository to remove — its numeric `id`, as `add_repository` returns and `list_repositories` reports, not the `org/repo` handle |

Authorization is the `repo.delete` capability at **either surface** — an owner, or a member granted
`repo.delete`, may remove the repository. A member without it is refused `403` with SpecGuard's own
sentence, verbatim. The repository's CI keys stop authenticating the moment it succeeds.

It reads either of two credentials, whichever is set — and a different one from the `sgk_…` key
`get_repository_overview` uses. `SPECGUARD_AGENT_API_KEY` (`sga_…`) when it is set: the call then
reaches only the repositories granted onto that key at mint time, and only where the grant carries
`repo.delete` — a repository outside that set is answered `404` in SpecGuard's own words.
`SPECGUARD_USER_API_KEY` (`sgu_…`) when no agent key is set: the same **person** key `add_repository`
reads. With both set the **agent** key wins, so the removal answers inside the same set
`list_repositories` reports.

### `create_repository_api_key`

Mints a new CI API key (`sgk_…`) for a SpecGuard repository, returning the new key **alone** in
an `api_key` block — the repository's full key set (keys minted in the web panel or in an
earlier session included) is `list_repository_api_keys`' answer, never this response's.
Minting does not disturb existing keys — each key on a repository authenticates independently
until revoked.

| argument | |
| --- | --- |
| `repository_id` | the repository to mint the key for — its numeric `id`, as `add_repository` returns and `list_repositories` reports, not the `org/repo` handle |
| `name` | an optional label for the key; omit it to let SpecGuard use its default name |

The body comes back as SpecGuard serves it: an `api_key` block (`id`, `name`, `token`, `hint`,
`created_at`) — the same shape `add_repository` serves. `id` is the key's durable handle (the
token is reveal-once); every **other** key's id comes from `list_repository_api_keys`.

> ⚠️ **`api_key.token` is shown once and never again.** Nothing stores it and no endpoint can
> re-serve it. Hand it to the person you are working for in your reply. If it is dropped, the
> recovery is minting another key with this same tool — the platform has no regenerate — then
> revoking the orphaned one with `revoke_repository_api_key`.

Authorization is the `keys_manage` capability; a member without it is refused `403` with SpecGuard's
own sentence, verbatim.

It reads either of two credentials, whichever is set — and a different one from the `sgk_…` key
`get_repository_overview` uses. `SPECGUARD_AGENT_API_KEY` (`sga_…`) when it is set: the call then
reaches only the repositories granted onto that key at mint time, and only where the grant carries
`keys.manage` — a repository outside that set is answered `404` in SpecGuard's own words.
`SPECGUARD_USER_API_KEY` (`sgu_…`) when no agent key is set: the same **person** key `add_repository`
reads. With both set the **agent** key wins, so the mint answers inside the same set
`list_repositories` reports. Minting under the agent credential attributes the new key to the agent
key's **owner** (the server's `attributed_user` rule), so minted-key counts keep naming a person
rather than nobody.

### `list_repository_api_keys`

Lists a SpecGuard repository's CI API keys (`sgk_…` keys) — every row of the repository's own key
set, **live and revoked alike** (unlike the `sga_` listing `list_repository_agent_keys`, which
serves live rows only). One row per key: `id`, `name`, `token_hint`, `created_at`, `created_by`,
`last_used_at` — `null` meaning the key has never presented, the negative served rather than
omitted — `rotated_at`, and `rotated_and_unused` (live-scoped: the stranded shape
`get_repository_overview`'s `credential_health` reports, at row grain). `status` is
`live`/`revoked`, with `revoked_at` present only on revoked rows — a revoked `sgk_` row is not a
credential either, but the rotation's story lives in the population. A **revoked** row also
serves `last_refused_at`: `null` means offboarding took (nothing has presented the dead token
since the cut), a timestamp means the dead token is still arriving and that row's `token_hint`
is what to hunt in the secret stores that may still hold it. Live rows carry no
`last_refused_at` key.

| argument | |
| --- | --- |
| `repository_id` | the repository whose API keys to list — its numeric `id`, as `add_repository` returns and `list_repositories` reports, not the `org/repo` handle |

**This is the verify step of the mint-and-replace rotation.** After minting a replacement with
`create_repository_api_key` and deploying it, list here and check the replacement's
`last_used_at` BEFORE revoking the old key — `null` means the new token has not presented itself
yet, and cutting the old key then locks the repository's CI out until a human mints in a browser.
Neither the mint response (point-in-time by construction: it describes only the key it just made)
nor `credential_health` (findings only — a never-presented replacement is neither stranded nor
presented-revoked) can answer this.

**This is the id source for every row this session did not mint.** The mint response carries the
id of the key IT minted, in its `api_key` block (SPGD-993); a key minted in the web panel, or in
an earlier session, has no other bridge-learnable id — list here to name a `key_id` for
`revoke_repository_api_key`.

`token_hint` is a hint, never the token — the plaintext existed for exactly one response at mint
time and nothing persisted it. There is no regenerate: rotation is mint-and-replace
(`create_repository_api_key` → deploy → verify here → `revoke_repository_api_key`).

It reads either of two credentials, whichever is set — and a different one from the `sgk_…` key
`get_repository_overview` uses. `SPECGUARD_AGENT_API_KEY` (`sga_…`) when it is set: the call then
reaches only the repositories granted onto that key at mint time, and only where the grant carries
`keys.manage` — a repository outside that set is answered `404` in SpecGuard's own words.
`SPECGUARD_USER_API_KEY` (`sgu_…`) when no agent key is set: the same **person** key
`add_repository` reads. With both set the **agent** key wins, so the listing answers inside the
same set `list_repositories` reports.

### `revoke_repository_api_key`

Revokes one CI API key on a SpecGuard repository. The key stops authenticating immediately; every
**other** key on the repository keeps working, so CI keeps ingesting if it holds a surviving key.

| argument | |
| --- | --- |
| `repository_id` | the repository the key belongs to — its numeric `id`, as `add_repository` returns and `list_repositories` reports, not the `org/repo` handle |
| `key_id` | the id of the key to revoke — the mint response carries the id of the key it minted in its `api_key` block (`create_repository_api_key`, or `add_repository` for the registration key); every **other** key's id comes from `list_repository_api_keys` |

The `key_id` is scoped to `repository_id`: a key id belonging to a different repository is refused
`404`, never a cross-repository delete. Authorization is the `keys_manage` capability; a member
without it is refused `403` with SpecGuard's own sentence, verbatim.

**Key rotation is mint-then-revoke, in that order.** The platform has no regenerate, so mint a
replacement with `create_repository_api_key` and deploy it BEFORE revoking the old one — revoke
first and the repository's CI is locked out until a human mints a new key in a browser. A `204`
means the key is revoked.

It reads either of two credentials, whichever is set — and a different one from the `sgk_…` key
`get_repository_overview` uses. `SPECGUARD_AGENT_API_KEY` (`sga_…`) when it is set: the call then
reaches only the repositories granted onto that key at mint time, and only where the grant carries
`keys.manage` — a repository outside that set is answered `404` in SpecGuard's own words.
`SPECGUARD_USER_API_KEY` (`sgu_…`) when no agent key is set: the same **person** key `add_repository`
reads. With both set the **agent** key wins, so the revoke answers inside the same set
`list_repositories` reports.

### `list_repository_agent_keys`

Lists the agent keys (`sga_…` keys) covering a SpecGuard repository — the keys minted for people
or automation principals from the /account page, each covering one or more repositories. One row
per live key: `id`, `name`, `owner`, `token_hint`, `repository_count` (how many repositories the
key's grant covers — the blast radius), `permissions`, `created_at` and `last_used_at` — when the
key's token last authenticated, `null` meaning the key has never been presented, so the inventory
answers "is this live key still being used" before you cut anything.

| argument | |
| --- | --- |
| `repository_id` | the repository whose agent keys to list — its numeric `id`, as `add_repository` returns and `list_repositories` reports, not the `org/repo` handle |

**The `id` here is the only way to name an agent key on this bridge.** Agent keys have no mint
tool — the platform mints them web-only at /account, deliberately — so unlike `sgk_` keys there is
no create response to read an id from. List first, then `revoke_repository_agent_key`. Revoked keys
leave this listing (a retained revoked row is not a credential); whether a dead token is still
arriving is `list_repository_agent_keys_presented_revoked`'s question.

`token_hint` is a hint, never the token — the plaintext existed for exactly one response at mint
time and nothing persisted it. Authorization is the `keys_manage` capability; a caller without it
is refused `403` with SpecGuard's own sentence, verbatim.

It reads either of two credentials, whichever is set — and a different one from the `sgk_…` key
`get_repository_overview` uses. `SPECGUARD_AGENT_API_KEY` (`sga_…`) when it is set: the call then
reaches only the repositories granted onto that key at mint time, and only where the grant carries
`keys.manage` — a repository outside that set is answered `404` in SpecGuard's own words, so an
agent credential administers the agent keys covering its own set without a person's `sgu_` key.
`SPECGUARD_USER_API_KEY` (`sgu_…`) when no agent key is set: the same **person** key `add_repository`
reads. With both set the **agent** key wins, so the answer stays inside the same set
`list_repositories` reports.

### `revoke_repository_agent_key`

Revokes one agent key (`sga_…`) on a SpecGuard repository. **Irreversible, and the blast radius is
the key's whole stored set:** an agent key's grant (repository set + permission set) is stored once
at mint time, so this one call cuts the token on EVERY repository the key covers, not just
`repository_id`. Check `repository_count` on `list_repository_agent_keys` before cutting a
multi-repository key.

| argument | |
| --- | --- |
| `repository_id` | the repository whose key-administration scope the call is made under — its numeric `id`, as `add_repository` returns and `list_repositories` reports, not the `org/repo` handle. The cut itself lands on every repository in the key's stored set |
| `key_id` | the id of the agent key to revoke, as served by `list_repository_agent_keys` — the only id source, because agent keys have no mint tool on this bridge |

This endpoint answers **`200` with the disclosure body**, not the empty `204` the sgk_ sibling
`revoke_repository_api_key` serves: an `agent_key` block carrying `revoked_at`, `repository_count`
and the still-existing `repositories` names (plus `deleted_repository_count` when repositories
have been deleted since mint) — the API's substitute for the web confirm dialog, so the body
itself is the confirmation.

The `key_id` is scoped to `repository_id`: a key whose stored set does not cover this repository,
or one already revoked, is refused `404`, never a cross-repository cut. Authorization is the
`keys_manage` capability; a caller without it is refused `403` with SpecGuard's own sentence,
verbatim.

It reads either of two credentials, whichever is set — and a different one from the `sgk_…` key
`get_repository_overview` uses. `SPECGUARD_AGENT_API_KEY` (`sga_…`) when it is set: the call then
reaches only the repositories granted onto that key at mint time, and only where the grant carries
`keys.manage` — a repository outside that set is answered `404` in SpecGuard's own words, so an
agent credential can cut one of its own kind without a person's `sgu_` key.
`SPECGUARD_USER_API_KEY` (`sgu_…`) when no agent key is set: the same **person** key `add_repository`
reads. With both set the **agent** key wins, so the cut answers inside the same set
`list_repositories` reports.

### `list_repository_agent_keys_presented_revoked`

Lists REVOKED agent keys whose token is still arriving at the deployment after revocation — the
verify half of offboarding. Run it after `revoke_repository_agent_key` to learn whether the dead
token is still being presented somewhere: each row carries `id`, `name`, `owner`, `token_hint`
(hunt this in whatever secret stores may still hold the dead token, and update them),
`repository_count`, `revoked_at` and `last_refused_at`.

| argument | |
| --- | --- |
| `repository_id` | the repository to triage — its numeric `id`, as `add_repository` returns and `list_repositories` reports, not the `org/repo` handle |

Two readings, stated plainly: **`last_refused_at` is the last observed presentation** — a recency,
never a claim that something is presenting the token right now. And **an empty
`{"agent_keys": []}` is an answer** — nothing is still arriving — not an absence of tracking; read
it as the offboarding having fully taken.

`token_hint` is a hint, never the token — the plaintext existed for exactly one response at mint
time and nothing persisted it. Authorization is the `keys_manage` capability; a caller without it
is refused `403` with SpecGuard's own sentence, verbatim.

It reads either of two credentials, whichever is set — and a different one from the `sgk_…` key
`get_repository_overview` uses. `SPECGUARD_AGENT_API_KEY` (`sga_…`) when it is set: the call then
reaches only the repositories granted onto that key at mint time, and only where the grant carries
`keys.manage` — a repository outside that set is answered `404` in SpecGuard's own words, so an
agent credential can verify its own offboarding without a person's `sgu_` key.
`SPECGUARD_USER_API_KEY` (`sgu_…`) when no agent key is set: the same **person** key `add_repository`
reads. With both set the **agent** key wins, so the triage stays inside the same set
`list_repositories` reports.

### `list_repository_members`

Lists who has access to a SpecGuard repository: one row per member with their `id` (the
**membership id** `update_repository_member_permissions` and `remove_repository_member` name),
`handle`, `permissions`, `granted_by` (who last set them) and `created_at`, ordered by handle.

| argument | |
| --- | --- |
| `repository_id` | the repository — its numeric `id`, as `add_repository` returns and `list_repositories` reports, not the `org/repo` handle |

The list answers **memberships only** and never reports how many CI keys a member has minted
(`keys_minted`) — that is a separate `keys.manage` disclosure this endpoint deliberately withholds;
the API-keys tools are the surface for it. Each row's `id` is the membership id — the identifier
`update_repository_member_permissions` and `remove_repository_member` name; ids are scoped to the
repository, and a foreign id is refused `404`.

Authorization is the `members.manage` capability: a caller who is not a member is refused `404`
(the repository's existence stays hidden), and a member without `members.manage` is refused `403`
with SpecGuard's own sentence, verbatim.

It reads either of two credentials, whichever is set — and a different one from the `sgk_…` key
`get_repository_overview` uses. `SPECGUARD_AGENT_API_KEY` (`sga_…`) when it is set: the call then
reaches only the repositories granted onto that key at mint time, and only where the grant carries
`members.manage` — a repository outside that set is answered `404` in SpecGuard's own words.
`SPECGUARD_USER_API_KEY` (`sgu_…`) when no agent key is set: the same **person** key `add_repository`
reads. With both set the **agent** key wins, so the answer stays inside the same set
`list_repositories` reports.

### `add_repository_member`

Grants a person access to a SpecGuard repository by their GitHub handle, with an optional list of
permissions.

| argument | |
| --- | --- |
| `repository_id` | the repository to grant access to — its numeric `id`, as `add_repository` returns and `list_repositories` reports, not the `org/repo` handle |
| `handle` | the person's GitHub **login** — `octocat`, not a profile URL and not a display name; SpecGuard refuses both with its own sentence |
| `permissions` | an optional list of permission strings (`view`, `keys.manage`, `members.manage`, `repo.delete`); omit it to grant access with no additional permissions |

Each resolution failure — nobody has signed in as that handle yet, the account is archived, the
handle is ambiguous, the handle is not a login — arrives as a **distinguishable** 400 message
naming the exact next move. The grantor recorded on the membership is always the person behind
the credential — the one an `sgu_…` key speaks for, or the **owner** of the `sga_…` agent key.
The server stamps it; no argument can name a different one.

On success (`201`) the response carries a `member` block whose **`id` is the membership id**
(alongside `handle`, `permissions`, `granted_by`, `created_at`) — the identifier
`update_repository_member_permissions` and `remove_repository_member` name. Authorization is the
`members.manage` capability; a member without it is refused `403` with SpecGuard's own sentence,
verbatim.

It reads either of two credentials, whichever is set — and a different one from the `sgk_…` key
`get_repository_overview` uses. `SPECGUARD_AGENT_API_KEY` (`sga_…`) when it is set: the call then
reaches only the repositories granted onto that key at mint time, and only where the grant carries
`members.manage` — a repository outside that set is answered `404` in SpecGuard's own words.
`SPECGUARD_USER_API_KEY` (`sgu_…`) when no agent key is set: the same **person** key `add_repository`
reads. With both set the **agent** key wins, so the grant answers inside the same set
`list_repositories` reports. Under the agent credential the grant is bounded twice: the key cannot
grant a permission it does not itself hold — SpecGuard answers `400` naming the over-reach — and
the membership is recorded against the key's **owner**.

### `update_repository_member_permissions`

Replaces one member's permission set on a SpecGuard repository.

| argument | |
| --- | --- |
| `repository_id` | the repository the membership belongs to — its numeric `id`, as `add_repository` returns and `list_repositories` reports, not the `org/repo` handle |
| `member_id` | the id of the **membership** row to edit — not a user id, not the handle. Scoped to `repository_id`: a foreign membership id is refused `404` |
| `permissions` | the member's **complete new** permission set (`view`, `keys.manage`, `members.manage`, `repo.delete`) — it replaces what they hold today, it is not merged |

**`permissions` replaces the whole set** — name every permission the member should end with.
Values are validated by SpecGuard; an unknown value is refused with its own sentence, verbatim.

**The membership id:** `member_id` comes straight off the API — every member response serves it
as `id`: the rows `list_repository_members` returns, the `add_repository_member` 201 body, and
this tool's own response. The server serves that `id` as a JSON number (the column is a bigint),
while `member_id` is a string argument refused by name before any request is made — pass a
numeric-looking id such as `7` as a string, not as a number. There is no name-based lookup, and
ids are scoped to the repository (a foreign id is refused `404`). Authorization is the
`members.manage` capability; a member without it is refused `403` with SpecGuard's own sentence,
verbatim.

It reads either of two credentials, whichever is set — and a different one from the `sgk_…` key
`get_repository_overview` uses. `SPECGUARD_AGENT_API_KEY` (`sga_…`) when it is set: the call then
reaches only the repositories granted onto that key at mint time, and only where the grant carries
`members.manage` — a repository outside that set is answered `404` in SpecGuard's own words.
`SPECGUARD_USER_API_KEY` (`sgu_…`) when no agent key is set: the same **person** key `add_repository`
reads. With both set the **agent** key wins, so the edit answers inside the same set
`list_repositories` reports. Under the agent credential the replacement set is bounded twice: the
key cannot grant a permission it does not itself hold — SpecGuard answers `400` naming the
over-reach — and the re-stamped grantor is the key's **owner**.

### `remove_repository_member`

Revokes one person's access to a SpecGuard repository by removing their membership. A `204` means
it is revoked.

| argument | |
| --- | --- |
| `repository_id` | the repository the membership belongs to — its numeric `id`, as `add_repository` returns and `list_repositories` reports, not the `org/repo` handle |
| `member_id` | the id of the **membership** row to revoke — not a user id, not the handle. Scoped to `repository_id`: a foreign membership id is refused `404` |

**Revoking does NOT revoke that member's minted CI keys.** Any `sgk_…` keys they created on the
repository keep authenticating by design; the lever for those is the API-keys surface
(`revoke_repository_api_key`), not this one. Self-revocation is permitted — after it, the caller's
next request to the member routes answers `404`, not `403`. The repository owner's membership
cannot be removed at all. The same source as the edit tool serves `member_id`: it is the `id`
field on the member rows `list_repository_members` and `add_repository_member` return — a JSON
number on the wire, so pass it as a string, not as a number (see the edit tool above).
Authorization is the `members.manage` capability; a member without it is refused `403` with
SpecGuard's own sentence, verbatim.

It reads either of two credentials, whichever is set — and a different one from the `sgk_…` key
`get_repository_overview` uses. `SPECGUARD_AGENT_API_KEY` (`sga_…`) when it is set: the call then
reaches only the repositories granted onto that key at mint time, and only where the grant carries
`members.manage` — a repository outside that set is answered `404` in SpecGuard's own words.
`SPECGUARD_USER_API_KEY` (`sgu_…`) when no agent key is set: the same **person** key `add_repository`
reads. With both set the **agent** key wins, so the revoke answers inside the same set
`list_repositories` reports.

### `rename_repository`

Renames a SpecGuard repository — changes the `org/repo` GitHub full name it is registered under —
keeping every API key, run and intent on it.

| argument | |
| --- | --- |
| `repository_id` | the id of the repository to rename — its numeric `id`, as `add_repository` returns and `list_repositories` reports, not the `org/repo` handle |
| `github_full_name` | the new GitHub full name, `org/repo` — sent top-level in the body, not nested under `repository` |

This is the alternative to the remove-and-re-register path: that one **deletes every API key, run
and intent** on the repository (`remove_repository`), while this keeps all of them. **Authorization
is owner-only** — deliberately narrower than removal: a member granted `repo.delete` may destroy
the repository but may not rename it. The owner check also redeems a browser-issued grant valid for
7 days; a nil or stale one is refused `403` with SpecGuard's own sentence naming the fix
(re-grant via the browser). A name another repository already holds is refused `400` — `taken`, not
`409` — verbatim. The `200` body is `{repository: …}` — the serializer `list_repositories` uses, but a
rename receipt, not a list entry: it carries the identity fields and `delivery_health`, and
`latest_run` is absent — an absent key, never a null — deliberately, so the receipt claims nothing
about the repository's CI state. `list_repositories` is where a run summary comes from.

It reads `SPECGUARD_USER_API_KEY` (`sgu_…`) — the same **person** key `add_repository` reads, and
a different one from the `sgk_…` key `get_repository_overview` uses; `list_repositories` also
accepts that key but does not require it (its agent key wins when both are set).

## How it works

```
agent  ⇄  specguard-mcp  ⇄  SpecGuard         (HTTP, the same API the dashboard uses)
        (stdio/MCP)      ⇄  specguard-lint    (subprocess, in your project)
```

The bridge is a **thin client**: it shells out and it calls the API, and it re-implements neither.
It carries no knowledge of the OpenTestIntent schema, holds no copy of the linter's rules, and
reshapes no response — every tool returns the shape of the capability it wraps, so a field added
upstream reaches the agent without a release here.

Authorization and project scoping are enforced by SpecGuard, never by this bridge, using keys you
issue there — the same `sgk_…` keys CI uses to ingest runs, an `sgu_…` user key for the tools that
answer to a person rather than to a repository, and an `sga_…` agent key for the tools that answer
for an automated agent — it speaks for nobody, and its reach is the repository set granted onto it
at mint time. Which of the three a request may carry is SpecGuard's decision and it is taken from
the token's prefix, before any credential is looked up, so the bridge cannot widen any key's reach:
it forwards the key the tool's own variable holds and reports what came back. It adds no
credentials of its own and stores nothing.

No argument ever reaches a shell: subprocesses are spawned with an argument list, so a path from a
model is a path that does not exist rather than a command.

## Adding a tool

The toolset fills in as more of SpecGuard lands. Adding one is two mechanical edits:

1. a new file under `src/tools/` that default-exports a `ToolDefinition`;
2. one entry appended to the array in `src/tools/index.ts`.

There is no third — no third *wiring* edit, at least: every tool also earns a `### ` section above,
and every argument earns a row in that section's table, since this README ships as the package's
published documentation, and `test/readme.test.ts` derives that obligation from the registry so a
missing section or an undocumented parameter fails the suite — as does a response key the tool's
description names that the section never mentions. A tool that genuinely takes no
arguments still earns the section, and is named in that file's `ARGUMENT_LESS_TOOLS` — a
deliberate line to add, rather than a floor relaxed for everyone. The set tracks the property,
not arrival order: an `inputSchema` that declares no `properties`, the shape
`registrable_repositories` has.
`src/server.ts` iterates that array and contains no per-tool code — no `switch`, no hard-coded name — and everything a tool
touches the world with (config, subprocesses, `fetch`) is injected, so a new tool is testable
without a live deployment for free. The property tests in `test/tools/registry.test.ts` run over
whatever the registry holds, so a tool added later is checked by tests written today.

**Only wrap capabilities that exist.** A tool in `tools/list` is a promise an agent acts on; one that
discovers cleanly and fails on use is worse than one that is absent, because the agent has already
committed to a plan by the time it finds out. Anything whose backing endpoint has not shipped stays
out — `/check-intent` is in that state today: the platform's `config/routes.rb` carries it as a
comment and nothing more, so there is nothing for a tool to wrap. Duplicate clustering once sat under
this same rule; the endpoint it needed shipped, and `near_duplicate_clusters` moved inside. What
moved was the platform, not the bar.

Transport is chosen in `bin/specguard-mcp.ts` and nowhere else — stdio today, so an HTTP/SSE
entrypoint is a sibling of that file rather than a change to the server.

## Development

```bash
npm run build      # compile to dist/
npm test           # typecheck, then run the suite
npm run typecheck  # types only
```

## Related repositories

- [`specguard`](https://github.com/yatfa-ai/specguard) — the platform: ingest API + Hotwire dashboard
- [`specguard-ruby`](https://github.com/yatfa-ai/specguard-ruby) — Ruby client (RSpec formatter, Minitest reporter + `@intent` linter)
- [`open-test-intent`](https://github.com/yatfa-ai/open-test-intent) — the annotation protocol SpecGuard consumes

## License

ISC

---

<p align="center">
  <a href="https://yatfa.com">
    <img src="assets/built-with-yatfa.png" alt="Built with yatfa — a team of AI agents that plans, builds &amp; ships software." width="100%">
  </a>
</p>
