feat(#1684): negotiated host-integration interface (ADR-1239 Phase A) (#1690)

* feat(#1684): add negotiated host-integration interface module

ADR-1239 Phase A: a pure, additive, no-I/O module exposing PROTOCOL_VERSION, the 8-axis HOST_INTEGRATION_AXES closed vocabulary, the UNDOCUMENTED fail-closed sentinel, negotiateHostCapabilities (effective subset of host-declared and engine-known), a typed degradation ladder, and host-capability profiles.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(#1684): validate and document host-integration axes (16 runtimes)

Extend validateRuntimeBody to validate the 8 hostIntegration axes (closed enums + undocumented sentinel + dispatch struct + reserved-key guards) and the widened runtime vocabulary; author a documentation-sourced hostIntegration block in all 16 runtime descriptors; regenerate the registry. Every per-CLI value is documented (cited) or the explicit undocumented sentinel.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(#1684): add host-integration capability matrix and adr amendment

New per-CLI, per-axis citation reference (value/source/evidence for all 16 CLIs); ADR-1239 Phase-A-implemented amendment; CONTEXT.md glossary seam entry.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#1684): harden dispatch negotiation edge cases

Code-review hardening: treat NaN/Infinity maxDepth as missing (fail-closed, +warning); reset nested/background when namedDispatch collapses to false (struct consistency); SAFE_DEFAULTS dispatch floor to read-only; warn on non-finite protocolVersion; symmetric undocumented warnings for dispatch fields. Pure module — no consumers; behaviour fail-closed throughout.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(#1684): register host-integration.cjs in lint-ignore and inventory

New tsc-generated bin/lib artifact: add to the eslint ignore list (ADR-457 — lint the .cts source), regenerate docs/INVENTORY-MANIFEST.json, and add the docs/INVENTORY.md CLI-modules row. Fixes the 3 gsd-test failures (551-eslint-bin-lib-coverage x2 + inventory-manifest-sync).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(#1684): add changeset fragment for host-integration interface

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(#1684): add how-to for sourcing a host's integration axes

Diataxis how-to guide for adding/updating a host's runtime.hostIntegration axes from authoritative docs, the undocumented-sentinel rule, validation, and extending the closed vocabulary. Completes the Step-5 doc quadrants (reference + explanation + how-to). Indexed in docs/README.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-06-25 11:33:06 -04:00
committed by GitHub
parent 2b215b4163
commit 30d4b85de5
35 changed files with 3716 additions and 48 deletions

View File

@@ -0,0 +1,5 @@
---
type: Added
pr: 1690
---
**Host-Integration Interface (ADR-1239 Phase A)** — a versioned, negotiated capability contract (`runtime.hostIntegration`) over the six host-integration points (command, dispatch, model, hooks, state, artifact). Adds an in-process `negotiateHostCapabilities` handshake that fail-closes on undeclared/unknown/`undocumented` values (`effective ⊆ host-declared ∩ engine-known`), a typed degradation ladder, host-capability profiles, and a documentation-sourced per-CLI capability matrix for all 16 runtimes. Interface-definition only — no change to install behaviour.

1
.gitignore vendored
View File

@@ -67,6 +67,7 @@ build/
# by `npm run build:lib`). Source of truth is src/; these are emitted, never edited.
# Published via prepublishOnly; built before test via pretest. Grows as modules migrate.
/tsconfig.build.tsbuildinfo
/gsd-core/bin/lib/host-integration.cjs
/gsd-core/bin/lib/capability-loader.cjs
/gsd-core/bin/lib/capability-source.cjs
/gsd-core/bin/lib/capability-ledger.cjs

View File

@@ -118,6 +118,9 @@ Module owning bounded, never-throw git repository introspection — the single s
### Runtime Name Policy Module
Module owning runtime identity normalization at runtime-selection seams. Canonicalizes alias signals from env/config (`GSD_RUNTIME`, `.planning/config.json:runtime`) to supported runtime IDs so output emitters and query runtime gates stay consistent across naming variants (for example `codex-app`/`codex-cli` -> `codex`). Sources: `gsd-core/bin/lib/runtime-name-policy.cjs`, alias manifest `gsd-core/bin/shared/runtime-aliases.manifest.json`.
### Host-Integration Interface
Pure, additive, no-I/O Module owning the versioned, negotiated contract over the six host-integration interface points (command, dispatch, model, hooks, state, artifact) — ADR-1239 Phase A. Extends the ADR-1016 runtime descriptor with eight closed-vocabulary axes carried under `capability.json` `runtime.hostIntegration`: `embeddingMode` (`imperative|declarative`), `commandSurface` (`slash-file|slash-programmatic|slash-toml|palette|prose-only`), `dispatch` (`{namedDispatch,nested,maxDepth,background,subagentToolkit}`), `modelMode` (`active|passive`), `hookBus` (`host|engine|none`), `stateIO` (`filesystem|sandboxed-storage|session-log-append`), `transport` (`mcp|native-extension`), `runtime` (`node|bun|sandboxed-web|python|go|rust|electron|other`). Interface: `negotiateHostCapabilities(host, engine?) → { protocolVersion, effective, points, warnings }` enforcing the trust-boundary invariant `effective ⊆ host-declared ∩ engine-known` (never augment with an undeclared or unknown/future-`protocolVersion` value — fail-closed via the most-restrictive-known `SAFE_DEFAULTS`); `degradationFor(point, axes) → { level, fallback }` (a pure Full/Degraded/Absent ladder table, never throws); `profileOf(axes) → 'programmatic-cli'|'declarative-cli'|'ide'|null`; plus `PROTOCOL_VERSION` (integer, starts at 1 — distinct from the package `version`/`engines.gsd` semver), `HOST_INTEGRATION_AXES` (the frozen closed vocabulary, single source of truth), and `PROFILE_BASELINES`. The runtime-descriptor validator (`gsd-core/bin/lib/capability-validator.cjs` `validateRuntimeBody`) mirrors the closed vocabulary inline (exported as `_HOST_INTEGRATION_VOCAB`) and is kept in lock-step by the parity guard `tests/host-integration-validator-parity.test.cjs`. Orthogonal axes (resolved explicitly per ADR-1239 Phase A): `commandStyle` (GSD emission style, retained) vs `commandSurface` (host surface type); `hookEvents` dialect vs `hookBus` ownership (a host with `hooksSurface:none` may still be `hookBus:host` — e.g. opencode); `runtimeCompat` (feature→host) vs these negotiated runtime→engine axes. Phase A is interface-definition only — no adapter/MCP/host-binding consumers yet (Phases B–E). Source of truth: `gsd-core/bin/lib/host-integration.cjs` (generated from `src/host-integration.cts`). See ADR-1239 and ADR-1016.
### Installer Migration Authoring Guard Module
Module owning validation for Installer Migration Module records and planned actions. It enforces migration metadata, explicit install scopes, ownership evidence for destructive/config actions, and runtime contract citations for runtime config rewrites before a migration can enter planning or apply.

View File

@@ -55,6 +55,16 @@
"installSurface": "settings-json",
"writesSharedSettings": true,
"permissionWriter": null,
"extendedHookEvents": []
"extendedHookEvents": [],
"hostIntegration": {
"embeddingMode": "declarative",
"commandSurface": "slash-file",
"dispatch": { "namedDispatch": "undocumented", "nested": "undocumented", "maxDepth": "undocumented", "background": true, "subagentToolkit": "undocumented" },
"modelMode": "passive",
"hookBus": "host",
"stateIO": "filesystem",
"transport": "mcp",
"runtime": "go"
}
}
}

View File

@@ -64,6 +64,16 @@
"installSurface": "settings-json",
"writesSharedSettings": true,
"permissionWriter": null,
"extendedHookEvents": []
"extendedHookEvents": [],
"hostIntegration": {
"embeddingMode": "declarative",
"commandSurface": "slash-file",
"dispatch": { "namedDispatch": true, "nested": "undocumented", "maxDepth": "undocumented", "background": true, "subagentToolkit": "full" },
"modelMode": "passive",
"hookBus": "host",
"stateIO": "filesystem",
"transport": "mcp",
"runtime": "node"
}
}
}

View File

@@ -61,6 +61,16 @@
"Stop",
"PreCompact",
"FileChanged"
]
],
"hostIntegration": {
"embeddingMode": "imperative",
"commandSurface": "slash-file",
"dispatch": { "namedDispatch": true, "nested": true, "maxDepth": 5, "background": true, "subagentToolkit": "full" },
"modelMode": "passive",
"hookBus": "host",
"stateIO": "filesystem",
"transport": "mcp",
"runtime": "node"
}
}
}

View File

@@ -38,6 +38,16 @@
"installSurface": "cline-rules",
"writesSharedSettings": false,
"permissionWriter": null,
"extendedHookEvents": []
"extendedHookEvents": [],
"hostIntegration": {
"embeddingMode": "imperative",
"commandSurface": "slash-file",
"dispatch": { "namedDispatch": true, "nested": false, "maxDepth": 1, "background": true, "subagentToolkit": "read-only" },
"modelMode": "active",
"hookBus": "host",
"stateIO": "filesystem",
"transport": "mcp",
"runtime": "node"
}
}
}

View File

@@ -64,6 +64,16 @@
"installSurface": "settings-json",
"writesSharedSettings": true,
"permissionWriter": null,
"extendedHookEvents": []
"extendedHookEvents": [],
"hostIntegration": {
"embeddingMode": "declarative",
"commandSurface": "slash-file",
"dispatch": { "namedDispatch": true, "nested": false, "maxDepth": 1, "background": true, "subagentToolkit": "full" },
"modelMode": "passive",
"hookBus": "host",
"stateIO": "filesystem",
"transport": "mcp",
"runtime": "node"
}
}
}

View File

@@ -48,6 +48,16 @@
"installSurface": "codex-toml",
"writesSharedSettings": false,
"permissionWriter": null,
"extendedHookEvents": []
"extendedHookEvents": [],
"hostIntegration": {
"embeddingMode": "declarative",
"commandSurface": "slash-file",
"dispatch": { "namedDispatch": true, "nested": true, "maxDepth": 1, "background": true, "subagentToolkit": "full" },
"modelMode": "passive",
"hookBus": "host",
"stateIO": "filesystem",
"transport": "mcp",
"runtime": "node"
}
}
}

View File

@@ -48,6 +48,16 @@
"installSurface": "copilot-instructions",
"writesSharedSettings": false,
"permissionWriter": null,
"extendedHookEvents": []
"extendedHookEvents": [],
"hostIntegration": {
"embeddingMode": "declarative",
"commandSurface": "slash-file",
"dispatch": { "namedDispatch": true, "nested": false, "maxDepth": 1, "background": true, "subagentToolkit": "full" },
"modelMode": "passive",
"hookBus": "host",
"stateIO": "filesystem",
"transport": "mcp",
"runtime": "undocumented"
}
}
}

View File

@@ -64,6 +64,16 @@
"installSurface": "cursor-hooks-json",
"writesSharedSettings": false,
"permissionWriter": null,
"extendedHookEvents": []
"extendedHookEvents": [],
"hostIntegration": {
"embeddingMode": "imperative",
"commandSurface": "slash-file",
"dispatch": { "namedDispatch": true, "nested": true, "maxDepth": 2, "background": true, "subagentToolkit": "full" },
"modelMode": "passive",
"hookBus": "host",
"stateIO": "filesystem",
"transport": "mcp",
"runtime": "node"
}
}
}

View File

@@ -52,6 +52,16 @@
"BeforeAgent",
"AfterAgent",
"BeforeModel"
]
],
"hostIntegration": {
"embeddingMode": "declarative",
"commandSurface": "slash-toml",
"dispatch": { "namedDispatch": true, "nested": false, "maxDepth": 1, "background": "undocumented", "subagentToolkit": "undocumented" },
"modelMode": "passive",
"hookBus": "host",
"stateIO": "filesystem",
"transport": "mcp",
"runtime": "node"
}
}
}

View File

@@ -48,6 +48,16 @@
"installSurface": "settings-json",
"writesSharedSettings": true,
"permissionWriter": null,
"extendedHookEvents": []
"extendedHookEvents": [],
"hostIntegration": {
"embeddingMode": "imperative",
"commandSurface": "slash-programmatic",
"dispatch": { "namedDispatch": false, "nested": true, "maxDepth": 1, "background": true, "subagentToolkit": "read-only" },
"modelMode": "active",
"hookBus": "host",
"stateIO": "filesystem",
"transport": "mcp",
"runtime": "python"
}
}
}

View File

@@ -70,6 +70,16 @@
"installSurface": "settings-json",
"writesSharedSettings": false,
"permissionWriter": "kilo",
"extendedHookEvents": []
"extendedHookEvents": [],
"hostIntegration": {
"embeddingMode": "imperative",
"commandSurface": "slash-file",
"dispatch": { "namedDispatch": true, "nested": true, "maxDepth": -1, "background": true, "subagentToolkit": "undocumented" },
"modelMode": "active",
"hookBus": "host",
"stateIO": "filesystem",
"transport": "mcp",
"runtime": "bun"
}
}
}

View File

@@ -51,6 +51,16 @@
"installSurface": "profile-marker-only",
"writesSharedSettings": false,
"permissionWriter": null,
"extendedHookEvents": []
"extendedHookEvents": [],
"hostIntegration": {
"embeddingMode": "imperative",
"commandSurface": "slash-file",
"dispatch": { "namedDispatch": true, "nested": false, "maxDepth": 1, "background": true, "subagentToolkit": "undocumented" },
"modelMode": "passive",
"hookBus": "host",
"stateIO": "filesystem",
"transport": "mcp",
"runtime": "python"
}
}
}

View File

@@ -65,6 +65,16 @@
"installSurface": "settings-json",
"writesSharedSettings": true,
"permissionWriter": "opencode",
"extendedHookEvents": []
"extendedHookEvents": [],
"hostIntegration": {
"embeddingMode": "imperative",
"commandSurface": "slash-file",
"dispatch": { "namedDispatch": true, "nested": "undocumented", "maxDepth": "undocumented", "background": false, "subagentToolkit": "full" },
"modelMode": "active",
"hookBus": "host",
"stateIO": "filesystem",
"transport": "mcp",
"runtime": "bun"
}
}
}

View File

@@ -52,6 +52,16 @@
"SubagentStop",
"Stop",
"PreCompact"
]
],
"hostIntegration": {
"embeddingMode": "imperative",
"commandSurface": "slash-file",
"dispatch": { "namedDispatch": true, "nested": false, "maxDepth": 1, "background": true, "subagentToolkit": "full" },
"modelMode": "passive",
"hookBus": "host",
"stateIO": "filesystem",
"transport": "mcp",
"runtime": "node"
}
}
}

View File

@@ -47,6 +47,16 @@
"installSurface": "profile-marker-only",
"writesSharedSettings": false,
"permissionWriter": null,
"extendedHookEvents": []
"extendedHookEvents": [],
"hostIntegration": {
"embeddingMode": "imperative",
"commandSurface": "slash-file",
"dispatch": { "namedDispatch": true, "nested": "undocumented", "maxDepth": "undocumented", "background": true, "subagentToolkit": "undocumented" },
"modelMode": "passive",
"hookBus": "engine",
"stateIO": "filesystem",
"transport": "mcp",
"runtime": "node"
}
}
}

View File

@@ -39,6 +39,16 @@
"installSurface": "profile-marker-only",
"writesSharedSettings": false,
"permissionWriter": null,
"extendedHookEvents": []
"extendedHookEvents": [],
"hostIntegration": {
"embeddingMode": "declarative",
"commandSurface": "slash-file",
"dispatch": { "namedDispatch": "undocumented", "nested": "undocumented", "maxDepth": "undocumented", "background": "undocumented", "subagentToolkit": "undocumented" },
"modelMode": "passive",
"hookBus": "host",
"stateIO": "filesystem",
"transport": "mcp",
"runtime": "undocumented"
}
}
}

View File

@@ -329,6 +329,7 @@
"graphify-command-router.cjs",
"graphify.cjs",
"gsd2-import.cjs",
"host-integration.cjs",
"init-command-router.cjs",
"init.cjs",
"install-profiles.cjs",

View File

