ADR-017: Canonical hook/MCP spec → per-surface generator for non-CLI surfaces
- Status: Accepted
- Date: 2026-06-21
- Implements: epic #359 (workstream B — non-CLI / GUI agent surfaces)
- Relates to: ADR-001 (deterministic scaffolder, no LLM calls), ADR-007
(enforcement layers — git/CI are the real boundary), ADR-010 (plugin
dual-ship +
tools/sync_plugin.pycanonical→derived sync), PI-137 (harness-agnostic--agentsoverlays) - Grounded in:
docs/development/non-cli-surface-matrix.md(spike #365)
Update — PI-386 (2026-06-22): the Gemini CLI overlay referenced in the Context below (
.gemini-extension/) was removed. Google sunset Gemini CLI's free/Pro/Ultra tiers on 2026-06-18; Antigravity (agy) is the Google target and reads the same.agents/tree, so it became a self-sufficient surface (an.agents/skills/layer +.agents/hooks.json+.agents/mcp_config.json). The generator pattern in this ADR is unchanged.
Context
The scaffolded .agents/ output is consumed losslessly only by the Claude Code
CLI (and, per the #365 spike, the Anthropic Claude Code VS Code extension, which
shares the CLI's config). The other agent surfaces our users run — Cursor, the
OpenAI Codex IDE, Google Antigravity, GitHub Copilot agent mode — read different
files, with different schemas. Today project-init reaches them only incidentally:
it emits .codex/hooks.json + .agents/skills/ (Codex) and (until PI-386) a
.gemini-extension/ overlay for the now-removed Gemini CLI, but nothing for Cursor
or Antigravity, and no MCP config in any runnable form. The user's principle for this epic is agnostic = visible to all
surfaces: one canonical definition should reach every surface, and adding a
surface should be a one-line change.
The #365 spike validated the surface behaviors and surfaced the shape of the
problem (full matrix in docs/development/non-cli-surface-matrix.md):
- Skills are nearly a free, cross-surface standard.
SKILL.mdin.agents/skills/and.agents/skills/is already read by Claude (ext), Copilot, Cursor, Codex, and Antigravity. No per-surface skill rendering needed. - Hooks do not translate cleanly. Three incompatible dialects:
Claude-family PascalCase in
.agents/settings.json/.codex/hooks.json(Claude ext, Copilot, Codex); Cursor's own camelCase vocabulary in.cursor/hooks.json(beforeShellExecution,beforeSubmitPrompt, …); Antigravity'ssafety-gate/PreToolUsemodel in.agents/hooks.json. Matcher fidelity also varies — Copilot parses but ignores matchers, so aBash-scoped guard fires on every tool. - MCP has three target schemas:
mcpServers(Claude root.mcp.json,.cursor/mcp.json, Antigravity~/.gemini/config/mcp_config.json),servers(VS Code.vscode/mcp.json), and TOML[mcp_servers.*](Codex.codex/config.toml). MCP is not currently scaffolded as runnable config. - Instructions are covered —
AGENTS.md(redirecting to CLAUDE.md) is read by every surface.
The scaffolder must stay deterministic and call no LLM (ADR-001).
Decision
1. One canonical spec → per-surface generator (Option 2)
A single source of truth for hooks and for MCP, generated outward to every
surface's native file — the exact pattern tools/sync_plugin.py already uses for
skills (canonical templates/fallback → Codex/Gemini/plugin copies, CI-enforced
against drift). The existing fallback hooks remain the hook source; a new
canonical MCP spec is introduced (MCP is not scaffolded today, so this
defines it).
Rejected alternatives (see below): hand-maintained per-surface overlays (drift), and a full data-driven plugin registry (too much machinery for the current surface count — escalate later if it grows).
2. A small surface table (code, not a registry)
Per-surface differences live in one short table in the generator. Each entry declares:
| Field | Examples |
|---|---|
| hooks file | .agents/settings.json · .cursor/hooks.json · .codex/hooks.json · .agents/hooks.json |
| hook event mapping | PascalCase identity (Claude/Codex) · PascalCase→camelCase (Cursor) · subset→safety-gate (Antigravity) |
| matcher fidelity | honored · advisory (Copilot/where matchers are dropped) |
| MCP file + key | .mcp.json/mcpServers · .vscode/mcp.json/servers · .cursor/mcp.json/mcpServers · .codex/config.toml/[mcp_servers.*] |
| skills path | .agents/skills + .agents/skills (shared; not per-surface) |
Adding a surface = appending one entry.
3. Scope — what each surface needs (from the spike)
| Surface | Emission |
|---|---|
| Claude Code CLI / Desktop / VS Code ext | none for hooks/skills (native .agents/); root .mcp.json only when MCPs are configured (shareable project scope) |
| VS Code Copilot agent mode | reads .agents/ natively; .vscode/mcp.json (servers) when MCPs configured; matchers advisory |
| Cursor | .cursor/hooks.json (camelCase translation) + .cursor/mcp.json; skills already via .agents/skills |
| Codex IDE / CLI | schema-correct emission (.codex/hooks.json + .agents/skills); add .codex/config.toml [mcp_servers.*] when MCPs configured. NB: codex 0.138.0 may not fire project-scoped hooks without an enable step (advisory; git + CI are the boundary, ADR-007) |
| Antigravity | .agents/hooks.json (PreToolUse only, confirmed) + MCP variant; skills via .agents/skills |
| (future) | one surface-table entry |
4. Selection & opt-in
Which surfaces to emit is opt-in (wizard question + flag) and recorded in the
existing .agents/config.yaml scaffold-record, which upgrade already reads back
(so re-renders stay consistent, PI-189). Clean-by-default: emit nothing extra
unless asked.
5. Drift enforcement
CI contract tests assert each generated per-surface file matches what the
canonical spec + surface table would produce — the same guard model as the
skill-sync contract tests (test_plugin_marketplace.py). Editing the canonical
spec without regenerating fails CI.
6. Synergy with the capabilities inventory (#374)
The same canonical specs + config.yaml selections feed the agnostic
capabilities inventory (#374): workstreams B and C share one source of truth
rather than each enumerating surfaces independently.
Consequences
Positive
- One canonical definition reaches every surface; adding a surface is a one-line
table entry — the stated "agnostic = visible to all surfaces" goal.
- Reuses the proven sync_plugin.py canonical→derived + CI-drift pattern; no new
architectural machinery.
- Honest about fidelity: matcher-blind surfaces are marked advisory, and git/CI
enforcement (ADR-007) remains the real boundary regardless of surface.
Negative / accepted - Hook translation is genuinely lossy across dialects (Cursor camelCase, Antigravity subset) — the generator encodes best-effort mappings, not identity. - Antigravity rows are MED confidence (official docs JS-rendered); the generator must treat them as provisional until live-verified, and may ship behind a flag. - More generated files = more contract tests to keep green.
Alternatives considered
- Hand-maintained per-surface overlays — what exists ad hoc today for Codex/Gemini. Rejected: drifts as the canonical hooks evolve; the spike already found MCP entirely missing.
- Full data-driven plugin registry (every surface a declarative manifest the engine consumes) — more flexible but more machinery than 4–5 surfaces justify. Rejected for now; the surface table can graduate into this later if the count grows.
- Do nothing / rely on native cross-reads — partially viable (skills + AGENTS.md already cross-read), but leaves hooks and MCP unsupported on Cursor and Antigravity, missing the epic's goal.
References
- Spike matrix:
docs/development/non-cli-surface-matrix.md(#365) - epic #359; implementation #366; capabilities inventory #374; enforcement doc #367
- ADR-010 (sync pattern), ADR-007 (enforcement boundary), ADR-001 (no LLM)