diff --git a/.changeset/graceful-badgers-dance.md b/.changeset/graceful-badgers-dance.md new file mode 100644 index 000000000..0f8d58286 --- /dev/null +++ b/.changeset/graceful-badgers-dance.md @@ -0,0 +1,5 @@ +--- +type: Changed +pr: 1719 +--- +**#853 dispatch-flatten is now data-driven (ADR-1239 Phase B)** — whether GSD backgrounds the plan/execute orchestrator is decided from a documentation-sourced `backgroundDispatch` capability per host (via `gsd_run query dispatch-should-flatten`) instead of a hardcoded `runtime === 'codex'` check. **Cursor now backgrounds the orchestrator** (its docs document backgrounded subagent nesting); codex unchanged; all other hosts run inline. Fail-closed to inline on any uncertainty. diff --git a/CONTEXT.md b/CONTEXT.md index 2d34acbcb..450f735c8 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -119,7 +119,7 @@ Module owning bounded, never-throw git repository introspection — the single s 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. +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,backgroundDispatch,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), `PROFILE_BASELINES`, and `shouldFlattenDispatch(dispatch) → boolean` (ADR-1239 Phase B / #1708 — graduates the #853 rule: returns `true` = run the orchestrator inline UNLESS the host is documented to background a nesting-capable orchestrator (`background === true && backgroundDispatch === true`); fail-closed to inline; exposed to the plan/execute workflows via the `gsd_run query dispatch-should-flatten --raw` CLI, which replaced the former scattered `RUNTIME === 'codex'` prose check). 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 defined the interface; Phase B (#1679) wires it incrementally — `destSubpath` write-confinement (#1704) and the typed documentation-sourced #853 dispatch-flatten (#1708, the first consumer of a negotiated `dispatch` axis); adapters/MCP/host-bindings remain Phases C–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 a714fefdd..471da735f 100644 --- a/capabilities/antigravity/capability.json +++ b/capabilities/antigravity/capability.json @@ -59,7 +59,7 @@ "hostIntegration": { "embeddingMode": "declarative", "commandSurface": "slash-file", - "dispatch": { "namedDispatch": "undocumented", "nested": "undocumented", "maxDepth": "undocumented", "background": true, "subagentToolkit": "undocumented" }, + "dispatch": { "namedDispatch": "undocumented", "nested": "undocumented", "maxDepth": "undocumented", "background": true, "subagentToolkit": "undocumented", "backgroundDispatch": "undocumented" }, "modelMode": "passive", "hookBus": "host", "stateIO": "filesystem", diff --git a/capabilities/augment/capability.json b/capabilities/augment/capability.json index 5c536fd64..18320b74d 100644 --- a/capabilities/augment/capability.json +++ b/capabilities/augment/capability.json @@ -68,7 +68,7 @@ "hostIntegration": { "embeddingMode": "declarative", "commandSurface": "slash-file", - "dispatch": { "namedDispatch": true, "nested": "undocumented", "maxDepth": "undocumented", "background": true, "subagentToolkit": "full" }, + "dispatch": { "namedDispatch": true, "nested": "undocumented", "maxDepth": "undocumented", "background": true, "subagentToolkit": "full", "backgroundDispatch": "undocumented" }, "modelMode": "passive", "hookBus": "host", "stateIO": "filesystem", diff --git a/capabilities/claude/capability.json b/capabilities/claude/capability.json index fa3089d5c..b3806cfd7 100644 --- a/capabilities/claude/capability.json +++ b/capabilities/claude/capability.json @@ -65,7 +65,7 @@ "hostIntegration": { "embeddingMode": "imperative", "commandSurface": "slash-file", - "dispatch": { "namedDispatch": true, "nested": true, "maxDepth": 5, "background": true, "subagentToolkit": "full" }, + "dispatch": { "namedDispatch": true, "nested": true, "maxDepth": 5, "background": true, "subagentToolkit": "full", "backgroundDispatch": false }, "modelMode": "passive", "hookBus": "host", "stateIO": "filesystem", diff --git a/capabilities/cline/capability.json b/capabilities/cline/capability.json index 759302094..113675b95 100644 --- a/capabilities/cline/capability.json +++ b/capabilities/cline/capability.json @@ -42,7 +42,7 @@ "hostIntegration": { "embeddingMode": "imperative", "commandSurface": "slash-file", - "dispatch": { "namedDispatch": true, "nested": false, "maxDepth": 1, "background": true, "subagentToolkit": "read-only" }, + "dispatch": { "namedDispatch": true, "nested": false, "maxDepth": 1, "background": true, "subagentToolkit": "read-only", "backgroundDispatch": false }, "modelMode": "active", "hookBus": "host", "stateIO": "filesystem", diff --git a/capabilities/codebuddy/capability.json b/capabilities/codebuddy/capability.json index b22ce6d90..8f1558ceb 100644 --- a/capabilities/codebuddy/capability.json +++ b/capabilities/codebuddy/capability.json @@ -68,7 +68,7 @@ "hostIntegration": { "embeddingMode": "declarative", "commandSurface": "slash-file", - "dispatch": { "namedDispatch": true, "nested": false, "maxDepth": 1, "background": true, "subagentToolkit": "full" }, + "dispatch": { "namedDispatch": true, "nested": false, "maxDepth": 1, "background": true, "subagentToolkit": "full", "backgroundDispatch": false }, "modelMode": "passive", "hookBus": "host", "stateIO": "filesystem", diff --git a/capabilities/codex/capability.json b/capabilities/codex/capability.json index c5ff5e2a3..c2b332408 100644 --- a/capabilities/codex/capability.json +++ b/capabilities/codex/capability.json @@ -52,7 +52,7 @@ "hostIntegration": { "embeddingMode": "declarative", "commandSurface": "slash-file", - "dispatch": { "namedDispatch": true, "nested": true, "maxDepth": 1, "background": true, "subagentToolkit": "full" }, + "dispatch": { "namedDispatch": true, "nested": true, "maxDepth": 1, "background": true, "subagentToolkit": "full", "backgroundDispatch": true }, "modelMode": "passive", "hookBus": "host", "stateIO": "filesystem", diff --git a/capabilities/copilot/capability.json b/capabilities/copilot/capability.json index 488335a9a..0164c4dee 100644 --- a/capabilities/copilot/capability.json +++ b/capabilities/copilot/capability.json @@ -52,7 +52,7 @@ "hostIntegration": { "embeddingMode": "declarative", "commandSurface": "slash-file", - "dispatch": { "namedDispatch": true, "nested": false, "maxDepth": 1, "background": true, "subagentToolkit": "full" }, + "dispatch": { "namedDispatch": true, "nested": false, "maxDepth": 1, "background": true, "subagentToolkit": "full", "backgroundDispatch": false }, "modelMode": "passive", "hookBus": "host", "stateIO": "filesystem", diff --git a/capabilities/cursor/capability.json b/capabilities/cursor/capability.json index 2366a96e1..f83065df3 100644 --- a/capabilities/cursor/capability.json +++ b/capabilities/cursor/capability.json @@ -68,7 +68,7 @@ "hostIntegration": { "embeddingMode": "imperative", "commandSurface": "slash-file", - "dispatch": { "namedDispatch": true, "nested": true, "maxDepth": 2, "background": true, "subagentToolkit": "full" }, + "dispatch": { "namedDispatch": true, "nested": true, "maxDepth": 2, "background": true, "subagentToolkit": "full", "backgroundDispatch": true }, "modelMode": "passive", "hookBus": "host", "stateIO": "filesystem", diff --git a/capabilities/gemini/capability.json b/capabilities/gemini/capability.json index b5b6a41ed..97d539fed 100644 --- a/capabilities/gemini/capability.json +++ b/capabilities/gemini/capability.json @@ -56,7 +56,7 @@ "hostIntegration": { "embeddingMode": "declarative", "commandSurface": "slash-toml", - "dispatch": { "namedDispatch": true, "nested": false, "maxDepth": 1, "background": "undocumented", "subagentToolkit": "undocumented" }, + "dispatch": { "namedDispatch": true, "nested": false, "maxDepth": 1, "background": "undocumented", "subagentToolkit": "undocumented", "backgroundDispatch": false }, "modelMode": "passive", "hookBus": "host", "stateIO": "filesystem", diff --git a/capabilities/hermes/capability.json b/capabilities/hermes/capability.json index 68b60df94..1c9034c8d 100644 --- a/capabilities/hermes/capability.json +++ b/capabilities/hermes/capability.json @@ -52,7 +52,7 @@ "hostIntegration": { "embeddingMode": "imperative", "commandSurface": "slash-programmatic", - "dispatch": { "namedDispatch": false, "nested": true, "maxDepth": 1, "background": true, "subagentToolkit": "read-only" }, + "dispatch": { "namedDispatch": false, "nested": true, "maxDepth": 1, "background": true, "subagentToolkit": "read-only", "backgroundDispatch": false }, "modelMode": "active", "hookBus": "host", "stateIO": "filesystem", diff --git a/capabilities/kilo/capability.json b/capabilities/kilo/capability.json index 7a7fbbedb..6bae4c38e 100644 --- a/capabilities/kilo/capability.json +++ b/capabilities/kilo/capability.json @@ -74,7 +74,7 @@ "hostIntegration": { "embeddingMode": "imperative", "commandSurface": "slash-file", - "dispatch": { "namedDispatch": true, "nested": true, "maxDepth": -1, "background": true, "subagentToolkit": "undocumented" }, + "dispatch": { "namedDispatch": true, "nested": true, "maxDepth": -1, "background": true, "subagentToolkit": "undocumented", "backgroundDispatch": false }, "modelMode": "active", "hookBus": "host", "stateIO": "filesystem", diff --git a/capabilities/kimi/capability.json b/capabilities/kimi/capability.json index c79cc006a..5f81e5675 100644 --- a/capabilities/kimi/capability.json +++ b/capabilities/kimi/capability.json @@ -55,7 +55,7 @@ "hostIntegration": { "embeddingMode": "imperative", "commandSurface": "slash-file", - "dispatch": { "namedDispatch": true, "nested": false, "maxDepth": 1, "background": true, "subagentToolkit": "undocumented" }, + "dispatch": { "namedDispatch": true, "nested": false, "maxDepth": 1, "background": true, "subagentToolkit": "undocumented", "backgroundDispatch": false }, "modelMode": "passive", "hookBus": "host", "stateIO": "filesystem", diff --git a/capabilities/opencode/capability.json b/capabilities/opencode/capability.json index 2fc9ef777..0030c7f04 100644 --- a/capabilities/opencode/capability.json +++ b/capabilities/opencode/capability.json @@ -69,7 +69,7 @@ "hostIntegration": { "embeddingMode": "imperative", "commandSurface": "slash-file", - "dispatch": { "namedDispatch": true, "nested": "undocumented", "maxDepth": "undocumented", "background": false, "subagentToolkit": "full" }, + "dispatch": { "namedDispatch": true, "nested": "undocumented", "maxDepth": "undocumented", "background": false, "subagentToolkit": "full", "backgroundDispatch": "undocumented" }, "modelMode": "active", "hookBus": "host", "stateIO": "filesystem", diff --git a/capabilities/qwen/capability.json b/capabilities/qwen/capability.json index 5528a8638..bd833b655 100644 --- a/capabilities/qwen/capability.json +++ b/capabilities/qwen/capability.json @@ -56,7 +56,7 @@ "hostIntegration": { "embeddingMode": "imperative", "commandSurface": "slash-file", - "dispatch": { "namedDispatch": true, "nested": false, "maxDepth": 1, "background": true, "subagentToolkit": "full" }, + "dispatch": { "namedDispatch": true, "nested": false, "maxDepth": 1, "background": true, "subagentToolkit": "full", "backgroundDispatch": false }, "modelMode": "passive", "hookBus": "host", "stateIO": "filesystem", diff --git a/capabilities/trae/capability.json b/capabilities/trae/capability.json index b6a5cb979..1ce5d1e61 100644 --- a/capabilities/trae/capability.json +++ b/capabilities/trae/capability.json @@ -51,7 +51,7 @@ "hostIntegration": { "embeddingMode": "imperative", "commandSurface": "slash-file", - "dispatch": { "namedDispatch": true, "nested": "undocumented", "maxDepth": "undocumented", "background": true, "subagentToolkit": "undocumented" }, + "dispatch": { "namedDispatch": true, "nested": "undocumented", "maxDepth": "undocumented", "background": true, "subagentToolkit": "undocumented", "backgroundDispatch": "undocumented" }, "modelMode": "passive", "hookBus": "engine", "stateIO": "filesystem", diff --git a/capabilities/windsurf/capability.json b/capabilities/windsurf/capability.json index c48989a73..06a2bc705 100644 --- a/capabilities/windsurf/capability.json +++ b/capabilities/windsurf/capability.json @@ -43,7 +43,7 @@ "hostIntegration": { "embeddingMode": "declarative", "commandSurface": "slash-file", - "dispatch": { "namedDispatch": "undocumented", "nested": "undocumented", "maxDepth": "undocumented", "background": "undocumented", "subagentToolkit": "undocumented" }, + "dispatch": { "namedDispatch": "undocumented", "nested": "undocumented", "maxDepth": "undocumented", "background": "undocumented", "subagentToolkit": "undocumented", "backgroundDispatch": "undocumented" }, "modelMode": "passive", "hookBus": "host", "stateIO": "filesystem", diff --git a/docs/reference/host-integration-capability-matrix.md b/docs/reference/host-integration-capability-matrix.md index 35dedaedb..3b6f06d08 100644 --- a/docs/reference/host-integration-capability-matrix.md +++ b/docs/reference/host-integration-capability-matrix.md @@ -34,6 +34,7 @@ consumed verbatim by `gen:capability-registry` and validated by `capability-vali | `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`. | +| `backgroundDispatch` | Whether a BACKGROUND-dispatched sub-agent can itself spawn further named sub-agents — the #853 discriminator (true/false/`undocumented`). | ### Interface points @@ -64,6 +65,7 @@ consumed verbatim by `gen:capability-registry` and validated by `capability-vali | 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." | +| dispatch.backgroundDispatch | false | https://code.claude.com/docs/en/sub-agents | "Background subagents are limited to a depth of five and cannot spawn further, " | Sources consulted: - https://code.claude.com/docs/en/sub-agents @@ -97,6 +99,7 @@ Sources consulted: | 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." | +| dispatch.backgroundDispatch | true | https://github.com/openai/codex/blob/main/codex-rs/core/templates/collab/experimental_prompt.md | "Sub-agents have access to the same set of tools as you do so you must tell them if they are allowed to spawn sub-agents themselves or not." The config (codex-rs/config/src/config_toml.rs) exposes an | Sources consulted: - https://github.com/openai/codex (repo via gh CLI) @@ -132,6 +135,7 @@ Documentation gaps: | 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) | — | +| dispatch.backgroundDispatch | false | https://raw.githubusercontent.com/google-gemini/gemini-cli/main/docs/core/subagents.md | "To prevent infinite loops and excessive token usage, subagents cannot call other subagents." Additionally: "If a subagent is granted the `*` tool wildcard, it will still be unable to see or invoke ot | Sources consulted: - https://github.com/google-gemini/gemini-cli/blob/main/docs/cli/custom-commands.md @@ -164,6 +168,7 @@ Documentation gaps: | 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.\"" | +| dispatch.backgroundDispatch | undocumented | no authoritative doc — https://github.com/anomalyco/opencode/issues/18100 and https://github.com/anomalyco/opencode/blob/dev/opencode/packages/opencode/src/tool/task.ts | Opencode supports background task dispatch via the Task tool's `background: true` parameter but whether a background-spawned agent can itself spawn further sub-agents is not documented. | Sources consulted: - https://opencode.ai/docs/plugins @@ -196,6 +201,7 @@ Documentation gaps: | 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" | +| dispatch.backgroundDispatch | true | https://cursor.com/docs/subagents (FAQ: Can subagents launch other subagents?) and https://cursor.com/docs/sdk/typescript (Subagents > Nested subagents) | FAQ: "As of Cursor 2.5, subagents have the capability to launch child subagents, enabling the creation of a hierarchical structure for coordinated tasks. This nested launching functionality requires T | Sources consulted: - https://cursor.com/docs/subagents @@ -225,6 +231,7 @@ Sources consulted: | 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" | +| dispatch.backgroundDispatch | false | https://docs.cline.bot/features/subagents (mirrored at https://github.com/cline/cline/blob/main/docs/features/subagents.mdx) | "They cannot edit files, use the browser, or spawn nested subagents" — and from the GitHub source: "subagents are restricted from editing files, using the browser, accessing MCP servers, or creating n | Sources consulted: - https://github.com/cline/cline/blob/main/docs/sdk/plugins.mdx @@ -254,6 +261,7 @@ Sources consulted: | 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" | +| dispatch.backgroundDispatch | false | https://github.com/nousresearch/hermes-agent/blob/main/website/docs/user-guide/features/delegation.md (via Context7 query of /nousresearch/hermes-agent) | "Nested delegation is an opt-in feature, requiring role=\"orchestrator\" for children and an increased max_spawn_depth from its default of 1. It can also be globally disabled with orchestrator_enabled | Sources consulted: - https://hermes-agent.nousresearch.com/docs/user-guide/features/delegation @@ -287,6 +295,7 @@ Documentation gaps: | 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 | — | +| dispatch.backgroundDispatch | undocumented | no authoritative doc — Multiple sources consulted: antigravity.google/docs/cli-subagents (returned blank/JS-rendered), antigravity.google/docs/agent (blank), github.com/google-antigravity/antigravity-cli README, Context7 /google-antigravity/antigravity-cli | All documentation consulted describes a two-level orchestrator→subagent architecture. Background subagents run asynchronously while the main agent continues accepting prompts. The DataCamp tutorial st | Sources consulted: - https://github.com/alphaperseii3000/google-antigravity-docs/blob/master/google-antigravity-docs.md @@ -321,6 +330,7 @@ Documentation gaps: | 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)." | +| dispatch.backgroundDispatch | undocumented | no authoritative doc — https://docs.augmentcode.com/cosmos/automations | The Augment Code (Cosmos) docs describe workers as 'sub-agents launched mid-session by a manager Expert using the worker-launch command. Each worker is its own session with its own messages and permis | Sources consulted: - https://docs.augmentcode.com/cli/plugins @@ -353,6 +363,7 @@ Documentation gaps: | 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." | +| dispatch.backgroundDispatch | false | https://qwenlm.github.io/qwen-code-docs/en/users/features/sub-agents/ (official Qwen Code documentation, 'Subagents' user guide page) and https://qwenlm.github.io/qwen-code-docs/en/design/fork-subagent/fork-subagent-design (Qwen Code fork-subagent design document, section '4. Recursive Fork Prevention') | The official user-facing Qwen Code docs state verbatim: "Fork children cannot create further forks. If a fork attempts spawning another fork, it receives an error instructing direct task execution ins | Sources consulted: - https://qwenlm.github.io/qwen-code-docs/en/developers/channel-plugins @@ -385,6 +396,7 @@ Documentation gaps: | 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" | +| dispatch.backgroundDispatch | false | https://www.codebuddy.ai/docs/cli/sub-agents | "This prevents infinite nesting of agents (sub-agents cannot spawn other sub-agents)" — the restriction is stated as universal in the Sub-Agents documentation page. The daemon/background docs (https:/ | Sources consulted: - https://www.codebuddy.ai/docs/cli/plugins @@ -413,6 +425,7 @@ Sources consulted: | 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" | +| dispatch.backgroundDispatch | false | https://code.visualstudio.com/docs/copilot/agents/subagents | "By default, subagents cannot spawn further subagents. This prevents infinite recursion when agents accidentally call themselves in a loop." The setting `chat.subagents.allowInvocationsFromSubagents` | Sources consulted: - https://github.com/github/copilot-cli/blob/main/README.md (via Context7 /github/copilot-cli) @@ -444,6 +457,7 @@ Documentation gaps: | 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 | — | +| dispatch.backgroundDispatch | false | https://kilo.ai/docs/automate/tools/new-task | "Importantly, subagents cannot spawn further subagents; only primary agents can use the `new_task` tool." | Sources consulted: - https://kilo.ai/docs/automate/extending/plugins @@ -477,6 +491,7 @@ Documentation gaps: | 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 | — | +| dispatch.backgroundDispatch | undocumented | no authoritative doc — https://docs.devin.ai/desktop/cascade/cascade and https://docs.devin.ai/desktop/devin-local (official Windsurf/Devin docs, via docs.windsurf.com redirects) | The Windsurf/Cascade docs describe a background planning agent only in these terms: "In the background, a specialized planning agent continuously refines the long-term plan while your selected model f | Sources consulted: - https://docs.devin.ai/desktop/cascade/workflows @@ -514,6 +529,7 @@ Documentation gaps: | 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 | — | +| dispatch.backgroundDispatch | undocumented | no authoritative doc — https://docs.trae.ai/ide/agent; https://github.com/bytedance/trae-agent/blob/main/docs/roadmap.md | Trae's official documentation (docs.trae.ai) and the trae-agent GitHub roadmap do not document background/async agent dispatch or whether a background-spawned agent can itself spawn further sub-agents | Sources consulted: - https://docs.trae.ai/ide/model-context-protocol @@ -549,6 +565,7 @@ Documentation gaps: | 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 | — | +| dispatch.backgroundDispatch | false | https://github.com/moonshotai/kimi-cli/blob/main/docs/en/customization/agents.md (also mirrored at https://moonshotai.github.io/kimi-cli/en/customization/agents.html) | "All subagent types are prohibited from nesting the `Agent` tool, meaning subagents cannot create their own subagents. Only the root agent has access to the `Agent` tool for launching further subagent | Sources consulted: - https://moonshotai.github.io/kimi-cli/en/customization/hooks.html diff --git a/gsd-core/bin/gsd-tools.cjs b/gsd-core/bin/gsd-tools.cjs index 4c9548a48..581257a2d 100755 --- a/gsd-core/bin/gsd-tools.cjs +++ b/gsd-core/bin/gsd-tools.cjs @@ -1238,6 +1238,56 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand break; } + case 'dispatch-should-flatten': { + // #1708 / #853: typed query replacing the `RUNTIME === 'codex'` prose rule. + // + // Resolves the current runtime (GSD_RUNTIME > config.runtime > 'claude'), + // looks up registry.runtimes[id].runtime.hostIntegration.dispatch, and + // calls shouldFlattenDispatch(dispatch) from host-integration.cjs. + // + // Fail-closed: any unknown runtime, missing dispatch, or thrown error + // yields `true` (inline — the always-safe default). + // + // Output: + // --raw → prints exactly `true` or `false` + // --json → prints { runtime, shouldFlatten, dispatch } + // default → same as --raw + try { + // Resolve runtime using the same precedence as `config-get runtime`. + const { resolveRuntime } = require('./lib/runtime-slash.cjs'); + const runtimeId = resolveRuntime(cwd); + + // Look up dispatch from the capability registry. + const registry = require('./lib/capability-registry.cjs'); + const runtimeEntry = registry.runtimes != null + ? registry.runtimes[runtimeId] + : null; + const dispatch = runtimeEntry?.runtime?.hostIntegration?.dispatch ?? null; + + // Call shouldFlattenDispatch from host-integration.cjs. + const hostIntegration = require('./lib/host-integration.cjs'); + const shouldFlat = dispatch !== null + ? hostIntegration.shouldFlattenDispatch(dispatch) + : true; // fail-closed: unknown runtime → inline + + const jsonIdx = args.indexOf('--json'); + if (jsonIdx !== -1) { + output({ + runtime: runtimeId, + shouldFlatten: shouldFlat, + dispatch: dispatch, + }, raw); + } else { + // --raw or default: print exactly true or false + process.stdout.write(shouldFlat ? 'true' : 'false'); + } + } catch { + // Fail-closed on any error: inline is always safe. + process.stdout.write('true'); + } + break; + } + case 'config-new-project': { // Phase 6 (#3575): dispatch via SDK executeForCjs when available. const handled = _dispatchNonFamily({ diff --git a/gsd-core/bin/lib/capability-registry.cjs b/gsd-core/bin/lib/capability-registry.cjs index 6ee2f398b..f4f964253 100644 --- a/gsd-core/bin/lib/capability-registry.cjs +++ b/gsd-core/bin/lib/capability-registry.cjs @@ -126,7 +126,8 @@ const capabilities = { "nested": "undocumented", "maxDepth": "undocumented", "background": true, - "subagentToolkit": "undocumented" + "subagentToolkit": "undocumented", + "backgroundDispatch": "undocumented" }, "modelMode": "passive", "hookBus": "host", @@ -248,7 +249,8 @@ const capabilities = { "nested": "undocumented", "maxDepth": "undocumented", "background": true, - "subagentToolkit": "full" + "subagentToolkit": "full", + "backgroundDispatch": "undocumented" }, "modelMode": "passive", "hookBus": "host", @@ -330,7 +332,8 @@ const capabilities = { "nested": true, "maxDepth": 5, "background": true, - "subagentToolkit": "full" + "subagentToolkit": "full", + "backgroundDispatch": false }, "modelMode": "passive", "hookBus": "host", @@ -389,7 +392,8 @@ const capabilities = { "nested": false, "maxDepth": 1, "background": true, - "subagentToolkit": "read-only" + "subagentToolkit": "read-only", + "backgroundDispatch": false }, "modelMode": "active", "hookBus": "host", @@ -535,7 +539,8 @@ const capabilities = { "nested": false, "maxDepth": 1, "background": true, - "subagentToolkit": "full" + "subagentToolkit": "full", + "backgroundDispatch": false }, "modelMode": "passive", "hookBus": "host", @@ -604,7 +609,8 @@ const capabilities = { "nested": true, "maxDepth": 1, "background": true, - "subagentToolkit": "full" + "subagentToolkit": "full", + "backgroundDispatch": true }, "modelMode": "passive", "hookBus": "host", @@ -673,7 +679,8 @@ const capabilities = { "nested": false, "maxDepth": 1, "background": true, - "subagentToolkit": "full" + "subagentToolkit": "full", + "backgroundDispatch": false }, "modelMode": "passive", "hookBus": "host", @@ -758,7 +765,8 @@ const capabilities = { "nested": true, "maxDepth": 2, "background": true, - "subagentToolkit": "full" + "subagentToolkit": "full", + "backgroundDispatch": true }, "modelMode": "passive", "hookBus": "host", @@ -950,7 +958,8 @@ const capabilities = { "nested": false, "maxDepth": 1, "background": "undocumented", - "subagentToolkit": "undocumented" + "subagentToolkit": "undocumented", + "backgroundDispatch": false }, "modelMode": "passive", "hookBus": "host", @@ -1060,7 +1069,8 @@ const capabilities = { "nested": true, "maxDepth": 1, "background": true, - "subagentToolkit": "read-only" + "subagentToolkit": "read-only", + "backgroundDispatch": false }, "modelMode": "active", "hookBus": "host", @@ -1203,7 +1213,8 @@ const capabilities = { "nested": true, "maxDepth": -1, "background": true, - "subagentToolkit": "undocumented" + "subagentToolkit": "undocumented", + "backgroundDispatch": false }, "modelMode": "active", "hookBus": "host", @@ -1275,7 +1286,8 @@ const capabilities = { "nested": false, "maxDepth": 1, "background": true, - "subagentToolkit": "undocumented" + "subagentToolkit": "undocumented", + "backgroundDispatch": false }, "modelMode": "passive", "hookBus": "host", @@ -1585,7 +1597,8 @@ const capabilities = { "nested": "undocumented", "maxDepth": "undocumented", "background": false, - "subagentToolkit": "full" + "subagentToolkit": "full", + "backgroundDispatch": "undocumented" }, "modelMode": "active", "hookBus": "host", @@ -1789,7 +1802,8 @@ const capabilities = { "nested": false, "maxDepth": 1, "background": true, - "subagentToolkit": "full" + "subagentToolkit": "full", + "backgroundDispatch": false }, "modelMode": "passive", "hookBus": "host", @@ -2107,7 +2121,8 @@ const capabilities = { "nested": "undocumented", "maxDepth": "undocumented", "background": true, - "subagentToolkit": "undocumented" + "subagentToolkit": "undocumented", + "backgroundDispatch": "undocumented" }, "modelMode": "passive", "hookBus": "engine", @@ -2262,7 +2277,8 @@ const capabilities = { "nested": "undocumented", "maxDepth": "undocumented", "background": "undocumented", - "subagentToolkit": "undocumented" + "subagentToolkit": "undocumented", + "backgroundDispatch": "undocumented" }, "modelMode": "passive", "hookBus": "host", @@ -3062,7 +3078,8 @@ const runtimes = { "nested": "undocumented", "maxDepth": "undocumented", "background": true, - "subagentToolkit": "undocumented" + "subagentToolkit": "undocumented", + "backgroundDispatch": "undocumented" }, "modelMode": "passive", "hookBus": "host", @@ -3147,7 +3164,8 @@ const runtimes = { "nested": "undocumented", "maxDepth": "undocumented", "background": true, - "subagentToolkit": "full" + "subagentToolkit": "full", + "backgroundDispatch": "undocumented" }, "modelMode": "passive", "hookBus": "host", @@ -3229,7 +3247,8 @@ const runtimes = { "nested": true, "maxDepth": 5, "background": true, - "subagentToolkit": "full" + "subagentToolkit": "full", + "backgroundDispatch": false }, "modelMode": "passive", "hookBus": "host", @@ -3288,7 +3307,8 @@ const runtimes = { "nested": false, "maxDepth": 1, "background": true, - "subagentToolkit": "read-only" + "subagentToolkit": "read-only", + "backgroundDispatch": false }, "modelMode": "active", "hookBus": "host", @@ -3373,7 +3393,8 @@ const runtimes = { "nested": false, "maxDepth": 1, "background": true, - "subagentToolkit": "full" + "subagentToolkit": "full", + "backgroundDispatch": false }, "modelMode": "passive", "hookBus": "host", @@ -3442,7 +3463,8 @@ const runtimes = { "nested": true, "maxDepth": 1, "background": true, - "subagentToolkit": "full" + "subagentToolkit": "full", + "backgroundDispatch": true }, "modelMode": "passive", "hookBus": "host", @@ -3511,7 +3533,8 @@ const runtimes = { "nested": false, "maxDepth": 1, "background": true, - "subagentToolkit": "full" + "subagentToolkit": "full", + "backgroundDispatch": false }, "modelMode": "passive", "hookBus": "host", @@ -3596,7 +3619,8 @@ const runtimes = { "nested": true, "maxDepth": 2, "background": true, - "subagentToolkit": "full" + "subagentToolkit": "full", + "backgroundDispatch": true }, "modelMode": "passive", "hookBus": "host", @@ -3669,7 +3693,8 @@ const runtimes = { "nested": false, "maxDepth": 1, "background": "undocumented", - "subagentToolkit": "undocumented" + "subagentToolkit": "undocumented", + "backgroundDispatch": false }, "modelMode": "passive", "hookBus": "host", @@ -3738,7 +3763,8 @@ const runtimes = { "nested": true, "maxDepth": 1, "background": true, - "subagentToolkit": "read-only" + "subagentToolkit": "read-only", + "backgroundDispatch": false }, "modelMode": "active", "hookBus": "host", @@ -3829,7 +3855,8 @@ const runtimes = { "nested": true, "maxDepth": -1, "background": true, - "subagentToolkit": "undocumented" + "subagentToolkit": "undocumented", + "backgroundDispatch": false }, "modelMode": "active", "hookBus": "host", @@ -3901,7 +3928,8 @@ const runtimes = { "nested": false, "maxDepth": 1, "background": true, - "subagentToolkit": "undocumented" + "subagentToolkit": "undocumented", + "backgroundDispatch": false }, "modelMode": "passive", "hookBus": "host", @@ -3987,7 +4015,8 @@ const runtimes = { "nested": "undocumented", "maxDepth": "undocumented", "background": false, - "subagentToolkit": "full" + "subagentToolkit": "full", + "backgroundDispatch": "undocumented" }, "modelMode": "active", "hookBus": "host", @@ -4060,7 +4089,8 @@ const runtimes = { "nested": false, "maxDepth": 1, "background": true, - "subagentToolkit": "full" + "subagentToolkit": "full", + "backgroundDispatch": false }, "modelMode": "passive", "hookBus": "host", @@ -4128,7 +4158,8 @@ const runtimes = { "nested": "undocumented", "maxDepth": "undocumented", "background": true, - "subagentToolkit": "undocumented" + "subagentToolkit": "undocumented", + "backgroundDispatch": "undocumented" }, "modelMode": "passive", "hookBus": "engine", @@ -4188,7 +4219,8 @@ const runtimes = { "nested": "undocumented", "maxDepth": "undocumented", "background": "undocumented", - "subagentToolkit": "undocumented" + "subagentToolkit": "undocumented", + "backgroundDispatch": "undocumented" }, "modelMode": "passive", "hookBus": "host", diff --git a/gsd-core/bin/lib/capability-validator.cjs b/gsd-core/bin/lib/capability-validator.cjs index 2177ce326..b1494091c 100644 --- a/gsd-core/bin/lib/capability-validator.cjs +++ b/gsd-core/bin/lib/capability-validator.cjs @@ -1188,6 +1188,18 @@ function validateRuntimeBody(cap) { 'runtime.hostIntegration.dispatch.maxDepth must be an integer >= -1 or "undocumented" (got: ' + JSON.stringify(d.maxDepth) + ')', ); } + + // backgroundDispatch — REQUIRED (all 16 runtime descriptors carry it, matching the sibling fields + // namedDispatch/nested/background/subagentToolkit/maxDepth which are all required). + if (!Object.prototype.hasOwnProperty.call(d, 'backgroundDispatch')) { + errors.push( + 'runtime.hostIntegration.dispatch.backgroundDispatch is required (must be a boolean or "undocumented")', + ); + } else if (typeof d.backgroundDispatch !== 'boolean' && d.backgroundDispatch !== 'undocumented') { + errors.push( + 'runtime.hostIntegration.dispatch.backgroundDispatch must be a boolean or "undocumented" (got: ' + JSON.stringify(d.backgroundDispatch) + ')', + ); + } } } diff --git a/gsd-core/workflows/autonomous.md b/gsd-core/workflows/autonomous.md index 3e259f6c9..6f779630a 100644 --- a/gsd-core/workflows/autonomous.md +++ b/gsd-core/workflows/autonomous.md @@ -61,7 +61,7 @@ fi When `--only` is set, also set `FROM_PHASE` to the same value so existing filter logic applies. -When `--interactive` is set, discuss runs inline with questions. On Codex, where a backgrounded agent can still spawn subagents, plan and execute are dispatched as background agents — keeping the main context lean (only discuss conversations accumulate) and enabling overlap. On every other runtime (Claude Code and all other non-Codex runtimes), backgrounded agents cannot reliably nest subagents, so plan and execute run inline to preserve worktree isolation and independent verification, and phases run sequentially with their work accumulating in the main context. Either way, user input is preserved on all design decisions. +When `--interactive` is set, discuss runs inline with questions. When `dispatch-should-flatten` returns `false` (e.g. codex, cursor — runtimes where a backgrounded agent can still spawn subagents), plan and execute are dispatched as background agents — keeping the main context lean (only discuss conversations accumulate) and enabling overlap. When `dispatch-should-flatten` returns `true` (e.g. claude and other runtimes where backgrounded agents cannot reliably nest subagents), plan and execute run inline to preserve worktree isolation and independent verification, and phases run sequentially with their work accumulating in the main context. Either way, user input is preserved on all design decisions. When `PLAN_STRATEGY=converge`, the planning step MUST invoke the plan-review convergence workflow instead of `gsd-plan-phase`. `--cross-ai` is an alias for `--converge`. Forward `CONVERGENCE_ARGS` exactly as parsed so reviewer flags and `--max-cycles N` retain the same meaning as they have on `/gsd:plan-review-convergence`. @@ -358,13 +358,13 @@ UI_SPEC_FILE=$(ls "${PHASE_DIR}"/*-UI-SPEC.md 2>/dev/null | head -1) **3b. Plan** -**If `INTERACTIVE` is set:** Background dispatch is only safe on a runtime where a backgrounded agent can still nest the pipeline's subagents (plan-checker / worktree executors / verifier). Among supported runtimes only **Codex** (`spawn_agent`) can do this; Claude Code's backgrounded agents have no `Agent`/`Task` tool, and every other runtime either prohibits nested subagents or disables them by default. So run **inline** everywhere except Codex, which is dispatched in the background. Resolve the runtime first: +**If `INTERACTIVE` is set:** Background dispatch is only safe on a runtime where a backgrounded agent can still nest the pipeline's subagents (plan-checker / worktree executors / verifier). This is determined from the documentation-sourced dispatch capability in the registry (#1708); Claude Code's backgrounded agents have no `Agent`/`Task` tool, and every other runtime either prohibits nested subagents or disables them by default. So run **inline** everywhere except where `dispatch-should-flatten` returns `false`. Resolve first: ```bash -RUNTIME=$(gsd_run query config-get runtime --default claude --raw 2>/dev/null || echo "claude") +FLATTEN=$(gsd_run query dispatch-should-flatten --raw 2>/dev/null || echo "true") ``` -- **If `RUNTIME` is `codex`:** Dispatch plan as a background agent to keep the main context lean. While plan runs, the workflow can immediately start discussing the next phase (see step 4). +- **If `FLATTEN` is `false`:** Dispatch plan as a background agent to keep the main context lean. While plan runs, the workflow can immediately start discussing the next phase (see step 4). - If `PLAN_STRATEGY=converge`, print: `◆ Spawning background plan-convergence loop for phase ${PHASE_NUM}... (runs in a subagent — no output until it returns, ~1–5 min; expected, not a freeze)` @@ -388,7 +388,7 @@ RUNTIME=$(gsd_run query config-get runtime --default claude --raw 2>/dev/null || Store the agent task_id. After discuss for the next phase completes (or if no next phase), wait for the plan agent to finish before proceeding to execute. -- **Otherwise (Claude Code or any other non-Codex runtime):** Run plan **inline** (do NOT background) so the plan-checker runs. The next phase's discuss does not overlap planning here — correctness over overlap. +- **Otherwise (`FLATTEN` is `true` — run inline):** Run plan **inline** (do NOT background) so the plan-checker runs. The next phase's discuss does not overlap planning here — correctness over overlap. - If `PLAN_STRATEGY=converge`: @@ -420,13 +420,13 @@ Verify plan produced output — re-run `init phase-op` and check `has_plans`. If **3c. Execute** -**If `INTERACTIVE` is set:** Wait for the plan agent to complete (if not already) and verify plans exist. Background dispatch is only safe on a runtime where a backgrounded agent can still nest the pipeline's subagents (plan-checker / worktree executors / verifier). Among supported runtimes only **Codex** (`spawn_agent`) can do this; Claude Code's backgrounded agents have no `Agent`/`Task` tool, and every other runtime either prohibits nested subagents or disables them by default. So run **inline** everywhere except Codex, which is dispatched in the background. Resolve the runtime first: +**If `INTERACTIVE` is set:** Wait for the plan agent to complete (if not already) and verify plans exist. Background dispatch is only safe on a runtime where a backgrounded agent can still nest the pipeline's subagents (plan-checker / worktree executors / verifier). This is determined from the documentation-sourced dispatch capability in the registry (#1708); Claude Code's backgrounded agents have no `Agent`/`Task` tool, and every other runtime either prohibits nested subagents or disables them by default. So run **inline** everywhere except where `dispatch-should-flatten` returns `false`. Resolve first: ```bash -RUNTIME=$(gsd_run query config-get runtime --default claude --raw 2>/dev/null || echo "claude") +FLATTEN=$(gsd_run query dispatch-should-flatten --raw 2>/dev/null || echo "true") ``` -- **If `RUNTIME` is `codex`:** Dispatch execute as a background agent: +- **If `FLATTEN` is `false`:** Dispatch execute as a background agent: ``` Agent( @@ -438,7 +438,7 @@ Agent( Store the agent task_id. The workflow can now start discussing the next phase while this phase executes in the background. Before starting post-execution routing for this phase, wait for the execute agent to complete. -- **Otherwise (Claude Code or any other non-Codex runtime):** Run execute **inline** (do NOT background) so worktree isolation and verification run: +- **Otherwise (`FLATTEN` is `true` — run inline):** Run execute **inline** (do NOT background) so worktree isolation and verification run: ``` Skill(skill="gsd-execute-phase", args="${PHASE_NUM} --no-transition") diff --git a/gsd-core/workflows/manager.md b/gsd-core/workflows/manager.md index 92a30bf68..9d45dee34 100644 --- a/gsd-core/workflows/manager.md +++ b/gsd-core/workflows/manager.md @@ -1,6 +1,6 @@ -Interactive command center for managing a milestone from a single terminal. Shows a dashboard of all phases with visual status, dispatches discuss inline and runs plan/execute inline (backgrounded only on Codex), and loops back to the dashboard after each action. Enables parallel phase work from one terminal. +Interactive command center for managing a milestone from a single terminal. Shows a dashboard of all phases with visual status, dispatches discuss inline and runs plan/execute inline (backgrounded when dispatch-should-flatten returns false), and loops back to the dashboard after each action. Enables parallel phase work from one terminal. @@ -45,7 +45,7 @@ Display startup banner: {milestone_version} — {milestone_name} {phase_count} phases · {completed_count} complete - ✓ Discuss → inline ◆ Plan/Execute → inline (background on Codex) + ✓ Discuss → inline ◆ Plan/Execute → inline (background when FLATTEN=false) Dashboard auto-refreshes when background work is active. ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ``` @@ -222,10 +222,10 @@ Go to exit step. ### Compound Action (background + inline) -When the user selects a compound option, behavior depends on the runtime — the Plan Phase N / Execute Phase N handlers below resolve it via `gsd_run query config-get runtime`: +When the user selects a compound option, behavior depends on whether the runtime supports background dispatch of nesting-capable orchestrators — the Plan Phase N / Execute Phase N handlers below resolve it via `gsd_run query dispatch-should-flatten` (#1708): -- **On Codex:** **Spawn all background agents first** (plan/execute) — dispatch them in parallel using the Plan Phase N / Execute Phase N handlers below — then run verification actions, then run the inline discuss; the background agents continue while you verify/discuss. -- **On Claude Code or any other non-Codex runtime:** run the chosen plan/execute step(s) **inline** via their handlers below (in order), then run verification actions, then run the inline discuss. There is no overlap. +- **If `FLATTEN` is `false` (the host can background a nesting-capable orchestrator — e.g. codex, cursor):** **Spawn all background agents first** (plan/execute) — dispatch them in parallel using the Plan Phase N / Execute Phase N handlers below — then run verification actions, then run the inline discuss; the background agents continue while you verify/discuss. +- **Otherwise (`FLATTEN` is `true` — run inline):** run the chosen plan/execute step(s) **inline** via their handlers below (in order), then run verification actions, then run the inline discuss. There is no overlap. Inline verification: @@ -254,13 +254,13 @@ After discuss completes, loop back to dashboard step. ### Plan Phase N -Planning runs autonomously. **First resolve the runtime.** Background dispatch is only safe on a runtime where a backgrounded agent can still nest the pipeline's subagents (plan-checker / worktree executors / verifier). Among supported runtimes only **Codex** (`spawn_agent`) can do this; Claude Code's backgrounded agents have no `Agent`/`Task` tool, and every other runtime either prohibits nested subagents or disables them by default. So run **inline** everywhere except Codex, which is dispatched in the background. +Planning runs autonomously. **First resolve whether background dispatch is safe.** Background dispatch is only safe on a runtime where a backgrounded agent can still nest the pipeline's subagents (plan-checker / worktree executors / verifier). This is determined from the documentation-sourced dispatch capability in the registry (#1708); Claude Code's backgrounded agents have no `Agent`/`Task` tool, and every other runtime either prohibits nested subagents or disables them by default. So run **inline** everywhere except where `dispatch-should-flatten` returns `false`. ```bash -RUNTIME=$(gsd_run query config-get runtime --default claude --raw 2>/dev/null || echo "claude") +FLATTEN=$(gsd_run query dispatch-should-flatten --raw 2>/dev/null || echo "true") ``` -**If `RUNTIME` is `codex`:** Spawn a background agent that delegates to the Skill pipeline with any configured flags: +**If `FLATTEN` is `false`:** Spawn a background agent that delegates to the Skill pipeline with any configured flags: ``` Agent( @@ -282,7 +282,7 @@ Important: You are running in the background. Do NOT use AskUserQuestion — mak ) ``` -> **ORCHESTRATOR RULE — CODEX RUNTIME**: After calling Agent() above with `run_in_background=true`, do NOT do any planning work for this phase independently. Return to the dashboard immediately and wait for the background agent to report back. Only resume planning-related work when the subagent result is available. +> **ORCHESTRATOR RULE — BACKGROUND DISPATCH**: After calling Agent() above with `run_in_background=true`, do NOT do any planning work for this phase independently. Return to the dashboard immediately and wait for the background agent to report back. Only resume planning-related work when the subagent result is available. Display: @@ -292,7 +292,7 @@ Display: Loop back to dashboard step. -**Otherwise (Claude Code or any other non-Codex runtime):** Run plan inline so the plan-checker and quality gates actually run — do NOT wrap it in `Agent(run_in_background=true, …)`: +**Otherwise (`FLATTEN` is `true` — run inline):** Run plan inline so the plan-checker and quality gates actually run — do NOT wrap it in `Agent(run_in_background=true, …)`: ``` Skill(skill="gsd-plan-phase", args="{N} --auto {manager_flags.plan}") @@ -308,13 +308,13 @@ Then loop back to dashboard step. ### Execute Phase N -Execution runs autonomously. **First resolve the runtime.** Background dispatch is only safe on a runtime where a backgrounded agent can still nest the pipeline's subagents (plan-checker / worktree executors / verifier). Among supported runtimes only **Codex** (`spawn_agent`) can do this; Claude Code's backgrounded agents have no `Agent`/`Task` tool, and every other runtime either prohibits nested subagents or disables them by default. So run **inline** everywhere except Codex, which is dispatched in the background. +Execution runs autonomously. **First resolve whether background dispatch is safe.** Background dispatch is only safe on a runtime where a backgrounded agent can still nest the pipeline's subagents (plan-checker / worktree executors / verifier). This is determined from the documentation-sourced dispatch capability in the registry (#1708); Claude Code's backgrounded agents have no `Agent`/`Task` tool, and every other runtime either prohibits nested subagents or disables them by default. So run **inline** everywhere except where `dispatch-should-flatten` returns `false`. ```bash -RUNTIME=$(gsd_run query config-get runtime --default claude --raw 2>/dev/null || echo "claude") +FLATTEN=$(gsd_run query dispatch-should-flatten --raw 2>/dev/null || echo "true") ``` -**If `RUNTIME` is `codex`:** Spawn a background agent that delegates to the Skill pipeline with any configured flags: +**If `FLATTEN` is `false`:** Spawn a background agent that delegates to the Skill pipeline with any configured flags: ``` Agent( @@ -336,7 +336,7 @@ Important: You are running in the background. Do NOT use AskUserQuestion — mak ) ``` -> **ORCHESTRATOR RULE — CODEX RUNTIME**: After calling Agent() above with `run_in_background=true`, do NOT do any execution work for this phase independently. Return to the dashboard immediately and wait for the background agent to report back. Only resume execution-related work when the subagent result is available. +> **ORCHESTRATOR RULE — BACKGROUND DISPATCH**: After calling Agent() above with `run_in_background=true`, do NOT do any execution work for this phase independently. Return to the dashboard immediately and wait for the background agent to report back. Only resume execution-related work when the subagent result is available. Display: @@ -346,7 +346,7 @@ Display: Loop back to dashboard step. -**Otherwise (Claude Code or any other non-Codex runtime):** Run execute inline so worktree isolation and the verifier actually run — do NOT wrap it in `Agent(run_in_background=true, …)`: +**Otherwise (`FLATTEN` is `true` — run inline):** Run execute inline so worktree isolation and the verifier actually run — do NOT wrap it in `Agent(run_in_background=true, …)`: ``` Skill(skill="gsd-execute-phase", args="{N} {manager_flags.execute}") diff --git a/src/host-integration.cts b/src/host-integration.cts index 4344cb98a..e428cafd0 100644 --- a/src/host-integration.cts +++ b/src/host-integration.cts @@ -70,6 +70,7 @@ interface DispatchCapability { maxDepth: number; background: boolean; subagentToolkit: SubagentToolkit; + backgroundDispatch: boolean; } interface HostIntegrationAxes { @@ -97,7 +98,7 @@ interface DegradationResult { const SAFE_DEFAULTS: HostIntegrationAxes = { embeddingMode: 'declarative', commandSurface: 'prose-only', - dispatch: { namedDispatch: false, nested: false, maxDepth: 0, background: false, subagentToolkit: 'read-only' }, + dispatch: { namedDispatch: false, nested: false, maxDepth: 0, background: false, subagentToolkit: 'read-only', backgroundDispatch: false }, modelMode: 'passive', hookBus: 'none', stateIO: 'session-log-append', @@ -110,7 +111,7 @@ const PROFILE_BASELINES: Readonly & { background?: unknown; backgroundDispatch?: unknown }) | null | undefined; + +function shouldFlattenDispatch(dispatch: UnvalidatedDispatch): boolean { + if (!dispatch || typeof dispatch !== 'object') return true; + const canBackground = dispatch.background === true && dispatch.backgroundDispatch === true; + return !canBackground; +} + // --------------------------------------------------------------------------- // Module export (CommonJS — matches existing src/*.cts pattern) // --------------------------------------------------------------------------- @@ -488,4 +526,5 @@ export = { degradationFor, profileOf, negotiateHostCapabilities, + shouldFlattenDispatch, }; diff --git a/tests/bug-853-bg-dispatch-runtime-gating.test.cjs b/tests/bug-853-bg-dispatch-runtime-gating.test.cjs index 5fdad00e0..76ea5cd90 100644 --- a/tests/bug-853-bg-dispatch-runtime-gating.test.cjs +++ b/tests/bug-853-bg-dispatch-runtime-gating.test.cjs @@ -5,87 +5,225 @@ * dispatched Plan/Execute via Agent(run_in_background=true). On Claude Code a * backgrounded agent has no Agent/Task tool, so it cannot spawn the nested * subagents (worktree executors, plan-checker, verifier). The workflows must - * now resolve the runtime and run inline everywhere except Codex, which is the - * only supported runtime where a backgrounded agent can still nest subagents. + * now resolve dispatch capability from the registry (#1708) and run inline + * everywhere except runtimes where dispatch.background && dispatch.backgroundDispatch + * are both true (currently: codex, cursor). + * + * Phase B (#1708): the prose `RUNTIME === 'codex'` rule is graduated to a typed + * `gsd_run query dispatch-should-flatten` query backed by shouldFlattenDispatch() + * from host-integration.cjs and the documentation-sourced capability registry. */ const { describe, test } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); +const { createTempProject, cleanup: cleanupDir, runGsdTools } = require('./helpers.cjs'); const WORKFLOWS_DIR = path.join(__dirname, '..', 'gsd-core', 'workflows'); +// allow-test-rule: source-text-is-the-product (see #1708) const MANAGER = fs.readFileSync(path.join(WORKFLOWS_DIR, 'manager.md'), 'utf8'); +// allow-test-rule: source-text-is-the-product (see #1708) const AUTONOMOUS = fs.readFileSync(path.join(WORKFLOWS_DIR, 'autonomous.md'), 'utf8'); describe('bug-853 — manager/autonomous gate background dispatch by runtime', () => { - test('manager.md resolves the runtime before dispatching plan/execute', () => { - // Two dispatch sites (plan + execute), each must resolve the runtime. - const matches = MANAGER.match(/config-get runtime/g) || []; - assert.ok(matches.length >= 2, 'manager.md must resolve runtime for both plan and execute dispatch'); + test('manager.md resolves dispatch-should-flatten before dispatching plan/execute', () => { + // Two dispatch sites (plan + execute), each must use dispatch-should-flatten. + // allow-test-rule: source-text-is-the-product (see #1708) + const matches = MANAGER.match(/dispatch-should-flatten/g) || []; + assert.ok(matches.length >= 2, 'manager.md must use dispatch-should-flatten for both plan and execute dispatch'); }); test('manager.md documents why most runtimes cannot background-dispatch', () => { // Accept both old singular form (backgrounded agent has no) and new plural form (backgrounded agents have no) + // allow-test-rule: source-text-is-the-product (see #1708) assert.match(MANAGER, /backgrounded agents? ha(?:s|ve) no `Agent`\/`Task` tool/); }); - test('manager.md gates background dispatch on codex and runs plan/execute inline otherwise', () => { - // Codex takes the background path - assert.match(MANAGER, /If `RUNTIME` is `codex`[\s\S]{0,400}?run_in_background=true/); - // Inline is the default/else branch for plan — anchored on the explicit non-Codex label + test('manager.md gates background dispatch on FLATTEN=false and runs plan/execute inline otherwise', () => { + // Background path uses FLATTEN is false + // allow-test-rule: source-text-is-the-product (see #1708) + assert.match(MANAGER, /If `FLATTEN` is `false`[\s\S]{0,400}?run_in_background=true/); + // Inline is the default/else branch for plan — anchored on FLATTEN=true language (not runtime name) assert.match( MANAGER, - /Otherwise \(Claude Code or any other non-Codex runtime\)[\s\S]{0,400}?Skill\(skill="gsd-plan-phase"/, + /Otherwise[\s\S]{0,100}?`FLATTEN`[\s\S]{0,400}?Skill\(skill="gsd-plan-phase"/, ); - // Inline is the default/else branch for execute — anchored on the explicit non-Codex label + // Inline is the default/else branch for execute — anchored on FLATTEN=true language (not runtime name) assert.match( MANAGER, - /Otherwise \(Claude Code or any other non-Codex runtime\)[\s\S]{0,400}?Skill\(skill="gsd-execute-phase"/, + /Otherwise[\s\S]{0,100}?`FLATTEN`[\s\S]{0,400}?Skill\(skill="gsd-execute-phase"/, ); }); - test('manager.md compound actions only background plan/execute on Codex', () => { + test('manager.md compound action preamble uses FLATTEN language (not hardcoded runtime names)', () => { + // allow-test-rule: source-text-is-the-product (see #1708) const compoundActionSection = MANAGER.match( /### Compound Action \(background \+ inline\)[\s\S]*?Inline verification:/, ); assert.ok(compoundActionSection, 'manager.md must document compound action runtime dispatch'); + // Must gate on FLATTEN being false (not runtime name) assert.match( compoundActionSection[0], - /On Codex:[\s\S]{0,260}?Spawn all background agents first[\s\S]{0,220}?plan\/execute/, + /If `FLATTEN` is `false`[\s\S]{0,400}?Spawn all background agents first[\s\S]{0,300}?plan\/execute/, ); + // Otherwise / inline branch must reference FLATTEN being true assert.match( compoundActionSection[0], - /On Claude Code or any other non-Codex runtime:[\s\S]{0,260}?inline/, + /Otherwise[\s\S]{0,260}?`FLATTEN`[\s\S]{0,260}?`true`[\s\S]{0,260}?inline/, ); + // Must NOT still hardcode "On Codex:" in this section assert.doesNotMatch( compoundActionSection[0], - /On other runtimes:[\s\S]{0,260}?Spawn all background agents first/, + /\*\*On Codex:\*\*/, + ); + // Must NOT still hardcode "On Claude Code or any other non-Codex runtime:" + assert.doesNotMatch( + compoundActionSection[0], + /On Claude Code or any other non-Codex runtime:/, ); }); - test('autonomous.md gates interactive background dispatch by runtime', () => { - const autoRuntimeMatches = AUTONOMOUS.match(/config-get runtime/g) || []; - assert.ok(autoRuntimeMatches.length >= 2, 'autonomous.md must resolve runtime in both 3b (plan) and 3c (execute) interactive branches'); + test('autonomous.md gates interactive background dispatch using dispatch-should-flatten', () => { + // Two dispatch sites (3b plan + 3c execute), each must use dispatch-should-flatten. + // allow-test-rule: source-text-is-the-product (see #1708) + const autoFlattenMatches = AUTONOMOUS.match(/dispatch-should-flatten/g) || []; + assert.ok(autoFlattenMatches.length >= 2, 'autonomous.md must use dispatch-should-flatten in both 3b (plan) and 3c (execute) interactive branches'); // Accept both old singular form (backgrounded agent has no) and new plural form (backgrounded agents have no) assert.match(AUTONOMOUS, /backgrounded agents? ha(?:s|ve) no `Agent`\/`Task` tool/); }); - test('autonomous.md gates interactive background dispatch on codex; runs plan/execute inline otherwise', () => { - // Codex block: run_in_background=true appears within the codex branch and gsd-plan-phase is nearby - assert.match(AUTONOMOUS, /If `RUNTIME` is `codex`[\s\S]{0,1200}?run_in_background=true[\s\S]{0,600}?gsd-plan-phase/); - // Codex block: run_in_background=true appears within the codex branch and gsd-execute-phase is nearby - assert.match(AUTONOMOUS, /If `RUNTIME` is `codex`[\s\S]{0,3000}?run_in_background=true[\s\S]{0,200}?gsd-execute-phase/); - // Inline is the otherwise/else branch for plan — anchored on the explicit non-Codex label + test('autonomous.md gates interactive background dispatch on FLATTEN=false; runs plan/execute inline otherwise', () => { + // Background block: run_in_background=true appears within the FLATTEN=false branch and gsd-plan-phase is nearby + // allow-test-rule: source-text-is-the-product (see #1708) + assert.match(AUTONOMOUS, /If `FLATTEN` is `false`[\s\S]{0,1200}?run_in_background=true[\s\S]{0,600}?gsd-plan-phase/); + // Background block: run_in_background=true appears within the FLATTEN=false branch and gsd-execute-phase is nearby + assert.match(AUTONOMOUS, /If `FLATTEN` is `false`[\s\S]{0,3000}?run_in_background=true[\s\S]{0,200}?gsd-execute-phase/); + // Inline is the otherwise/else branch for plan — anchored on FLATTEN=true language (not runtime name) assert.match( AUTONOMOUS, - /Otherwise \(Claude Code or any other non-Codex runtime\)[\s\S]{0,400}?Skill\(skill="gsd-plan-phase"/, + /Otherwise[\s\S]{0,100}?`FLATTEN`[\s\S]{0,400}?Skill\(skill="gsd-plan-phase"/, ); - // Inline is the otherwise/else branch for execute — anchored on the explicit non-Codex label + // Inline is the otherwise/else branch for execute — anchored on FLATTEN=true language (not runtime name) assert.match( AUTONOMOUS, - /Otherwise \(Claude Code or any other non-Codex runtime\)[\s\S]{0,400}?Skill\(skill="gsd-execute-phase"/, + /Otherwise[\s\S]{0,100}?`FLATTEN`[\s\S]{0,400}?Skill\(skill="gsd-execute-phase"/, ); }); }); + +describe('dispatch-should-flatten query — behavioral', () => { + // #853 / #1708: The typed query replaces prose-level RUNTIME===codex checks. + // shouldFlattenDispatch returns false only when both dispatch.background AND + // dispatch.backgroundDispatch are true in the capability registry. + // + // Registry values (from host-integration-capability-matrix.md): + // codex: background=true, backgroundDispatch=true → shouldFlatten=false (may background) + // claude: background=true, backgroundDispatch=false → shouldFlatten=true (must inline) + // cursor: background=true, backgroundDispatch=true → shouldFlatten=false (may background) + // unknown: no entry → fail-closed → shouldFlatten=true (must inline) + + test('runtime=codex → shouldFlatten=false (background dispatch safe)', () => { + const tmpDir = createTempProject(); + try { + const result = runGsdTools(['query', 'dispatch-should-flatten', '--raw'], tmpDir, { + GSD_RUNTIME: 'codex', + }); + assert.ok(result.success, `Expected success, got error: ${result.error}`); + assert.strictEqual(result.output, 'false', `codex should return false (may background), got: ${result.output}`); + } finally { + cleanupDir(tmpDir); + } + }); + + test('runtime=claude → shouldFlatten=true (must inline)', () => { + const tmpDir = createTempProject(); + try { + const result = runGsdTools(['query', 'dispatch-should-flatten', '--raw'], tmpDir, { + GSD_RUNTIME: 'claude', + }); + assert.ok(result.success, `Expected success, got error: ${result.error}`); + assert.strictEqual(result.output, 'true', `claude should return true (must inline), got: ${result.output}`); + } finally { + cleanupDir(tmpDir); + } + }); + + test('runtime=cursor → shouldFlatten=false (background dispatch safe)', () => { + const tmpDir = createTempProject(); + try { + const result = runGsdTools(['query', 'dispatch-should-flatten', '--raw'], tmpDir, { + GSD_RUNTIME: 'cursor', + }); + assert.ok(result.success, `Expected success, got error: ${result.error}`); + assert.strictEqual(result.output, 'false', `cursor should return false (may background), got: ${result.output}`); + } finally { + cleanupDir(tmpDir); + } + }); + + test('unknown runtime → shouldFlatten=true (fail-closed → must inline)', () => { + // An unknown runtime has no registry entry → dispatch is null → fail-closed to true. + const tmpDir = createTempProject(); + try { + const result = runGsdTools(['query', 'dispatch-should-flatten', '--raw'], tmpDir, { + GSD_RUNTIME: 'unknown-runtime-xyz', + }); + // The query must succeed (exit 0) even for unknown runtimes — fail-closed not crash-closed. + assert.ok(result.success, `Expected success (fail-closed), got error: ${result.error}`); + assert.strictEqual(result.output, 'true', `unknown runtime should return true (fail-closed), got: ${result.output}`); + } finally { + cleanupDir(tmpDir); + } + }); + + test('--json flag returns structured { runtime, shouldFlatten, dispatch }', () => { + const tmpDir = createTempProject(); + try { + const result = runGsdTools(['query', 'dispatch-should-flatten', '--json'], tmpDir, { + GSD_RUNTIME: 'codex', + }); + assert.ok(result.success, `Expected success, got error: ${result.error}`); + let parsed; + try { + parsed = JSON.parse(result.output); + } catch { + assert.fail(`Expected valid JSON output, got: ${result.output}`); + } + assert.strictEqual(parsed.runtime, 'codex'); + assert.strictEqual(parsed.shouldFlatten, false); + assert.ok(parsed.dispatch !== null && typeof parsed.dispatch === 'object', 'dispatch should be an object'); + assert.strictEqual(parsed.dispatch.backgroundDispatch, true); + } finally { + cleanupDir(tmpDir); + } + }); + + test('config.runtime takes precedence when GSD_RUNTIME not set', () => { + // GSD_RUNTIME > config.runtime > 'claude' + // Write config.json with runtime=codex; no GSD_RUNTIME override. + const tmpDir = createTempProject(); + try { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'config.json'), + JSON.stringify({ runtime: 'codex' }), + 'utf-8', + ); + // Override GSD_RUNTIME to '' (empty string) so any ambient value is cleared. + // resolveRuntimeNameFromCandidates treats empty string as absent (normalizes + // to '' which is falsy → skipped → falls through to config.runtime=codex). + // This is the only way to suppress an ambient GSD_RUNTIME since runGsdTools + // merges { ...process.env, ...TEST_ENV_BASE, ...env } — passing '' as the + // override overwrites the ambient value at the correct merge position. + const result = runGsdTools(['query', 'dispatch-should-flatten', '--raw'], tmpDir, { + GSD_RUNTIME: '', + }); + // config.runtime=codex with GSD_RUNTIME cleared → codex backgrounds → shouldFlatten=false + assert.ok(result.success, `Expected success, got error: ${result.error}`); + assert.strictEqual(result.output, 'false', `config.runtime=codex (GSD_RUNTIME cleared) should return false (may background), got: ${result.output}`); + } finally { + cleanupDir(tmpDir); + } + }); +}); diff --git a/tests/capability-manifest-version.test.cjs b/tests/capability-manifest-version.test.cjs index 6a0ea0fc9..918e67ab7 100644 --- a/tests/capability-manifest-version.test.cjs +++ b/tests/capability-manifest-version.test.cjs @@ -89,7 +89,7 @@ function runtimeCap(overrides) { hostIntegration: { embeddingMode: 'imperative', commandSurface: 'slash-file', - dispatch: { namedDispatch: true, nested: true, maxDepth: -1, background: true, subagentToolkit: 'full' }, + dispatch: { namedDispatch: true, nested: true, maxDepth: -1, background: true, subagentToolkit: 'full', backgroundDispatch: false }, modelMode: 'passive', hookBus: 'host', stateIO: 'filesystem', diff --git a/tests/capability-registry.test.cjs b/tests/capability-registry.test.cjs index 5a9559e29..481906a8a 100644 --- a/tests/capability-registry.test.cjs +++ b/tests/capability-registry.test.cjs @@ -1778,7 +1778,7 @@ describe('C3: role:runtime body validation', () => { hostIntegration: { embeddingMode: 'declarative', commandSurface: 'slash-file', - dispatch: { namedDispatch: true, nested: true, maxDepth: 2, background: false, subagentToolkit: 'full' }, + dispatch: { namedDispatch: true, nested: true, maxDepth: 2, background: false, subagentToolkit: 'full', backgroundDispatch: false }, modelMode: 'passive', hookBus: 'host', stateIO: 'filesystem', @@ -3231,7 +3231,7 @@ function makeRuntimeCap(overrides) { hostIntegration: { embeddingMode: 'imperative', commandSurface: 'slash-file', - dispatch: { namedDispatch: true, nested: true, maxDepth: -1, background: true, subagentToolkit: 'full' }, + dispatch: { namedDispatch: true, nested: true, maxDepth: -1, background: true, subagentToolkit: 'full', backgroundDispatch: false }, modelMode: 'passive', hookBus: 'host', stateIO: 'filesystem', @@ -4306,7 +4306,7 @@ describe('ADR-857 phase 5f: cross-field consistency gate rejection tests (DEFECT hostIntegration: { embeddingMode: 'imperative', commandSurface: 'slash-file', - dispatch: { namedDispatch: true, nested: true, maxDepth: -1, background: true, subagentToolkit: 'full' }, + dispatch: { namedDispatch: true, nested: true, maxDepth: -1, background: true, subagentToolkit: 'full', backgroundDispatch: false }, modelMode: 'passive', hookBus: 'host', stateIO: 'filesystem', @@ -5168,7 +5168,7 @@ describe('activationKey validation', () => { hostIntegration: { embeddingMode: 'declarative', commandSurface: 'slash-file', - dispatch: { namedDispatch: true, nested: true, maxDepth: 2, background: false, subagentToolkit: 'full' }, + dispatch: { namedDispatch: true, nested: true, maxDepth: 2, background: false, subagentToolkit: 'full', backgroundDispatch: false }, modelMode: 'passive', hookBus: 'host', stateIO: 'filesystem', diff --git a/tests/fix-1521-non-claude-runtime-default-resolution.test.cjs b/tests/fix-1521-non-claude-runtime-default-resolution.test.cjs index a5297b0b6..d31e94834 100644 --- a/tests/fix-1521-non-claude-runtime-default-resolution.test.cjs +++ b/tests/fix-1521-non-claude-runtime-default-resolution.test.cjs @@ -169,12 +169,14 @@ test('execute-phase.md, quick.md, and diagnose-issues.md guards are generalized }); // --------------------------------------------------------------------------- -// Orchestration gating: manager.md + autonomous.md now gate on codex for -// background dispatch, not on "not claude". (#1521 Stage 2) +// Orchestration gating: manager.md + autonomous.md gate background dispatch on +// the typed FLATTEN query. #1708 (ADR-1239 Phase B) graduated #1521's +// codex-specific check to a documentation-sourced shouldFlattenDispatch — the +// prose now branches on `FLATTEN` (false = background), not a runtime name. // --------------------------------------------------------------------------- -test('manager.md and autonomous.md gate run_in_background on codex specifically (#1521)', () => { - // allow-test-rule: orchestration dispatch gating in manager/autonomous .md is the runtime contract surface (#1521) +test('manager.md and autonomous.md gate run_in_background on FLATTEN=false, not a runtime name (#1521, graduated by #1708)', () => { + // allow-test-rule: orchestration dispatch gating in manager/autonomous .md is the runtime contract surface (#1521/#1708) const manager = fs.readFileSync( path.join(__dirname, '..', 'gsd-core', 'workflows', 'manager.md'), 'utf8', @@ -184,24 +186,29 @@ test('manager.md and autonomous.md gate run_in_background on codex specifically 'utf8', ); - // Both files must gate run_in_background on codex (not on a generic "not claude" condition) + // Both files must gate run_in_background on the typed FLATTEN decision (not a runtime name) assert.ok( - /`RUNTIME` is `codex`[\s\S]{0,500}?run_in_background=true/.test(manager), - 'manager.md: expected run_in_background dispatch gated on RUNTIME=codex specifically', + /If `FLATTEN` is `false`[\s\S]{0,500}?run_in_background=true/.test(manager), + 'manager.md: expected run_in_background dispatch gated on FLATTEN=false (typed dispatch-should-flatten query)', ); assert.ok( - /`RUNTIME` is `codex`[\s\S]{0,700}?run_in_background=true/.test(autonomous), - 'autonomous.md: expected run_in_background dispatch gated on RUNTIME=codex specifically', + /If `FLATTEN` is `false`[\s\S]{0,1200}?run_in_background=true/.test(autonomous), + 'autonomous.md: expected run_in_background dispatch gated on FLATTEN=false (typed dispatch-should-flatten query)', ); - // Inline is the default/else branch (not just claude) + // Inline is the else branch, keyed on FLATTEN — never a runtime name assert.ok( - /Otherwise[\s\S]{0,200}?Claude Code or any other non-Codex runtime/.test(manager), - 'manager.md: expected "Otherwise (Claude Code or any other non-Codex runtime)" inline branch', + /Otherwise[\s\S]{0,250}?inline/i.test(manager), + 'manager.md: expected "Otherwise ... inline" branch keyed on FLATTEN', ); assert.ok( - /Otherwise[\s\S]{0,200}?Claude Code or any other non-Codex runtime/.test(autonomous), - 'autonomous.md: expected "Otherwise (Claude Code or any other non-Codex runtime)" inline branch', + /Otherwise[\s\S]{0,250}?inline/i.test(autonomous), + 'autonomous.md: expected "Otherwise ... inline" branch keyed on FLATTEN', + ); + // And the old runtime-name gating must be gone (no `RUNTIME` is `codex` dispatch gate) + assert.ok( + !/`RUNTIME` is `codex`[\s\S]{0,500}?run_in_background=true/.test(manager), + 'manager.md: must no longer gate run_in_background on the runtime name', ); }); diff --git a/tests/host-integration-descriptors.test.cjs b/tests/host-integration-descriptors.test.cjs index ea66f2b00..146c5c1c4 100644 --- a/tests/host-integration-descriptors.test.cjs +++ b/tests/host-integration-descriptors.test.cjs @@ -17,6 +17,7 @@ const path = require('node:path'); const { negotiateHostCapabilities, profileOf, + shouldFlattenDispatch, } = require(path.join(__dirname, '../gsd-core/bin/lib/host-integration.cjs')); const registry = require(path.join(__dirname, '../gsd-core/bin/lib/capability-registry.cjs')); @@ -27,8 +28,8 @@ const { // 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 6 dispatch sub-keys (includes backgroundDispatch added in feat/1679-dispatch-flatten) +const DISPATCH_KEYS = ['namedDispatch', 'nested', 'maxDepth', 'background', 'subagentToolkit', 'backgroundDispatch']; // All 16 runtime IDs (ordered alphabetically) const RUNTIME_IDS = [ @@ -95,7 +96,7 @@ describe('ADR-1239 Phase A: hostIntegration descriptors', () => { }); // (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', () => { + test('(ii) cap.runtime.hostIntegration is present with all 8 axis keys and 6 dispatch sub-keys', () => { assert.ok( hi !== undefined && hi !== null && typeof hi === 'object', id + ': cap.runtime.hostIntegration must be a non-null object', @@ -225,6 +226,77 @@ describe('ADR-1239 Phase A: hostIntegration descriptors', () => { assert.strictEqual(counts['ide'], 0, 'Must have exactly 0 ide runtimes'); }); + // ─── backgroundDispatch presence ───────────────────────────────────────────── + + test('every runtime descriptor has dispatch.backgroundDispatch (boolean or "undocumented")', () => { + for (const id of RUNTIME_IDS) { + const cap = registry.runtimes[id]; + const dispatch = cap && cap.runtime && cap.runtime.hostIntegration && cap.runtime.hostIntegration.dispatch; + assert.ok( + dispatch !== null && typeof dispatch === 'object', + id + ': hostIntegration.dispatch must be an object', + ); + assert.ok( + Object.prototype.hasOwnProperty.call(dispatch, 'backgroundDispatch'), + id + ': dispatch must have a backgroundDispatch key', + ); + const v = dispatch.backgroundDispatch; + assert.ok( + v === true || v === false || v === 'undocumented', + id + ': dispatch.backgroundDispatch must be true, false, or "undocumented", got: ' + JSON.stringify(v), + ); + } + }); + + // ─── shouldFlattenDispatch per-host (#853 discriminator) ───────────────────── + + // Expected: false (may background) for codex and cursor ONLY; true (must inline) for the other 14. + const EXPECTED_FLATTEN = { + antigravity: true, + augment: true, + claude: true, + cline: true, + codebuddy: true, + codex: false, + copilot: true, + cursor: false, + gemini: true, + hermes: true, + kilo: true, + kimi: true, + opencode: true, + qwen: true, + trae: true, + windsurf: true, + }; + + for (const id of RUNTIME_IDS) { + test('shouldFlattenDispatch(' + id + ') === ' + EXPECTED_FLATTEN[id], () => { + const cap = registry.runtimes[id]; + const dispatch = cap && cap.runtime && cap.runtime.hostIntegration && cap.runtime.hostIntegration.dispatch; + assert.ok(dispatch, id + ': dispatch must exist'); + const result = shouldFlattenDispatch(dispatch); + assert.strictEqual( + result, + EXPECTED_FLATTEN[id], + id + ': shouldFlattenDispatch must return ' + EXPECTED_FLATTEN[id] + ' (got: ' + result + ')', + ); + }); + } + + test('contract-pin: exactly 2 hosts are background-eligible (shouldFlattenDispatch === false): codex and cursor', () => { + const eligible = RUNTIME_IDS.filter((id) => { + const cap = registry.runtimes[id]; + const dispatch = cap && cap.runtime && cap.runtime.hostIntegration && cap.runtime.hostIntegration.dispatch; + return dispatch && shouldFlattenDispatch(dispatch) === false; + }); + assert.deepEqual( + eligible.slice().sort(), + ['codex', 'cursor'], + 'Exactly codex and cursor must be background-eligible, got: ' + JSON.stringify(eligible.sort()), + ); + }); + test('contract-pin: spot-check claude→programmatic-cli, codex→declarative-cli, opencode→programmatic-cli, gemini→declarative-cli', () => { const checks = [ ['claude', 'programmatic-cli'], @@ -273,7 +345,7 @@ describe('ADR-1239 Phase A: hostIntegration descriptors', () => { hostIntegration: { embeddingMode: 'bogus', commandSurface: 'slash-file', - dispatch: { namedDispatch: true, nested: false, maxDepth: 1, background: false, subagentToolkit: 'full' }, + dispatch: { namedDispatch: true, nested: false, maxDepth: 1, background: false, subagentToolkit: 'full', backgroundDispatch: false }, modelMode: 'passive', hookBus: 'host', stateIO: 'filesystem', diff --git a/tests/host-integration-validator-parity.test.cjs b/tests/host-integration-validator-parity.test.cjs index 846290657..7837cd7a3 100644 --- a/tests/host-integration-validator-parity.test.cjs +++ b/tests/host-integration-validator-parity.test.cjs @@ -68,6 +68,7 @@ function makeMinimalRuntimeCap(overrides = {}) { maxDepth: 1, background: false, subagentToolkit: 'full', + backgroundDispatch: false, }, }, ...overrides, @@ -213,6 +214,7 @@ describe('ADR-1239 validator behavioral: undocumented sentinel passes, bogus fai maxDepth: 'undocumented', background: 'undocumented', subagentToolkit: 'undocumented', + backgroundDispatch: 'undocumented', }, }, }); @@ -229,6 +231,77 @@ describe('ADR-1239 validator behavioral: undocumented sentinel passes, bogus fai assert.strictEqual(dispatchErrors.length, 0, `Valid boolean dispatch fields must produce no errors; got: ${dispatchErrors.join(', ')}`); }); + + // Phase B: backgroundDispatch field validation + test('dispatch.backgroundDispatch:true → ZERO validator errors', () => { + const cap = makeMinimalRuntimeCap({ + hostIntegration: { + ...makeMinimalRuntimeCap().runtime.hostIntegration, + dispatch: { ...makeMinimalRuntimeCap().runtime.hostIntegration.dispatch, backgroundDispatch: true }, + }, + }); + const errors = validateCapability(cap, 'test-runtime'); + const bdErrors = errors.filter((e) => e.includes('backgroundDispatch')); + assert.strictEqual(bdErrors.length, 0, + `backgroundDispatch:true must produce no errors; got: ${bdErrors.join(', ')}`); + }); + + test('dispatch.backgroundDispatch:false → ZERO validator errors', () => { + const cap = makeMinimalRuntimeCap({ + hostIntegration: { + ...makeMinimalRuntimeCap().runtime.hostIntegration, + dispatch: { ...makeMinimalRuntimeCap().runtime.hostIntegration.dispatch, backgroundDispatch: false }, + }, + }); + const errors = validateCapability(cap, 'test-runtime'); + const bdErrors = errors.filter((e) => e.includes('backgroundDispatch')); + assert.strictEqual(bdErrors.length, 0, + `backgroundDispatch:false must produce no errors; got: ${bdErrors.join(', ')}`); + }); + + test('dispatch.backgroundDispatch:"undocumented" → ZERO validator errors', () => { + const cap = makeMinimalRuntimeCap({ + hostIntegration: { + ...makeMinimalRuntimeCap().runtime.hostIntegration, + dispatch: { ...makeMinimalRuntimeCap().runtime.hostIntegration.dispatch, backgroundDispatch: 'undocumented' }, + }, + }); + const errors = validateCapability(cap, 'test-runtime'); + const bdErrors = errors.filter((e) => e.includes('backgroundDispatch')); + assert.strictEqual(bdErrors.length, 0, + `backgroundDispatch:"undocumented" must produce no errors; got: ${bdErrors.join(', ')}`); + }); + + test('dispatch.backgroundDispatch:"zzz" → produces a validator error', () => { + const cap = makeMinimalRuntimeCap({ + hostIntegration: { + ...makeMinimalRuntimeCap().runtime.hostIntegration, + dispatch: { ...makeMinimalRuntimeCap().runtime.hostIntegration.dispatch, backgroundDispatch: 'zzz' }, + }, + }); + const errors = validateCapability(cap, 'test-runtime'); + const bdErrors = errors.filter((e) => e.includes('backgroundDispatch')); + assert.ok(bdErrors.length > 0, + `bogus value "zzz" for backgroundDispatch must produce a validator error`); + }); + + test('dispatch without backgroundDispatch key → validator error (required field — all 16 descriptors carry it)', () => { + // backgroundDispatch is now REQUIRED (matches sibling fields namedDispatch/nested/background/subagentToolkit/maxDepth). + const cap = makeMinimalRuntimeCap({ + hostIntegration: { + ...makeMinimalRuntimeCap().runtime.hostIntegration, + dispatch: (() => { + const d = { ...makeMinimalRuntimeCap().runtime.hostIntegration.dispatch }; + delete d.backgroundDispatch; + return d; + })(), + }, + }); + const errors = validateCapability(cap, 'test-runtime'); + const bdErrors = errors.filter((e) => e.includes('backgroundDispatch')); + assert.ok(bdErrors.length > 0, + `Missing backgroundDispatch (required field) must produce a validator error`); + }); }); // --------------------------------------------------------------------------- @@ -270,6 +343,7 @@ describe('Fix 3: reserved-key guard on hostIntegration and hostIntegration.dispa maxDepth: 5, background: true, subagentToolkit: 'full', + backgroundDispatch: true, }, modelMode: 'passive', hookBus: 'host', diff --git a/tests/host-integration.test.cjs b/tests/host-integration.test.cjs index 9e466f8aa..a4fd96cfd 100644 --- a/tests/host-integration.test.cjs +++ b/tests/host-integration.test.cjs @@ -844,6 +844,155 @@ describe('Fix: degradationFor unknown point → {level:"absent", unknown:true}', }); }); +// --------------------------------------------------------------------------- +// Phase B: shouldFlattenDispatch — ADR-1239 Phase B / #1708 +// --------------------------------------------------------------------------- + +describe('Phase B: shouldFlattenDispatch — contract pin', () => { + const { shouldFlattenDispatch } = hi; + + test('shouldFlattenDispatch is exported as a function', () => { + assert.strictEqual(typeof shouldFlattenDispatch, 'function', + 'shouldFlattenDispatch must be exported from host-integration module'); + }); + + test('{background:true, backgroundDispatch:true} → false (background OK)', () => { + assert.strictEqual(shouldFlattenDispatch({ background: true, backgroundDispatch: true }), false, + 'canBackground=true when both background===true AND backgroundDispatch===true → flatten=false'); + }); + + test('{background:true, backgroundDispatch:false} → true (must flatten)', () => { + assert.strictEqual(shouldFlattenDispatch({ background: true, backgroundDispatch: false }), true, + 'backgroundDispatch===false → canBackground=false → flatten=true'); + }); + + test('{background:true, backgroundDispatch:"undocumented"} → true (undocumented is not === true)', () => { + assert.strictEqual(shouldFlattenDispatch({ background: true, backgroundDispatch: 'undocumented' }), true, + '"undocumented" is not === true → canBackground=false → flatten=true'); + }); + + test('{background:false, backgroundDispatch:true} → true (background is false)', () => { + assert.strictEqual(shouldFlattenDispatch({ background: false, backgroundDispatch: true }), true, + 'background===false → canBackground=false → flatten=true'); + }); + + test('{} (empty) → true (missing fields → fail-closed)', () => { + assert.strictEqual(shouldFlattenDispatch({}), true, + 'empty dispatch → canBackground=false → flatten=true'); + }); + + test('missing fields individually', () => { + assert.strictEqual(shouldFlattenDispatch({ background: true }), true, + 'backgroundDispatch missing → not === true → flatten=true'); + assert.strictEqual(shouldFlattenDispatch({ backgroundDispatch: true }), true, + 'background missing → not === true → flatten=true'); + }); + + // M1: null-safety — null/undefined/non-object dispatch must fail-closed (not throw) + test('null dispatch → true (fail-closed, no throw)', () => { + assert.strictEqual(shouldFlattenDispatch(null), true, + 'null dispatch must fail-closed to true'); + }); + + test('undefined dispatch → true (fail-closed, no throw)', () => { + assert.strictEqual(shouldFlattenDispatch(undefined), true, + 'undefined dispatch must fail-closed to true'); + }); + + test('string dispatch → true (fail-closed, no throw)', () => { + assert.strictEqual(shouldFlattenDispatch('x'), true, + 'non-object dispatch (string) must fail-closed to true'); + }); + + // #853 codex-like profile: full dispatch including backgroundDispatch:true → background OK + test('#853 codex-like: {namedDispatch:true,nested:true,maxDepth:1,background:true,subagentToolkit:"full",backgroundDispatch:true} → false (background OK)', () => { + assert.strictEqual( + shouldFlattenDispatch({ namedDispatch: true, nested: true, maxDepth: 1, background: true, subagentToolkit: 'full', backgroundDispatch: true }), + false, + 'codex-like dispatch with backgroundDispatch:true must be background-OK (flatten=false)', + ); + }); + + // #853 claude-like profile: backgroundDispatch:false → must flatten + test('#853 claude-like: {...,background:true,backgroundDispatch:false} → true (inline)', () => { + assert.strictEqual( + shouldFlattenDispatch({ namedDispatch: true, nested: true, maxDepth: -1, background: true, subagentToolkit: 'full', backgroundDispatch: false }), + true, + 'claude-like dispatch with backgroundDispatch:false must flatten inline', + ); + }); +}); + +// --------------------------------------------------------------------------- +// Phase B: negotiateHostCapabilities — backgroundDispatch field +// --------------------------------------------------------------------------- + +describe('Phase B: negotiateHostCapabilities — backgroundDispatch', () => { + test('host dispatch.backgroundDispatch:true against DEFAULT_ENGINE → effective.dispatch.backgroundDispatch===true', () => { + const result = negotiateHostCapabilities({ + ...PROFILE_BASELINES['programmatic-cli'], + dispatch: { namedDispatch: true, nested: true, maxDepth: -1, background: true, subagentToolkit: 'full', backgroundDispatch: true }, + }); + assert.strictEqual(result.effective.dispatch.backgroundDispatch, true, + 'backgroundDispatch:true on host AND engine must yield effective backgroundDispatch===true'); + }); + + test('host dispatch.backgroundDispatch:"undocumented" → effective.dispatch.backgroundDispatch===false + warning', () => { + const result = negotiateHostCapabilities({ + ...PROFILE_BASELINES['programmatic-cli'], + dispatch: { namedDispatch: true, nested: true, maxDepth: -1, background: true, subagentToolkit: 'full', backgroundDispatch: 'undocumented' }, + }); + assert.strictEqual(result.effective.dispatch.backgroundDispatch, false, + '"undocumented" must fail-closed to false'); + const warnText = result.warnings.join(' '); + assert.ok(warnText.includes('backgroundDispatch') && warnText.includes('undocumented'), + `Expected warning about backgroundDispatch being undocumented; got: ${warnText}`); + }); + + test('host dispatch.backgroundDispatch:false → effective.dispatch.backgroundDispatch===false', () => { + const result = negotiateHostCapabilities({ + ...PROFILE_BASELINES['programmatic-cli'], + dispatch: { namedDispatch: true, nested: true, maxDepth: -1, background: true, subagentToolkit: 'full', backgroundDispatch: false }, + }); + assert.strictEqual(result.effective.dispatch.backgroundDispatch, false); + }); + + test('host dispatch without backgroundDispatch key → effective.dispatch.backgroundDispatch===false (fail-closed)', () => { + const result = negotiateHostCapabilities({ + ...PROFILE_BASELINES['programmatic-cli'], + dispatch: { namedDispatch: true, nested: true, maxDepth: -1, background: true, subagentToolkit: 'full' }, + }); + assert.strictEqual(result.effective.dispatch.backgroundDispatch, false, + 'Missing backgroundDispatch key must fail-closed to false'); + }); + + test('negotiateHostCapabilities({}) → effective.dispatch.backgroundDispatch===false', () => { + const result = negotiateHostCapabilities({}); + assert.strictEqual(result.effective.dispatch.backgroundDispatch, false, + 'Empty host must produce backgroundDispatch===false (SAFE_DEFAULTS)'); + }); + + test('SAFE_DEFAULTS.dispatch.backgroundDispatch is false', () => { + // Verified via negotiation with empty host + const result = negotiateHostCapabilities({}); + assert.strictEqual(result.effective.dispatch.backgroundDispatch, false); + }); + + test('DEFAULT_ENGINE.axes.dispatch.backgroundDispatch is true', () => { + assert.strictEqual(DEFAULT_ENGINE.axes.dispatch.backgroundDispatch, true, + 'DEFAULT_ENGINE (full engine) must declare backgroundDispatch:true'); + }); + + test('existing dispatch tests still pass — effective.dispatch.namedDispatch present alongside backgroundDispatch', () => { + const result = negotiateHostCapabilities(PROFILE_BASELINES['programmatic-cli']); + const d = result.effective.dispatch; + assert.ok('namedDispatch' in d, 'namedDispatch must still be present'); + assert.ok('backgroundDispatch' in d, 'backgroundDispatch must be present'); + assert.ok('nested' in d && 'maxDepth' in d && 'background' in d && 'subagentToolkit' in d, + 'all original dispatch fields must still be present'); + }); +}); + // --------------------------------------------------------------------------- // Fix 2: negotiateHostCapabilities — host omitting 'dispatch' → subagentToolkit 'read-only' // --------------------------------------------------------------------------- diff --git a/tests/workflow-size-baseline.json b/tests/workflow-size-baseline.json index 6e785faa5..a7bd5fdfd 100644 --- a/tests/workflow-size-baseline.json +++ b/tests/workflow-size-baseline.json @@ -8,7 +8,7 @@ "audit-fix.md": 10988, "audit-milestone.md": 17637, "audit-uat.md": 7425, - "autonomous.md": 42675, + "autonomous.md": 42747, "check-todos.md": 9431, "cleanup.md": 9897, "code-review-fix.md": 23890, @@ -40,7 +40,7 @@ "list-phase-assumptions.md": 4305, "list-seeds.md": 6943, "list-workspaces.md": 5655, - "manager.md": 26966, + "manager.md": 27258, "map-codebase.md": 20789, "milestone-summary.md": 11774, "mvp-phase.md": 13582,