@@ -439,6 +439,7 @@ Full listing: `gsd-core/bin/lib/*.cjs`.
| `graphify.cjs` | Knowledge-graph build/query/status/diff for `/gsd-graphify` |
| `graphify-command-router.cjs` | ADR-959 capability command router for `gsd-tools graphify` — dispatches build/query/status/diff subcommands; first real capability command cutover (phase 4d-impl-2) |
| `gsd2-import.cjs` | External-plan ingest for `/gsd-import --from-gsd2` |
| `host-integration.cjs` | Host-Integration Interface (ADR-1239 Phase A) — negotiated capability contract over the six host-integration points; `negotiateHostCapabilities` fail-closes on undeclared/unknown/`undocumented` values, typed degradation ladder, host-capability profiles; the 8 `runtime.hostIntegration` axes are validated in `capability-validator.cjs` and sourced per-CLI in `docs/reference/host-integration-capability-matrix.md` |
| `init-command-router.cjs` | Thin CJS subcommand router adapter for `gsd-tools init` |
| `init.cjs` | Compound context loading for each workflow type |
| `install-profiles.cjs` | Install profile allowlist + skill staging for `--minimal` install (#2762); single source of truth for which `gsd-*` skills/agents land in runtime config dirs |

View File

@@ -36,6 +36,7 @@ Language versions: [English](README.md) · [Português (pt-BR)](pt-BR/README.md)
- [Spike and sketch](how-to/spike-and-sketch.md) — use `/gsd-spike` and `/gsd-sketch` for exploratory work before committing to a plan
- [Design a UI phase](how-to/design-a-ui-phase.md) — use the UI phase loop for frontend and visual work
- [Develop a Capability for GSD 1.5+](how-to/develop-a-capability.md) — add feature Capabilities, hook fragments, and registry entries
- [Add or update a host's integration](how-to/add-or-update-a-host-integration.md) — set a host's documentation-sourced `runtime.hostIntegration` axes (ADR-1239 Phase A), with the `undocumented` sentinel rule
- [Turn a capability off (and keep it off)](how-to/turn-a-capability-off.md) — disable a capability via the surface, or gate individual hooks off without removing the capability
- [Drive GSD from a tracker issue](how-to/drive-gsd-from-a-tracker-issue.md) — start a phase from a GitHub, Linear, or Jira issue
- [Migrate from GSD 2](how-to/migrate-from-gsd-2.md) — upgrade an existing GSD 2 project to GSD Core

View File

@@ -85,6 +85,20 @@ The **primitive vocabulary stays closed and first-party** (ADR-857 Decision 8):
Each phase is its own `approved-*` issue + PR with equivalence/parity proof.
### Amendment — Phase A implemented (#1684, v1.7.0)
Phase A is **implemented** (the ADR itself remains `Proposed` overall until Phases B–E land). The negotiated capability schema is materialized as a pure, additive, no-I/O module — the **Host-Integration Interface** (`src/host-integration.cts` → `gsd-core/bin/lib/host-integration.cjs`):
- **The eight negotiated axes** are carried under `capability.json` `runtime.hostIntegration` (extending, not replacing, the ADR-1016 axes), validated by `validateRuntimeBody` (`capability-validator.cjs`) across all 16 runtime descriptors, with the closed vocabulary kept in lock-step by a parity guard.
- **`PROTOCOL_VERSION`** is an integer starting at `1`, **distinct** from the package `version` / `engines.gsd` semver (the `version`/`protocolVersion` overlap, resolved).
- **`negotiateHostCapabilities(host, engine?)`** performs the in-process `initialize` exchange and enforces the trust-boundary invariant `effective ⊆ host-declared ∩ engine-known`: an undeclared axis or an unknown / higher-`protocolVersion` value is **never** trusted — it degrades to the most-restrictive known value (fail-closed), never throws.
- **`degradationFor`** is the typed Full/Degraded/Absent ladder table; **`profileOf` + `PROFILE_BASELINES`** classify each descriptor into `programmatic-cli` (9 hosts: claude, opencode, cursor, cline, hermes, qwen, kilo, trae, kimi), `declarative-cli` (7 hosts: codex, gemini, antigravity, augment, codebuddy, copilot, windsurf), or `ide` (defined as a baseline; no installed host yet — VS Code lands in Phase D).
- **Overlap resolutions (explicit):** `commandStyle` (GSD emission style, retained) ⊥ `commandSurface` (host surface type); `hookEvents` dialect ⊥ `hookBus` ownership (a host with `hooksSurface:none` may still be `hookBus:host` — e.g. opencode); the `opencode-subset` `hookEvents` value remains reserved for the Phase D OpenCode hook-dialect consumer; `runtimeCompat` (feature→host) stays an independent override, orthogonal to these runtime→engine axes.
**Every per-host axis value is documentation-sourced, with citations.** Each of the 8 axes for all 16 installed CLIs was determined from that CLI's authoritative documentation (Context7 + the official dev docs/source), never inferred. The full per-CLI, per-axis matrix — value, source, and an evidence quote — is recorded in [`docs/reference/host-integration-capability-matrix.md`](reference/host-integration-capability-matrix.md), the deployment source-of-truth that Phases B–E build on. Where a CLI's docs genuinely do not state an axis, the descriptor carries the explicit `undocumented` sentinel (which `negotiateHostCapabilities` fail-closes on) rather than a guessed value — 22 such markers exist today, each with its search trail in the matrix. Two findings corrected this ADR's original appendix matrix: (1) current OpenAI **Codex** docs document slash-commands, so its `commandSurface` is `slash-file`, not `prose-only`; (2) several hosts run non-Node runtimes (opencode & kilo on **bun**; hermes & kimi on **python**; antigravity on **go**), so the `runtime` axis vocabulary was widened to `node|bun|sandboxed-web|python|go|rust|electron|other`. The documented `embeddingMode` split (9 imperative / 7 declarative, above) likewise reflects each CLI's real plugin/extension API, not a profile assumption.
No consumer wires the negotiated result yet — Phase A is interface-definition only; the engine↔host boundary (Phase B) and the adapters (Phase C) are where it is consumed.
## Host-capability profiles (negotiation baselines)
- **Programmatic-CLI** (Claude Code, pi, OpenCode): imperative; full dispatch; host hook bus; MCP; `slash` surface. The richest target — minimal degradation.

View File

@@ -0,0 +1,111 @@
# How to add or update a host's integration capabilities
This guide is for GSD maintainers adding a new host CLI, or updating an existing host's
host-integration axes (ADR-1239 Phase A). It covers the **documentation-sourcing rule**, the
eight `runtime.hostIntegration` axes, the `undocumented` sentinel, and how to validate.
The governing rule for this whole process: **every axis value must come from the host's own
authoritative documentation. Never infer, guess, or assume.** Where the docs do not state an axis,
record the explicit `undocumented` sentinel — not a plausible default. The reference matrix
(`docs/reference/host-integration-capability-matrix.md`) is the source of truth, and every value in
it carries a citation and an evidence quote.
---
## 1. Find the host's authoritative documentation
In order of preference:
1. **Context7** — `resolve-library-id` for the host, then `query-docs` for "plugins / subagents / hooks / commands / MCP / model API".
2. **Official dev docs / source repo** — the host's documentation site or GitHub repo (plugin API, agents, hooks, MCP, command authoring).
Capture the exact source (Context7 library id + query, or the doc URL) and a short verbatim quote
for each value you determine. You will paste these into the matrix in step 4.
## 2. Determine each of the eight axes from the docs
Read the docs and map them to the closed vocabulary. Do not pick a value unless a source states it.
| Axis | What to look for in the docs |
|---|---|
| `embeddingMode` | An in-process programmatic plugin/extension API (`imperative`) vs. configuration files only (`declarative`). |
| `commandSurface` | How custom commands are authored/invoked: `slash-file` (.md), `slash-toml`, `slash-programmatic`, `palette`, `prose-only`. |
| `dispatch` | Sub-agent delegation: `namedDispatch`, `nested`, `maxDepth` (int; `-1` = documented-unbounded), `background`, `subagentToolkit` (`full`/`read-only`). |
| `modelMode` | A programmatic model request/provider API (`active`) vs. instruction/per-agent-field only (`passive`). |
| `hookBus` | The host fires lifecycle events a plugin subscribes to (`host`), an extension host owns the bus (`engine`), or no bus (`none`). **Independent of `hooksSurface`** — e.g. opencode has `hooksSurface: none` but `hookBus: host`. |
| `stateIO` | `filesystem`, `sandboxed-storage` (web IDE, no arbitrary FS), or `session-log-append`. |
| `transport` | `mcp` (native MCP support) vs. `native-extension` (MCP needs a community extension). |
| `runtime` | The plugin/extension runtime: `node`, `bun`, `sandboxed-web`, `python`, `go`, `rust`, `electron`, `other`. |
## 3. Write the `runtime.hostIntegration` block
In `capabilities/<id>/capability.json`, inside the `runtime` object, add (or edit) the block. Use a
documented closed-vocabulary value, or the literal string `"undocumented"` for any axis the docs do
not state:
```json
"hostIntegration": {
"embeddingMode": "declarative",
"commandSurface": "slash-file",
"dispatch": { "namedDispatch": true, "nested": false, "maxDepth": 1, "background": false, "subagentToolkit": "undocumented" },
"modelMode": "passive",
"hookBus": "host",
"stateIO": "filesystem",
"transport": "mcp",
"runtime": "node"
}
```
**When to use `undocumented`:** only when you searched and the host's docs genuinely do not state the
axis. It validates, but `negotiateHostCapabilities` **fail-closes** on it (degrades to the most
restrictive known value) — so it is always safe and never a silent capability claim. A dispatch
boolean or `maxDepth` may also be `"undocumented"`.
**Do not conflate the orthogonal axes:** `commandStyle` (GSD's emission style) is *not*
`commandSurface` (the host's surface type); the `hookEvents` dialect is *not* `hookBus` (bus
ownership); `runtimeCompat` (which features run on a host) is independent of these runtime→engine
axes.
## 4. Record the citations in the reference matrix
Add (or update) the host's section in `docs/reference/host-integration-capability-matrix.md` with a
row per axis: `Axis | Value | Source | Evidence`. For an `undocumented` value, put the search trail
in the Source column. This file is the deployment source of truth — a value without a citation here
is not allowed.
## 5. Validate
```bash
npm run build:lib
npm run gen:capability-registry # validateRuntimeBody runs on every descriptor
```
`gen:capability-registry` must succeed with zero errors. The validator
(`gsd-core/bin/lib/capability-validator.cjs`) rejects out-of-vocabulary values, malformed dispatch
structs, and reserved keys (`__proto__`/`constructor`/`prototype`).
Then run the host-integration tests and the full cross-platform suite:
```bash
node --test tests/host-integration-descriptors.test.cjs # asserts every descriptor validates + profiles
gsd-test-both # Mac + Linux Docker (run before any PR)
```
## 6. If you need a vocabulary value that does not exist yet
The vocabulary is intentionally **closed** (ADR-857 Decision 8): a genuinely new host shape requires
a first-party primitive, reviewed. To add one (e.g. a new `runtime` kind):
1. Add the value to the relevant axis in `HOST_INTEGRATION_AXES` in `src/host-integration.cts`.
2. Add the same value to the matching `VALID_*` set in `capability-validator.cjs`.
The parity guard (`tests/host-integration-validator-parity.test.cjs`) fails if these two drift, so
they must be updated together. Document the new value's meaning in the matrix legend.
---
## Related
- Reference: [`docs/reference/host-integration-capability-matrix.md`](../reference/host-integration-capability-matrix.md) — the per-CLI sourced values.
- ADR: [`docs/adr/1239-gsd-embeddable-orchestration-engine.md`](../adr/1239-gsd-embeddable-orchestration-engine.md) — why the interface exists and the Phase A amendment.
- The closed-vocabulary runtime descriptor it extends: [ADR-1016](../adr/1016-runtime-capability-descriptor.md).

View File

@@ -0,0 +1,565 @@
# Host Integration Capability Matrix
This document is the maintainer-facing source of truth for the `hostIntegration` block in every
`capabilities/<cli>/capability.json` runtime descriptor. Every per-CLI axis value is either:
- **documented** — backed by a cited authoritative source and evidence quote, or
- **`undocumented`** — the explicit fail-closed sentinel used when the CLI's public documentation
does not state a value for that axis. `undocumented` validates in the registry but never
propagates into effective axes: negotiation degrades closed to the safe default.
Values are generated from per-CLI documentation research (Context7 + official docs). They are
consumed verbatim by `gen:capability-registry` and validated by `capability-validator.cjs`.
---
## Axes legend
| Axis | Meaning |
|---|---|
| `embeddingMode` | Whether the CLI exposes an in-process programmatic API (`imperative`) or integrates purely through configuration files (`declarative`). |
| `commandSurface` | How slash commands are registered: `slash-file` (markdown), `slash-toml` (TOML), `slash-programmatic` (code API), `palette`, `prose-only`. |
| `modelMode` | Whether extensions can programmatically request or supply a model (`active`) or select only by config (`passive`). |
| `hookBus` | Who owns the hook lifecycle: `host` (the CLI fires hooks), `engine` (VS Code/Electron extension host), `none`. |
| `stateIO` | Filesystem access model: `filesystem` (full local FS), `sandboxed-storage`, `session-log-append`. |
| `transport` | Integration transport: `mcp` (Model Context Protocol), `native-extension`. |
| `runtime` | Plugin/extension execution runtime: `node`, `bun`, `python`, `go`, `rust`, `electron`, `sandboxed-web`, `other`. |
### dispatch sub-axes
| Sub-axis | Meaning |
|---|---|
| `namedDispatch` | Whether agents can be invoked by name (true/false/`undocumented`). |
| `nested` | Whether subagents can themselves spawn subagents (true/false/`undocumented`). |
| `maxDepth` | Maximum nesting depth (integer; -1 = unbounded; `undocumented`). |
| `background` | Whether subagents can run asynchronously in the background (true/false/`undocumented`). |
| `subagentToolkit` | Tool surface available to subagents: `full`, `read-only`, or `undocumented`. |
### Interface points
| Point | Meaning |
|---|---|
| `command` | Slash-command routing and invocation capability. |
| `dispatch` | Subagent/multi-agent dispatch capability. |
| `model` | Programmatic model selection capability. |
| `hooks` | Lifecycle hook registration capability. |
| `state` | Filesystem/state I/O capability. |
| `artifact` | Artifact delivery (skills, commands) surface capability. |
---
## claude
| Axis | Value | Source | Evidence |
|---|---|---|---|
| embeddingMode | imperative | https://code.claude.com/docs/en/agent-sdk/overview | "The Agent SDK offers hooks to execute custom code at critical points within the agent's lifecycle. These callback functions enable developer" |
| commandSurface | slash-file | https://code.claude.com/docs/en/agent-sdk/slash-commands | "Each custom command is a markdown file where the filename (without the `.md` extension) becomes the command name. The file content defines w" |
| modelMode | passive | https://code.claude.com/docs/en/agent-sdk/typescript | "setModel(model?: string): Changes the model (only available in streaming input mode) ... model overrides the default model for this subagent" |
| hookBus | host | https://code.claude.com/docs/en/agent-sdk/python | "HookEvent = Literal['PreToolUse', 'PostToolUse', 'PostToolUseFailure', 'UserPromptSubmit', 'Stop', 'SubagentStop', 'PreCompact', 'Notificati" |
| stateIO | filesystem | https://code.claude.com/docs/en/sandboxing | "The sandboxed Bash tool restricts file system access, granting read and write access to the current working directory and session temp direc" |
| transport | mcp | https://code.claude.com/docs/en/mcp | "Project-Scoped MCP Server Configuration in .mcp.json ... This JSON structure illustrates the format for a project-scoped MCP server configur" |
| runtime | node | https://code.claude.com/docs/en/agent-sdk/typescript | "import { query } from \"@anthropic-ai/claude-agent-sdk\"; ... pathToClaudeCodeExecutable (string) - Specifies the path to the Claude Code CLI" |
| dispatch.namedDispatch | true | https://code.claude.com/docs/en/agent-sdk/subagents | "agents: { 'code-reviewer': AgentDefinition({ description: 'Expert code reviewer.', ... }) } ... subagent_type: block.inp" |
| dispatch.nested | true | https://code.claude.com/docs/en/sub-agents | "As of Claude Code v2.1.172, a subagent can spawn its own subagents, allowing delegated tasks to split into parallel subt" |
| dispatch.maxDepth | 5 | https://code.claude.com/docs/en/sub-agents | "foreground subagents can spawn at any depth, blocking their parent until completion. Background subagents are limited to" |
| dispatch.background | true | https://code.claude.com/docs/en/sub-agents | "Subagents can run in the foreground, blocking the main conversation and passing permission prompts to you, or in the bac" |
| dispatch.subagentToolkit | full | https://code.claude.com/docs/en/sub-agents | "If all tools remain selected, the subagent inherits all tools available to the main conversation." |
Sources consulted:
- https://code.claude.com/docs/en/sub-agents
- https://code.claude.com/docs/en/agent-sdk/slash-commands
- https://code.claude.com/docs/en/agent-sdk/subagents
- https://code.claude.com/docs/en/agent-sdk/python
- https://code.claude.com/docs/en/agent-sdk/typescript
- https://code.claude.com/docs/en/agent-sdk/overview
- https://code.claude.com/docs/en/mcp
- https://code.claude.com/docs/en/sandboxing
- Context7 /websites/code_claude
- Context7 /llmstxt/code_claude_llms_txt
---
## codex
> **Note:** ADR-1239's host matrix lists Codex as `prose-only`; current OpenAI Codex dev docs document slash-commands, so `commandSurface` is `slash-file` here (docs are the source of truth).
| Axis | Value | Source | Evidence |
|---|---|---|---|
| embeddingMode | declarative | https://developers.openai.com/codex/plugins/build | "No in-process programmatic API exists. Plugins integrate through: External command execution (hooks), MCP server processes, Configuration fi" |
| commandSurface | slash-file | https://github.com/openai/codex/blob/main/codex-rs/core-skills/src/loader.rs | "const SKILLS_FILENAME: &str = \"SKILL.md\"; ... Each skill is a folder with a SKILL.md file containing YAML frontmatter with name and descript" |
| modelMode | passive | https://github.com/openai/codex/blob/main/codex-rs/config/src/config_toml.rs | "pub model_provider: Option<String> ... model is selected by config field; no programmatic model request API" |
| hookBus | host | https://github.com/openai/codex/blob/main/codex/codex-rs/hooks/src/lib.rs | "pub const HOOK_EVENT_NAMES: [&str; 10] = [\"PreToolUse\", \"PermissionRequest\", \"PostToolUse\", \"PreCompact\", \"PostCompact\", \"SessionStart\", \"Us" |
| stateIO | filesystem | https://developers.openai.com/codex/concepts/sandboxing | "workspace-write: The default mode allowing Codex to read files, edit within the workspace, and run routine local commands inside that bounda" |
| transport | mcp | https://github.com/openai/codex/blob/main/codex-rs/config/src/config_toml.rs | "pub mcp_servers: HashMap<String, McpServerConfig> ... Definition for MCP servers that Codex can reach out to for tool calls." |
| runtime | node | https://github.com/openai/codex/blob/main/codex-cli/package.json | "\"engines\": {\"node\": \">=16\"} ... The npm-distributed CLI wrapper is a Node.js script (#!/usr/bin/env node)" |
| dispatch.namedDispatch | true | https://github.com/openai/codex/blob/main/codex-rs/core/src/tools/handlers/multi_agents_spec.rs | "\"agent_type\".to_string(), JsonSchema::string(Some(agent_type_description.to_string())) ... apply_role_to_config(&mut con" |
| dispatch.nested | true | https://developers.openai.com/codex/multi-agent | "agents.max_depth defaults to 1, which allows a direct child agent to spawn but prevents deeper nesting." |
| dispatch.maxDepth | 1 | https://developers.openai.com/codex/config-reference | "agents.max_depth: Maximum nesting depth allowed for spawned agent threads (root sessions start at depth 0; default: 1)" |
| dispatch.background | true | https://github.com/openai/codex/blob/main/codex-rs/core/src/tools/handlers/multi_agents_spec.rs | "spawn_agent returns the spawned agent id immediately; a separate wait_agent tool polls for final status." |
| dispatch.subagentToolkit | full | https://developers.openai.com/codex/multi-agent | "Subagents inherit the sandbox policy and tool surface from the parent session." |
Sources consulted:
- https://github.com/openai/codex (repo via gh CLI)
- /openai/codex (Context7 library ID)
- https://github.com/openai/codex/blob/main/codex-rs/config/src/config_toml.rs
- https://github.com/openai/codex/blob/main/codex-rs/core/src/tools/handlers/multi_agents_spec.rs
- https://github.com/openai/codex/blob/main/codex-rs/core-skills/src/loader.rs
- https://developers.openai.com/codex/config-reference
- https://developers.openai.com/codex/plugins/build
- https://developers.openai.com/codex/multi-agent
- https://developers.openai.com/codex/cli/slash-commands
Documentation gaps:
- dispatch.maxDepth is configurable (Option<i32> with no documented upper bound); the documented default is 1 but the actual enforced maximum is not stated.
- dispatch.subagentToolkit: docs say subagents 'inherit the tool surface' but do not enumerate whether any tools are excluded.
- runtime: the Node.js entry point is a thin launcher shim; the actual agent execution runtime is a compiled Rust binary — axis classification is ambiguous.
---
## gemini
| Axis | Value | Source | Evidence |
|---|---|---|---|
| embeddingMode | declarative | https://github.com/google-gemini/gemini-cli/blob/main/packages/sdk/SDK_DESIGN.md | "This feature is currently not implemented. (repeated for both extension and subagent SDK APIs; all actual integration is via files: TOML com" |
| commandSurface | slash-toml | https://github.com/google-gemini/gemini-cli/blob/main/docs/cli/custom-commands.md | "Custom commands in Gemini CLI are defined in TOML format with the .toml file extension … Commands are invoked as slash commands in the CLI." |
| modelMode | passive | https://github.com/google-gemini/gemini-cli/blob/main/docs/hooks/reference.md | "BeforeModel Hook: Fires before sending a request to the LLM … hookSpecificOutput.llm_request (object) — An object that overrides parts of th" |
| hookBus | host | https://github.com/google-gemini/gemini-cli/blob/main/docs/hooks/reference.md | "Hooks function as host-fired events … The CLI fires events at predetermined lifecycle points [BeforeAgent, AfterAgent, BeforeTool, AfterTool" |
| stateIO | filesystem | https://github.com/google-gemini/gemini-cli/blob/main/docs/reference/configuration.md | "Local access by default with gitignore/geminiignore respect … Set to a boolean to enable or disable the sandbox" |
| transport | mcp | https://github.com/google-gemini/gemini-cli/blob/main/docs/tools/mcp-server.md | "Configure a Node.js MCP server using stdio … { \"mcpServers\": { \"nodeServer\": { \"command\": \"node\", \"args\": [\"dist/server.js\"] } } }" |
| runtime | node | https://github.com/google-gemini/gemini-cli/blob/main/docs/reference/configuration.md | "References to node-pty and child_process indicate JavaScript/Node.js execution environment" |
| dispatch.namedDispatch | true | https://github.com/google-gemini/gemini-cli/blob/main/docs/core/subagents.md | "Example of a custom subagent definition file (.gemini/agents/security-auditor.md) … name: security-auditor" |
| dispatch.nested | false | https://github.com/google-gemini/gemini-cli/blob/main/docs/core/subagents.md | "Each subagent operates in an isolated context loop … This isolation also includes recursion protection, preventing subag" |
| dispatch.maxDepth | 1 | https://github.com/google-gemini/gemini-cli/blob/main/docs/core/subagents.md | "To prevent infinite loops and excessive token usage, subagents cannot call other subagents. … The architecture enforces" |
| dispatch.background | undocumented | no authoritative doc — searched: https://github.com/google-gemini/gemini-cli/blob/main/docs/core/subagents.md, /google-gemini/gemini-cli (Context7 library) | — |
| dispatch.subagentToolkit | undocumented | no authoritative doc — searched: https://github.com/google-gemini/gemini-cli/blob/main/docs/core/subagents.md, /google-gemini/gemini-cli (Context7 library) | — |
Sources consulted:
- https://github.com/google-gemini/gemini-cli/blob/main/docs/cli/custom-commands.md
- https://github.com/google-gemini/gemini-cli/blob/main/docs/core/subagents.md
- https://github.com/google-gemini/gemini-cli/blob/main/docs/hooks/reference.md
- https://github.com/google-gemini/gemini-cli/blob/main/docs/reference/configuration.md
- https://github.com/google-gemini/gemini-cli/blob/main/docs/tools/mcp-server.md
- https://github.com/google-gemini/gemini-cli/blob/main/packages/sdk/SDK_DESIGN.md
- /google-gemini/gemini-cli (Context7 library)
Documentation gaps:
- dispatch.background — docs describe subagents as operating in isolated context loops but do not state whether they execute synchronously or asynchronously.
- dispatch.subagentToolkit — subagents have a configurable tool grant model but no single fixed value of 'full' or 'read-only' is stated as the architecture-level constraint.
---
## opencode
| Axis | Value | Source | Evidence |
|---|---|---|---|
| embeddingMode | imperative | https://opencode.ai/docs/plugins | "Plugins are JavaScript/TypeScript modules that export plugin functions; they register hooks via `import type { Plugin } from '@opencode-ai/p'" |
| commandSurface | slash-file | https://opencode.ai/docs/commands | "\"Create markdown files in the `commands/` directory to define custom commands.\" and \"The markdown file name becomes the command name." |
| modelMode | active | /anomalyco/opencode (Context7) — packages/plugin/src/v2/promise/README.md | "`ctx.aisdk.sdk(async (event) => { ... event.sdk = mod.createXai(event.options) })` and `ctx.aisdk.language((event) => { ... event.language =" |
| hookBus | host | https://opencode.ai/docs/plugins | "Host fires events including: `tool.execute.before`, `tool.execute.after`, `session.created`, `session.compacted`, `session.deleted`" |
| stateIO | filesystem | https://opencode.ai/docs/plugins | "Plugin context includes `directory` (working directory path), `worktree` (git worktree path), and `$` (\"Bun's shell API\")" |
| transport | mcp | https://opencode.ai/docs/mcp-servers | "\"OpenCode supports both local and remote servers.\" and \"Once added, MCP tools are automatically available to the LLM\"" |
| runtime | bun | https://opencode.ai/docs/plugins | "\"$\": Bun's shell API for executing commands\" (plugin context property); \"OpenCode runs `bun install` at startup\"" |
| dispatch.namedDispatch | true | https://opencode.ai/docs/agents | "\"Subagents can be invoked: Automatically by primary agents for specialized tasks based on their descriptions. Manually b" |
| dispatch.nested | undocumented | no authoritative doc — searched: https://opencode.ai/docs/agents | — |
| dispatch.maxDepth | undocumented | no authoritative doc — searched: https://opencode.ai/docs/agents | — |
| dispatch.background | false | https://github.com/sst/opencode/issues/5887 | "\"Currently, sub-agent delegation in `opencode` appears to be synchronous or modal... There is no native 'fire-and-forget'" |
| dispatch.subagentToolkit | full | https://opencode.ai/docs/agents | "The 'general' subagent \"Has full tool access (except todo), so it can make file changes when needed.\"" |
Sources consulted:
- https://opencode.ai/docs/plugins
- https://opencode.ai/docs/agents
- https://opencode.ai/docs/commands
- https://opencode.ai/docs/mcp-servers
- /websites/opencode_ai_plugins (Context7)
- /anomalyco/opencode (Context7)
- https://github.com/sst/opencode/issues/5887
Documentation gaps:
- dispatch.nested
- dispatch.maxDepth
---
## cursor
| Axis | Value | Source | Evidence |
|---|---|---|---|
| embeddingMode | imperative | https://cursor.com/docs/sdk/typescript | "local.customTools where you define tool functions that execute 'in your process, so it can reach anything your code can'; Agent.create()" |
| commandSurface | slash-file | https://cursor.com/docs/enterprise/llm-safety-and-controls | "Commands are reusable prompts invoked via slash commands (e.g., /test), while workflows enable multi-step processes" |
| modelMode | passive | https://cursor.com/docs/sdk/python | "The model used for a run can be overridden by passing a ModelSelection object in SendOptions to agent.send()." |
| hookBus | host | https://cursor.com/docs/hooks | "Agent hooks: sessionStart, sessionEnd, preToolUse, postToolUse, subagentStart, subagentStop, beforeShellExecution, afterShellExecution" |
| stateIO | filesystem | https://cursor.com/docs/reference/sandbox | "Local agents run with sandbox options disabled by default." |
| transport | mcp | https://cursor.com/docs/mcp | "The Model Context Protocol (MCP) allows Cursor to connect to external tools and data sources." |
| runtime | node | https://cursor.com/docs/sdk/typescript | "The SDK runs on Node.js. It requires Node.js 22.13 or later and is described as a Node-first package." |
| dispatch.namedDispatch | true | https://cursor.com/docs/subagents | "Invoke specific subagents using slash commands in your prompt. This allows for direct control over which agent performs" |
| dispatch.nested | true | https://cursor.com/docs/sdk/typescript | "The top-level agent and its direct subagents can launch subagents, but a subagent launched by another subagent can't lau" |
| dispatch.maxDepth | 2 | https://cursor.com/docs/sdk/typescript | "The top-level agent and its direct subagents can launch subagents, but a subagent launched by another subagent can't lau" |
| dispatch.background | true | https://cursor.com/docs/subagents | "Background, which returns immediately while the subagent works independently, best for long-running tasks or parallel wo" |
| dispatch.subagentToolkit | full | https://cursor.com/docs/subagents | "Subagents can utilize MCP tools, inheriting all tools available to their parent agent, including those from configured s" |
Sources consulted:
- https://cursor.com/docs/subagents
- https://cursor.com/docs/hooks
- https://cursor.com/docs/sdk/typescript
- https://cursor.com/docs/sdk/python
- https://cursor.com/docs/mcp
- https://cursor.com/docs/reference/sandbox
- https://cursor.com/docs/enterprise/llm-safety-and-controls
- /websites/cursor (Context7)
---
## cline
| Axis | Value | Source | Evidence |
|---|---|---|---|
| embeddingMode | imperative | /cline/cline (Context7) — https://github.com/cline/cline/blob/main/docs/sdk/plugins.mdx | "Implement the AgentPlugin interface to register tools, hooks, and configuration. The setup function is used for registering capabilities." |
| commandSurface | slash-file | /cline/cline (Context7) — https://github.com/cline/cline/blob/main/cline/apps/vscode/src/test/slash-commands.test.ts | "workflow markdown files (with .md, .markdown, or .txt extensions) are invoked as slash commands using their filename." |
| modelMode | active | /cline/cline (Context7) — https://github.com/cline/cline/blob/main/sdk/packages/llms/README.md | "The Runtime API, accessible via createLlmsRuntime(...), allows for the creation of a registry that manages configured providers and their de" |
| hookBus | host | /cline/cline (Context7) — https://github.com/cline/cline/blob/main/sdk/README.md | "Package agent capabilities as extensions (plugins) that can register tools, observe lifecycle events, and modify agent behavior." |
| stateIO | filesystem | /cline/cline (Context7) — https://github.com/cline/cline/blob/main/cline/sdk/packages/shared/src/storage/paths.ts | "resolveClineDir() returns ~/.cline; resolveDocumentsExtensionPath('Workflows') returns ~/Documents/Cline/Workflows." |
| transport | mcp | /cline/cline (Context7) — https://github.com/cline/cline/blob/main/docs/mcp/mcp-overview.mdx | "MCP (Model Context Protocol) enables Cline to interact with external tools and data sources" |
| runtime | node | /cline/cline (Context7) — https://github.com/cline/cline/blob/main/sdk/examples/plugins/typescript-lsp/README.md | "Installs a portable subagent plugin ... cp examples/plugins/agents-squad/index.ts ~/.cline/plugins/portable-subagents.ts." |
| dispatch.namedDispatch | true | /cline/cline (Context7) — https://github.com/cline/cline/blob/main/sdk/examples/plugins/agents-squad/README.md | "parent → start_subagent(preset: \"phantom\", task: \"Map the auth module\") → phantom: save_handoff(...)" |
| dispatch.nested | false | /cline/cline (Context7) — https://github.com/cline/cline/blob/main/docs/features/subagents.mdx | "subagents are restricted from editing files, using the browser, accessing MCP servers, or creating nested subagents." |
| dispatch.maxDepth | 1 | /cline/cline (Context7) — https://github.com/cline/cline/blob/main/docs/features/subagents.mdx | "They are explicitly prohibited from ... spawning other subagents." |
| dispatch.background | true | /cline/cline (Context7) — https://github.com/cline/cline/blob/main/docs/features/subagents.mdx | "Commands executed by subagents run in the background and are strictly limited to read-only operations" |
| dispatch.subagentToolkit | read-only | /cline/cline (Context7) — https://github.com/cline/cline/blob/main/docs/features/subagents.mdx | "Subagents are equipped with tools for read-only operations, including reading file contents (read_file), listing directo" |
Sources consulted:
- https://github.com/cline/cline/blob/main/docs/sdk/plugins.mdx
- https://github.com/cline/cline/blob/main/sdk/README.md
- https://github.com/cline/cline/blob/main/sdk/packages/agents/README.md
- https://github.com/cline/cline/blob/main/sdk/examples/plugins/agents-squad/README.md
- https://github.com/cline/cline/blob/main/docs/features/subagents.mdx
- https://github.com/cline/cline/blob/main/docs/mcp/mcp-overview.mdx
- https://github.com/cline/cline/blob/main/sdk/packages/llms/README.md
- /cline/cline (Context7)
---
## hermes
| Axis | Value | Source | Evidence |
|---|---|---|---|
| embeddingMode | imperative | https://hermes-agent.nousresearch.com/docs/guides/build-a-hermes-plugin | "ctx.register_tool() puts your tool in the registry — the model sees it immediately" |
| commandSurface | slash-programmatic | https://hermes-agent.nousresearch.com/docs/guides/build-a-hermes-plugin | "ctx.register_command('mystatus', handler=_handle_status, description='Show plugin status') — The command appears in autocomplete, /help output" |
| modelMode | active | https://hermes-agent.nousresearch.com/docs/guides/build-a-hermes-plugin | "register_provider(ProviderProfile(name=..., aliases=(...), display_name=..., env_vars=(...), base_url=..., auth_type=..., default_aux_model=" |
| hookBus | host | https://hermes-agent.nousresearch.com/docs/user-guide/features/hooks | "Hermes owns and manages the entire hook infrastructure. At runtime, HookRegistry.discover_and_load() scans ~/.hermes/hooks/" |
| stateIO | filesystem | https://hermes-agent.nousresearch.com/docs/user-guide/configuration | "The agent has the same filesystem access as your user account." |
| transport | mcp | https://hermes-agent.nousresearch.com/docs/user-guide/features/mcp | "MCP support ships with the standard install — no extra step needed." |
| runtime | python | Context7 /nousresearch/hermes-agent | "The plugin and agent runtime is Python (confirmed by register(ctx) in __init__.py, importlib.import_module, run_agent.py, tools/registry.py)" |
| dispatch.namedDispatch | false | https://hermes-agent.nousresearch.com/docs/user-guide/features/delegation | "The documentation contains no mention of named agents. Subagents are identified only by role ('leaf' or 'orchestrator')" |
| dispatch.nested | true | /nousresearch/hermes-agent (Context7) — configuration.md | "max_spawn_depth: 1 — Delegation tree depth cap (1-3, clamped). 1 = flat (default): parent spawns leaves that cannot dele" |
| dispatch.maxDepth | 1 | /nousresearch/hermes-agent (Context7) — configuration.md | "max_spawn_depth: 1 # Delegation tree depth cap (1-3, clamped). 1 = flat (default): parent spawns leaves that cannot dele" |
| dispatch.background | true | https://github.com/NousResearch/hermes-agent/releases/tag/v2026.6.19 | "delegate_task(background=true) dispatches a subagent that runs in the background and returns a handle immediately" |
| dispatch.subagentToolkit | read-only | https://hermes-agent.nousresearch.com/docs/guides/delegation-patterns | "Nested delegation is opt-in; by default, leaf subagents cannot call delegate_task, clarify, memory, send_message, or exe" |
Sources consulted:
- https://hermes-agent.nousresearch.com/docs/user-guide/features/delegation
- https://hermes-agent.nousresearch.com/docs/user-guide/features/hooks
- https://hermes-agent.nousresearch.com/docs/user-guide/features/mcp
- https://hermes-agent.nousresearch.com/docs/user-guide/configuration
- https://hermes-agent.nousresearch.com/docs/guides/build-a-hermes-plugin
- https://hermes-agent.nousresearch.com/docs/guides/delegation-patterns
- https://github.com/NousResearch/hermes-agent/releases/tag/v2026.6.19
- /nousresearch/hermes-agent (Context7)
Documentation gaps:
- runtime — Hermes plugins and agent core run in Python, but this was confirmed by code inspection rather than explicit docs statement.
- dispatch.namedDispatch — docs explicitly confirm no named-agent dispatch in delegate_task; Kanban has named profiles but that is a separate board system not a dispatch mechanism.
---
## antigravity
| Axis | Value | Source | Evidence |
|---|---|---|---|
| embeddingMode | declarative | https://github.com/alphaperseii3000/google-antigravity-docs/blob/master/google-antigravity-docs.md | "Skills require a SKILL.md file; Workflows are saved as markdown files; Rules are manually defined constraints — all configuration-file-based" |
| commandSurface | slash-file | https://github.com/alphaperseii3000/google-antigravity-docs/blob/master/google-antigravity-docs.md | "Workflows are saved as markdown files, providing a repeatable method for executing key processes. They can be invoked in the Agent using a s" |
| modelMode | passive | https://dev.to/arindam_1729/antigravity-cli-a-hands-on-guide-to-googles-terminal-coding-agent-5bc7 | "Selection occurs via `-m` flag or `/model` command inside the TUI. No programmatic model request API is documented for extensions/skills" |
| hookBus | host | https://www.aibuilderclub.com/blog/antigravity-cli-guide | "The CLI fires hooks, not the engine. These are JSON lifecycle interceptors (before tool call, after file edit, on session start)." |
| stateIO | filesystem | https://www.explainx.ai/blog/antigravity-cli-features-sandbox-plugins-subagents-2026 | "Plugin staging at ~/.gemini/antigravity-cli/plugins/<name>/; skills at ~/.gemini/antigravity-cli/skills/" |
| transport | mcp | https://dev.to/arindam_1729/antigravity-cli-a-hands-on-guide-to-googles-terminal-coding-agent-5bc7 | "Both local (stdio) and remote (HTTP) Model Context Protocol servers are supported" |
| runtime | go | https://developers.googleblog.com/an-important-update-transitioning-gemini-cli-to-antigravity-cli/ | "Built in Go, Antigravity CLI is snappier and more responsive." |
| dispatch.namedDispatch | undocumented | no authoritative doc — searched: https://www.aibuilderclub.com/blog/antigravity-cli-guide, https://antigravity.google/docs/agents | — |
| dispatch.nested | undocumented | no authoritative doc — searched: https://antigravity.google/docs/agents | — |
| dispatch.maxDepth | undocumented | no authoritative doc — searched: https://antigravity.google/docs/agents | — |
| dispatch.background | true | https://developers.googleblog.com/an-important-update-transitioning-gemini-cli-to-antigravity-cli/ | "Antigravity CLI orchestrates multiple agents for complex tasks in the background" |
| dispatch.subagentToolkit | undocumented | no authoritative doc — searched: https://www.explainx.ai/blog/antigravity-cli-features-sandbox-plugins-subagents-2026 | — |
Sources consulted:
- https://github.com/alphaperseii3000/google-antigravity-docs/blob/master/google-antigravity-docs.md
- https://developers.googleblog.com/an-important-update-transitioning-gemini-cli-to-antigravity-cli/
- https://dev.to/arindam_1729/antigravity-cli-a-hands-on-guide-to-googles-terminal-coding-agent-5bc7
- https://www.explainx.ai/blog/antigravity-cli-features-sandbox-plugins-subagents-2026
- https://www.aibuilderclub.com/blog/antigravity-cli-guide
- https://antigravity.google/docs/agents
- https://antigravity.google/docs/hooks
Documentation gaps:
- dispatch.namedDispatch — docs describe dynamic plain-English goal dispatch where agent names subagents at runtime; no pre-registered named sub-agent API documented.
- dispatch.nested — no documentation found on whether subagents can themselves spawn further subagents.
- dispatch.maxDepth — no documented depth limit or explicit unbounded statement found.
- dispatch.subagentToolkit — docs describe a permissions approval model but do not explicitly state 'full' vs 'read-only' toolkit scope for subagents.
---
## augment
| Axis | Value | Source | Evidence |
|---|---|---|---|
| embeddingMode | declarative | https://docs.augmentcode.com/cli/plugins | "Plugins can provide several types of components, including Custom Commands defined in Markdown files within the `commands/` directory... Hoo" |
| commandSurface | slash-file | https://docs.augmentcode.com/cli/plugins | "Slash commands are Markdown files in the `commands/` directory. The filename becomes the command name" |
| modelMode | passive | https://docs.augmentcode.com/cli/subagents | "| model | No | Model to use for the agent. If not specified, the CLI default model is used." |
| hookBus | host | https://docs.augmentcode.com/cli/hooks | "Hook event types: PreToolUse (before a tool executes), PostToolUse (immediately after a tool completes), Stop (when the agent stops respondi" |
| stateIO | filesystem | https://github.com/augmentcode/auggie | "Node.js 22+ required. Hook configurations use `${AUGMENT_PLUGIN_ROOT}`" |
| transport | mcp | https://docs.augmentcode.com/cli/plugins | "Auggie supports a plugin system that allows you to extend its functionality with... MCP server integrations." |
| runtime | node | https://github.com/augmentcode/auggie | "Node.js 22+ required" |
| dispatch.namedDispatch | true | https://docs.augmentcode.com/cli/subagents | "| **name** | Yes | Name of the agent | ... you can trigger it by sending a message that references the agent name." |
| dispatch.nested | undocumented | no authoritative doc — searched: https://docs.augmentcode.com/cli/subagents | — |
| dispatch.maxDepth | undocumented | no authoritative doc — searched: https://docs.augmentcode.com/cli/subagents | — |
| dispatch.background | true | https://docs.augmentcode.com/cli/subagents | "Subagents run in parallel with other subagents... will show a summary of their current progress in the main thread." |
| dispatch.subagentToolkit | full | https://docs.augmentcode.com/cli/subagents | "If neither [tools nor disabled_tools] is specified, the subagent has access to all tools (default behavior)." |
Sources consulted:
- https://docs.augmentcode.com/cli/plugins
- https://docs.augmentcode.com/cli/hooks
- https://docs.augmentcode.com/cli/subagents
- https://docs.augmentcode.com/cli/sdk-typescript
- https://docs.augmentcode.com/setup-augment/mcp
- https://github.com/augmentcode/auggie
- /llmstxt/augmentcode_llms-full_txt (Context7)
Documentation gaps:
- dispatch.nested
- dispatch.maxDepth
---
## qwen
| Axis | Value | Source | Evidence |
|---|---|---|---|
| embeddingMode | imperative | https://qwenlm.github.io/qwen-code-docs/en/developers/channel-plugins | "Your entry point exports a ChannelPlugin object... this.registerCommand('mycommand', async (envelope, args) => { ... }); ... plugins load at startup as extensions." |
| commandSurface | slash-file | https://qwenlm.github.io/qwen-code-docs/en/users/extension/introduction | "Extensions can provide custom commands by placing Markdown files in a commands/ subdirectory" |
| modelMode | passive | https://qwenlm.github.io/qwen-code-docs/en/developers/channel-plugins | "The documentation does not expose a direct API for plugins to invoke the LLM or model directly." |
| hookBus | host | https://qwenlm.github.io/qwen-code-docs/en/users/features/hooks | "Qwen Code provides 14 distinct hook events: PreToolUse, PostToolUse, PostToolUseFailure, UserPromptSubmit, SessionStart, SessionEnd, Stop" |
| stateIO | filesystem | https://qwenlm.github.io/qwen-code-docs/en/developers/channel-plugins | "Runtime Environment: Node.js only. The architecture uses standard Node.js APIs: import, async/await, file I/O (writeFileSync), OS utilities" |
| transport | mcp | https://qwenlm.github.io/qwen-code-docs/en/developers/tools/mcp-server | "Qwen Code integrates with MCP servers through a sophisticated discovery and execution system" |
| runtime | node | https://qwenlm.github.io/qwen-code-docs/en/developers/channel-plugins | "Language: Node.js (TypeScript/JavaScript). Execution model: In-process — plugins load at startup as extensions." |
| dispatch.namedDispatch | true | https://qwenlm.github.io/qwen-code-docs/en/users/features/sub-agents/ | "Named subagents are invoked when the AI identifies tasks matching their specialization... Users can also explicitly requ" |
| dispatch.nested | false | https://qwenlm.github.io/qwen-code-docs/en/users/features/sub-agents/ | "Fork children cannot create further forks. This is enforced at runtime — if a fork attempts to spawn another fork, it re" |
| dispatch.maxDepth | 1 | https://qwenlm.github.io/qwen-code-docs/en/users/features/sub-agents/ | "Fork children cannot create further forks. This is enforced at runtime" |
| dispatch.background | true | https://qwenlm.github.io/qwen-code-docs/en/users/features/sub-agents/ | "Runs in background, parent continues immediately... Forks run parallel to the parent; the main conversation continues im" |
| dispatch.subagentToolkit | full | https://qwenlm.github.io/qwen-code-docs/en/users/features/sub-agents/ | "When omitted, the subagent inherits all available tools from the parent session." |
Sources consulted:
- https://qwenlm.github.io/qwen-code-docs/en/developers/channel-plugins
- https://qwenlm.github.io/qwen-code-docs/en/users/features/sub-agents/
- https://qwenlm.github.io/qwen-code-docs/en/users/features/hooks
- https://qwenlm.github.io/qwen-code-docs/en/users/extension/introduction
- https://qwenlm.github.io/qwen-code-docs/en/developers/tools/mcp-server
- /websites/qwenlm_github_io_qwen-code-docs_en (Context7)
- /qwenlm/qwen-code (Context7)
Documentation gaps:
- dispatch.nested — docs only restrict fork-type sub-agents from nesting; whether named sub-agents can themselves spawn named sub-agents is not stated.
- dispatch.maxDepth — depth=1 is documented only for fork sub-agents; depth for named sub-agent chains is undocumented.
---
## codebuddy
| Axis | Value | Source | Evidence |
|---|---|---|---|
| embeddingMode | declarative | https://www.codebuddy.ai/docs/cli/plugins-reference | "Commands are 'plain Markdown file[s]' located in commands/ by default ... a skill is a directory containing a SKILL.md ... The documentation" |
| commandSurface | slash-file | https://www.codebuddy.ai/docs/cli/plugins-reference | "Commands are 'plain Markdown file[s]' located in commands/ by default ... Skills are prefixed with this (e.g., /my-first-plugin:hello)" |
| modelMode | passive | https://www.codebuddy.ai/docs/cli/sdk | "The SDK is not for building plugins that run inside CodeBuddy. It's an external SDK for standalone applications" |
| hookBus | host | https://www.codebuddy.ai/docs/cli/hooks | "Full support for the hook event family (27+ events), covering tool lifecycle (PreToolUse / PostToolUse / PostToolUseFailure)" |
| stateIO | filesystem | https://www.codebuddy.ai/docs/cli/settings | "Storage operates in non-sandboxed mode by default ... Default: Full filesystem access governed by permission rules" |
| transport | mcp | https://www.codebuddy.ai/docs/cli/cli-reference | "MCP (Model Context Protocol) is built-in as a core feature ... codebuddy mcp command to 'Configure Model Context Protocol (MCP) servers'" |
| runtime | node | https://www.codebuddy.ai/docs/cli/sdk | "TypeScript/JavaScript: Node.js >= 18.20 ... npm install @tencent-ai/agent-sdk" |
| dispatch.namedDispatch | true | https://www.codebuddy.ai/docs/cli/sub-agents | "Sub-agents can be invoked explicitly by name: 'Request a specific sub-agent by mentioning it in your command'" |
| dispatch.nested | false | https://www.codebuddy.ai/docs/cli/sub-agents | "This prevents infinite nesting of agents (sub-agents cannot spawn other sub-agents)" |
| dispatch.maxDepth | 1 | https://www.codebuddy.ai/docs/cli/sub-agents | "The architecture enforces exactly one level of nesting — only the main CodeBuddy Code instance can invoke sub-agents." |
| dispatch.background | true | https://www.codebuddy.ai/docs/cli/sub-agents | "Launch a background agent using the run_in_background: true parameter ... Tasks return immediately with an ID" |
| dispatch.subagentToolkit | full | https://www.codebuddy.ai/docs/cli/sub-agents | "By default, sub-agents inherit all tools when the tools field is omitted ... Sub-agents can access MCP tools from config" |
Sources consulted:
- https://www.codebuddy.ai/docs/cli/plugins
- https://www.codebuddy.ai/docs/cli/plugins-reference
- https://www.codebuddy.ai/docs/cli/sub-agents
- https://www.codebuddy.ai/docs/cli/hooks
- https://www.codebuddy.ai/docs/cli/sdk
- https://www.codebuddy.ai/docs/cli/settings
- /websites/codebuddy_cn (Context7)
---
## copilot
| Axis | Value | Source | Evidence |
|---|---|---|---|
| embeddingMode | declarative | https://docs.github.com/en/copilot/concepts/agents/copilot-cli/comparing-cli-features | "Declarative elements include custom instructions, skills, custom agents, and plugin configurations—all defined through configuration files" |
| commandSurface | slash-file | https://docs.github.com/en/copilot/concepts/agents/copilot-cli/comparing-cli-features | "Skills: Markdown files with instructions for specific contexts. Users can invoke via slash commands (e.g., /Markdown-Checker check README.md)" |
| modelMode | passive | https://github.com/github/copilot-sdk/blob/main/docs/auth/byok.md | "Model selection via config: model: 'gpt-4.1', provider: { type: 'openai', ... }." |
| hookBus | host | https://docs.github.com/en/copilot/reference/hooks-reference | "Hooks allow you to extend and customize the behavior of GitHub Copilot agents by executing custom shell commands at key points during agent" |
| stateIO | filesystem | https://docs.github.com/en/copilot/how-tos/copilot-cli/customize-copilot/add-mcp-servers | "Configuration file Location: ~/.copilot/mcp-config.json. Hook config files stored in .github/hooks/*.json" |
| transport | mcp | https://docs.github.com/en/copilot/how-tos/copilot-cli/customize-copilot/add-mcp-servers | "Copilot CLI comes with the GitHub MCP server already configured. STDIO is the standard transport." |
| runtime | undocumented | no authoritative doc — searched: https://github.com/github/copilot-cli/blob/main/README.md, https://github.com/github/copilot-sdk/blob/main/nodejs/README.md | — |
| dispatch.namedDispatch | true | https://github.com/github/copilot-sdk/blob/main/docs/features/custom-agents.md | "A custom agent is a named agent configuration that includes its own prompt and tool set. A sub-agent is a custom agent i" |
| dispatch.nested | false | https://awesome-copilot.github.com/learning-hub/agents-and-subagents/ | "By default, subagents do not keep spawning additional subagents." |
| dispatch.maxDepth | 1 | https://awesome-copilot.github.com/learning-hub/agents-and-subagents/ | "Depth counts how many agents are nested within one another. When the depth limit is reached, the innermost agent cannot" |
| dispatch.background | true | https://docs.github.com/en/copilot/how-tos/copilot-cli/speed-up-task-completion | "Allow Copilot to use subagents and work autonomously to implement the plan without any further input." |
| dispatch.subagentToolkit | full | https://docs.github.com/en/copilot/how-tos/copilot-cli/customize-copilot/create-custom-agents-for-cli | "By default, custom agents have access to all tools. If you restrict an agent's access, a tools specification is added" |
Sources consulted:
- https://github.com/github/copilot-cli/blob/main/README.md (via Context7 /github/copilot-cli)
- https://github.com/github/copilot-sdk/blob/main/docs/features/custom-agents.md (via Context7 /github/copilot-sdk)
- https://docs.github.com/en/copilot/how-tos/copilot-cli/customize-copilot/add-mcp-servers
- https://docs.github.com/en/copilot/reference/hooks-reference
- https://docs.github.com/en/copilot/concepts/agents/copilot-cli/comparing-cli-features
- https://awesome-copilot.github.com/learning-hub/agents-and-subagents/
Documentation gaps:
- runtime — docs describe the CLI binary and the SDK (Node.js/Go/Python/Rust) but do not state what runtime the CLI host itself or its plugin/extension loader executes in.
- dispatch.nested exact authoritative source is awesome-copilot.github.com (community docs) not docs.github.com.
---
## kilo
| Axis | Value | Source | Evidence |
|---|---|---|---|
| embeddingMode | imperative | https://kilo.ai/docs/automate/extending/plugins | "Plugins extend Kilo by hooking into events and adding functionality. They can: add custom tools the model can call (like read, write, bash)" |
| commandSurface | slash-file | https://kilo.ai/docs/customize/workflows | "Workflows, also known as slash commands, allow users to automate repetitive tasks by defining step-by-step instructions" |
| modelMode | active | https://kilo.ai/docs/automate/extending/plugins | "provider — dynamically supply model catalogs. auth — register OAuth or API-key flows for model providers. chat.params — Mutate temperature" |
| hookBus | host | https://kilo.ai/docs/automate/extending/plugins | "event — fires for every internal bus event. Session: session.created, session.updated, session.idle, session.error, session.deleted" |
| stateIO | filesystem | https://kilo.ai/docs/contributing/architecture | "Local execution and hosted execution are separate boundaries. Local runtime instances are Directory-keyed runtime context" |
| transport | mcp | https://kilo.ai/docs/automate/mcp/what-is-mcp | "Kilo Code implements the Model Context Protocol to connect to both local and remote MCP servers" |
| runtime | bun | https://kilo.ai/docs/automate/extending/plugins | "npm plugins are installed automatically at startup using Bun. Plugin context includes $ (Bun shell). Plugins are TypeScript or JavaScript mo" |
| dispatch.namedDispatch | true | https://kilo.ai/docs/customize/custom-subagents | "Configured subagents can be invoked automatically by primary agents (like the Orchestrator) using the Task tool" |
| dispatch.nested | true | https://github.com/Kilo-Org/kilocode/issues/7055 | "A subagent can still call the task tool if its merged permissions contain an explicit task rule, which enables nested su" |
| dispatch.maxDepth | -1 | https://github.com/Kilo-Org/kilocode/issues/8637 | "there is no maximum nesting depth and the system relies entirely on permission gating" |
| dispatch.background | true | https://kilo.ai/docs/code-with-ai/agents/orchestrator-mode | "Agents are also capable of launching multiple subagent sessions concurrently to facilitate parallel processing." |
| dispatch.subagentToolkit | undocumented | no authoritative doc — searched: https://kilo.ai/docs/customize/custom-subagents | — |
Sources consulted:
- https://kilo.ai/docs/automate/extending/plugins
- https://kilo.ai/docs/customize/custom-subagents
- https://kilo.ai/docs/customize/workflows
- https://kilo.ai/docs/automate/mcp/what-is-mcp
- https://kilo.ai/docs/code-with-ai/agents/orchestrator-mode
- https://kilo.ai/docs/contributing/architecture
- https://github.com/Kilo-Org/kilocode/issues/7055
- https://github.com/Kilo-Org/kilocode/issues/8637
- /websites/kilo_ai (Context7)
Documentation gaps:
- dispatch.subagentToolkit — docs describe per-subagent configurable permissions (allow/ask/deny) but do not document a single default toolkit level (full vs read-only) for subagents that lack explicit permission overrides.
---
## windsurf
| Axis | Value | Source | Evidence |
|---|---|---|---|
| embeddingMode | declarative | https://docs.devin.ai/desktop/cascade/cascade | "Cascade operates through configuration files rather than code plugins: .codeiumignore for file filtering, Memories and Rules for customizing" |
| commandSurface | slash-file | https://docs.devin.ai/desktop/cascade/workflows | "Workflows are authored as markdown files (.md extension) … triggered through slash commands using the format /[workflow-name]." |
| modelMode | passive | https://docs.devin.ai/desktop/models.md | "Models are selectable via configuration/UI only (SWE-1.5, SWE-1.6, Adaptive, Arena tiers, Claude, GPT)." |
| hookBus | host | https://docs.devin.ai/desktop/cascade/hooks.md | "Cascade supports twelve hook events covering critical workflow points … Pre-hooks (can block actions): pre_read_code, pre_write_code, pre_ru" |
| stateIO | filesystem | https://docs.devin.ai/desktop/cascade/cascade | "Cascade can create and modify codebases directly … File access can be restricted through .codeiumignore files" |
| transport | mcp | https://docs.devin.ai/desktop/cascade/mcp | "Cascade now natively integrates with MCP, allowing you to bring your own selection of MCP servers for Cascade to use." |
| runtime | undocumented | no authoritative doc — searched: https://docs.devin.ai/windsurf/plugins/getting-started.md, /llmstxt/windsurf_llms-full_txt (Context7) | — |
| dispatch.namedDispatch | undocumented | no authoritative doc — searched: https://docs.devin.ai/cli/subagents.md, https://docs.devin.ai/desktop/agent-command-center.md | — |
| dispatch.nested | undocumented | no authoritative doc — searched: https://docs.devin.ai/cli/subagents.md | — |
| dispatch.maxDepth | undocumented | no authoritative doc — searched: https://docs.devin.ai/cli/subagents.md | — |
| dispatch.background | undocumented | no authoritative doc — searched: https://docs.devin.ai/desktop/acp.md, https://docs.devin.ai/cli/subagents.md | — |
| dispatch.subagentToolkit | undocumented | no authoritative doc — searched: https://docs.devin.ai/cli/subagents.md | — |
Sources consulted:
- https://docs.devin.ai/desktop/cascade/workflows
- https://docs.devin.ai/desktop/cascade/mcp
- https://docs.devin.ai/desktop/cascade/hooks.md
- https://docs.devin.ai/desktop/cascade/cascade
- https://docs.devin.ai/desktop/models.md
- https://docs.devin.ai/windsurf/plugins/getting-started.md
- https://docs.devin.ai/cli/subagents.md
- /llmstxt/windsurf_llms-full_txt (Context7)
Documentation gaps:
- dispatch.namedDispatch — Cascade docs do not document a user-facing named sub-agent dispatch system.
- dispatch.nested — no documentation for nested sub-agent support in Windsurf Cascade.
- dispatch.maxDepth — no documented depth limit for Cascade sub-agents.
- dispatch.background — Cascade has an internal background planning agent but no documented user-facing background sub-agent dispatch.
- dispatch.subagentToolkit — no documentation for toolkit restrictions on Cascade sub-agents.
- runtime — Windsurf IDE is Electron-based but no programmatic plugin runtime is documented to developers.
---
## trae
| Axis | Value | Source | Evidence |
|---|---|---|---|
| embeddingMode | imperative | https://traeide.com/docs/how-to-manage-extensions-in-trae-ide | "Trae IDE is a VSCode fork; 'If an extension isn't available in Trae's store, you can install it from VS Code's marketplace' — inherits VSCode in-process extension model" |
| commandSurface | slash-file | https://docs.trae.ai/ide/skills | "Skills stored as SKILL.md files in '.trae/skills/{skill_name}/' directory; 'Trae allows you to manually trigger skills if needed'" |
| modelMode | passive | https://docs.trae.ai/ide/models | "Model selection via UI: 'click on the current model name to open the model list'; no programmatic model/LLM request API documented for plugins" |
| hookBus | engine | https://news.ycombinator.com/item?id=44703164 | "Trae is 'ByteDance's VSCode fork' built on Electron/Monaco; inherits VSCode extension host lifecycle (activate/deactivate hooks, event subsc" |
| stateIO | filesystem | https://traeide.com/news/6 | "Rules at '.trae/project_rules.md', skills at '.trae/skills/', MCP config at '.trae/mcp.json'; 'codebase files always remain on your local de" |
| transport | mcp | https://docs.trae.ai/ide/model-context-protocol | "Page title from official docs: 'In TRAE IDE, MCP servers support three transport types' — MCP is built-in" |
| runtime | node | https://news.ycombinator.com/item?id=44703164 | "Trae is a VSCode fork built on Electron; 'Electron is designed to create desktop applications… a backend using the Node.js runtime'" |
| dispatch.namedDispatch | true | https://docs.trae.ai/ide/agent | "Agents in Trae 'can be called individually, or automatically called by SOLO Agent at the corresponding stage'" |
| dispatch.nested | undocumented | no authoritative doc — searched: https://docs.trae.ai/ide/solo-mode, https://docs.trae.ai/ide/agent | — |
| dispatch.maxDepth | undocumented | no authoritative doc — searched: https://docs.trae.ai/ide/solo-mode | — |
| dispatch.background | true | https://news.aibase.com/news/22829 | "SOLO 'supports multi-tasking, allowing you to work on multiple development tasks simultaneously'; 'run multiple agents i" |
| dispatch.subagentToolkit | undocumented | no authoritative doc — searched: https://docs.trae.ai/ide/agent | — |
Sources consulted:
- https://docs.trae.ai/ide/model-context-protocol
- https://docs.trae.ai/ide/agent
- https://docs.trae.ai/ide/skills
- https://docs.trae.ai/ide/solo-mode
- https://docs.trae.ai/ide/solo-coder
- https://traeide.com/news/6
- https://traeide.com/docs/how-to-manage-extensions-in-trae-ide
- https://news.ycombinator.com/item?id=44703164
- https://news.aibase.com/news/22829
Documentation gaps:
- dispatch.nested — docs describe two-tier orchestration (SOLO → named agents) but do not state whether a spawned sub-agent can itself spawn further sub-agents.
- dispatch.maxDepth — no integer depth limit documented beyond one orchestrator level.
- dispatch.subagentToolkit — docs say agents can be configured with 'callable MCP services and other capabilities' but do not state whether sub-agents receive a full vs. restricted tool set.
---
## kimi
| Axis | Value | Source | Evidence |
|---|---|---|---|
| embeddingMode | imperative | https://context7.com/moonshotai/kimi-cli/llms.txt | "from kimi_cli.app import KimiCLI, enable_logging ... instance = await KimiCLI.create(session, agent_file=myagent) ... class Ls(CallableTool2)" |
| commandSurface | slash-file | https://github.com/moonshotai/kimi-cli/blob/main/docs/en/customization/skills.md | "/skill:code-style ... /flow:code-review — Skills are SKILL.md markdown files with YAML frontmatter that become /skill:<name> and /flow:<name>" |
| modelMode | passive | https://github.com/moonshotai/kimi-cli/blob/main/docs/en/configuration/providers.md | "Use the `/model` command to switch between available models and thinking modes ... `--model` option overrides the default model" |
| hookBus | host | https://moonshotai.github.io/kimi-cli/en/customization/hooks.html | "Core: Add hooks system (Beta) — configure `[[hooks]]` in `config.toml` to run custom shell commands at 13 lifecycle events including `PreToo" |
| stateIO | filesystem | https://github.com/MoonshotAI/kimi-cli | "Kimi Code CLI is an AI agent that runs in the terminal ... capable of reading and editing code, executing shell commands, searching files" |
| transport | mcp | https://github.com/moonshotai/kimi-cli/blob/main/docs/en/reference/kimi-mcp.md | "kimi mcp add ... --transport stdio|http ... Manage MCP Servers: Use the kimi mcp sub-command group to add, list, remove, or authorize MCP se" |
| runtime | python | https://context7.com/moonshotai/kimi-cli/llms.txt | "from kimi_cli.app import KimiCLI ... from kosong.tooling import CallableTool2 — CLI core is Python" |
| dispatch.namedDispatch | true | https://moonshotai.github.io/kimi-cli/en/customization/agents.html | "subagents:\n coder:\n path: ./coder-sub.yaml\n description: \"Handle coding tasks\"\n reviewer:\n path: ./reviewer-sub.yaml" |
| dispatch.nested | false | https://moonshotai.github.io/kimi-cli/en/customization/agents.html | "All subagent types are prohibited from nesting the `Agent` tool (subagents cannot create their own subagents). Only root" |
| dispatch.maxDepth | 1 | https://moonshotai.github.io/kimi-cli/en/customization/agents.html | "All subagent types are prohibited from nesting the `Agent` tool (subagents cannot create their own subagents). Only root" |
| dispatch.background | true | https://moonshotai.github.io/kimi-cli/en/customization/agents.html | "Subagents support foreground and background modes. The `run_in_background` parameter allows tasks to execute asynchronou" |
| dispatch.subagentToolkit | undocumented | no authoritative doc — searched: https://moonshotai.github.io/kimi-cli/en/customization/agents.html | — |
Sources consulted:
- https://moonshotai.github.io/kimi-cli/en/customization/hooks.html
- https://moonshotai.github.io/kimi-cli/en/customization/agents.html
- https://github.com/MoonshotAI/kimi-cli
- https://github.com/moonshotai/kimi-cli/blob/main/docs/en/customization/skills.md
- https://github.com/moonshotai/kimi-cli/blob/main/docs/en/customization/agents.md
- https://github.com/moonshotai/kimi-cli/blob/main/docs/en/reference/kimi-mcp.md
- https://context7.com/moonshotai/kimi-cli/llms.txt
- /moonshotai/kimi-cli (Context7)
Documentation gaps:
- dispatch.subagentToolkit — docs show three built-in subagent types each with different tool subsets (coder=full, explore=read-only, plan=no shell/write); no single 'full' or 'read-only' value covers all types; maintainer should clarify the intended classification.
- runtime — CLI core is Python; a Rust Wire implementation also exists; docs do not state a canonical plugin extension runtime.

View File

@@ -39,6 +39,7 @@ export default tseslint.config(
'**/*.generated.cjs',
// ADR-457: tsc-generated runtime artifact — lint the src/*.cts source, not the emitted .cjs.
'gsd-core/bin/lib/semver-compare.cjs',
'gsd-core/bin/lib/host-integration.cjs',
'gsd-core/bin/lib/capability-loader.cjs',
'gsd-core/bin/lib/capability-source.cjs',
'gsd-core/bin/lib/capability-ledger.cjs',

View File

@@ -117,7 +117,23 @@ const capabilities = {
"installSurface": "settings-json",
"writesSharedSettings": true,
"permissionWriter": null,
"extendedHookEvents": []
"extendedHookEvents": [],
"hostIntegration": {
"embeddingMode": "declarative",
"commandSurface": "slash-file",
"dispatch": {
"namedDispatch": "undocumented",
"nested": "undocumented",
"maxDepth": "undocumented",
"background": true,
"subagentToolkit": "undocumented"
},
"modelMode": "passive",
"hookBus": "host",
"stateIO": "filesystem",
"transport": "mcp",
"runtime": "go"
}
}
},
"audit": {
@@ -223,7 +239,23 @@ const capabilities = {
"installSurface": "settings-json",
"writesSharedSettings": true,
"permissionWriter": null,
"extendedHookEvents": []
"extendedHookEvents": [],
"hostIntegration": {
"embeddingMode": "declarative",
"commandSurface": "slash-file",
"dispatch": {
"namedDispatch": true,
"nested": "undocumented",
"maxDepth": "undocumented",
"background": true,
"subagentToolkit": "full"
},
"modelMode": "passive",
"hookBus": "host",
"stateIO": "filesystem",
"transport": "mcp",
"runtime": "node"
}
}
},
"claude": {
@@ -289,7 +321,23 @@ const capabilities = {
"Stop",
"PreCompact",
"FileChanged"
]
],
"hostIntegration": {
"embeddingMode": "imperative",
"commandSurface": "slash-file",
"dispatch": {
"namedDispatch": true,
"nested": true,
"maxDepth": 5,
"background": true,
"subagentToolkit": "full"
},
"modelMode": "passive",
"hookBus": "host",
"stateIO": "filesystem",
"transport": "mcp",
"runtime": "node"
}
}
},
"cline": {
@@ -332,7 +380,23 @@ const capabilities = {
"installSurface": "cline-rules",
"writesSharedSettings": false,
"permissionWriter": null,
"extendedHookEvents": []
"extendedHookEvents": [],
"hostIntegration": {
"embeddingMode": "imperative",
"commandSurface": "slash-file",
"dispatch": {
"namedDispatch": true,
"nested": false,
"maxDepth": 1,
"background": true,
"subagentToolkit": "read-only"
},
"modelMode": "active",
"hookBus": "host",
"stateIO": "filesystem",
"transport": "mcp",
"runtime": "node"
}
}
},
"code-review": {
@@ -462,7 +526,23 @@ const capabilities = {
"installSurface": "settings-json",
"writesSharedSettings": true,
"permissionWriter": null,
"extendedHookEvents": []
"extendedHookEvents": [],
"hostIntegration": {
"embeddingMode": "declarative",
"commandSurface": "slash-file",
"dispatch": {
"namedDispatch": true,
"nested": false,
"maxDepth": 1,
"background": true,
"subagentToolkit": "full"
},
"modelMode": "passive",
"hookBus": "host",
"stateIO": "filesystem",
"transport": "mcp",
"runtime": "node"
}
}
},
"codex": {
@@ -515,7 +595,23 @@ const capabilities = {
"installSurface": "codex-toml",
"writesSharedSettings": false,
"permissionWriter": null,
"extendedHookEvents": []
"extendedHookEvents": [],
"hostIntegration": {
"embeddingMode": "declarative",
"commandSurface": "slash-file",
"dispatch": {
"namedDispatch": true,
"nested": true,
"maxDepth": 1,
"background": true,
"subagentToolkit": "full"
},
"modelMode": "passive",
"hookBus": "host",
"stateIO": "filesystem",
"transport": "mcp",
"runtime": "node"
}
}
},
"copilot": {
@@ -568,7 +664,23 @@ const capabilities = {
"installSurface": "copilot-instructions",
"writesSharedSettings": false,
"permissionWriter": null,
"extendedHookEvents": []
"extendedHookEvents": [],
"hostIntegration": {
"embeddingMode": "declarative",
"commandSurface": "slash-file",
"dispatch": {
"namedDispatch": true,
"nested": false,
"maxDepth": 1,
"background": true,
"subagentToolkit": "full"
},
"modelMode": "passive",
"hookBus": "host",
"stateIO": "filesystem",
"transport": "mcp",
"runtime": "undocumented"
}
}
},
"cursor": {
@@ -637,7 +749,23 @@ const capabilities = {
"installSurface": "cursor-hooks-json",
"writesSharedSettings": false,
"permissionWriter": null,
"extendedHookEvents": []
"extendedHookEvents": [],
"hostIntegration": {
"embeddingMode": "imperative",
"commandSurface": "slash-file",
"dispatch": {
"namedDispatch": true,
"nested": true,
"maxDepth": 2,
"background": true,
"subagentToolkit": "full"
},
"modelMode": "passive",
"hookBus": "host",
"stateIO": "filesystem",
"transport": "mcp",
"runtime": "node"
}
}
},
"drift": {
@@ -813,7 +941,23 @@ const capabilities = {
"BeforeAgent",
"AfterAgent",
"BeforeModel"
]
],
"hostIntegration": {
"embeddingMode": "declarative",
"commandSurface": "slash-toml",
"dispatch": {
"namedDispatch": true,
"nested": false,
"maxDepth": 1,
"background": "undocumented",
"subagentToolkit": "undocumented"
},
"modelMode": "passive",
"hookBus": "host",
"stateIO": "filesystem",
"transport": "mcp",
"runtime": "node"
}
}
},
"graphify": {
@@ -907,7 +1051,23 @@ const capabilities = {
"installSurface": "settings-json",
"writesSharedSettings": true,
"permissionWriter": null,
"extendedHookEvents": []
"extendedHookEvents": [],
"hostIntegration": {
"embeddingMode": "imperative",
"commandSurface": "slash-programmatic",
"dispatch": {
"namedDispatch": false,
"nested": true,
"maxDepth": 1,
"background": true,
"subagentToolkit": "read-only"
},
"modelMode": "active",
"hookBus": "host",
"stateIO": "filesystem",
"transport": "mcp",
"runtime": "python"
}
}
},
"intel": {
@@ -1034,7 +1194,23 @@ const capabilities = {
"installSurface": "settings-json",
"writesSharedSettings": false,
"permissionWriter": "kilo",
"extendedHookEvents": []
"extendedHookEvents": [],
"hostIntegration": {
"embeddingMode": "imperative",
"commandSurface": "slash-file",
"dispatch": {
"namedDispatch": true,
"nested": true,
"maxDepth": -1,
"background": true,
"subagentToolkit": "undocumented"
},
"modelMode": "active",
"hookBus": "host",
"stateIO": "filesystem",
"transport": "mcp",
"runtime": "bun"
}
}
},
"kimi": {
@@ -1090,7 +1266,23 @@ const capabilities = {
"installSurface": "profile-marker-only",
"writesSharedSettings": false,
"permissionWriter": null,
"extendedHookEvents": []
"extendedHookEvents": [],
"hostIntegration": {
"embeddingMode": "imperative",
"commandSurface": "slash-file",
"dispatch": {
"namedDispatch": true,
"nested": false,
"maxDepth": 1,
"background": true,
"subagentToolkit": "undocumented"
},
"modelMode": "passive",
"hookBus": "host",
"stateIO": "filesystem",
"transport": "mcp",
"runtime": "python"
}
}
},
"mempalace": {
@@ -1384,7 +1576,23 @@ const capabilities = {
"installSurface": "settings-json",
"writesSharedSettings": true,
"permissionWriter": "opencode",
"extendedHookEvents": []
"extendedHookEvents": [],
"hostIntegration": {
"embeddingMode": "imperative",
"commandSurface": "slash-file",
"dispatch": {
"namedDispatch": true,
"nested": "undocumented",
"maxDepth": "undocumented",
"background": false,
"subagentToolkit": "full"
},
"modelMode": "active",
"hookBus": "host",
"stateIO": "filesystem",
"transport": "mcp",
"runtime": "bun"
}
}
},
"pattern-mapper": {
@@ -1572,7 +1780,23 @@ const capabilities = {
"SubagentStop",
"Stop",
"PreCompact"
]
],
"hostIntegration": {
"embeddingMode": "imperative",
"commandSurface": "slash-file",
"dispatch": {
"namedDispatch": true,
"nested": false,
"maxDepth": 1,
"background": true,
"subagentToolkit": "full"
},
"modelMode": "passive",
"hookBus": "host",
"stateIO": "filesystem",
"transport": "mcp",
"runtime": "node"
}
}
},
"research": {
@@ -1874,7 +2098,23 @@ const capabilities = {
"installSurface": "profile-marker-only",
"writesSharedSettings": false,
"permissionWriter": null,
"extendedHookEvents": []
"extendedHookEvents": [],
"hostIntegration": {
"embeddingMode": "imperative",
"commandSurface": "slash-file",
"dispatch": {
"namedDispatch": true,
"nested": "undocumented",
"maxDepth": "undocumented",
"background": true,
"subagentToolkit": "undocumented"
},
"modelMode": "passive",
"hookBus": "engine",
"stateIO": "filesystem",
"transport": "mcp",
"runtime": "node"
}
}
},
"ui": {
@@ -2013,7 +2253,23 @@ const capabilities = {
"installSurface": "profile-marker-only",
"writesSharedSettings": false,
"permissionWriter": null,
"extendedHookEvents": []
"extendedHookEvents": [],
"hostIntegration": {
"embeddingMode": "declarative",
"commandSurface": "slash-file",
"dispatch": {
"namedDispatch": "undocumented",
"nested": "undocumented",
"maxDepth": "undocumented",
"background": "undocumented",
"subagentToolkit": "undocumented"
},
"modelMode": "passive",
"hookBus": "host",
"stateIO": "filesystem",
"transport": "mcp",
"runtime": "undocumented"
}
}
}
};
@@ -2797,7 +3053,23 @@ const runtimes = {
"installSurface": "settings-json",
"writesSharedSettings": true,
"permissionWriter": null,
"extendedHookEvents": []
"extendedHookEvents": [],
"hostIntegration": {
"embeddingMode": "declarative",
"commandSurface": "slash-file",
"dispatch": {
"namedDispatch": "undocumented",
"nested": "undocumented",
"maxDepth": "undocumented",
"background": true,
"subagentToolkit": "undocumented"
},
"modelMode": "passive",
"hookBus": "host",
"stateIO": "filesystem",
"transport": "mcp",
"runtime": "go"
}
}
},
"augment": {
@@ -2866,7 +3138,23 @@ const runtimes = {
"installSurface": "settings-json",
"writesSharedSettings": true,
"permissionWriter": null,
"extendedHookEvents": []
"extendedHookEvents": [],
"hostIntegration": {
"embeddingMode": "declarative",
"commandSurface": "slash-file",
"dispatch": {
"namedDispatch": true,
"nested": "undocumented",
"maxDepth": "undocumented",
"background": true,
"subagentToolkit": "full"
},
"modelMode": "passive",
"hookBus": "host",
"stateIO": "filesystem",
"transport": "mcp",
"runtime": "node"
}
}
},
"claude": {
@@ -2932,7 +3220,23 @@ const runtimes = {
"Stop",
"PreCompact",
"FileChanged"
]
],
"hostIntegration": {
"embeddingMode": "imperative",
"commandSurface": "slash-file",
"dispatch": {
"namedDispatch": true,
"nested": true,
"maxDepth": 5,
"background": true,
"subagentToolkit": "full"
},
"modelMode": "passive",
"hookBus": "host",
"stateIO": "filesystem",
"transport": "mcp",
"runtime": "node"
}
}
},
"cline": {
@@ -2975,7 +3279,23 @@ const runtimes = {
"installSurface": "cline-rules",
"writesSharedSettings": false,
"permissionWriter": null,
"extendedHookEvents": []
"extendedHookEvents": [],
"hostIntegration": {
"embeddingMode": "imperative",
"commandSurface": "slash-file",
"dispatch": {
"namedDispatch": true,
"nested": false,
"maxDepth": 1,
"background": true,
"subagentToolkit": "read-only"
},
"modelMode": "active",
"hookBus": "host",
"stateIO": "filesystem",
"transport": "mcp",
"runtime": "node"
}
}
},
"codebuddy": {
@@ -3044,7 +3364,23 @@ const runtimes = {
"installSurface": "settings-json",
"writesSharedSettings": true,
"permissionWriter": null,
"extendedHookEvents": []
"extendedHookEvents": [],
"hostIntegration": {
"embeddingMode": "declarative",
"commandSurface": "slash-file",
"dispatch": {
"namedDispatch": true,
"nested": false,
"maxDepth": 1,
"background": true,
"subagentToolkit": "full"
},
"modelMode": "passive",
"hookBus": "host",
"stateIO": "filesystem",
"transport": "mcp",
"runtime": "node"
}
}
},
"codex": {
@@ -3097,7 +3433,23 @@ const runtimes = {
"installSurface": "codex-toml",
"writesSharedSettings": false,
"permissionWriter": null,
"extendedHookEvents": []
"extendedHookEvents": [],
"hostIntegration": {
"embeddingMode": "declarative",
"commandSurface": "slash-file",
"dispatch": {
"namedDispatch": true,
"nested": true,
"maxDepth": 1,
"background": true,
"subagentToolkit": "full"
},
"modelMode": "passive",
"hookBus": "host",
"stateIO": "filesystem",
"transport": "mcp",
"runtime": "node"
}
}
},
"copilot": {
@@ -3150,7 +3502,23 @@ const runtimes = {
"installSurface": "copilot-instructions",
"writesSharedSettings": false,
"permissionWriter": null,
"extendedHookEvents": []
"extendedHookEvents": [],
"hostIntegration": {
"embeddingMode": "declarative",
"commandSurface": "slash-file",
"dispatch": {
"namedDispatch": true,
"nested": false,
"maxDepth": 1,
"background": true,
"subagentToolkit": "full"
},
"modelMode": "passive",
"hookBus": "host",
"stateIO": "filesystem",
"transport": "mcp",
"runtime": "undocumented"
}
}
},
"cursor": {
@@ -3219,7 +3587,23 @@ const runtimes = {
"installSurface": "cursor-hooks-json",
"writesSharedSettings": false,
"permissionWriter": null,
"extendedHookEvents": []
"extendedHookEvents": [],
"hostIntegration": {
"embeddingMode": "imperative",
"commandSurface": "slash-file",
"dispatch": {
"namedDispatch": true,
"nested": true,
"maxDepth": 2,
"background": true,
"subagentToolkit": "full"
},
"modelMode": "passive",
"hookBus": "host",
"stateIO": "filesystem",
"transport": "mcp",
"runtime": "node"
}
}
},
"gemini": {
@@ -3276,7 +3660,23 @@ const runtimes = {
"BeforeAgent",
"AfterAgent",
"BeforeModel"
]
],
"hostIntegration": {
"embeddingMode": "declarative",
"commandSurface": "slash-toml",
"dispatch": {
"namedDispatch": true,
"nested": false,
"maxDepth": 1,
"background": "undocumented",
"subagentToolkit": "undocumented"
},
"modelMode": "passive",
"hookBus": "host",
"stateIO": "filesystem",
"transport": "mcp",
"runtime": "node"
}
}
},
"hermes": {
@@ -3329,7 +3729,23 @@ const runtimes = {
"installSurface": "settings-json",
"writesSharedSettings": true,
"permissionWriter": null,
"extendedHookEvents": []
"extendedHookEvents": [],
"hostIntegration": {
"embeddingMode": "imperative",
"commandSurface": "slash-programmatic",
"dispatch": {
"namedDispatch": false,
"nested": true,
"maxDepth": 1,
"background": true,
"subagentToolkit": "read-only"
},
"modelMode": "active",
"hookBus": "host",
"stateIO": "filesystem",
"transport": "mcp",
"runtime": "python"
}
}
},
"kilo": {
@@ -3404,7 +3820,23 @@ const runtimes = {
"installSurface": "settings-json",
"writesSharedSettings": false,
"permissionWriter": "kilo",
"extendedHookEvents": []
"extendedHookEvents": [],
"hostIntegration": {
"embeddingMode": "imperative",
"commandSurface": "slash-file",
"dispatch": {
"namedDispatch": true,
"nested": true,
"maxDepth": -1,
"background": true,
"subagentToolkit": "undocumented"
},
"modelMode": "active",
"hookBus": "host",
"stateIO": "filesystem",
"transport": "mcp",
"runtime": "bun"
}
}
},
"kimi": {
@@ -3460,7 +3892,23 @@ const runtimes = {
"installSurface": "profile-marker-only",
"writesSharedSettings": false,
"permissionWriter": null,
"extendedHookEvents": []
"extendedHookEvents": [],
"hostIntegration": {
"embeddingMode": "imperative",
"commandSurface": "slash-file",
"dispatch": {
"namedDispatch": true,
"nested": false,
"maxDepth": 1,
"background": true,
"subagentToolkit": "undocumented"
},
"modelMode": "passive",
"hookBus": "host",
"stateIO": "filesystem",
"transport": "mcp",
"runtime": "python"
}
}
},
"opencode": {
@@ -3530,7 +3978,23 @@ const runtimes = {
"installSurface": "settings-json",
"writesSharedSettings": true,
"permissionWriter": "opencode",
"extendedHookEvents": []
"extendedHookEvents": [],
"hostIntegration": {
"embeddingMode": "imperative",
"commandSurface": "slash-file",
"dispatch": {
"namedDispatch": true,
"nested": "undocumented",
"maxDepth": "undocumented",
"background": false,
"subagentToolkit": "full"
},
"modelMode": "active",
"hookBus": "host",
"stateIO": "filesystem",
"transport": "mcp",
"runtime": "bun"
}
}
},
"qwen": {
@@ -3587,7 +4051,23 @@ const runtimes = {
"SubagentStop",
"Stop",
"PreCompact"
]
],
"hostIntegration": {
"embeddingMode": "imperative",
"commandSurface": "slash-file",
"dispatch": {
"namedDispatch": true,
"nested": false,
"maxDepth": 1,
"background": true,
"subagentToolkit": "full"
},
"modelMode": "passive",
"hookBus": "host",
"stateIO": "filesystem",
"transport": "mcp",
"runtime": "node"
}
}
},
"trae": {
@@ -3639,7 +4119,23 @@ const runtimes = {
"installSurface": "profile-marker-only",
"writesSharedSettings": false,
"permissionWriter": null,
"extendedHookEvents": []
"extendedHookEvents": [],
"hostIntegration": {
"embeddingMode": "imperative",
"commandSurface": "slash-file",
"dispatch": {
"namedDispatch": true,
"nested": "undocumented",
"maxDepth": "undocumented",
"background": true,
"subagentToolkit": "undocumented"
},
"modelMode": "passive",
"hookBus": "engine",
"stateIO": "filesystem",
"transport": "mcp",
"runtime": "node"
}
}
},
"windsurf": {
@@ -3683,7 +4179,23 @@ const runtimes = {
"installSurface": "profile-marker-only",
"writesSharedSettings": false,
"permissionWriter": null,
"extendedHookEvents": []
"extendedHookEvents": [],
"hostIntegration": {
"embeddingMode": "declarative",
"commandSurface": "slash-file",
"dispatch": {
"namedDispatch": "undocumented",
"nested": "undocumented",
"maxDepth": "undocumented",
"background": "undocumented",
"subagentToolkit": "undocumented"
},
"modelMode": "passive",
"hookBus": "host",
"stateIO": "filesystem",
"transport": "mcp",
"runtime": "undocumented"
}
}
}
};

