* feat(#3274): make reviewer-lane timeout configurable via timeoutConfigKey Add an optional `timeoutConfigKey` field to the reviewer lane descriptor, resolved in `resolveLanePlan` at invocation time and falling back to the frozen `timeoutFloorMs` when unset or invalid, in the same spirit as the existing `promptBudgetKey`/`modelConfigKey` fields. All 12 shipped lanes declare `review.timeouts.<slug>` on both surfaces (the descriptor and their capability.json manifest), validated by capability-validator.cjs. For the antigravity lane, the native `agy --print-timeout` flag — previously a second hardcoded literal (`540s`) independent of the outer cap — is now derived from the same resolved outer timeout in `antigravityArgv`, preserving the existing 60-second buffer relationship (ADR-2782 D6: the outer bound is declared data, the inner one is handler-owned). The antigravity default timeoutFloorMs stays at 600s per the maintainer's disposition; users raise it through the new config key instead. * docs(#3274): document review.timeouts.* and extract resolveTimeoutMs helper Address code-review findings on the timeoutConfigKey change: extract the inline timeout-resolution logic into a named, exported, directly-tested resolveTimeoutMs helper (matching the file's existing configString/ normalizeHost convention); document the new review.timeouts.* federated config keys in docs/CONFIGURATION.md, docs/reference/capability-manifest.md, and docs/how-to/ship-a-reviewer-lane.md; add the changeset fragment. * fix(#3274): resolve native antigravity timeout in resolveLanePlan, not the runner gsd-test caught two design mistakes in the prior commits: 1. SpawnPlan.argv is documented and tested as fully resolved by resolveLanePlan (model/effort/output/prompt already folded in) — leaving the antigravity '{{nativeTimeout}}' marker unresolved until the runner's antigravityArgv violated that contract and broke tests that read plan.argv directly (tests/antigravity-reviewer.test.cjs, tests/review-default-reviewers-workflow.test.cjs). Fix: '{{nativeTimeout}}' is now a fifth ARGV_PLACEHOLDER member, resolved by resolveLanePlan itself via the new nativeTimeoutToken() helper, exactly like the other four. antigravityArgv reverts to its pre-#3274 four-argument form. Also missed updating capabilities/antigravity/capability.json's invoke.args to match the descriptor, which broke the manifest/descriptor parity test. 2. tests/reviewer-config-federation.test.cjs enforces a deliberate, narrow invariant (#3691 narrows #2797): qwen, cursor, and coderabbit — the three lanes with neither a model flag nor a host — may own no config key beyond their own prompt-budget key. Adding review.timeouts.<slug> to all 12 lanes violated it. Fix: those three keep timeoutConfigKey: null and own no review.timeouts.* key, matching their existing modelConfigKey: null. The other 9 lanes are unaffected. * chore(#3274): backfill changeset PR number (pr:0 -> 4083) --------- Co-authored-by: sim <sim@local>
294 lines
17 KiB
Markdown
294 lines
17 KiB
Markdown
# How to ship a reviewer lane in your capability
|
||
|
||
**Goal:** Declare a *reviewer lane* in a capability manifest so `/gsd-review` discovers your external review CLI or model endpoint, offers a flag for it, invokes it, and renders its output into `REVIEWS.md` — without patching GSD core.
|
||
|
||
**Prerequisites:** You already have a capability (`capability.json`), or you are creating one. The reviewer tool is installed and works from your shell. GSD 1.9.0 or later.
|
||
|
||
Before 1.9.0 a reviewer was a core patch: a hardcoded roster entry, a hand-authored bash leg in `review.md`, a hardcoded output heading, and central config keys. From 1.9.0 a lane is manifest data, so shipping a reviewer is shipping a capability. See [ADR-2782](../adr/2782-reviewer-lane-capability-surface.md) for the decision record.
|
||
|
||
---
|
||
|
||
## Decide which shape your lane takes
|
||
|
||
A `reviewer` body is admissible on two roles. Pick by whether GSD installs *into* your tool.
|
||
|
||
| Your situation | Use | Why |
|
||
|---|---|---|
|
||
| Your capability is already a runtime GSD installs into (it has a `runtime` body) and that same CLI can also review | Keep `role: "runtime"`, add a `reviewer` body | One manifest stays one manifest — this is how `codex`, `cursor`, and `antigravity` ship |
|
||
| Your tool only reviews — GSD never installs commands, agents, or skills into it | `role: "reviewer"` | The honest description: a lane with no install surface, like `gemini`, `coderabbit`, and `ollama` |
|
||
| Your capability adds planning steps, gates, or contributions | `role: "feature"` — and a separate lane capability | A feature manifest may not carry a `reviewer` body; the validator rejects it |
|
||
|
||
A `role: "reviewer"` capability **must** carry a `reviewer` body, **must not** carry a `runtime` body, and **must not** carry any feature-only field — `skills`, `agents`, `steps`, `contributions`, `gates`, `hooks`, or `activationKey`. A lane owns no artifacts and wires no loop extension point.
|
||
|
||
---
|
||
|
||
## Declare a spawned-CLI lane
|
||
|
||
Most lanes are `transport: "spawn"` — GSD runs a binary and reads its output. Add a `reviewer` block to your manifest:
|
||
|
||
```json
|
||
{
|
||
"id": "acme-review",
|
||
"role": "reviewer",
|
||
"version": "1.0.0",
|
||
"title": "Acme Review CLI",
|
||
"description": "Acme CLI — cross-AI /gsd-review reviewer lane only; not a GSD install target.",
|
||
"tier": "full",
|
||
"requires": [],
|
||
"engines": { "gsd": ">=1.9.0" },
|
||
|
||
"reviewer": {
|
||
"slug": "acme",
|
||
"flags": ["--acme"],
|
||
"transport": "spawn",
|
||
"probe": { "kind": "command-exists", "binary": "acme" },
|
||
"invoke": {
|
||
"binary": "acme",
|
||
"args": ["review", "{{model}}", "-p", "-"],
|
||
"promptChannel": "stdin",
|
||
"outputChannel": "stdout",
|
||
"modelArg": "--model",
|
||
"effortChannel": "none"
|
||
},
|
||
"timeoutFloorMs": 900000,
|
||
"timeoutConfigKey": "review.timeouts.acme",
|
||
"emptyOutput": "stub-with-stderr",
|
||
"reviewsSection": "Acme",
|
||
"evidenceClass": "source-grounded",
|
||
"requiresBinaries": [],
|
||
"promptBudgetKey": null,
|
||
"modelConfigKey": "review.models.acme",
|
||
"handler": null
|
||
},
|
||
|
||
"config": {
|
||
"review.models.acme": {
|
||
"type": "string",
|
||
"default": "",
|
||
"description": "Model passed to the Acme reviewer lane."
|
||
},
|
||
"review.timeouts.acme": {
|
||
"type": "number",
|
||
"default": -1,
|
||
"description": "Outer wall-clock timeout override (seconds) for the Acme reviewer lane."
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
Four fields decide whether the lane works at all, so get these right first:
|
||
|
||
- **`invoke.args`** carries the `{{model}}`, `{{prompt}}`, `{{effort}}`, and `{{output}}` placeholders. GSD substitutes them; anything else is passed through literally.
|
||
- **`promptChannel`** says how the plan text reaches the tool — `stdin` when it reads a pipe, `argv` or `argv-file-ref` when it takes the prompt path as an argument, `none` when the tool reads the working tree itself (as CodeRabbit does).
|
||
- **`outputChannel`** is `stdout`, or `file-arg` when the tool writes to a path you name (then also declare `invoke.outputArg`, as `codex` does with `-o`).
|
||
- **`timeoutFloorMs`** is the measured wall-clock floor for *your* tool. Lane divergence here is expected and correct — the descriptor exists to declare divergence in one place, not to impose one number on every lane.
|
||
|
||
`reviewsSection` is the heading your findings render under in `REVIEWS.md`. It must be unique across every installed lane; two lanes sharing a heading would silently merge their output into apparent consensus that never happened.
|
||
|
||
For the full field table — every type, enum member, and default — see [Capability manifest § Reviewer body](../reference/capability-manifest.md#reviewer-body-role-reviewer-or-on-any-role).
|
||
|
||
---
|
||
|
||
## Declare an OpenAI-compatible HTTP lane instead
|
||
|
||
If your reviewer is a served model endpoint rather than a CLI, use `transport: "openai-http"`. The `invoke` block takes a different shape — a destination, not a binary:
|
||
|
||
```json
|
||
"reviewer": {
|
||
"slug": "acme_local",
|
||
"flags": ["--acme-local"],
|
||
"transport": "openai-http",
|
||
"probe": {
|
||
"kind": "http-reachable",
|
||
"hostConfigKey": "review.acme_host",
|
||
"path": "/v1/models",
|
||
"timeoutMs": 2000
|
||
},
|
||
"invoke": {
|
||
"hostConfigKey": "review.acme_host",
|
||
"defaultHost": "http://localhost:8080",
|
||
"path": "/v1/chat/completions",
|
||
"modelDiscovery": "first-from-models-endpoint",
|
||
"fallbackModel": "acme-7b",
|
||
"effortChannel": "none"
|
||
},
|
||
"timeoutFloorMs": 120000,
|
||
"timeoutConfigKey": "review.timeouts.acme_local",
|
||
"emptyOutput": "stub-with-stderr",
|
||
"reviewsSection": "Acme Local",
|
||
"evidenceClass": "source-grounded",
|
||
"requiresBinaries": [],
|
||
"promptBudgetKey": "review.max_prompt_tokens_per_reviewer.acme_local",
|
||
"modelConfigKey": "review.models.acme_local",
|
||
"handler": "openai-compatible"
|
||
}
|
||
```
|
||
|
||
`handler: "openai-compatible"` is what gives you model discovery against `/v1/models`, the request and response shape, and the served-model mismatch warning. Declare the matching `review.acme_host` key in your `config` block alongside the model key.
|
||
|
||
**Every probe is bounded.** An unbounded `--help | grep` probe is a named defect in this repo. `http-reachable` requires `timeoutMs`; so does `command-capability`, the probe kind you use when a bare binary name is ambiguous.
|
||
|
||
---
|
||
|
||
## Own your lane's config keys
|
||
|
||
Declare the lane's keys in your own manifest `config` block, never in the central schema. A key present in both is a build failure, not a warning — federated ownership is exclusive.
|
||
|
||
Name `modelConfigKey`, `promptBudgetKey`, and `timeoutConfigKey` to match keys you actually declare. A lane pointing at a key nobody owns resolves to nothing, which reads to the user as "my model override is being ignored."
|
||
|
||
Users then set them the ordinary way, in `.planning/config.json`:
|
||
|
||
```json
|
||
{ "review": { "models": { "acme": "acme-large" } } }
|
||
```
|
||
|
||
---
|
||
|
||
## Build and install
|
||
|
||
If your capability lives in the GSD repo, regenerate the committed registry and check for drift:
|
||
|
||
```bash
|
||
npm run gen:capability-registry
|
||
npm run lint:generated-sync
|
||
```
|
||
|
||
If you are shipping out-of-tree, package and install it like any other capability:
|
||
|
||
```bash
|
||
gsd capability install <url>
|
||
```
|
||
|
||
Uniqueness is checked across the merged first-party ∪ overlay set — a duplicate `slug`, a duplicate entry in `flags`, or a duplicate `reviewsSection` collides. **Expect that collision to be quiet.** `gsd capability install` does not run the cross-capability check; it runs at *load* time, and a colliding overlay is dropped from the active set with a warning rather than failing the install. First-party always wins.
|
||
|
||
That failure mode is worth internalizing before you debug it: the install command reports success, and your lane simply never appears. If a lane you just installed is missing from `gsd-tools review-lane sections`, suspect a name collision before you suspect the probe. Malformed values inside the body — a `slug` outside `^[a-z0-9][a-z0-9_-]*$`, an enum member that does not exist, an `outputArg` without `outputChannel: "file-arg"` — are ordinary validation errors and are reported directly.
|
||
|
||
Two naming rules are easy to conflate, so keep them apart. Your `slug` may not be `__proto__`, `constructor`, or `prototype` — a prototype-pollution guard, not a namespace policy; any other grammatical slug is yours, including one starting `gsd-`. Your capability **`id`**, separately, may not begin with `gsd-`, `gsd-core-`, or `anthropic-`; those prefixes are reserved so nothing can impersonate a first-party capability.
|
||
|
||
An *unknown* field inside your `reviewer` body behaves differently: it is a non-fatal warning on stderr, never a build failure. A manifest built against a newer GSD degrades visibly instead of crashing.
|
||
|
||
### Publish it so people can find it
|
||
|
||
From GSD 1.9.1, a lane has its own discoverability catalog: the [Reviewer Lane Registry](../registries/reviewer-registry.md). Listing is a documentation PR — append one entry to `docs/registries/reviewers.json`, regenerate, open a PR. Register once; your GitHub Releases are the update channel from then on.
|
||
|
||
Follow [List your reviewer lane in the registry](list-your-reviewer-lane.md) for the task flow.
|
||
|
||
The Reviewer Lane Registry is for lanes that are **not install targets in their own right**. If your `reviewer` body rides on a `role: "runtime"` capability, list that capability under whichever catalog matches its primary install shape instead — one entry, not two.
|
||
|
||
---
|
||
|
||
## Verify the lane resolves
|
||
|
||
Check that GSD sees your lane before you run a real review:
|
||
|
||
```bash
|
||
gsd-tools review-lane sections
|
||
gsd-tools review-lane flags
|
||
```
|
||
|
||
`sections` lists every `slug` with the heading it renders under; `flags` lists every selector flag. Your lane appears in both, or it is not installed.
|
||
|
||
Then dry-check the invocation plan for your lane alone:
|
||
|
||
```bash
|
||
gsd-tools review-lane plan --selected acme
|
||
```
|
||
|
||
A resolvable lane returns `"ok": true` with its `section`, `transport`, and prompt path. Once that is green, run it for real against a planned phase:
|
||
|
||
```bash
|
||
/gsd-review --phase 3 --acme
|
||
```
|
||
|
||
If the lane is absent from `--all`, the probe is the usual culprit: `command-exists` fails silently when the binary is not on `PATH` in the environment GSD runs in.
|
||
|
||
---
|
||
|
||
## Know what your users are consenting to
|
||
|
||
A reviewer lane is a **fourth executable-surface disclosure class**, alongside hooks, command modules, and MCP servers — and it is the only one that *receives* data. Your lane is piped plan text, requirements, research findings, and `CONTEXT.md` decisions. Install-time disclosure says so plainly:
|
||
|
||
```text
|
||
reviewer lane (1): an external reviewer receives plan/review data on every run
|
||
- acme -> acme review --model acme-large -p -
|
||
sends: plan text, requirements, research findings, CONTEXT.md decisions
|
||
```
|
||
|
||
Be clear-eyed about what that buys, because your users are trusting your judgment as much as the mechanism. ADR-2782 D5 says it plainly: disclosure and host pinning make the channel *"visible, pinned, and revocable — it does not make it safe"*, and consent-at-install is a **weaker gate for a standing egress channel than for a hook**. A user consents once; your lane thereafter receives every plan on every review run. A per-run prompt was considered and rejected as consent fatigue. Design your lane as if that single consent is the only one you will ever get, because it is.
|
||
|
||
Three consequences you should design for:
|
||
|
||
- **Your `args` are signature-bound, not just your binary.** Changing `binary`, `args`, `hostConfigKey`, `promptChannel`, or `handler` in a new version re-triggers consent on update. Changing `reviewsSection` or `timeoutFloorMs` does not — a cosmetic prompt is how users learn to click through.
|
||
- **An `openai-http` lane binds the *resolved host*, not just the config key.** GSD re-resolves `hostConfigKey` before every invocation and blocks the lane if the destination changed, rather than silently sending plans somewhere new. Users see the lane refuse and must re-consent. Point `defaultHost` at the address you actually mean.
|
||
- **What the user saw is only tamper-evident because the bundle is pinned.** The disclosure is trustworthy because the downloaded capability is integrity-checked and its hash recorded at install; a later change to your `args` or `hostConfigKey` shows up as a changed signature rather than sliding in quietly. Keep `engines.gsd` honest for the same reason — it is a hard gate, so a lane declaring a range it does not actually work on is blocked at install and skipped at load rather than failing confusingly at review time.
|
||
|
||
For the reasoning behind consent-plus-integrity rather than a sandbox, see [The capability trust model](../explanation/capability-trust-model.md).
|
||
|
||
---
|
||
|
||
## Migrate off the removed `reviewerCli` flag
|
||
|
||
Before 1.9.0, a runtime capability declared itself a reviewer with a boolean in the open host-behaviors bag:
|
||
|
||
```json
|
||
"runtime": { "hostBehaviors": { "reviewerCli": true } }
|
||
```
|
||
|
||
That flag carried no invocation data — it only added the capability id to the roster, leaving the probe, argv shape, timeout, and output policy hardcoded in GSD core. It was superseded by the `reviewer` body in 1.9.0, kept working for one release as a derived alias, and **was removed in the release after that**.
|
||
|
||
**Symptom.** Your capability installs and validates exactly as before, but `/gsd-review` no longer offers your flag and your lane never runs. On a registry build or a capability install you will see:
|
||
|
||
```
|
||
⚠ capability "your-cap" runtime.hostBehaviors.reviewerCli was removed (ADR-2782 D9)
|
||
— ignored, and it contributes no reviewer lane. Declare a `reviewer` body instead;
|
||
see docs/how-to/ship-a-reviewer-lane.md
|
||
```
|
||
|
||
**Fix.** Delete the flag and declare a `reviewer` body, following [Declare a spawned-CLI lane](#declare-a-spawned-cli-lane) above. Your `reviewer.slug` should be whatever the flag used to contribute — your **capability id** — so existing `review.default_reviewers` entries and `--<slug>` flags keep working:
|
||
|
||
```json
|
||
{
|
||
"id": "your-cap",
|
||
"role": "runtime",
|
||
"runtime": { "hostBehaviors": { } },
|
||
"reviewer": {
|
||
"slug": "your-cap",
|
||
"flags": ["--your-cap"]
|
||
}
|
||
}
|
||
```
|
||
|
||
Then rebuild and re-verify with the steps in [Build and install](#build-and-install) and [Verify the lane resolves](#verify-the-lane-resolves).
|
||
|
||
Two things the migration buys you beyond restoring the lane: your invocation shape becomes declared data rather than something GSD core has to know about, and your lane can own its own config keys (see [Own your lane's config keys](#own-your-lanes-config-keys)).
|
||
|
||
> **Nothing else about your capability changes.** A manifest still carrying the removed key parses, validates, and installs exactly as before — it simply contributes no lane, and says so. The key is inert, not fatal.
|
||
|
||
---
|
||
|
||
## Conditionals: when the vocabulary does not fit your tool
|
||
|
||
Third-party lanes are **data-only**. `handler` is a closed enum of first-party names (`antigravity`, `openai-compatible`, `opencode`, or `null`) — you may reference an existing member, but you cannot ship your own handler module.
|
||
|
||
| Your tool | What to do |
|
||
|---|---|
|
||
| Runs one command, reads a prompt, writes a review | Declare it — the vocabulary covers this, which is eight of the twelve shipped lanes |
|
||
| Is an OpenAI-compatible endpoint | `transport: "openai-http"` with `handler: "openai-compatible"` |
|
||
| Needs a stateful setup turn before it can review (Plandex-style `new` then `review`) | Not expressible today — the descriptor describes one invocation. File an issue naming the primitive |
|
||
| Edits files or commits by default (Aider-style) | Not expressible today — there is no way to declare a read-only invocation posture, and the prompt asking politely is not a guarantee. File an issue naming the primitive |
|
||
| Needs genuinely imperative behavior for an upstream bug | File an issue. Named handlers are added first-party after review, the same path that widened the vocabulary to add `openai-http` |
|
||
|
||
Filing the issue is the supported route, not a workaround. The `openai-http` transport exists because three real lanes did not fit and the vocabulary widened on that evidence.
|
||
|
||
---
|
||
|
||
## Related
|
||
|
||
- [Capability manifest](../reference/capability-manifest.md) — the full `reviewer` body field table and validation rules
|
||
- [Capability manifest → `hostBehaviors`](../reference/capability-manifest.md#hostbehaviors) — why the bag is unvalidated, and the one removal notice inside it
|
||
- [Set up cross-AI review](set-up-cross-ai-review.md) — the user-facing side: choosing, configuring, and running reviewers
|
||
- [Develop a Capability for GSD 1.5+](develop-a-capability.md) — manifests, registry generation, and federated config
|
||
- [Publish a capability](publish-a-capability.md) — versioning, `engines.gsd`, and distribution
|
||
- [The capability trust model](../explanation/capability-trust-model.md) — disclosure, consent, and integrity
|
||
- [ADR-2782](../adr/2782-reviewer-lane-capability-surface.md) — why the lane became a capability surface, and what it deliberately excludes
|