diff --git a/.changeset/vivid-seals-purr.md b/.changeset/vivid-seals-purr.md new file mode 100644 index 000000000..63c2649d7 --- /dev/null +++ b/.changeset/vivid-seals-purr.md @@ -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. diff --git a/.gitignore b/.gitignore index 12009ded1..fed10be39 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/CONTEXT.md b/CONTEXT.md index 17a8a6bc5..3a28224d8 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -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. diff --git a/capabilities/antigravity/capability.json b/capabilities/antigravity/capability.json index 3ad49e14d..a714fefdd 100644 --- a/capabilities/antigravity/capability.json +++ b/capabilities/antigravity/capability.json @@ -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" + } } } diff --git a/capabilities/augment/capability.json b/capabilities/augment/capability.json index 5fa6e875f..5c536fd64 100644 --- a/capabilities/augment/capability.json +++ b/capabilities/augment/capability.json @@ -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" + } } } diff --git a/capabilities/claude/capability.json b/capabilities/claude/capability.json index f2f12751c..fa3089d5c 100644 --- a/capabilities/claude/capability.json +++ b/capabilities/claude/capability.json @@ -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" + } } } diff --git a/capabilities/cline/capability.json b/capabilities/cline/capability.json index 1c0d85403..759302094 100644 --- a/capabilities/cline/capability.json +++ b/capabilities/cline/capability.json @@ -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" + } } } diff --git a/capabilities/codebuddy/capability.json b/capabilities/codebuddy/capability.json index 76538c71f..b22ce6d90 100644 --- a/capabilities/codebuddy/capability.json +++ b/capabilities/codebuddy/capability.json @@ -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" + } } } diff --git a/capabilities/codex/capability.json b/capabilities/codex/capability.json index 893b9afeb..c5ff5e2a3 100644 --- a/capabilities/codex/capability.json +++ b/capabilities/codex/capability.json @@ -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" + } } } diff --git a/capabilities/copilot/capability.json b/capabilities/copilot/capability.json index 73af7b196..488335a9a 100644 --- a/capabilities/copilot/capability.json +++ b/capabilities/copilot/capability.json @@ -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" + } } } diff --git a/capabilities/cursor/capability.json b/capabilities/cursor/capability.json index cea99308c..2366a96e1 100644 --- a/capabilities/cursor/capability.json +++ b/capabilities/cursor/capability.json @@ -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" + } } } diff --git a/capabilities/gemini/capability.json b/capabilities/gemini/capability.json index 65cd8aaff..b5b6a41ed 100644 --- a/capabilities/gemini/capability.json +++ b/capabilities/gemini/capability.json @@ -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" + } } } diff --git a/capabilities/hermes/capability.json b/capabilities/hermes/capability.json index c2f861741..68b60df94 100644 --- a/capabilities/hermes/capability.json +++ b/capabilities/hermes/capability.json @@ -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" + } } } diff --git a/capabilities/kilo/capability.json b/capabilities/kilo/capability.json index 59a1d6899..7a7fbbedb 100644 --- a/capabilities/kilo/capability.json +++ b/capabilities/kilo/capability.json @@ -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" + } } } diff --git a/capabilities/kimi/capability.json b/capabilities/kimi/capability.json index a4e74e4e3..c79cc006a 100644 --- a/capabilities/kimi/capability.json +++ b/capabilities/kimi/capability.json @@ -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" + } } } diff --git a/capabilities/opencode/capability.json b/capabilities/opencode/capability.json index 1ad177037..2fc9ef777 100644 --- a/capabilities/opencode/capability.json +++ b/capabilities/opencode/capability.json @@ -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" + } } } diff --git a/capabilities/qwen/capability.json b/capabilities/qwen/capability.json index 9da38ac16..5528a8638 100644 --- a/capabilities/qwen/capability.json +++ b/capabilities/qwen/capability.json @@ -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" + } } } diff --git a/capabilities/trae/capability.json b/capabilities/trae/capability.json index 3713c8300..b6a5cb979 100644 --- a/capabilities/trae/capability.json +++ b/capabilities/trae/capability.json @@ -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" + } } } diff --git a/capabilities/windsurf/capability.json b/capabilities/windsurf/capability.json index 293e18b5f..c48989a73 100644 --- a/capabilities/windsurf/capability.json +++ b/capabilities/windsurf/capability.json @@ -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" + } } } diff --git a/docs/INVENTORY-MANIFEST.json b/docs/INVENTORY-MANIFEST.json index 61245e35e..2617e3f74 100644 --- a/docs/INVENTORY-MANIFEST.json +++ b/docs/INVENTORY-MANIFEST.json @@ -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", diff --git a/docs/INVENTORY.md b/docs/INVENTORY.md index 1474fb47d..3bb195c30 100644 --- a/docs/INVENTORY.md +++ b/docs/INVENTORY.md @@ -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 | diff --git a/docs/README.md b/docs/README.md index 509434581..f05fce2cd 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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 diff --git a/docs/adr/1239-gsd-embeddable-orchestration-engine.md b/docs/adr/1239-gsd-embeddable-orchestration-engine.md index e24a95d82..5ba27d05d 100644 --- a/docs/adr/1239-gsd-embeddable-orchestration-engine.md +++ b/docs/adr/1239-gsd-embeddable-orchestration-engine.md @@ -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. diff --git a/docs/how-to/add-or-update-a-host-integration.md b/docs/how-to/add-or-update-a-host-integration.md new file mode 100644 index 000000000..5dd8a1fc5 --- /dev/null +++ b/docs/how-to/add-or-update-a-host-integration.md @@ -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//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). diff --git a/docs/reference/host-integration-capability-matrix.md b/docs/reference/host-integration-capability-matrix.md new file mode 100644 index 000000000..35dedaedb --- /dev/null +++ b/docs/reference/host-integration-capability-matrix.md @@ -0,0 +1,565 @@ +# Host Integration Capability Matrix + +This document is the maintainer-facing source of truth for the `hostIntegration` block in every +`capabilities//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 ... 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 ... 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 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//; 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: and /flow:" | +| 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. diff --git a/eslint.config.mjs b/eslint.config.mjs index 20ec0157b..b8344707c 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -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', diff --git a/gsd-core/bin/lib/capability-registry.cjs b/gsd-core/bin/lib/capability-registry.cjs index 79de051b7..6ee2f398b 100644 --- a/gsd-core/bin/lib/capability-registry.cjs +++ b/gsd-core/bin/lib/capability-registry.cjs @@ -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" + } } } }; diff --git a/gsd-core/bin/lib/capability-validator.cjs b/gsd-core/bin/lib/capability-validator.cjs index 6f3cad16b..2177ce326 100644 --- a/gsd-core/bin/lib/capability-validator.cjs +++ b/gsd-core/bin/lib/capability-validator.cjs @@ -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, diff --git a/scripts/lint-test-file-count.allowlist.json b/scripts/lint-test-file-count.allowlist.json index 6c83710ad..557b7c67f 100644 --- a/scripts/lint-test-file-count.allowlist.json +++ b/scripts/lint-test-file-count.allowlist.json @@ -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" } } } diff --git a/src/host-integration.cts b/src/host-integration.cts new file mode 100644 index 000000000..4344cb98a --- /dev/null +++ b/src/host-integration.cts @@ -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> = + 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): DegradationResult { + const UNKNOWN: DegradationResult = { + level: 'absent', + fallback: 'unknown capability — degraded closed', + unknown: true, + }; + + switch (point) { + case 'command': { + const cs = (axes as Record).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).dispatch; + if (!d || typeof d !== 'object') return UNKNOWN; + const disp = d as Record; + 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).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).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).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).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): 'programmatic-cli' | 'declarative-cli' | 'ide' | null { + const a = axes as Record; + 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; + 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 & { protocolVersion?: number }, + engine: EngineCapabilities = DEFAULT_ENGINE, +): NegotiationResult { + const warnings: string[] = []; + const h = host as Record; + // 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( + axis: K, + ): (typeof HOST_INTEGRATION_AXES)[K][number] { + type V = (typeof HOST_INTEGRATION_AXES)[K][number]; + const knownValues: ReadonlyArray = 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 + : 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; + 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, +}; diff --git a/tests/capability-manifest-version.test.cjs b/tests/capability-manifest-version.test.cjs index 3427f74c5..6a0ea0fc9 100644 --- a/tests/capability-manifest-version.test.cjs +++ b/tests/capability-manifest-version.test.cjs @@ -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, }; diff --git a/tests/capability-registry.test.cjs b/tests/capability-registry.test.cjs index 871f75d34..5a9559e29 100644 --- a/tests/capability-registry.test.cjs +++ b/tests/capability-registry.test.cjs @@ -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'); diff --git a/tests/host-integration-descriptors.test.cjs b/tests/host-integration-descriptors.test.cjs new file mode 100644 index 000000000..ea66f2b00 --- /dev/null +++ b/tests/host-integration-descriptors.test.cjs @@ -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), + ); + }); + }); +}); diff --git a/tests/host-integration-validator-parity.test.cjs b/tests/host-integration-validator-parity.test.cjs new file mode 100644 index 000000000..846290657 --- /dev/null +++ b/tests/host-integration-validator-parity.test.cjs @@ -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(', ')); + }); +}); diff --git a/tests/host-integration.test.cjs b/tests/host-integration.test.cjs new file mode 100644 index 000000000..9e466f8aa --- /dev/null +++ b/tests/host-integration.test.cjs @@ -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 + '"'); + }); +});