View File

@@ -713,6 +713,16 @@ const VALID_INSTALL_SURFACES = new Set(['settings-json', 'codex-toml', 'copilot-
const VALID_PERMISSION_WRITERS = new Set(['opencode', 'kilo']);
const VALID_EXTENDED_HOOK_EVENTS = new Set(['SubagentStop', 'Stop', 'PreCompact', 'FileChanged', 'BeforeAgent', 'AfterAgent', 'BeforeModel']);
// ADR-1239 Phase A: hostIntegration axes (MUST stay parity-identical to HOST_INTEGRATION_AXES in src/host-integration.cts)
const VALID_EMBEDDING_MODES = new Set(['imperative', 'declarative']);
const VALID_COMMAND_SURFACES = new Set(['slash-file', 'slash-programmatic', 'slash-toml', 'palette', 'prose-only']);
const VALID_MODEL_MODES = new Set(['active', 'passive']);
const VALID_HOOK_BUSES = new Set(['host', 'engine', 'none']);
const VALID_STATE_IO = new Set(['filesystem', 'sandboxed-storage', 'session-log-append']);
const VALID_TRANSPORTS = new Set(['mcp', 'native-extension']);
const VALID_HOST_RUNTIMES = new Set(['node', 'bun', 'sandboxed-web', 'python', 'go', 'rust', 'electron', 'other']);
const VALID_SUBAGENT_TOOLKITS = new Set(['full', 'read-only']);
// GATE A: installSurface → allowed hooksSurface values (DEFECT.GENERATIVE-FIX: parity invariant)
// Derived from the actual pairings in the 16 real runtime descriptors.
const INSTALL_SURFACE_TO_ALLOWED_HOOKS_SURFACES = new Map([
@@ -1037,6 +1047,150 @@ function validateRuntimeBody(cap) {
}
}
// hostIntegration — ADR-1239 Phase A: required object with closed-enum axes
if (typeof r.hostIntegration !== 'object' || r.hostIntegration === null || Array.isArray(r.hostIntegration)) {
errors.push('runtime.hostIntegration is required and must be an object');
} else {
const hi = r.hostIntegration;
// S2b: reserved-OWN-KEY guard on hostIntegration (CodeQL barrier — inline literal comparisons)
if (Object.prototype.hasOwnProperty.call(hi, '__proto__')) {
errors.push('runtime.hostIntegration must not contain reserved key "__proto__"');
}
if (Object.prototype.hasOwnProperty.call(hi, 'constructor')) {
errors.push('runtime.hostIntegration must not contain reserved key "constructor"');
}
if (Object.prototype.hasOwnProperty.call(hi, 'prototype')) {
errors.push('runtime.hostIntegration must not contain reserved key "prototype"');
}
// embeddingMode
if (hi.embeddingMode === '__proto__' || hi.embeddingMode === 'constructor' || hi.embeddingMode === 'prototype') {
errors.push('runtime.hostIntegration.embeddingMode "' + hi.embeddingMode + '" is a reserved name');
} else if (hi.embeddingMode !== 'undocumented' && !VALID_EMBEDDING_MODES.has(hi.embeddingMode)) {
errors.push(
'runtime.hostIntegration.embeddingMode must be one of: ' + [...VALID_EMBEDDING_MODES].join(', ') +
' (or "undocumented") (got: ' + JSON.stringify(hi.embeddingMode) + ')',
);
}
// commandSurface
if (hi.commandSurface === '__proto__' || hi.commandSurface === 'constructor' || hi.commandSurface === 'prototype') {
errors.push('runtime.hostIntegration.commandSurface "' + hi.commandSurface + '" is a reserved name');
} else if (hi.commandSurface !== 'undocumented' && !VALID_COMMAND_SURFACES.has(hi.commandSurface)) {
errors.push(
'runtime.hostIntegration.commandSurface must be one of: ' + [...VALID_COMMAND_SURFACES].join(', ') +
' (or "undocumented") (got: ' + JSON.stringify(hi.commandSurface) + ')',
);
}
// modelMode
if (hi.modelMode === '__proto__' || hi.modelMode === 'constructor' || hi.modelMode === 'prototype') {
errors.push('runtime.hostIntegration.modelMode "' + hi.modelMode + '" is a reserved name');
} else if (hi.modelMode !== 'undocumented' && !VALID_MODEL_MODES.has(hi.modelMode)) {
errors.push(
'runtime.hostIntegration.modelMode must be one of: ' + [...VALID_MODEL_MODES].join(', ') +
' (or "undocumented") (got: ' + JSON.stringify(hi.modelMode) + ')',
);
}
// hookBus
if (hi.hookBus === '__proto__' || hi.hookBus === 'constructor' || hi.hookBus === 'prototype') {
errors.push('runtime.hostIntegration.hookBus "' + hi.hookBus + '" is a reserved name');
} else if (hi.hookBus !== 'undocumented' && !VALID_HOOK_BUSES.has(hi.hookBus)) {
errors.push(
'runtime.hostIntegration.hookBus must be one of: ' + [...VALID_HOOK_BUSES].join(', ') +
' (or "undocumented") (got: ' + JSON.stringify(hi.hookBus) + ')',
);
}
// stateIO
if (hi.stateIO === '__proto__' || hi.stateIO === 'constructor' || hi.stateIO === 'prototype') {
errors.push('runtime.hostIntegration.stateIO "' + hi.stateIO + '" is a reserved name');
} else if (hi.stateIO !== 'undocumented' && !VALID_STATE_IO.has(hi.stateIO)) {
errors.push(
'runtime.hostIntegration.stateIO must be one of: ' + [...VALID_STATE_IO].join(', ') +
' (or "undocumented") (got: ' + JSON.stringify(hi.stateIO) + ')',
);
}
// transport
if (hi.transport === '__proto__' || hi.transport === 'constructor' || hi.transport === 'prototype') {
errors.push('runtime.hostIntegration.transport "' + hi.transport + '" is a reserved name');
} else if (hi.transport !== 'undocumented' && !VALID_TRANSPORTS.has(hi.transport)) {
errors.push(
'runtime.hostIntegration.transport must be one of: ' + [...VALID_TRANSPORTS].join(', ') +
' (or "undocumented") (got: ' + JSON.stringify(hi.transport) + ')',
);
}
// runtime (axis)
if (hi.runtime === '__proto__' || hi.runtime === 'constructor' || hi.runtime === 'prototype') {
errors.push('runtime.hostIntegration.runtime "' + hi.runtime + '" is a reserved name');
} else if (hi.runtime !== 'undocumented' && !VALID_HOST_RUNTIMES.has(hi.runtime)) {
errors.push(
'runtime.hostIntegration.runtime must be one of: ' + [...VALID_HOST_RUNTIMES].join(', ') +
' (or "undocumented") (got: ' + JSON.stringify(hi.runtime) + ')',
);
}
// dispatch — required object
if (typeof hi.dispatch !== 'object' || hi.dispatch === null || Array.isArray(hi.dispatch)) {
errors.push('runtime.hostIntegration.dispatch must be an object');
} else {
const d = hi.dispatch;
// S2b: reserved-OWN-KEY guard on dispatch (CodeQL barrier — inline literal comparisons)
if (Object.prototype.hasOwnProperty.call(d, '__proto__')) {
errors.push('runtime.hostIntegration.dispatch must not contain reserved key "__proto__"');
}
if (Object.prototype.hasOwnProperty.call(d, 'constructor')) {
errors.push('runtime.hostIntegration.dispatch must not contain reserved key "constructor"');
}
if (Object.prototype.hasOwnProperty.call(d, 'prototype')) {
errors.push('runtime.hostIntegration.dispatch must not contain reserved key "prototype"');
}
// namedDispatch — boolean or 'undocumented'
if (typeof d.namedDispatch !== 'boolean' && d.namedDispatch !== 'undocumented') {
errors.push(
'runtime.hostIntegration.dispatch.namedDispatch must be a boolean or "undocumented" (got: ' + JSON.stringify(d.namedDispatch) + ')',
);
}
// nested — boolean or 'undocumented'
if (typeof d.nested !== 'boolean' && d.nested !== 'undocumented') {
errors.push(
'runtime.hostIntegration.dispatch.nested must be a boolean or "undocumented" (got: ' + JSON.stringify(d.nested) + ')',
);
}
// background — boolean or 'undocumented'
if (typeof d.background !== 'boolean' && d.background !== 'undocumented') {
errors.push(
'runtime.hostIntegration.dispatch.background must be a boolean or "undocumented" (got: ' + JSON.stringify(d.background) + ')',
);
}
// subagentToolkit — closed enum or 'undocumented'
if (d.subagentToolkit === '__proto__' || d.subagentToolkit === 'constructor' || d.subagentToolkit === 'prototype') {
errors.push('runtime.hostIntegration.dispatch.subagentToolkit "' + d.subagentToolkit + '" is a reserved name');
} else if (d.subagentToolkit !== 'undocumented' && !VALID_SUBAGENT_TOOLKITS.has(d.subagentToolkit)) {
errors.push(
'runtime.hostIntegration.dispatch.subagentToolkit must be one of: ' + [...VALID_SUBAGENT_TOOLKITS].join(', ') +
' (or "undocumented") (got: ' + JSON.stringify(d.subagentToolkit) + ')',
);
}
// maxDepth — integer >= -1 or 'undocumented'
if (d.maxDepth !== 'undocumented' && (!Number.isInteger(d.maxDepth) || d.maxDepth < -1)) {
errors.push(
'runtime.hostIntegration.dispatch.maxDepth must be an integer >= -1 or "undocumented" (got: ' + JSON.stringify(d.maxDepth) + ')',
);
}
}
}
// GATE A: installSurface ↔ hooksSurface consistency (DEFECT.GENERATIVE-FIX)
// Only check if both fields are valid strings (individual field validators above report type errors).
if (typeof r.installSurface === 'string' && typeof r.hooksSurface === 'string') {
@@ -2052,6 +2206,24 @@ module.exports = {
VALID_INSTALL_SURFACES,
VALID_PERMISSION_WRITERS,
VALID_EXTENDED_HOOK_EVENTS,
VALID_EMBEDDING_MODES,
VALID_COMMAND_SURFACES,
VALID_MODEL_MODES,
VALID_HOOK_BUSES,
VALID_STATE_IO,
VALID_TRANSPORTS,
VALID_HOST_RUNTIMES,
VALID_SUBAGENT_TOOLKITS,
_HOST_INTEGRATION_VOCAB: {
embeddingMode: [...VALID_EMBEDDING_MODES],
commandSurface: [...VALID_COMMAND_SURFACES],
modelMode: [...VALID_MODEL_MODES],
hookBus: [...VALID_HOOK_BUSES],
stateIO: [...VALID_STATE_IO],
transport: [...VALID_TRANSPORTS],
runtime: [...VALID_HOST_RUNTIMES],
subagentToolkit: [...VALID_SUBAGENT_TOOLKITS],
},
INSTALL_SURFACE_TO_ALLOWED_HOOKS_SURFACES,
GEMINI_AGENT_EVENTS,
CLAUDE_FAMILY_EVENTS,

View File

@@ -184,6 +184,14 @@
"fix-1464-docs-manifest-validation.test.cjs"
],
"issue": "1496"
},
"host-integration": {
"files": [
"host-integration.test.cjs",
"host-integration-validator-parity.test.cjs",
"host-integration-descriptors.test.cjs"
],
"issue": "1684"
}
}
}

491
src/host-integration.cts Normal file
View File

@@ -0,0 +1,491 @@
'use strict';
/**
* Host Integration module — ADR-1239 Phase A.
*
* Pure, additive, no-I/O module providing a closed vocabulary for host
* integration axes, degradation ladder, profile classification, and
* capability negotiation.
*
* The SINGLE source of truth for integration axes and degradation levels.
* All functions are pure (no side effects, no I/O).
*
* Per-CLI sourced axis VALUES (with citations) live in docs/reference/host-integration-capability-matrix.md — every value is documented or explicitly 'undocumented'.
*/
// ---------------------------------------------------------------------------
// Protocol version
// ---------------------------------------------------------------------------
const PROTOCOL_VERSION = 1;
// ---------------------------------------------------------------------------
// Undocumented sentinel — fail-closed when a host omits CLI docs for an axis
// ---------------------------------------------------------------------------
/**
* Sentinel value used when a host descriptor's CLI docs do not state a value
* for an axis. It VALIDATES (accepted by the validator) but NEVER propagates
* into effective axes — it fails closed exactly like an unknown/missing value.
*
* Do NOT add to HOST_INTEGRATION_AXES (which is the documented vocabulary).
*/
const UNDOCUMENTED = 'undocumented';
// ---------------------------------------------------------------------------
// Closed vocabulary — axes and interface points
// ---------------------------------------------------------------------------
const HOST_INTEGRATION_AXES = Object.freeze({
embeddingMode: Object.freeze(['imperative', 'declarative'] as const),
commandSurface: Object.freeze(['slash-file', 'slash-programmatic', 'slash-toml', 'palette', 'prose-only'] as const),
modelMode: Object.freeze(['active', 'passive'] as const),
hookBus: Object.freeze(['host', 'engine', 'none'] as const),
stateIO: Object.freeze(['filesystem', 'sandboxed-storage', 'session-log-append'] as const),
transport: Object.freeze(['mcp', 'native-extension'] as const),
runtime: Object.freeze(['node', 'bun', 'sandboxed-web', 'python', 'go', 'rust', 'electron', 'other'] as const),
subagentToolkit: Object.freeze(['full', 'read-only'] as const),
});
const INTERFACE_POINTS = Object.freeze(['command', 'dispatch', 'model', 'hooks', 'state', 'artifact'] as const);
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type EmbeddingMode = 'imperative' | 'declarative';
type CommandSurface = 'slash-file' | 'slash-programmatic' | 'slash-toml' | 'palette' | 'prose-only';
type ModelMode = 'active' | 'passive';
type HookBus = 'host' | 'engine' | 'none';
type StateIO = 'filesystem' | 'sandboxed-storage' | 'session-log-append';
type Transport = 'mcp' | 'native-extension';
type HostRuntime = 'node' | 'bun' | 'sandboxed-web' | 'python' | 'go' | 'rust' | 'electron' | 'other';
type SubagentToolkit = 'full' | 'read-only';
type DegradationLevel = 'full' | 'degraded' | 'absent';
type InterfacePoint = 'command' | 'dispatch' | 'model' | 'hooks' | 'state' | 'artifact';
interface DispatchCapability {
namedDispatch: boolean;
nested: boolean;
maxDepth: number;
background: boolean;
subagentToolkit: SubagentToolkit;
}
interface HostIntegrationAxes {
embeddingMode: EmbeddingMode;
commandSurface: CommandSurface;
dispatch: DispatchCapability;
modelMode: ModelMode;
hookBus: HookBus;
stateIO: StateIO;
transport: Transport;
runtime: HostRuntime;
}
interface DegradationResult {
level: DegradationLevel;
fallback: string;
unknown?: boolean;
}
// ---------------------------------------------------------------------------
// Profile baselines
// ---------------------------------------------------------------------------
// Fail-closed floor: the most restrictive known value per axis, injected when a host omits an axis (degrade-closed, never assume capability).
const SAFE_DEFAULTS: HostIntegrationAxes = {
embeddingMode: 'declarative',
commandSurface: 'prose-only',
dispatch: { namedDispatch: false, nested: false, maxDepth: 0, background: false, subagentToolkit: 'read-only' },
modelMode: 'passive',
hookBus: 'none',
stateIO: 'session-log-append',
transport: 'mcp',
runtime: 'node',
};
const PROFILE_BASELINES: Readonly<Record<'programmatic-cli' | 'declarative-cli' | 'ide', HostIntegrationAxes>> =
Object.freeze({
'programmatic-cli': Object.freeze({
embeddingMode: 'imperative',
commandSurface: 'slash-file',
dispatch: Object.freeze({ namedDispatch: true, nested: true, maxDepth: -1, background: true, subagentToolkit: 'full' }),
modelMode: 'passive',
hookBus: 'host',
stateIO: 'filesystem',
transport: 'mcp',
runtime: 'node',
} as HostIntegrationAxes),
'declarative-cli': Object.freeze({
embeddingMode: 'declarative',
commandSurface: 'slash-file',
dispatch: Object.freeze({ namedDispatch: true, nested: false, maxDepth: 1, background: false, subagentToolkit: 'full' }),
modelMode: 'passive',
hookBus: 'host',
stateIO: 'filesystem',
transport: 'mcp',
runtime: 'node',
} as HostIntegrationAxes),
'ide': Object.freeze({
embeddingMode: 'imperative',
commandSurface: 'palette',
dispatch: Object.freeze({ namedDispatch: true, nested: true, maxDepth: 5, background: true, subagentToolkit: 'full' }),
modelMode: 'active',
hookBus: 'engine',
stateIO: 'sandboxed-storage',
transport: 'mcp',
runtime: 'sandboxed-web',
} as HostIntegrationAxes),
});
// ---------------------------------------------------------------------------
// degradationFor — plain data-table lookup (NOT clever code)
// ---------------------------------------------------------------------------
/**
* Look up the degradation level for a given interface point and partial axes.
*
* NEVER throws. Returns { level:'absent', fallback:'...', unknown:true } for
* any missing or unrecognised axis value.
*/
function degradationFor(point: InterfacePoint, axes: Partial<HostIntegrationAxes>): DegradationResult {
const UNKNOWN: DegradationResult = {
level: 'absent',
fallback: 'unknown capability — degraded closed',
unknown: true,
};
switch (point) {
case 'command': {
const cs = (axes as Record<string, unknown>).commandSurface;
if (cs === 'slash-file' || cs === 'slash-programmatic') return { level: 'full', fallback: '' };
if (cs === 'slash-toml' || cs === 'palette') return { level: 'degraded', fallback: 'toml/palette surface — limited command routing' };
if (cs === 'prose-only') return { level: 'absent', fallback: 'AGENTS.md prose + skills menu' };
return UNKNOWN;
}
case 'dispatch': {
const d = (axes as Record<string, unknown>).dispatch;
if (!d || typeof d !== 'object') return UNKNOWN;
const disp = d as Record<string, unknown>;
if (disp.namedDispatch !== true || disp.maxDepth === 0) {
return { level: 'absent', fallback: 'single-agent inline / SDK sub-session' };
}
// maxDepth < 0 means unbounded
const isUnbounded = typeof disp.maxDepth === 'number' && Number.isFinite(disp.maxDepth) && disp.maxDepth < 0;
const depth = (typeof disp.maxDepth === 'number' && Number.isFinite(disp.maxDepth)) ? disp.maxDepth : 0;
const isFullDepth = isUnbounded || (disp.nested === true && depth >= 2);
if (isFullDepth) {
// Fail-closed: return 'full' ONLY when subagentToolkit is explicitly 'full';
// any other value (read-only, undocumented, unknown, missing) → degraded.
if (disp.subagentToolkit === 'full') {
return { level: 'full', fallback: '' };
}
return { level: 'degraded', fallback: 'restricted/undocumented subagent toolkit — limited dispatch surface' };
}
// flat (maxDepth===1)
return { level: 'degraded', fallback: 'flat dispatch — waves run inline' };
}
case 'model': {
const mm = (axes as Record<string, unknown>).modelMode;
if (mm === 'active') return { level: 'full', fallback: '' };
if (mm === 'passive') return { level: 'degraded', fallback: 'instruction-injection / per-agent model field' };
return UNKNOWN;
}
case 'hooks': {
const hb = (axes as Record<string, unknown>).hookBus;
if (hb === 'host') return { level: 'full', fallback: '' };
if (hb === 'engine') return { level: 'degraded', fallback: 'engine-owned bus' };
if (hb === 'none') return { level: 'absent', fallback: 'rule-text instructions' };
return UNKNOWN;
}
case 'state': {
const si = (axes as Record<string, unknown>).stateIO;
if (si === 'filesystem') return { level: 'full', fallback: '' };
if (si === 'sandboxed-storage') return { level: 'degraded', fallback: 'sandboxed storage' };
if (si === 'session-log-append') return { level: 'degraded', fallback: 'append-only session log' };
return UNKNOWN;
}
case 'artifact': {
const cs = (axes as Record<string, unknown>).commandSurface;
if (cs === 'slash-file' || cs === 'slash-programmatic') return { level: 'full', fallback: '' };
if (cs === 'slash-toml' || cs === 'prose-only') return { level: 'degraded', fallback: 'menu / @-only' };
if (cs === 'palette') return { level: 'absent', fallback: 'palette + chat participant; skills become LM tools' };
return UNKNOWN;
}
default:
return UNKNOWN;
}
}
// ---------------------------------------------------------------------------
// profileOf
// ---------------------------------------------------------------------------
/**
* Classify a partial set of integration axes into a named profile.
* Returns null when no profile can be determined.
*/
function profileOf(axes: Partial<HostIntegrationAxes>): 'programmatic-cli' | 'declarative-cli' | 'ide' | null {
const a = axes as Record<string, unknown>;
if (a.embeddingMode === 'imperative' && a.runtime === 'sandboxed-web') return 'ide';
if (a.embeddingMode === 'imperative') return 'programmatic-cli';
if (a.embeddingMode === 'declarative') return 'declarative-cli';
return null;
}
// ---------------------------------------------------------------------------
// EngineCapabilities + DEFAULT_ENGINE
// ---------------------------------------------------------------------------
interface EngineCapabilities {
protocolVersion: number;
axes: HostIntegrationAxes;
known: typeof HOST_INTEGRATION_AXES;
}
const DEFAULT_ENGINE: EngineCapabilities = {
protocolVersion: PROTOCOL_VERSION,
axes: {
embeddingMode: 'imperative',
commandSurface: 'slash-file',
dispatch: { namedDispatch: true, nested: true, maxDepth: -1, background: true, subagentToolkit: 'full' },
modelMode: 'active',
hookBus: 'host',
stateIO: 'filesystem',
transport: 'mcp',
runtime: 'node',
},
known: HOST_INTEGRATION_AXES,
};
// ---------------------------------------------------------------------------
// NegotiationResult
// ---------------------------------------------------------------------------
interface NegotiationResult {
protocolVersion: number;
effective: HostIntegrationAxes;
points: Record<InterfacePoint, { hostLevel: DegradationLevel; effectiveLevel: DegradationLevel; fallback: string }>;
warnings: string[];
}
// ---------------------------------------------------------------------------
// negotiateHostCapabilities
// ---------------------------------------------------------------------------
/**
* Negotiate host integration capabilities against an engine.
*
* POST-CONDITION: every effective scalar axis value is in engine.known[axis].
* effective never contains a value the host didn't declare AND the engine
* cannot drive.
*
* NEVER throws. Returns a fresh object each call (mutation-safe).
*/
function negotiateHostCapabilities(
host: Partial<HostIntegrationAxes> & { protocolVersion?: number },
engine: EngineCapabilities = DEFAULT_ENGINE,
): NegotiationResult {
const warnings: string[] = [];
const h = host as Record<string, unknown>;
// Warn if protocolVersion is present but not a finite number
if (h.protocolVersion !== undefined && (typeof h.protocolVersion !== 'number' || !Number.isFinite(h.protocolVersion))) {
warnings.push(`host protocolVersion is not a finite number — using engine version ${engine.protocolVersion}`);
}
const hostPV = (typeof h.protocolVersion === 'number' && Number.isFinite(h.protocolVersion)) ? h.protocolVersion : engine.protocolVersion;
const enginePV = engine.protocolVersion;
// Warn if host declares a newer protocol version
if (hostPV > enginePV) {
warnings.push(
`host protocolVersion ${hostPV} newer than engine ${enginePV} — capabilities beyond version ${enginePV} not trusted`,
);
}
// ---------------------------------------------------------------------------
// Helper: negotiate a single scalar axis
// ---------------------------------------------------------------------------
function negotiateScalar<K extends keyof typeof HOST_INTEGRATION_AXES>(
axis: K,
): (typeof HOST_INTEGRATION_AXES)[K][number] {
type V = (typeof HOST_INTEGRATION_AXES)[K][number];
const knownValues: ReadonlyArray<V> = engine.known[axis];
const hostVal = h[axis] as V | undefined;
const engineVal = engine.axes[axis as keyof HostIntegrationAxes] as V;
const safeDefault = SAFE_DEFAULTS[axis as keyof HostIntegrationAxes] as V;
if (hostVal === undefined || hostVal === null) {
// Host did not declare this axis
warnings.push(`host did not declare '${axis}'`);
return safeDefault;
}
if ((hostVal as unknown) === UNDOCUMENTED) {
// Host declared the undocumented sentinel — treat as fail-closed (degrade to safe default)
warnings.push(`host axis '${axis}' is undocumented — degraded closed`);
return safeDefault;
}
if (!knownValues.includes(hostVal)) {
// Host declared an unknown/future value — NEVER copy into effective
warnings.push(
`host declared unknown '${axis}' value '${String(hostVal)}' — not trusted (host protocolVersion ${hostPV} vs engine ${enginePV})`,
);
return safeDefault;
}
// Engine capability cap: if the engine can't drive the host's value,
// use the engine's lesser capability.
// For modelMode: 'active' > 'passive' — if host wants active but engine
// is passive, cap to passive.
if (axis === 'modelMode') {
if (hostVal === 'active' && engineVal === 'passive') return 'passive';
}
return hostVal;
}
// Negotiate all scalar axes
const effectiveEmbeddingMode = negotiateScalar('embeddingMode');
const effectiveCommandSurface = negotiateScalar('commandSurface');
const effectiveModelMode = negotiateScalar('modelMode');
const effectiveHookBus = negotiateScalar('hookBus');
const effectiveStateIO = negotiateScalar('stateIO');
const effectiveTransport = negotiateScalar('transport');
const effectiveRuntime = negotiateScalar('runtime');
// ---------------------------------------------------------------------------
// Dispatch struct negotiation
// ---------------------------------------------------------------------------
const hostDispatch = (typeof h.dispatch === 'object' && h.dispatch !== null)
? h.dispatch as Record<string, unknown>
: null;
const engineDispatch = engine.axes.dispatch;
let effectiveNamedDispatch: boolean;
let effectiveNested: boolean;
let effectiveBackground: boolean;
let effectiveSubagentToolkit: SubagentToolkit;
let effectiveMaxDepth: number;
if (hostDispatch === null) {
// Host didn't declare dispatch at all — fail-closed to most-restrictive values
warnings.push(`host did not declare 'dispatch'`);
effectiveNamedDispatch = false;
effectiveNested = false;
effectiveBackground = false;
effectiveSubagentToolkit = 'read-only';
effectiveMaxDepth = 0;
} else {
// N1: observability warnings for 'undocumented' sentinel on dispatch fields
if (hostDispatch.namedDispatch === 'undocumented') {
warnings.push(`dispatch.namedDispatch is undocumented — degraded closed`);
}
if (hostDispatch.nested === 'undocumented') {
warnings.push(`dispatch.nested is undocumented — degraded closed`);
}
if (hostDispatch.background === 'undocumented') {
warnings.push(`dispatch.background is undocumented — degraded closed`);
}
if (hostDispatch.subagentToolkit === 'undocumented') {
warnings.push(`dispatch.subagentToolkit is undocumented — degraded closed (read-only)`);
}
effectiveNamedDispatch = (hostDispatch.namedDispatch === true) && engineDispatch.namedDispatch;
effectiveNested = (hostDispatch.nested === true) && engineDispatch.nested;
effectiveBackground = (hostDispatch.background === true) && engineDispatch.background;
// subagentToolkit: fail closed to read-only unless explicitly 'full'
// (an 'undocumented' or 'read-only' value → read-only)
const hostToolkit = hostDispatch.subagentToolkit === 'full' ? 'full' : 'read-only';
const engineToolkit = engineDispatch.subagentToolkit === 'read-only' ? 'read-only' : 'full';
effectiveSubagentToolkit = (hostToolkit === 'read-only' || engineToolkit === 'read-only') ? 'read-only' : 'full';
// maxDepth: missing/non-number/non-finite → 0 + warning
let hostMaxDepth: number;
if (typeof hostDispatch.maxDepth !== 'number' || !Number.isFinite(hostDispatch.maxDepth)) {
warnings.push(`host dispatch.maxDepth is missing or not a number — treating as 0`);
hostMaxDepth = 0;
} else {
hostMaxDepth = hostDispatch.maxDepth;
}
// Treat negative as +Infinity for the min, then if result is +Infinity emit -1
const hDepthNum = hostMaxDepth < 0 ? Infinity : hostMaxDepth;
const eDepthNum = engineDispatch.maxDepth < 0 ? Infinity : engineDispatch.maxDepth;
const minDepth = Math.min(hDepthNum, eDepthNum);
effectiveMaxDepth = minDepth === Infinity ? -1 : minDepth;
// If namedDispatch is false, cap maxDepth/nested/background to 0/false/false (struct consistency)
if (!effectiveNamedDispatch) {
effectiveMaxDepth = 0;
effectiveNested = false;
effectiveBackground = false;
}
}
const effectiveDispatch: DispatchCapability = {
namedDispatch: effectiveNamedDispatch,
nested: effectiveNested,
maxDepth: effectiveMaxDepth,
background: effectiveBackground,
subagentToolkit: effectiveSubagentToolkit,
};
// ---------------------------------------------------------------------------
// Assemble effective axes
// ---------------------------------------------------------------------------
const effective: HostIntegrationAxes = {
embeddingMode: effectiveEmbeddingMode,
commandSurface: effectiveCommandSurface,
dispatch: effectiveDispatch,
modelMode: effectiveModelMode,
hookBus: effectiveHookBus,
stateIO: effectiveStateIO,
transport: effectiveTransport,
runtime: effectiveRuntime,
};
// ---------------------------------------------------------------------------
// Compute points (fresh objects — mutation-safe)
// ---------------------------------------------------------------------------
const points = {} as Record<InterfacePoint, { hostLevel: DegradationLevel; effectiveLevel: DegradationLevel; fallback: string }>;
for (const point of INTERFACE_POINTS) {
const hostDeg = degradationFor(point, host);
const effectiveDeg = degradationFor(point, effective);
points[point] = {
hostLevel: hostDeg.level,
effectiveLevel: effectiveDeg.level,
fallback: effectiveDeg.fallback,
};
}
// protocolVersion: min of host and engine
const resultProtocolVersion = Math.min(hostPV, enginePV);
return {
protocolVersion: resultProtocolVersion,
effective,
points,
warnings: [...warnings], // fresh copy
};
}
// ---------------------------------------------------------------------------
// Module export (CommonJS — matches existing src/*.cts pattern)
// ---------------------------------------------------------------------------
export = {
PROTOCOL_VERSION,
UNDOCUMENTED,
HOST_INTEGRATION_AXES,
INTERFACE_POINTS,
PROFILE_BASELINES,
DEFAULT_ENGINE,
degradationFor,
profileOf,
negotiateHostCapabilities,
};

View File

@@ -86,6 +86,16 @@ function runtimeCap(overrides) {
writesSharedSettings: false,
permissionWriter: null,
extendedHookEvents: [],
hostIntegration: {
embeddingMode: 'imperative',
commandSurface: 'slash-file',
dispatch: { namedDispatch: true, nested: true, maxDepth: -1, background: true, subagentToolkit: 'full' },
modelMode: 'passive',
hookBus: 'host',
stateIO: 'filesystem',
transport: 'mcp',
runtime: 'node',
},
},
...overrides,
};

View File

@@ -1775,6 +1775,16 @@ describe('C3: role:runtime body validation', () => {
writesSharedSettings: false,
permissionWriter: null,
extendedHookEvents: [],
hostIntegration: {
embeddingMode: 'declarative',
commandSurface: 'slash-file',
dispatch: { namedDispatch: true, nested: true, maxDepth: 2, background: false, subagentToolkit: 'full' },
modelMode: 'passive',
hookBus: 'host',
stateIO: 'filesystem',
transport: 'mcp',
runtime: 'node',
},
},
};
@@ -3218,6 +3228,16 @@ function makeRuntimeCap(overrides) {
writesSharedSettings: true,
permissionWriter: null,
extendedHookEvents: [],
hostIntegration: {
embeddingMode: 'imperative',
commandSurface: 'slash-file',
dispatch: { namedDispatch: true, nested: true, maxDepth: -1, background: true, subagentToolkit: 'full' },
modelMode: 'passive',
hookBus: 'host',
stateIO: 'filesystem',
transport: 'mcp',
runtime: 'node',
},
...((overrides && overrides.runtime) ? overrides.runtime : {}),
},
...overrides,
@@ -4283,6 +4303,16 @@ describe('ADR-857 phase 5f: cross-field consistency gate rejection tests (DEFECT
writesSharedSettings: true,
permissionWriter: null,
extendedHookEvents: [],
hostIntegration: {
embeddingMode: 'imperative',
commandSurface: 'slash-file',
dispatch: { namedDispatch: true, nested: true, maxDepth: -1, background: true, subagentToolkit: 'full' },
modelMode: 'passive',
hookBus: 'host',
stateIO: 'filesystem',
transport: 'mcp',
runtime: 'node',
},
},
};
if (overrides && typeof overrides === 'object') {
@@ -5135,6 +5165,16 @@ describe('activationKey validation', () => {
writesSharedSettings: false,
permissionWriter: null,
extendedHookEvents: [],
hostIntegration: {
embeddingMode: 'declarative',
commandSurface: 'slash-file',
dispatch: { namedDispatch: true, nested: true, maxDepth: 2, background: false, subagentToolkit: 'full' },
modelMode: 'passive',
hookBus: 'host',
stateIO: 'filesystem',
transport: 'mcp',
runtime: 'node',
},
},
};
const errors = validateCapability(cap, 'cursor');

View File

@@ -0,0 +1,328 @@
'use strict';
/**
* ADR-1239 Phase A: Descriptor tests — validate that all 16 role:runtime
* capability descriptors have correct hostIntegration axes, pass the validator,
* and negotiate correctly via the host-integration module.
*
* Expectations are derived from the generated capability registry and
* .host-cli-final.json (source of truth). Values are verbatim; 'undocumented'
* sentinels fail-closed in negotiation (safe documented default, never propagate).
*/
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const {
negotiateHostCapabilities,
profileOf,
} = require(path.join(__dirname, '../gsd-core/bin/lib/host-integration.cjs'));
const registry = require(path.join(__dirname, '../gsd-core/bin/lib/capability-registry.cjs'));
const {
validateCapability,
} = require(path.join(__dirname, '../gsd-core/bin/lib/capability-validator.cjs'));
// All 8 scalar hostIntegration axis keys
const SCALAR_AXES = ['embeddingMode', 'commandSurface', 'modelMode', 'hookBus', 'stateIO', 'transport', 'runtime'];
// All 5 dispatch sub-keys
const DISPATCH_KEYS = ['namedDispatch', 'nested', 'maxDepth', 'background', 'subagentToolkit'];
// All 16 runtime IDs (ordered alphabetically)
const RUNTIME_IDS = [
'antigravity', 'augment', 'claude', 'cline', 'codebuddy',
'codex', 'copilot', 'cursor', 'gemini', 'hermes',
'kilo', 'kimi', 'opencode', 'qwen', 'trae', 'windsurf',
];
// Contract-pinned profile split (derived from .host-cli-final.json):
// programmatic-cli: claude, cline, cursor, hermes, kilo, kimi, opencode, qwen, trae (9)
// declarative-cli: antigravity, augment, codebuddy, codex, copilot, gemini, windsurf (7)
// ide: 0
const EXPECTED_PROFILES = {
claude: 'programmatic-cli',
cline: 'programmatic-cli',
cursor: 'programmatic-cli',
hermes: 'programmatic-cli',
kilo: 'programmatic-cli',
kimi: 'programmatic-cli',
opencode: 'programmatic-cli',
qwen: 'programmatic-cli',
trae: 'programmatic-cli',
antigravity: 'declarative-cli',
augment: 'declarative-cli',
codebuddy: 'declarative-cli',
codex: 'declarative-cli',
copilot: 'declarative-cli',
gemini: 'declarative-cli',
windsurf: 'declarative-cli',
};
describe('ADR-1239 Phase A: hostIntegration descriptors', () => {
// ─── Registry shape ──────────────────────────────────────────────────────────
test('registry.runtimes contains all 16 expected runtime ids', () => {
for (const id of RUNTIME_IDS) {
assert.ok(
Object.prototype.hasOwnProperty.call(registry.runtimes, id),
'registry.runtimes must contain "' + id + '"',
);
}
assert.strictEqual(
Object.keys(registry.runtimes).length,
16,
'registry.runtimes must have exactly 16 entries',
);
});
// ─── Per-runtime assertions ───────────────────────────────────────────────────
for (const id of RUNTIME_IDS) {
describe('runtime: ' + id, () => {
const cap = registry.runtimes[id];
const hi = cap && cap.runtime && cap.runtime.hostIntegration;
// (i) Validator passes with zero errors
test('(i) validateCapability returns zero errors', () => {
const errors = validateCapability(cap, id);
assert.deepEqual(
errors,
[],
id + ': validateCapability must return no errors, got: ' + JSON.stringify(errors),
);
});
// (ii) hostIntegration object is present with all required keys
test('(ii) cap.runtime.hostIntegration is present with all 8 axis keys and 5 dispatch sub-keys', () => {
assert.ok(
hi !== undefined && hi !== null && typeof hi === 'object',
id + ': cap.runtime.hostIntegration must be a non-null object',
);
// All 8 scalar axes present
for (const axis of SCALAR_AXES) {
assert.ok(
Object.prototype.hasOwnProperty.call(hi, axis),
id + ': hostIntegration must have axis "' + axis + '"',
);
}
// dispatch is an object
assert.ok(
hi.dispatch !== null && typeof hi.dispatch === 'object',
id + ': hostIntegration.dispatch must be a non-null object',
);
// All 5 dispatch sub-keys present
for (const key of DISPATCH_KEYS) {
assert.ok(
Object.prototype.hasOwnProperty.call(hi.dispatch, key),
id + ': hostIntegration.dispatch must have key "' + key + '"',
);
}
});
// (iii) negotiateHostCapabilities does not throw and behaves correctly
test('(iii) negotiateHostCapabilities: documented scalars pass through; undocumented scalars degrade with warning', () => {
assert.ok(hi, id + ': hostIntegration must exist to negotiate');
let result;
assert.doesNotThrow(() => {
result = negotiateHostCapabilities(hi);
}, id + ': negotiateHostCapabilities must not throw');
const eff = result.effective;
// For each scalar axis: if declared !== 'undocumented', effective === declared
// If declared === 'undocumented', effective !== 'undocumented' (safe default) and
// warnings must mention that axis.
for (const axis of SCALAR_AXES) {
const declared = hi[axis];
if (declared !== 'undocumented') {
assert.strictEqual(
eff[axis],
declared,
id + ': effective.' + axis + ' must equal declared (' + JSON.stringify(declared) + '), got: ' + JSON.stringify(eff[axis]),
);
} else {
// fail-closed: effective must be a documented safe default, not 'undocumented'
assert.notStrictEqual(
eff[axis],
'undocumented',
id + ': effective.' + axis + ' must NOT be "undocumented" (fail-closed)',
);
// warnings must mention this axis
const mentionsAxis = result.warnings.some((w) => w.includes(axis));
assert.ok(
mentionsAxis,
id + ': result.warnings must mention axis "' + axis + '" when declared is undocumented, got: ' + JSON.stringify(result.warnings),
);
}
}
});
// (iii-b) dispatch negotiation for namedDispatch
test('(iii-b) dispatch.namedDispatch negotiation', () => {
assert.ok(hi, id + ': hostIntegration must exist to negotiate');
const result = negotiateHostCapabilities(hi);
const declaredND = hi.dispatch && hi.dispatch.namedDispatch;
if (declaredND === true) {
// documented as true → effective must be true
assert.strictEqual(
result.effective.dispatch.namedDispatch,
true,
id + ': effective.dispatch.namedDispatch must be true when declared is true',
);
} else if (declaredND === 'undocumented') {
// undocumented → fail-closed: effective namedDispatch must be false
assert.strictEqual(
result.effective.dispatch.namedDispatch,
false,
id + ': effective.dispatch.namedDispatch must be false when declared is "undocumented" (fail-closed)',
);
// dispatch.effectiveLevel must be 'absent' (no named dispatch)
assert.strictEqual(
result.points.dispatch.effectiveLevel,
'absent',
id + ': points.dispatch.effectiveLevel must be "absent" when namedDispatch is undocumented',
);
}
});
// (iv) profileOf returns expected profile
test('(iv) profileOf returns expected profile', () => {
assert.ok(hi, id + ': hostIntegration must exist to profile');
const profile = profileOf(hi);
assert.ok(
profile !== null,
id + ': profileOf must return a non-null profile',
);
assert.strictEqual(
profile,
EXPECTED_PROFILES[id],
id + ': profileOf must return "' + EXPECTED_PROFILES[id] + '" (got: "' + profile + '")',
);
});
});
}
// ─── Contract-pin profile split ───────────────────────────────────────────────
test('contract-pin: exactly 9 programmatic-cli, 7 declarative-cli, 0 ide', () => {
const counts = { 'programmatic-cli': 0, 'declarative-cli': 0, 'ide': 0 };
for (const id of RUNTIME_IDS) {
const cap = registry.runtimes[id];
const hi = cap && cap.runtime && cap.runtime.hostIntegration;
assert.ok(hi, id + ': hostIntegration must exist for profile count');
const profile = profileOf(hi);
assert.ok(profile !== null, id + ': profileOf must be non-null');
if (counts[profile] !== undefined) {
counts[profile]++;
}
}
assert.strictEqual(counts['programmatic-cli'], 9, 'Must have exactly 9 programmatic-cli runtimes');
assert.strictEqual(counts['declarative-cli'], 7, 'Must have exactly 7 declarative-cli runtimes');
assert.strictEqual(counts['ide'], 0, 'Must have exactly 0 ide runtimes');
});
test('contract-pin: spot-check claude→programmatic-cli, codex→declarative-cli, opencode→programmatic-cli, gemini→declarative-cli', () => {
const checks = [
['claude', 'programmatic-cli'],
['codex', 'declarative-cli'],
['opencode', 'programmatic-cli'],
['gemini', 'declarative-cli'],
];
for (const [id, expectedProfile] of checks) {
const cap = registry.runtimes[id];
const hi = cap && cap.runtime && cap.runtime.hostIntegration;
assert.ok(hi, id + ': hostIntegration must exist');
const profile = profileOf(hi);
assert.strictEqual(
profile,
expectedProfile,
id + ': profileOf must return "' + expectedProfile + '" (got: "' + profile + '")',
);
}
});
// ─── NEGATIVE cases ───────────────────────────────────────────────────────────
describe('NEGATIVE: invalid hostIntegration.embeddingMode triggers validator error', () => {
test('embeddingMode "bogus" produces a validator error naming embeddingMode', () => {
const cap = {
id: 'test-neg',
role: 'runtime',
version: '1.0.0',
title: 'Test Negative',
description: 'Negative case for hostIntegration validation.',
tier: 'core',
requires: [],
runtime: {
configHome: { kind: 'dot-home', name: '.test-neg', env: [] },
configFormat: 'settings-json',
artifactLayout: { global: [], local: [] },
commandStyle: 'slash-hyphen',
hooksSurface: 'settings-json',
hookEvents: 'claude',
sandboxTier: 'none',
supportTier: 1,
installSurface: 'settings-json',
writesSharedSettings: true,
permissionWriter: null,
extendedHookEvents: [],
hostIntegration: {
embeddingMode: 'bogus',
commandSurface: 'slash-file',
dispatch: { namedDispatch: true, nested: false, maxDepth: 1, background: false, subagentToolkit: 'full' },
modelMode: 'passive',
hookBus: 'host',
stateIO: 'filesystem',
transport: 'mcp',
runtime: 'node',
},
},
};
const errors = validateCapability(cap, 'test-neg');
assert.ok(errors.length > 0, 'Expected validation errors for bogus embeddingMode');
assert.ok(
errors.some((e) => e.includes('embeddingMode')),
'At least one error must mention embeddingMode, got: ' + JSON.stringify(errors),
);
});
});
describe('NEGATIVE: missing hostIntegration produces required-object error', () => {
test('runtime body without hostIntegration produces the required-object error', () => {
const cap = {
id: 'test-missing-hi',
role: 'runtime',
version: '1.0.0',
title: 'Test Missing HI',
description: 'Negative case for missing hostIntegration.',
tier: 'core',
requires: [],
runtime: {
configHome: { kind: 'dot-home', name: '.test-missing-hi', env: [] },
configFormat: 'settings-json',
artifactLayout: { global: [], local: [] },
commandStyle: 'slash-hyphen',
hooksSurface: 'settings-json',
hookEvents: 'claude',
sandboxTier: 'none',
supportTier: 1,
installSurface: 'settings-json',
writesSharedSettings: true,
permissionWriter: null,
extendedHookEvents: [],
// hostIntegration intentionally absent
},
};
const errors = validateCapability(cap, 'test-missing-hi');
assert.ok(errors.length > 0, 'Expected validation errors for missing hostIntegration');
assert.ok(
errors.some((e) => e.includes('hostIntegration') && e.includes('required')),
'At least one error must mention hostIntegration and required, got: ' + JSON.stringify(errors),
);
});
});
});

View File

@@ -0,0 +1,356 @@
'use strict';
/**
* ADR-1239 Phase A: Parity guard — validator VALID_* sets MUST exactly match
* HOST_INTEGRATION_AXES arrays from host-integration.cjs.
*
* If either side drifts, this test fails immediately (not silently).
*/
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const {
HOST_INTEGRATION_AXES,
} = require(path.join(__dirname, '../gsd-core/bin/lib/host-integration.cjs'));
const {
_HOST_INTEGRATION_VOCAB,
validateCapability,
} = require(path.join(__dirname, '../gsd-core/bin/lib/capability-validator.cjs'));
// Sort for deterministic comparison
function sorted(arr) {
return [...arr].sort();
}
// Minimal valid runtime capability descriptor (all documented values)
function makeMinimalRuntimeCap(overrides = {}) {
return {
id: 'test-runtime',
role: 'runtime',
title: 'Test Runtime',
description: 'Test runtime capability for parity tests',
tier: 'core',
requires: [],
version: '1.0.0',
runtime: {
configHome: {
kind: 'dot-home',
name: '.test-runtime',
env: [],
},
configFormat: 'markdown',
artifactLayout: {
global: [],
local: [],
},
commandStyle: 'slash-hyphen',
hooksSurface: 'none',
sandboxTier: 'none',
supportTier: 1,
installSurface: 'profile-marker-only',
writesSharedSettings: false,
permissionWriter: null,
extendedHookEvents: [],
hostIntegration: {
embeddingMode: 'imperative',
commandSurface: 'slash-file',
modelMode: 'passive',
hookBus: 'host',
stateIO: 'filesystem',
transport: 'mcp',
runtime: 'node',
dispatch: {
namedDispatch: true,
nested: false,
maxDepth: 1,
background: false,
subagentToolkit: 'full',
},
},
...overrides,
},
};
}
describe('ADR-1239 Phase A: host-integration validator parity', () => {
test('_HOST_INTEGRATION_VOCAB is exported from capability-validator.cjs', () => {
assert.ok(
_HOST_INTEGRATION_VOCAB !== undefined && _HOST_INTEGRATION_VOCAB !== null,
'_HOST_INTEGRATION_VOCAB must be exported from capability-validator.cjs',
);
assert.strictEqual(typeof _HOST_INTEGRATION_VOCAB, 'object');
});
test('embeddingMode: validator set === HOST_INTEGRATION_AXES.embeddingMode', () => {
assert.deepEqual(
sorted(_HOST_INTEGRATION_VOCAB.embeddingMode),
sorted(HOST_INTEGRATION_AXES.embeddingMode),
'validator VALID_EMBEDDING_MODES must exactly match HOST_INTEGRATION_AXES.embeddingMode',
);
});
test('commandSurface: validator set === HOST_INTEGRATION_AXES.commandSurface', () => {
assert.deepEqual(
sorted(_HOST_INTEGRATION_VOCAB.commandSurface),
sorted(HOST_INTEGRATION_AXES.commandSurface),
'validator VALID_COMMAND_SURFACES must exactly match HOST_INTEGRATION_AXES.commandSurface',
);
});
test('modelMode: validator set === HOST_INTEGRATION_AXES.modelMode', () => {
assert.deepEqual(
sorted(_HOST_INTEGRATION_VOCAB.modelMode),
sorted(HOST_INTEGRATION_AXES.modelMode),
'validator VALID_MODEL_MODES must exactly match HOST_INTEGRATION_AXES.modelMode',
);
});
test('hookBus: validator set === HOST_INTEGRATION_AXES.hookBus', () => {
assert.deepEqual(
sorted(_HOST_INTEGRATION_VOCAB.hookBus),
sorted(HOST_INTEGRATION_AXES.hookBus),
'validator VALID_HOOK_BUSES must exactly match HOST_INTEGRATION_AXES.hookBus',
);
});
test('stateIO: validator set === HOST_INTEGRATION_AXES.stateIO', () => {
assert.deepEqual(
sorted(_HOST_INTEGRATION_VOCAB.stateIO),
sorted(HOST_INTEGRATION_AXES.stateIO),
'validator VALID_STATE_IO must exactly match HOST_INTEGRATION_AXES.stateIO',
);
});
test('transport: validator set === HOST_INTEGRATION_AXES.transport', () => {
assert.deepEqual(
sorted(_HOST_INTEGRATION_VOCAB.transport),
sorted(HOST_INTEGRATION_AXES.transport),
'validator VALID_TRANSPORTS must exactly match HOST_INTEGRATION_AXES.transport',
);
});
test('runtime (axis): validator set === HOST_INTEGRATION_AXES.runtime (8 documented values)', () => {
assert.deepEqual(
sorted(_HOST_INTEGRATION_VOCAB.runtime),
sorted(HOST_INTEGRATION_AXES.runtime),
'validator VALID_HOST_RUNTIMES must exactly match HOST_INTEGRATION_AXES.runtime',
);
});
test('subagentToolkit: validator set === HOST_INTEGRATION_AXES.subagentToolkit', () => {
assert.deepEqual(
sorted(_HOST_INTEGRATION_VOCAB.subagentToolkit),
sorted(HOST_INTEGRATION_AXES.subagentToolkit),
'validator VALID_SUBAGENT_TOOLKITS must exactly match HOST_INTEGRATION_AXES.subagentToolkit',
);
});
test('all axis keys in HOST_INTEGRATION_AXES are covered by _HOST_INTEGRATION_VOCAB', () => {
const axisKeys = Object.keys(HOST_INTEGRATION_AXES).sort();
const vocabKeys = Object.keys(_HOST_INTEGRATION_VOCAB).sort();
assert.deepEqual(
vocabKeys,
axisKeys,
'_HOST_INTEGRATION_VOCAB must cover exactly the same axis keys as HOST_INTEGRATION_AXES',
);
});
test('_HOST_INTEGRATION_VOCAB does NOT include "undocumented" (documented vocab only)', () => {
for (const [axis, values] of Object.entries(_HOST_INTEGRATION_VOCAB)) {
assert.ok(
!values.includes('undocumented'),
`_HOST_INTEGRATION_VOCAB.${axis} must not include "undocumented" (sentinel is NOT documented vocab)`,
);
}
});
});
// ---------------------------------------------------------------------------
// Behavioral: "undocumented" passes validator; bogus values still fail
// ---------------------------------------------------------------------------
describe('ADR-1239 validator behavioral: undocumented sentinel passes, bogus fails', () => {
const SCALAR_AXES = ['embeddingMode', 'commandSurface', 'modelMode', 'hookBus', 'stateIO', 'transport', 'runtime'];
for (const axis of SCALAR_AXES) {
test(`hostIntegration.${axis}:"undocumented" → ZERO validator errors`, () => {
const cap = makeMinimalRuntimeCap({
hostIntegration: {
...makeMinimalRuntimeCap().runtime.hostIntegration,
[axis]: 'undocumented',
},
});
const errors = validateCapability(cap, 'test-runtime');
const hiErrors = errors.filter((e) => e.includes('hostIntegration.' + axis));
assert.strictEqual(hiErrors.length, 0,
`"undocumented" for axis "${axis}" must produce no validator errors; got: ${hiErrors.join(', ')}`);
});
test(`hostIntegration.${axis}:"zzz" → produces a validator error`, () => {
const cap = makeMinimalRuntimeCap({
hostIntegration: {
...makeMinimalRuntimeCap().runtime.hostIntegration,
[axis]: 'zzz',
},
});
const errors = validateCapability(cap, 'test-runtime');
const hiErrors = errors.filter((e) => e.includes('hostIntegration.' + axis));
assert.ok(hiErrors.length > 0,
`bogus value "zzz" for axis "${axis}" must produce a validator error`);
});
}
test('dispatch.namedDispatch:"undocumented" → ZERO validator errors for that field', () => {
const cap = makeMinimalRuntimeCap({
hostIntegration: {
...makeMinimalRuntimeCap().runtime.hostIntegration,
dispatch: {
namedDispatch: 'undocumented',
nested: 'undocumented',
maxDepth: 'undocumented',
background: 'undocumented',
subagentToolkit: 'undocumented',
},
},
});
const errors = validateCapability(cap, 'test-runtime');
const dispatchErrors = errors.filter((e) => e.includes('hostIntegration.dispatch'));
assert.strictEqual(dispatchErrors.length, 0,
`"undocumented" for all dispatch fields must produce no validator errors; got: ${dispatchErrors.join(', ')}`);
});
test('dispatch boolean fields: true/false still accepted', () => {
const cap = makeMinimalRuntimeCap();
const errors = validateCapability(cap, 'test-runtime');
const dispatchErrors = errors.filter((e) => e.includes('hostIntegration.dispatch'));
assert.strictEqual(dispatchErrors.length, 0,
`Valid boolean dispatch fields must produce no errors; got: ${dispatchErrors.join(', ')}`);
});
});
// ---------------------------------------------------------------------------
// Fix 3: validator must reject reserved keys on hostIntegration and dispatch
// ---------------------------------------------------------------------------
describe('Fix 3: reserved-key guard on hostIntegration and hostIntegration.dispatch', () => {
// Base valid runtime body (all documented values, claude layout)
// We build it via JSON.parse to produce an own "__proto__" key that would
// normally be swallowed by a spread (JSON.parse always produces own props).
const BASE_RUNTIME_JSON = JSON.stringify({
id: 'test-runtime',
role: 'runtime',
title: 'Test',
description: 'Test runtime',
tier: 'core',
requires: [],
version: '1.6.0',
engines: { gsd: '>=1.6.0' },
runtime: {
configHome: { kind: 'dot-home', name: '.test', env: [] },
configFormat: 'settings-json',
artifactLayout: { global: [], local: [] },
commandStyle: 'slash-hyphen',
hooksSurface: 'settings-json',
hookEvents: 'claude',
sandboxTier: 'none',
supportTier: 1,
installSurface: 'settings-json',
writesSharedSettings: true,
permissionWriter: null,
extendedHookEvents: ['SubagentStop', 'Stop', 'PreCompact', 'FileChanged'],
hostIntegration: {
embeddingMode: 'imperative',
commandSurface: 'slash-file',
dispatch: {
namedDispatch: true,
nested: true,
maxDepth: 5,
background: true,
subagentToolkit: 'full',
},
modelMode: 'passive',
hookBus: 'host',
stateIO: 'filesystem',
transport: 'mcp',
runtime: 'node',
},
},
});
test('baseline (no reserved keys) → ZERO errors', () => {
const cap = JSON.parse(BASE_RUNTIME_JSON);
const errors = validateCapability(cap, 'test-runtime');
assert.strictEqual(errors.length, 0,
'Baseline with no reserved keys must produce zero errors; got: ' + errors.join(', '));
});
test('hostIntegration with own __proto__ key → error mentioning "reserved key" and "__proto__"', () => {
// Inject a raw __proto__ key via JSON string manipulation — JSON.parse gives it
// as an OWN property (unlike { ..., __proto__: ... } which sets prototype chain).
const json = BASE_RUNTIME_JSON.replace(
'"hostIntegration":{',
'"hostIntegration":{"__proto__":{"polluted":true},',
);
const cap = JSON.parse(json);
// Verify the own-key is actually present (our assumption about JSON.parse)
assert.ok(
Object.prototype.hasOwnProperty.call(cap.runtime.hostIntegration, '__proto__'),
'JSON.parse must produce an own __proto__ key on hostIntegration',
);
const errors = validateCapability(cap, 'test-runtime');
const reservedErrors = errors.filter((e) => e.includes('reserved key') && e.includes('__proto__'));
assert.ok(reservedErrors.length > 0,
'Must produce an error mentioning "reserved key" and "__proto__" for hostIntegration; got: ' + errors.join(', '));
});
test('hostIntegration with own "constructor" key → error mentioning "reserved key" and "constructor"', () => {
const json = BASE_RUNTIME_JSON.replace(
'"hostIntegration":{',
'"hostIntegration":{"constructor":"polluted",',
);
const cap = JSON.parse(json);
assert.ok(
Object.prototype.hasOwnProperty.call(cap.runtime.hostIntegration, 'constructor'),
'JSON.parse must produce an own constructor key on hostIntegration',
);
const errors = validateCapability(cap, 'test-runtime');
const reservedErrors = errors.filter((e) => e.includes('reserved key') && e.includes('constructor'));
assert.ok(reservedErrors.length > 0,
'Must produce an error for "constructor" reserved key on hostIntegration; got: ' + errors.join(', '));
});
test('hostIntegration.dispatch with own __proto__ key → error mentioning "reserved key" and "__proto__"', () => {
const json = BASE_RUNTIME_JSON.replace(
'"dispatch":{',
'"dispatch":{"__proto__":{"polluted":true},',
);
const cap = JSON.parse(json);
assert.ok(
Object.prototype.hasOwnProperty.call(cap.runtime.hostIntegration.dispatch, '__proto__'),
'JSON.parse must produce an own __proto__ key on dispatch',
);
const errors = validateCapability(cap, 'test-runtime');
const reservedErrors = errors.filter((e) => e.includes('reserved key') && e.includes('__proto__'));
assert.ok(reservedErrors.length > 0,
'Must produce an error for "__proto__" reserved key on dispatch; got: ' + errors.join(', '));
});
test('hostIntegration.dispatch with own "prototype" key → error mentioning "reserved key" and "prototype"', () => {
const json = BASE_RUNTIME_JSON.replace(
'"dispatch":{',
'"dispatch":{"prototype":{"polluted":true},',
);
const cap = JSON.parse(json);
assert.ok(
Object.prototype.hasOwnProperty.call(cap.runtime.hostIntegration.dispatch, 'prototype'),
'JSON.parse must produce an own prototype key on dispatch',
);
const errors = validateCapability(cap, 'test-runtime');
const reservedErrors = errors.filter((e) => e.includes('reserved key') && e.includes('prototype'));
assert.ok(reservedErrors.length > 0,
'Must produce an error for "prototype" reserved key on dispatch; got: ' + errors.join(', '));
});
});

View File

@@ -0,0 +1,888 @@
'use strict';
/**
* Unit tests for host-integration.cjs (ADR-1239 Phase A).
* Pure, additive, no-I/O module — no temp dirs needed.
* Uses node:test + node:assert/strict.
* Requires the COMPILED artifact: ../gsd-core/bin/lib/host-integration.cjs
*/
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const hi = require('../gsd-core/bin/lib/host-integration.cjs');
const {
PROTOCOL_VERSION,
HOST_INTEGRATION_AXES,
INTERFACE_POINTS,
PROFILE_BASELINES,
DEFAULT_ENGINE,
UNDOCUMENTED,
degradationFor,
profileOf,
negotiateHostCapabilities,
} = hi;
// ---------------------------------------------------------------------------
// CONTRACT-PIN: constants and vocabulary
// ---------------------------------------------------------------------------
describe('CONTRACT-PIN', () => {
test('PROTOCOL_VERSION === 1', () => {
assert.strictEqual(PROTOCOL_VERSION, 1);
});
test('HOST_INTEGRATION_AXES is frozen', () => {
assert.ok(Object.isFrozen(HOST_INTEGRATION_AXES), 'HOST_INTEGRATION_AXES must be frozen');
});
test('each axis sub-array is frozen', () => {
for (const [axis, arr] of Object.entries(HOST_INTEGRATION_AXES)) {
assert.ok(Object.isFrozen(arr), `HOST_INTEGRATION_AXES.${axis} must be frozen`);
}
});
test('embeddingMode values (sorted)', () => {
assert.deepStrictEqual(
[...HOST_INTEGRATION_AXES.embeddingMode].sort(),
['declarative', 'imperative'],
);
});
test('commandSurface values (sorted)', () => {
assert.deepStrictEqual(
[...HOST_INTEGRATION_AXES.commandSurface].sort(),
['palette', 'prose-only', 'slash-file', 'slash-programmatic', 'slash-toml'],
);
});
test('modelMode values (sorted)', () => {
assert.deepStrictEqual(
[...HOST_INTEGRATION_AXES.modelMode].sort(),
['active', 'passive'],
);
});
test('hookBus values (sorted)', () => {
assert.deepStrictEqual(
[...HOST_INTEGRATION_AXES.hookBus].sort(),
['engine', 'host', 'none'],
);
});
test('stateIO values (sorted)', () => {
assert.deepStrictEqual(
[...HOST_INTEGRATION_AXES.stateIO].sort(),
['filesystem', 'sandboxed-storage', 'session-log-append'],
);
});
test('transport values (sorted)', () => {
assert.deepStrictEqual(
[...HOST_INTEGRATION_AXES.transport].sort(),
['mcp', 'native-extension'],
);
});
test('runtime values (sorted) — 8 documented values', () => {
assert.deepStrictEqual(
[...HOST_INTEGRATION_AXES.runtime].sort(),
['bun', 'electron', 'go', 'node', 'other', 'python', 'rust', 'sandboxed-web'],
);
});
test('UNDOCUMENTED === "undocumented"', () => {
assert.equal(UNDOCUMENTED, 'undocumented');
});
test('subagentToolkit values (sorted)', () => {
assert.deepStrictEqual(
[...HOST_INTEGRATION_AXES.subagentToolkit].sort(),
['full', 'read-only'],
);
});
test('INTERFACE_POINTS frozen and contains expected values', () => {
assert.ok(Object.isFrozen(INTERFACE_POINTS), 'INTERFACE_POINTS must be frozen');
const expected = ['command', 'dispatch', 'model', 'hooks', 'state', 'artifact'].sort();
assert.deepStrictEqual([...INTERFACE_POINTS].sort(), expected);
});
});
// ---------------------------------------------------------------------------
// degradationFor — happy path per enum value
// ---------------------------------------------------------------------------
describe('degradationFor — happy path', () => {
test('command: slash-file → full', () => {
const r = degradationFor('command', { commandSurface: 'slash-file' });
assert.strictEqual(r.level, 'full');
assert.strictEqual(typeof r.fallback, 'string');
});
test('command: slash-programmatic → full', () => {
const r = degradationFor('command', { commandSurface: 'slash-programmatic' });
assert.strictEqual(r.level, 'full');
});
test('command: slash-toml → degraded', () => {
const r = degradationFor('command', { commandSurface: 'slash-toml' });
assert.strictEqual(r.level, 'degraded');
});
test('command: palette → degraded', () => {
const r = degradationFor('command', { commandSurface: 'palette' });
assert.strictEqual(r.level, 'degraded');
});
test('command: prose-only → absent', () => {
const r = degradationFor('command', { commandSurface: 'prose-only' });
assert.strictEqual(r.level, 'absent');
assert.ok(r.fallback.length > 0, 'fallback must be non-empty for prose-only');
});
test('model: active → full', () => {
const r = degradationFor('model', { modelMode: 'active' });
assert.strictEqual(r.level, 'full');
});
test('model: passive → degraded', () => {
const r = degradationFor('model', { modelMode: 'passive' });
assert.strictEqual(r.level, 'degraded');
});
test('hooks: host → full', () => {
const r = degradationFor('hooks', { hookBus: 'host' });
assert.strictEqual(r.level, 'full');
});
test('hooks: engine → degraded', () => {
const r = degradationFor('hooks', { hookBus: 'engine' });
assert.strictEqual(r.level, 'degraded');
});
test('hooks: none → absent', () => {
const r = degradationFor('hooks', { hookBus: 'none' });
assert.strictEqual(r.level, 'absent');
});
test('state: filesystem → full', () => {
const r = degradationFor('state', { stateIO: 'filesystem' });
assert.strictEqual(r.level, 'full');
});
test('state: sandboxed-storage → degraded', () => {
const r = degradationFor('state', { stateIO: 'sandboxed-storage' });
assert.strictEqual(r.level, 'degraded');
});
test('state: session-log-append → degraded', () => {
const r = degradationFor('state', { stateIO: 'session-log-append' });
assert.strictEqual(r.level, 'degraded');
});
test('artifact: slash-file → full', () => {
const r = degradationFor('artifact', { commandSurface: 'slash-file' });
assert.strictEqual(r.level, 'full');
});
test('artifact: slash-programmatic → full', () => {
const r = degradationFor('artifact', { commandSurface: 'slash-programmatic' });
assert.strictEqual(r.level, 'full');
});
test('artifact: slash-toml → degraded', () => {
const r = degradationFor('artifact', { commandSurface: 'slash-toml' });
assert.strictEqual(r.level, 'degraded');
});
test('artifact: prose-only → degraded', () => {
const r = degradationFor('artifact', { commandSurface: 'prose-only' });
assert.strictEqual(r.level, 'degraded');
});
test('artifact: palette → absent', () => {
const r = degradationFor('artifact', { commandSurface: 'palette' });
assert.strictEqual(r.level, 'absent');
});
// dispatch variants
test('dispatch: no namedDispatch → absent', () => {
const r = degradationFor('dispatch', { dispatch: { namedDispatch: false, nested: false, maxDepth: 0, background: false, subagentToolkit: 'full' } });
assert.strictEqual(r.level, 'absent');
});
test('dispatch: maxDepth===0 → absent', () => {
const r = degradationFor('dispatch', { dispatch: { namedDispatch: true, nested: true, maxDepth: 0, background: true, subagentToolkit: 'full' } });
assert.strictEqual(r.level, 'absent');
});
test('dispatch: unbounded (-1) nested → full', () => {
const r = degradationFor('dispatch', { dispatch: { namedDispatch: true, nested: true, maxDepth: -1, background: true, subagentToolkit: 'full' } });
assert.strictEqual(r.level, 'full');
});
test('dispatch: nested maxDepth>=2 → full', () => {
const r = degradationFor('dispatch', { dispatch: { namedDispatch: true, nested: true, maxDepth: 2, background: true, subagentToolkit: 'full' } });
assert.strictEqual(r.level, 'full');
});
test('dispatch: full but subagentToolkit read-only → degraded', () => {
const r = degradationFor('dispatch', { dispatch: { namedDispatch: true, nested: true, maxDepth: -1, background: true, subagentToolkit: 'read-only' } });
assert.strictEqual(r.level, 'degraded');
});
test('dispatch: flat (maxDepth===1) → degraded', () => {
const r = degradationFor('dispatch', { dispatch: { namedDispatch: true, nested: false, maxDepth: 1, background: false, subagentToolkit: 'full' } });
assert.strictEqual(r.level, 'degraded');
});
});
// ---------------------------------------------------------------------------
// degradationFor — EVERY enum value returns a defined result with valid level
// ---------------------------------------------------------------------------
describe('degradationFor — all enum values return valid level', () => {
const VALID_LEVELS = new Set(['full', 'degraded', 'absent']);
test('command — all commandSurface values', () => {
for (const v of HOST_INTEGRATION_AXES.commandSurface) {
const r = degradationFor('command', { commandSurface: v });
assert.ok(VALID_LEVELS.has(r.level), `command/${v}: level '${r.level}' invalid`);
assert.strictEqual(typeof r.fallback, 'string');
}
});
test('model — all modelMode values', () => {
for (const v of HOST_INTEGRATION_AXES.modelMode) {
const r = degradationFor('model', { modelMode: v });
assert.ok(VALID_LEVELS.has(r.level), `model/${v}: level '${r.level}' invalid`);
}
});
test('hooks — all hookBus values', () => {
for (const v of HOST_INTEGRATION_AXES.hookBus) {
const r = degradationFor('hooks', { hookBus: v });
assert.ok(VALID_LEVELS.has(r.level), `hooks/${v}: level '${r.level}' invalid`);
}
});
test('state — all stateIO values', () => {
for (const v of HOST_INTEGRATION_AXES.stateIO) {
const r = degradationFor('state', { stateIO: v });
assert.ok(VALID_LEVELS.has(r.level), `state/${v}: level '${r.level}' invalid`);
}
});
test('artifact — all commandSurface values', () => {
for (const v of HOST_INTEGRATION_AXES.commandSurface) {
const r = degradationFor('artifact', { commandSurface: v });
assert.ok(VALID_LEVELS.has(r.level), `artifact/${v}: level '${r.level}' invalid`);
}
});
});
// ---------------------------------------------------------------------------
// degradationFor — unknown / missing axis → absent + unknown:true, never throws
// ---------------------------------------------------------------------------
describe('degradationFor — unknown / missing axis', () => {
test('unknown commandSurface value for command → absent + unknown:true', () => {
const r = degradationFor('command', { commandSurface: 'zzz' });
assert.strictEqual(r.level, 'absent');
assert.strictEqual(r.unknown, true);
});
test('missing commandSurface for command → absent + unknown:true', () => {
const r = degradationFor('command', {});
assert.strictEqual(r.level, 'absent');
assert.strictEqual(r.unknown, true);
});
test('unknown modelMode → absent + unknown:true', () => {
const r = degradationFor('model', { modelMode: 'zzz' });
assert.strictEqual(r.level, 'absent');
assert.strictEqual(r.unknown, true);
});
test('missing hookBus for hooks → absent + unknown:true', () => {
const r = degradationFor('hooks', {});
assert.strictEqual(r.level, 'absent');
assert.strictEqual(r.unknown, true);
});
test('no throw on unknown axis value', () => {
assert.doesNotThrow(() => degradationFor('dispatch', { dispatch: 'not-an-object' }));
});
test('no throw on completely empty axes', () => {
for (const point of INTERFACE_POINTS) {
assert.doesNotThrow(() => degradationFor(point, {}));
}
});
});
// ---------------------------------------------------------------------------
// profileOf
// ---------------------------------------------------------------------------
describe('profileOf', () => {
test('profileOf(PROFILE_BASELINES["programmatic-cli"]) === "programmatic-cli"', () => {
assert.strictEqual(profileOf(PROFILE_BASELINES['programmatic-cli']), 'programmatic-cli');
});
test('profileOf(PROFILE_BASELINES["declarative-cli"]) === "declarative-cli"', () => {
assert.strictEqual(profileOf(PROFILE_BASELINES['declarative-cli']), 'declarative-cli');
});
test('profileOf(PROFILE_BASELINES["ide"]) === "ide"', () => {
assert.strictEqual(profileOf(PROFILE_BASELINES['ide']), 'ide');
});
test('imperative + sandboxed-web → ide', () => {
assert.strictEqual(
profileOf({ embeddingMode: 'imperative', runtime: 'sandboxed-web' }),
'ide',
);
});
test('imperative + node → programmatic-cli', () => {
assert.strictEqual(
profileOf({ embeddingMode: 'imperative', runtime: 'node' }),
'programmatic-cli',
);
});
test('declarative → declarative-cli', () => {
assert.strictEqual(
profileOf({ embeddingMode: 'declarative' }),
'declarative-cli',
);
});
test('empty axes → null', () => {
assert.strictEqual(profileOf({}), null);
});
test('PROFILE_BASELINES are frozen', () => {
assert.ok(Object.isFrozen(PROFILE_BASELINES), 'PROFILE_BASELINES must be frozen');
});
});
// ---------------------------------------------------------------------------
// negotiateHostCapabilities — HAPPY PATH
// ---------------------------------------------------------------------------
describe('negotiateHostCapabilities — happy path', () => {
test('declarative-cli baseline → effective matches, no warnings, points.command.effectiveLevel===full', () => {
const baseline = PROFILE_BASELINES['declarative-cli'];
const result = negotiateHostCapabilities(baseline);
// No warnings
assert.deepStrictEqual(result.warnings, [], 'Expected no warnings for full declarative-cli baseline');
// Key points
assert.strictEqual(result.points.command.effectiveLevel, 'full');
assert.strictEqual(result.points.hooks.effectiveLevel, 'full');
assert.strictEqual(result.points.state.effectiveLevel, 'full');
// protocolVersion
assert.strictEqual(result.protocolVersion, PROTOCOL_VERSION);
// effective axes match baseline (scalar)
assert.strictEqual(result.effective.embeddingMode, baseline.embeddingMode);
assert.strictEqual(result.effective.commandSurface, baseline.commandSurface);
assert.strictEqual(result.effective.modelMode, baseline.modelMode);
assert.strictEqual(result.effective.hookBus, baseline.hookBus);
assert.strictEqual(result.effective.stateIO, baseline.stateIO);
// effective dispatch has maxDepth resolved (declarative has maxDepth:1)
assert.strictEqual(result.effective.dispatch.maxDepth, 1);
assert.strictEqual(result.effective.dispatch.namedDispatch, true);
});
test('all INTERFACE_POINTS are present in result.points', () => {
const result = negotiateHostCapabilities(PROFILE_BASELINES['programmatic-cli']);
for (const point of INTERFACE_POINTS) {
assert.ok(point in result.points, `Missing point: ${point}`);
assert.ok(['full', 'degraded', 'absent'].includes(result.points[point].effectiveLevel),
`Invalid effectiveLevel for ${point}`);
}
});
});
// ---------------------------------------------------------------------------
// negotiateHostCapabilities — SECURITY / HOSTILE
// ---------------------------------------------------------------------------
describe('negotiateHostCapabilities — security / hostile', () => {
test('(1) host declares future commandSurface at protocolVersion 99 → effective is KNOWN value, NOT the unknown one', () => {
const result = negotiateHostCapabilities({
...PROFILE_BASELINES['programmatic-cli'],
commandSurface: 'future-surface',
protocolVersion: 99,
});
// effective.commandSurface must be a KNOWN value
assert.ok(
HOST_INTEGRATION_AXES.commandSurface.includes(result.effective.commandSurface),
`effective.commandSurface '${result.effective.commandSurface}' is not in known vocabulary`,
);
assert.notStrictEqual(result.effective.commandSurface, 'future-surface',
'future-surface must NOT appear in effective');
// A warning mentioning protocolVersion
const warnText = result.warnings.join(' ');
assert.ok(warnText.includes('protocolVersion') || warnText.includes('unknown'),
`Expected a warning about protocolVersion or unknown value; got: ${warnText}`);
});
test('(2) host modelMode active but engine passive → effective.modelMode === passive', () => {
const restrictedEngine = {
...DEFAULT_ENGINE,
axes: { ...DEFAULT_ENGINE.axes, modelMode: 'passive' },
};
const result = negotiateHostCapabilities(
{ ...PROFILE_BASELINES['programmatic-cli'], modelMode: 'active' },
restrictedEngine,
);
assert.strictEqual(result.effective.modelMode, 'passive');
});
test('(3) host dispatch maxDepth:5 nested:true but engine dispatch maxDepth:1 → effective.dispatch.maxDepth===1', () => {
const restrictedEngine = {
...DEFAULT_ENGINE,
axes: {
...DEFAULT_ENGINE.axes,
dispatch: { ...DEFAULT_ENGINE.axes.dispatch, maxDepth: 1, nested: false },
},
};
const result = negotiateHostCapabilities(
{
...PROFILE_BASELINES['programmatic-cli'],
dispatch: { namedDispatch: true, nested: true, maxDepth: 5, background: true, subagentToolkit: 'full' },
},
restrictedEngine,
);
assert.strictEqual(result.effective.dispatch.maxDepth, 1);
});
test('(4) host omits hookBus → effective.hookBus is safe default + warning present', () => {
const hostWithoutHookBus = { ...PROFILE_BASELINES['declarative-cli'] };
delete hostWithoutHookBus.hookBus;
const result = negotiateHostCapabilities(hostWithoutHookBus);
// effective hookBus must be a known value
assert.ok(
HOST_INTEGRATION_AXES.hookBus.includes(result.effective.hookBus),
`effective.hookBus '${result.effective.hookBus}' is not known`,
);
// points.hooks must be present
assert.ok('hooks' in result.points, 'points.hooks must be present');
// a warning mentioning hookBus
const warnText = result.warnings.join(' ');
assert.ok(warnText.includes('hookBus'), `Expected warning about hookBus; got: ${warnText}`);
});
test('(5) INVARIANT: every effective scalar ∈ engine.known[axis] for hostile hosts', () => {
const hostileHosts = [
// All unknown values
{
embeddingMode: 'future-mode',
commandSurface: 'future-surface',
modelMode: 'quantum',
hookBus: 'blockchain',
stateIO: 'cloud-magic',
transport: 'telepathy',
runtime: 'wasm',
protocolVersion: 999,
},
// Mix of known and unknown
{
embeddingMode: 'imperative',
commandSurface: 'palette',
modelMode: 'active',
hookBus: 'none',
stateIO: 'unknown-future',
transport: 'mcp',
runtime: 'sandboxed-web',
},
// Empty host
{},
// Only dispatch with extreme values
{
dispatch: { namedDispatch: true, nested: true, maxDepth: 9999, background: true, subagentToolkit: 'full' },
},
];
const scalarAxes = ['embeddingMode', 'commandSurface', 'modelMode', 'hookBus', 'stateIO', 'transport', 'runtime'];
for (const host of hostileHosts) {
const result = negotiateHostCapabilities(host);
for (const axis of scalarAxes) {
const effectiveVal = result.effective[axis];
assert.ok(
HOST_INTEGRATION_AXES[axis].includes(effectiveVal),
`INVARIANT VIOLATION: effective.${axis}='${effectiveVal}' is NOT in known vocabulary for host=${JSON.stringify(host)}`,
);
}
}
});
test('host protocolVersion > engine → warning mentioning protocolVersion', () => {
const result = negotiateHostCapabilities({
...PROFILE_BASELINES['declarative-cli'],
protocolVersion: 99,
});
const warnText = result.warnings.join(' ');
assert.ok(warnText.includes('protocolVersion'), `Expected protocolVersion warning; got: ${warnText}`);
assert.strictEqual(result.protocolVersion, PROTOCOL_VERSION);
});
});
// ---------------------------------------------------------------------------
// INDEPENDENCE: mutation safety
// ---------------------------------------------------------------------------
describe('independence / mutation safety', () => {
test('mutating returned result does not affect second call', () => {
const host = PROFILE_BASELINES['declarative-cli'];
const r1 = negotiateHostCapabilities(host);
// Mutate r1
r1.warnings.push('injected');
r1.effective.modelMode = 'active';
r1.points.command.effectiveLevel = 'absent';
const r2 = negotiateHostCapabilities(host);
// r2 must not be affected
assert.deepStrictEqual(r2.warnings, [], 'r2.warnings must not include injected warning');
assert.strictEqual(r2.effective.modelMode, host.modelMode, 'r2.effective.modelMode must be original value');
assert.strictEqual(r2.points.command.effectiveLevel, 'full', 'r2.points.command.effectiveLevel must be full');
});
test('all exports are present on the module', () => {
const expectedExports = [
'PROTOCOL_VERSION', 'HOST_INTEGRATION_AXES', 'INTERFACE_POINTS',
'PROFILE_BASELINES', 'DEFAULT_ENGINE', 'UNDOCUMENTED',
'degradationFor', 'profileOf', 'negotiateHostCapabilities',
];
for (const exp of expectedExports) {
assert.ok(exp in hi, `Missing export: ${exp}`);
}
});
});
// ---------------------------------------------------------------------------
// Decision 1: undocumented sentinel — fail-closed in negotiation
// ---------------------------------------------------------------------------
describe('Decision 1: UNDOCUMENTED sentinel — fail-closed negotiation', () => {
test('negotiate with embeddingMode:"undocumented" → effective is safe default (documented value), NOT "undocumented"', () => {
const host = {
...PROFILE_BASELINES['declarative-cli'],
embeddingMode: 'undocumented',
};
const result = negotiateHostCapabilities(host);
// effective.embeddingMode must be a documented value, NOT 'undocumented'
assert.ok(
HOST_INTEGRATION_AXES.embeddingMode.includes(result.effective.embeddingMode),
`effective.embeddingMode must be a documented value; got '${result.effective.embeddingMode}'`,
);
assert.notStrictEqual(result.effective.embeddingMode, 'undocumented',
'effective.embeddingMode must not be "undocumented"');
// A warning mentioning "undocumented"
const warnText = result.warnings.join(' ');
assert.ok(warnText.includes('undocumented'),
`Expected a warning mentioning "undocumented"; got: ${warnText}`);
});
test('negotiate with dispatch fields all "undocumented" → fail-closed dispatch', () => {
const host = {
...PROFILE_BASELINES['programmatic-cli'],
dispatch: {
namedDispatch: 'undocumented',
nested: 'undocumented',
maxDepth: 'undocumented',
background: 'undocumented',
subagentToolkit: 'undocumented',
},
};
const result = negotiateHostCapabilities(host);
const d = result.effective.dispatch;
assert.strictEqual(d.namedDispatch, false, 'namedDispatch must be false when "undocumented"');
assert.strictEqual(d.nested, false, 'nested must be false when "undocumented"');
assert.strictEqual(d.background, false, 'background must be false when "undocumented"');
assert.strictEqual(d.subagentToolkit, 'read-only', 'subagentToolkit must be "read-only" when "undocumented"');
assert.strictEqual(d.maxDepth, 0, 'maxDepth must be 0 when "undocumented"');
// points.dispatch must be absent
assert.strictEqual(result.points.dispatch.effectiveLevel, 'absent',
'points.dispatch.effectiveLevel must be "absent" when dispatch is all undocumented');
});
test('degradationFor dispatch with namedDispatch:"undocumented" → level "absent"', () => {
const r = degradationFor('dispatch', {
dispatch: {
namedDispatch: 'undocumented',
nested: false,
maxDepth: 0,
background: false,
subagentToolkit: 'full',
},
});
assert.strictEqual(r.level, 'absent',
`degradationFor with namedDispatch:"undocumented" must return absent; got "${r.level}"`);
});
test('subagentToolkit "undocumented" (truthy string) fails closed to read-only', () => {
const host = {
...PROFILE_BASELINES['programmatic-cli'],
dispatch: {
namedDispatch: true,
nested: true,
maxDepth: -1,
background: true,
subagentToolkit: 'undocumented',
},
};
const result = negotiateHostCapabilities(host);
assert.strictEqual(result.effective.dispatch.subagentToolkit, 'read-only',
'subagentToolkit "undocumented" must degrade to "read-only"');
});
});
// ---------------------------------------------------------------------------
// Decision 2: expanded runtime vocabulary (8 documented values)
// ---------------------------------------------------------------------------
describe('Decision 2: expanded runtime vocabulary', () => {
const newRuntimes = ['python', 'go', 'rust', 'electron', 'other'];
for (const rt of newRuntimes) {
test(`negotiate with runtime:"${rt}" → effective.runtime === "${rt}" (no warn about unknown)`, () => {
const host = {
...PROFILE_BASELINES['programmatic-cli'],
runtime: rt,
};
const result = negotiateHostCapabilities(host);
assert.strictEqual(result.effective.runtime, rt,
`effective.runtime must be "${rt}"; got "${result.effective.runtime}"`);
// Must NOT have an unknown-value warning for this runtime
const runtimeWarnings = result.warnings.filter((w) => w.includes('runtime') && w.includes('not trusted'));
assert.strictEqual(runtimeWarnings.length, 0,
`Must not warn about unknown runtime "${rt}"; warnings: ${result.warnings.join(', ')}`);
});
}
test('runtime "undocumented" (sentinel) → fail-closed to safe default', () => {
const host = {
...PROFILE_BASELINES['programmatic-cli'],
runtime: 'undocumented',
};
const result = negotiateHostCapabilities(host);
// Must be a documented value, not "undocumented"
assert.ok(
HOST_INTEGRATION_AXES.runtime.includes(result.effective.runtime),
`effective.runtime must be documented; got "${result.effective.runtime}"`,
);
assert.notStrictEqual(result.effective.runtime, 'undocumented');
const warnText = result.warnings.join(' ');
assert.ok(warnText.includes('undocumented'), `Expected undocumented warning; got: ${warnText}`);
});
test('"wasm" (genuinely unknown, not sentinel) → still fails closed with "not trusted" warning', () => {
const host = {
...PROFILE_BASELINES['programmatic-cli'],
runtime: 'wasm',
};
const result = negotiateHostCapabilities(host);
assert.ok(HOST_INTEGRATION_AXES.runtime.includes(result.effective.runtime),
`effective.runtime must be documented; got "${result.effective.runtime}"`);
const warnText = result.warnings.join(' ');
assert.ok(warnText.includes('not trusted') || warnText.includes('unknown'),
`Expected not-trusted/unknown warning; got: ${warnText}`);
});
});
// ---------------------------------------------------------------------------
// Fix 1: degradationFor('dispatch') fail-closed on non-'full' subagentToolkit
// ---------------------------------------------------------------------------
describe('Fix 1: degradationFor dispatch fails closed on non-full subagentToolkit', () => {
const FULL_DEPTH_DISPATCH = { namedDispatch: true, nested: true, maxDepth: -1, background: true };
test('subagentToolkit:"full" + full depth → level "full"', () => {
const r = degradationFor('dispatch', { dispatch: { ...FULL_DEPTH_DISPATCH, subagentToolkit: 'full' } });
assert.strictEqual(r.level, 'full',
'subagentToolkit:"full" with full depth must return level "full"');
});
test('subagentToolkit:"read-only" + full depth → level "degraded"', () => {
const r = degradationFor('dispatch', { dispatch: { ...FULL_DEPTH_DISPATCH, subagentToolkit: 'read-only' } });
assert.strictEqual(r.level, 'degraded',
'subagentToolkit:"read-only" must return level "degraded"');
assert.ok(r.fallback.length > 0, 'fallback must be non-empty');
});
test('subagentToolkit:"undocumented" + full depth → level "degraded" (fail-closed)', () => {
const r = degradationFor('dispatch', { dispatch: { ...FULL_DEPTH_DISPATCH, subagentToolkit: 'undocumented' } });
assert.strictEqual(r.level, 'degraded',
'subagentToolkit:"undocumented" must fail closed to level "degraded"; got "' + r.level + '"');
assert.ok(r.fallback.length > 0, 'fallback must be non-empty');
});
test('subagentToolkit:"future-xyz" + full depth → level "degraded" (fail-closed)', () => {
const r = degradationFor('dispatch', { dispatch: { ...FULL_DEPTH_DISPATCH, subagentToolkit: 'future-xyz' } });
assert.strictEqual(r.level, 'degraded',
'subagentToolkit:"future-xyz" (unknown) must fail closed to level "degraded"; got "' + r.level + '"');
assert.ok(r.fallback.length > 0, 'fallback must be non-empty');
});
test('subagentToolkit:"" (empty string) + full depth → level "degraded" (fail-closed)', () => {
const r = degradationFor('dispatch', { dispatch: { ...FULL_DEPTH_DISPATCH, subagentToolkit: '' } });
assert.strictEqual(r.level, 'degraded',
'subagentToolkit:"" must fail closed to level "degraded"; got "' + r.level + '"');
});
});
// ---------------------------------------------------------------------------
// New fixes: M1 maxDepth NaN, M2 struct consistency, L1 SAFE_DEFAULTS,
// L2 protocolVersion warn, N1 undocumented dispatch warnings
// ---------------------------------------------------------------------------
describe('Fix M1: maxDepth NaN bypasses number guard', () => {
test('negotiate with dispatch.maxDepth NaN → effective.dispatch.maxDepth === 0 AND warning about maxDepth AND Number.isFinite', () => {
const result = negotiateHostCapabilities({
dispatch: { namedDispatch: true, nested: false, maxDepth: NaN, background: false, subagentToolkit: 'full' },
});
const d = result.effective.dispatch;
assert.strictEqual(d.maxDepth, 0, 'NaN maxDepth must be normalized to 0');
assert.ok(Number.isFinite(d.maxDepth), 'effective.dispatch.maxDepth must be finite (Number.isFinite)');
const warnText = result.warnings.join(' ');
assert.ok(warnText.includes('maxDepth'), `Expected a warning about maxDepth; got: ${warnText}`);
});
test('degradationFor dispatch with maxDepth NaN → level "degraded" (not NaN-dependent, not "full")', () => {
const r = degradationFor('dispatch', {
dispatch: { namedDispatch: true, nested: true, maxDepth: NaN, subagentToolkit: 'full' },
});
// After fix: depth=(NaN not finite)→0; NaN===0 is false so initial check doesn't fire;
// isUnbounded=false; isFullDepth = false || (nested:true && 0>=2) = false → 'degraded' (flat)
assert.strictEqual(r.level, 'degraded',
`NaN maxDepth with nested:true must yield 'degraded' (depth=0, not full-depth); got: ${r.level}`);
assert.notStrictEqual(r.level, 'full', 'NaN maxDepth must NOT yield "full"');
});
});
describe('Fix M2: cap nested/background when namedDispatch is false', () => {
test('negotiate with namedDispatch:"undocumented" → namedDispatch false, nested false, background false, maxDepth 0; warnings include namedDispatch undocumented note', () => {
const result = negotiateHostCapabilities({
dispatch: { namedDispatch: 'undocumented', nested: true, background: true, maxDepth: 5, subagentToolkit: 'full' },
});
const d = result.effective.dispatch;
assert.strictEqual(d.namedDispatch, false, 'namedDispatch must be false');
assert.strictEqual(d.nested, false, 'nested must be false when namedDispatch is false');
assert.strictEqual(d.background, false, 'background must be false when namedDispatch is false');
assert.strictEqual(d.maxDepth, 0, 'maxDepth must be 0 when namedDispatch is false');
const warnText = result.warnings.join(' ');
assert.ok(warnText.includes('namedDispatch') || warnText.includes('dispatch.namedDispatch'),
`Expected a warning about namedDispatch being undocumented; got: ${warnText}`);
});
});
describe('Fix L1: SAFE_DEFAULTS.dispatch.subagentToolkit is read-only', () => {
// CONTRACT: negotiate({}) uses SAFE_DEFAULTS for each axis; dispatch uses its floor
test('negotiate({}) → effective axes match documented SAFE_DEFAULTS (CONTRACT)', () => {
const result = negotiateHostCapabilities({});
const eff = result.effective;
assert.strictEqual(eff.embeddingMode, 'declarative');
assert.strictEqual(eff.commandSurface, 'prose-only');
assert.strictEqual(eff.modelMode, 'passive');
assert.strictEqual(eff.hookBus, 'none');
assert.strictEqual(eff.stateIO, 'session-log-append');
assert.strictEqual(eff.transport, 'mcp');
assert.strictEqual(eff.runtime, 'node');
assert.strictEqual(eff.dispatch.subagentToolkit, 'read-only',
'SAFE_DEFAULTS dispatch floor must be read-only');
});
});
describe('Fix L2: warn on present-but-non-number protocolVersion', () => {
test('negotiate with protocolVersion:"beta" → warnings include protocolVersion note; result.protocolVersion === engine default (1)', () => {
const result = negotiateHostCapabilities({
embeddingMode: 'declarative',
commandSurface: 'slash-file',
modelMode: 'passive',
hookBus: 'host',
stateIO: 'filesystem',
transport: 'mcp',
runtime: 'node',
dispatch: { namedDispatch: true, nested: false, maxDepth: 1, background: false, subagentToolkit: 'full' },
protocolVersion: 'beta',
});
const warnText = result.warnings.join(' ');
assert.ok(warnText.includes('protocolVersion'),
`Expected a warning about protocolVersion being non-finite/non-number; got: ${warnText}`);
assert.strictEqual(result.protocolVersion, 1,
'result.protocolVersion must fall back to engine default (1)');
});
});
describe('Fix N1: symmetric observability warnings for undocumented dispatch fields', () => {
test('dispatch.subagentToolkit:"undocumented" → warning includes "dispatch.subagentToolkit is undocumented"', () => {
const result = negotiateHostCapabilities({
dispatch: { namedDispatch: true, nested: true, maxDepth: -1, background: true, subagentToolkit: 'undocumented' },
});
const warnText = result.warnings.join(' ');
assert.ok(warnText.includes('subagentToolkit') && warnText.includes('undocumented'),
`Expected warning about dispatch.subagentToolkit undocumented; got: ${warnText}`);
});
});
describe('Fix: degradationFor unknown point → {level:"absent", unknown:true}', () => {
test('degradationFor("totally-unknown-point", {}) → {level:"absent", unknown:true}', () => {
const r = degradationFor('totally-unknown-point', {});
assert.strictEqual(r.level, 'absent', 'unknown point must return absent');
assert.strictEqual(r.unknown, true, 'unknown point must have unknown:true');
});
});
// ---------------------------------------------------------------------------
// Fix 2: negotiateHostCapabilities — host omitting 'dispatch' → subagentToolkit 'read-only'
// ---------------------------------------------------------------------------
describe('Fix 2: negotiate — host omits dispatch → subagentToolkit read-only (fail-closed)', () => {
test('negotiateHostCapabilities({}) → effective.dispatch.subagentToolkit === "read-only"', () => {
const result = negotiateHostCapabilities({});
assert.strictEqual(result.effective.dispatch.subagentToolkit, 'read-only',
'When host omits dispatch, subagentToolkit must fail-closed to "read-only"; got "' + result.effective.dispatch.subagentToolkit + '"');
});
test('negotiateHostCapabilities({}) → effective.dispatch.namedDispatch===false, maxDepth===0, nested===false, background===false', () => {
const result = negotiateHostCapabilities({});
const d = result.effective.dispatch;
assert.strictEqual(d.namedDispatch, false);
assert.strictEqual(d.maxDepth, 0);
assert.strictEqual(d.nested, false);
assert.strictEqual(d.background, false);
});
test('negotiateHostCapabilities({}) → points.dispatch.effectiveLevel === "absent"', () => {
const result = negotiateHostCapabilities({});
assert.strictEqual(result.points.dispatch.effectiveLevel, 'absent',
'dispatch absent when host omits it');
});
test('host with all axes but no dispatch → subagentToolkit "read-only"', () => {
const hostWithoutDispatch = {
embeddingMode: 'imperative',
commandSurface: 'slash-file',
modelMode: 'passive',
hookBus: 'host',
stateIO: 'filesystem',
transport: 'mcp',
runtime: 'node',
// no dispatch key
};
const result = negotiateHostCapabilities(hostWithoutDispatch);
assert.strictEqual(result.effective.dispatch.subagentToolkit, 'read-only',
'Host missing dispatch must produce subagentToolkit "read-only"; got "' + result.effective.dispatch.subagentToolkit + '"');
});
});