From ff9cb6069fe7c63f822fca3fb228aaafd1c5ce6e Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Wed, 15 Jul 2026 19:18:28 -0400 Subject: [PATCH] fix(#2285): wire claude-orchestration Workflow backend into execute-phase (#2314) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The claude-orchestration capability (#1143) shipped registered 'active' but fully inert: detectWorkflowBackend/emitWorkflowScript had no caller outside their own CLI router, and execute-phase.md declared an execute:wave:pre hook point that the workflow body never rendered — so claude_orchestration.enabled:true had zero effect on real runs. Approach B (maintainer-chosen): - execute-phase.md now renders the execute:wave:pre hook (gsd_run loop render-hooks execute:wave:pre) at a new step 2.75, immediately before each wave's Agent() dispatch — fixing the latent dead-hook gap for any pre-wave capability. - Move the claude-orchestration contribution execute:wave:post -> execute:wave:pre (a pre-wave backend selector belongs before dispatch, not after); rename fragments/execute-wave-post.md -> execute-wave-pre.md with prose instructing the orchestrator to call resolve-wave-dispatch before step 3. Unrelated wave:post contributions (ui.safety-gate, drift, external-job, mempalace) untouched. - New .cts seam resolveWaveDispatch(input) composes detectWorkflowBackend + emitWorkflowScript into one {backend:'inline'|'workflow', ...} result; exposed as gsd-tools claude-orchestration resolve-wave-dispatch. This is a real non-CLI-router, non-test caller of both functions. Fail-closed: any gate miss (disabled, non-Claude runtime, Workflow tool absent, SDK below floor, execution_backend:inline, malformed input) or an emit failure resolves to inline with a byte-identical result shape — no regression to the default-off execute-phase path. Regression tests (tests/fix-2285-*) cover happy-path activation + SDK-floor BVA, the fail-closed gate-miss table with detectWorkflowBackend parity, a fast-check composition property, capability.json contribution assertions, and a source-contract guard that execute:wave:pre is now actually rendered. Dependent registry-shape assertions updated in-scope. Co-authored-by: Claude Opus 4.8 (1M context) --- .changeset/quick-ibex-bark.md | 5 + .../claude-orchestration/capability.json | 7 +- .../fragments/execute-wave-post.md | 64 -- .../fragments/execute-wave-pre.md | 167 +++++ .../claude-orchestration-capability.md | 23 +- docs/reference/capability-matrix.md | 2 +- gsd-core/bin/lib/capability-registry.cjs | 28 +- .../claude-orchestration-command-router.cjs | 142 +++- gsd-core/bin/lib/claude-orchestration.cjs | 93 ++- gsd-core/workflows/execute-phase.md | 18 +- src/claude-orchestration-command-router.cts | 154 ++++- src/claude-orchestration.cts | 142 +++- tests/claude-orchestration.test.cjs | 122 +++- ...ecute-wave-post-gate-pipeline-e2e.test.cjs | 26 +- ...-2285-claude-orchestration-wiring.test.cjs | 649 ++++++++++++++++++ .../golden-install-parity/antigravity.json | 2 +- .../golden-install-parity/augment.json | 2 +- .../golden-install-parity/claude-local.json | 2 +- .../golden-install-parity/claude.json | 2 +- .../fixtures/golden-install-parity/cline.json | 2 +- .../golden-install-parity/codebuddy.json | 2 +- .../fixtures/golden-install-parity/codex.json | 2 +- .../golden-install-parity/copilot.json | 2 +- .../golden-install-parity/cursor.json | 2 +- .../golden-install-parity/hermes.json | 2 +- .../fixtures/golden-install-parity/kilo.json | 2 +- .../fixtures/golden-install-parity/kimi.json | 2 +- .../golden-install-parity/opencode.json | 2 +- tests/fixtures/golden-install-parity/pi.json | 2 +- .../fixtures/golden-install-parity/qwen.json | 2 +- .../fixtures/golden-install-parity/trae.json | 2 +- .../golden-install-parity/windsurf.json | 2 +- .../fixtures/golden-install-parity/zcode.json | 2 +- tests/slurm-adapter.test.cjs | 9 +- tests/workflow-size-baseline.json | 2 +- 35 files changed, 1486 insertions(+), 203 deletions(-) create mode 100644 .changeset/quick-ibex-bark.md delete mode 100644 capabilities/claude-orchestration/fragments/execute-wave-post.md create mode 100644 capabilities/claude-orchestration/fragments/execute-wave-pre.md create mode 100644 tests/fix-2285-claude-orchestration-wiring.test.cjs diff --git a/.changeset/quick-ibex-bark.md b/.changeset/quick-ibex-bark.md new file mode 100644 index 000000000..aea89b137 --- /dev/null +++ b/.changeset/quick-ibex-bark.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 2314 +--- +**`claude_orchestration.enabled: true` now actually routes execute-phase waves through the Workflow backend** — the capability shipped registered-but-inert: nothing in `/gsd-execute-phase` ever called its backend detection, and the `execute:wave:pre` hook it needed was declared but never rendered, so enabling it had zero effect. execute-phase now renders `execute:wave:pre` before each wave and, when the capability is enabled and all gates pass, dispatches independent plans via the generated Workflow script; any gate miss or disabled config falls back to byte-identical inline dispatch. (#2285) diff --git a/capabilities/claude-orchestration/capability.json b/capabilities/claude-orchestration/capability.json index 6c9dd8f1b..af985e14c 100644 --- a/capabilities/claude-orchestration/capability.json +++ b/capabilities/claude-orchestration/capability.json @@ -25,7 +25,8 @@ "router": "routeClaudeOrchestrationCommand", "subcommands": [ "detect-backend", - "emit-workflow" + "emit-workflow", + "resolve-wave-dispatch" ] } ], @@ -55,10 +56,10 @@ "steps": [], "contributions": [ { - "point": "execute:wave:post", + "point": "execute:wave:pre", "into": "executor", "fragment": { - "path": "fragments/execute-wave-post.md" + "path": "fragments/execute-wave-pre.md" }, "produces": [], "consumes": [ diff --git a/capabilities/claude-orchestration/fragments/execute-wave-post.md b/capabilities/claude-orchestration/fragments/execute-wave-post.md deleted file mode 100644 index db0e76d5a..000000000 --- a/capabilities/claude-orchestration/fragments/execute-wave-post.md +++ /dev/null @@ -1,64 +0,0 @@ -# Claude orchestration — Workflow execution backend (BETA) - -> Injected at `execute:wave:post` `into: executor` only when -> `claude_orchestration.enabled` is true. Default-off; `onError: skip`. - -## When this contribution is active - -The Claude orchestration capability is **default-off and BETA**. It activates only -when ALL of the following hold: - -1. `claude_orchestration.enabled` is `true` in `.planning/config.json`, AND -2. the active runtime is **Claude Code** (the Workflow tool is Claude / Agent - SDK-specific), AND -3. `claude_orchestration.execution_backend` resolves to `workflow` — either - explicitly, or via `auto` — **and** the Agent SDK version is - `>= claude_orchestration.min_agent_sdk_version` (default `0.3.149`). The SDK - floor applies in both `auto` and `workflow` modes (fail-closed: a pre-release - or older SDK never activates the preview backend). - -Detection is fail-closed: any miss degrades to **inline, manual, one-agent-per- -message dispatch** — exactly today's behaviour. On a non-Claude runtime this -contribution is a no-op. - -## What the executor does when the Workflow backend is active - -Instead of the orchestrator fanning out one `Agent(subagent_type=gsd-executor, -isolation=worktree, run_in_background=true)` per message (which on Claude Code -cannot nest further subagents — #853 — and so degrades to sequential inline -execution), execute-phase **emits a generated Workflow script** and lets the main -loop orchestrate it: - -- **waves → one or more sequential `parallel()` barriers** — each wave is a - barrier group; when plans within a wave share `files_modified`, they are split - into separate sequential stages within that wave's barrier (the next wave - still waits for the previous wave to complete). -- **plans → `agent(brief, { agentType: 'gsd-executor', isolation: 'worktree' })`** - — the SAME executor agent and worktree isolation the inline path uses, so the - produced `SUMMARY.md` and commits are identical. -- **`files_modified` overlap → separate sequential stages** — two plans that - touch the same file are placed in different stages within the wave (the same - overlap rule execute-phase already applies inline). -- **`resumeFromRunId`** — wired to the phase run id, so an interrupted phase - resumes without re-running completed plans. -- **`budget(tokens)`** — a shared token pool across the whole phase when the - orchestrator passes a `budgetTokens` value to `emitWorkflowScript` (it is a - function parameter, not a config key; the orchestrator decides the budget). - -The emitter is a pure function exposed through the capability command surface: -`gsd-tools claude-orchestration emit-workflow --waves --run-id -[--phase-dir ] [--budget ]` (or `require('gsd-core/bin/lib/claude-orchestration.cjs').emitWorkflowScript` -directly). It maps the phase's wave/plan manifest to the Workflow script string -and never invokes the Workflow tool itself; the orchestrator runs the emitted -script. Detection is resolved by the orchestrator calling the pure -`detectWorkflowBackend` with the LIVE host descriptor (the CLI -`gsd-tools claude-orchestration detect-backend` is a simulation harness that -assumes a capable host unless `--no-nested-dispatch` is passed — it does not probe -the real runtime; the orchestrator supplies the real descriptor). - -## Fallback contract - -If detection resolves to `inline` (tool absent, SDK too old, runtime not Claude, -or the capability disabled), execute-phase MUST proceed with the standard inline -wave dispatch. The executor MUST NOT assume parallelism, a shared budget, or -resume-from-run-id semantics in that mode. diff --git a/capabilities/claude-orchestration/fragments/execute-wave-pre.md b/capabilities/claude-orchestration/fragments/execute-wave-pre.md new file mode 100644 index 000000000..1af4f39dc --- /dev/null +++ b/capabilities/claude-orchestration/fragments/execute-wave-pre.md @@ -0,0 +1,167 @@ +# Claude orchestration — Workflow execution backend (BETA) + +> Injected at `execute:wave:pre` `into: executor` only when +> `claude_orchestration.enabled` is true. Default-off; `onError: skip`. + +## When this contribution is active + +The Claude orchestration capability is **default-off and BETA**. It activates only +when ALL of the following hold: + +1. `claude_orchestration.enabled` is `true` in `.planning/config.json`, AND +2. the active runtime is **Claude Code** (the Workflow tool is Claude / Agent + SDK-specific), AND +3. `claude_orchestration.execution_backend` resolves to `workflow` — either + explicitly, or via `auto` — **and** the Agent SDK version is + `>= claude_orchestration.min_agent_sdk_version` (default `0.3.149`). The SDK + floor applies in both `auto` and `workflow` modes (fail-closed: a pre-release + or older SDK never activates the preview backend). + +Detection is fail-closed: any miss degrades to **inline, manual, one-agent-per- +message dispatch** — exactly today's behaviour. On a non-Claude runtime this +contribution is a no-op. + +## Why `execute:wave:pre` (not `execute:wave:post`) + +This is a **dispatch-backend selector** — it decides HOW a wave's executor agents +are spawned. That decision has to be made BEFORE the wave's `Agent()` calls in +`execute-phase.md` step 3, not after the wave has already finished (#2285). The +capability previously registered at `execute:wave:post`, which fires only after +worktree merge/post-merge tests/tracking updates — by then the wave was already +dispatched inline, so the contribution was structurally unable to change how +dispatch happened. This fragment is injected at the point that actually precedes +dispatch. + +## What the orchestrator does when the Workflow backend is active + +Before spawning executor agents for the current wave (execute-phase.md step 3), +resolve the dispatch backend through the single composed CLI seam: + +```bash +gsd-tools claude-orchestration resolve-wave-dispatch \ + --waves "$WAVE_MANIFEST_PATH" --run-id "$PHASE_RUN_ID" \ + --runtime "$RUNTIME" \ + ${AGENT_SDK_VERSION:+--agent-sdk-version "$AGENT_SDK_VERSION"} \ + --phase-dir "$PHASE_DIR" --raw +``` + +This composes `detectWorkflowBackend` (the gate ladder above) with +`emitWorkflowScript` (the wave→plan mapping below) in ONE call — the pure +function backing it is `resolveWaveDispatch` in +`gsd-core/bin/lib/claude-orchestration.cjs`. Response shape: +`{ backend: 'inline'|'workflow', reason, script?, summary? }`. + +### Manifest construction (`$WAVE_MANIFEST_PATH`, `$PHASE_RUN_ID`, `$PHASE_DIR`, `$AGENT_SDK_VERSION`) + +These are NOT pre-existing execute-phase.md variables — the orchestrator builds +them at this step, from data it already has in-context from `discover_and_group_plans` +(the `PLAN_INDEX` JSON) and step 2.5 (the per-plan `USE_WORKTREES_FOR_PLAN` decision): + +1. **`$PHASE_DIR`** — reuse `{phase_dir}` from the `INIT` bundle (already loaded + in the `initialize` step). No new value needed. + +2. **`$PHASE_RUN_ID`** — a stable identifier for THIS phase-execution attempt, so + `resumeFromRunId` can resume an interrupted run without re-dispatching plans + the Workflow tool already completed. Construct it deterministically — + `execute-{phase_number}-{phase_slug}` — from `INIT`'s `phase_number`/`phase_slug` + (both are already validated identifiers used elsewhere in this workflow, so + they satisfy `emitWorkflowScript`'s `isScriptableIdentifier` check). Do NOT + mint a new random id per wave — the SAME `$PHASE_RUN_ID` is reused for every + wave in the phase so the Workflow tool can correctly track cross-wave resume + state. + +3. **`$WAVE_MANIFEST_PATH`** — a fresh temp file for THIS wave's manifest (one + wave = one `waves` array with a single entry, matching the wave-by-wave + dispatch loop; do not batch multiple waves into one manifest — waves are + dispatched in wave order, not all at once): + + ```bash + WAVE_MANIFEST_PATH=$(mktemp "${TMPDIR:-/tmp}/gsd-wave-dispatch-XXXXXX") && mv "$WAVE_MANIFEST_PATH" "$WAVE_MANIFEST_PATH.json" && WAVE_MANIFEST_PATH="$WAVE_MANIFEST_PATH.json" + ``` + + Then **use the Write tool** (not a bash/jq pipeline — the orchestrator already + has every field parsed in-context) to write the manifest JSON to + `$WAVE_MANIFEST_PATH`: + + ```json + { + "waves": [ + { + "id": "wave-{N}", + "plans": [ + { + "id": "{plan_id}", + "brief": "{the SAME ... prompt block step 3 builds for this plan's inline Agent() call}", + "files_modified": ["{from PLAN_INDEX.plans[].files_modified for this plan}"], + "use_worktree": {true unless step 2.5 set USE_WORKTREES_FOR_PLAN=false for this plan} + } + ] + } + ] + } + ``` + + - **`id`** — the plan id from `PLAN_INDEX`, e.g. `"01-01"`. + - **`brief`** — MUST carry the same task content as step 3's inline `Agent()` + prompt (the ``/``/``/ + `` block, with `{plan_number}`/`{phase_number}`/ + `{phase_name}` substituted) — a short summary here would NOT reproduce + step 3's behavior and would violate the "identical artifacts" contract. + - **`files_modified`** — copy verbatim from the plan's `PLAN_INDEX` entry. + - **`use_worktree`** — `true` for every plan UNLESS step 2.5's per-plan + worktree gate (`execute-phase/steps/per-plan-worktree-gate.md`) set + `USE_WORKTREES_FOR_PLAN=false` for that plan (submodule-touching plan, or + project-level `USE_WORKTREES=false`) — in which case pass `false` here so + `emitWorkflowScript` omits `isolation: "worktree"` for that plan (#2772 / + #2285 finding 1). **Never** hardcode `true` — that would force worktree + isolation on a plan the inline path explicitly keeps out of worktrees. + +4. **`$AGENT_SDK_VERSION`** — see below; OMIT when unknown (fails closed). + +**Agent SDK version:** the orchestrator has no scriptable (bash-computable) way +to introspect the live Agent SDK version. When it can determine the version +(e.g. from a host-exposed value it can read directly), pass +`--agent-sdk-version`. When it cannot, OMIT the flag — `resolveWaveDispatch`'s +gate 5 (`agent_sdk_version_unknown`) then fails closed to `inline` by design; +this is not a bug, it is the same fail-closed posture documented above applied +to a real absence of information. + +**If `backend == "workflow"`:** run the emitted `script` via the Workflow tool +for THIS wave instead of the per-message `Agent()` loop in step 3. The script +composes the SAME `gsd-executor` agent type the inline path uses, with +worktree isolation applied PER PLAN from the manifest's `use_worktree` field +(see `emitWorkflowScript`): + +- **waves → one or more sequential `parallel()` barriers** — each wave is a + barrier group; when plans within a wave share `files_modified`, they are split + into separate sequential stages within that wave's barrier. +- **plans → `agent(brief, { agentType: 'gsd-executor', isolation: 'worktree' })`** + when `use_worktree` is not `false`, or `agent(brief, { agentType: 'gsd-executor' })` + (no isolation) when it is — so the produced `SUMMARY.md` and commits are + identical to inline dispatch, INCLUDING the inline path's submodule safety + gate (#2772 / #2285 finding 1). +- **`files_modified` overlap → separate sequential stages** — the same overlap + rule execute-phase already applies inline (step 1 of the wave loop). +- **`resumeFromRunId`** — wired to the phase run id, so an interrupted phase + resumes without re-running completed plans. + +The orchestrator still runs steps 4–5.8 (wait for completion, worktree cleanup, +post-merge gate, tracking update) exactly as it does for inline dispatch — the +Workflow backend only replaces HOW agents are spawned for this wave, not what +happens after they return. + +**If `backend == "inline"`** (any gate miss, or `resolve-wave-dispatch` itself +unavailable/erroring): proceed to step 3's standard per-message `Agent()` +dispatch — the default, byte-identical-to-today path. `onError: skip` on this +contribution means a `resolve-wave-dispatch` command failure is treated exactly +like an `inline` result, never as a fatal wave error. + +## Fallback contract + +Detection is fail-closed end-to-end: capability disabled, non-Claude runtime, +`execution_backend:"inline"`, missing/incapable host descriptor, unknown or +below-floor Agent SDK version, or an `emitWorkflowScript` failure on a malformed +wave manifest — ANY of these degrades to `backend:"inline"` and execute-phase's +standard inline dispatch (step 3) runs unmodified. The Workflow backend never +partially activates; the executor MUST NOT assume parallelism, a shared budget, +or resume-from-run-id semantics when `backend == "inline"`. diff --git a/docs/explanation/claude-orchestration-capability.md b/docs/explanation/claude-orchestration-capability.md index e044a4e1f..f7c7d0517 100644 --- a/docs/explanation/claude-orchestration-capability.md +++ b/docs/explanation/claude-orchestration-capability.md @@ -34,9 +34,13 @@ gate. It is blocked-on-nothing now that the ADR-857 capability system is release - **`role: feature`**, `runtimeCompat.supported: ["claude"]`, `tier: full`. - **`activationKey: claude_orchestration.enabled`** — default `false`. Nothing changes until you opt in. -- Registers at two **wired** loop points: `execute:wave:post` (into the executor) +- Registers at two **wired** loop points: `execute:wave:pre` (into the executor) and `plan:post` (into the planner). Both are `onError: skip` and gated by the - `enabled` key. + `enabled` key. The dispatch-backend selector fires at `execute:wave:pre` — the + seam that runs immediately BEFORE a wave's agents are dispatched — because a + selector fired *after* a wave already dispatched inline (the original + `execute:wave:post` placement, [#2285]) is structurally too late to change how + dispatch happens. ## How it decides whether to activate @@ -62,14 +66,19 @@ Workflow backend activates only when *every* gate passes; any miss degrades to | GSD concept | Workflow primitive | |---|---| | Wave | `parallel()` stage barrier | -| Plan | `agent(brief, { agentType: 'gsd-executor', isolation: 'worktree' })` | +| Plan (`use_worktree` not `false`) | `agent(brief, { agentType: 'gsd-executor', isolation: 'worktree' })` | +| Plan (`use_worktree: false`) | `agent(brief, { agentType: 'gsd-executor' })` (no isolation) | | `files_modified` overlap | forces the plans into separate sequential stages | | Phase run id | `resumeFromRunId("")` | | Phase token cap | `budget()` | -Because the emitted script composes the **same** `gsd-executor` agent and -**worktree isolation** the inline path uses, it produces the same `SUMMARY.md` -artifacts and commits — the only difference is the execution vehicle. +Because the emitted script composes the **same** `gsd-executor` agent the +inline path uses, with worktree isolation applied **per plan** from the +manifest's `use_worktree` field, it produces the same `SUMMARY.md` artifacts +and commits — the only difference is the execution vehicle. `use_worktree` +mirrors execute-phase.md step 2.5's per-plan submodule safety gate exactly: a +plan that touches a submodule path is never forced into worktree isolation, +whichever backend dispatches it ([#2772]). ## The fallback contract @@ -92,3 +101,5 @@ own runtime gate continues to no-op on non-Claude runtimes. [#853]: https://github.com/open-gsd/gsd-core/issues/853 [#1143]: https://github.com/open-gsd/gsd-core/issues/1143 +[#2772]: https://github.com/open-gsd/gsd-core/issues/2772 +[#2285]: https://github.com/open-gsd/gsd-core/issues/2285 diff --git a/docs/reference/capability-matrix.md b/docs/reference/capability-matrix.md index ca245dce4..359074d6f 100644 --- a/docs/reference/capability-matrix.md +++ b/docs/reference/capability-matrix.md @@ -55,7 +55,7 @@ points. | `ai-integration` | feature | full | `>=1.6.0` | `plan:pre`, `verify:pre` | step, contribution, gate | first-party | | `assumption-delta` | feature | full | `>=1.6.0` | `plan:pre` | contribution | first-party | | `audit` | feature | full | `>=1.6.0` | — | — | first-party | -| `claude-orchestration` | feature | full | `>=1.7.0` | `plan:post`, `execute:wave:post` | contribution | first-party | +| `claude-orchestration` | feature | full | `>=1.7.0` | `plan:post`, `execute:wave:pre` | contribution | first-party | | `code-review` | feature | full | `>=1.6.0` | `execute:post` | step | first-party | | `drift` | feature | full | `>=1.6.0` | `plan:pre`, `execute:wave:post` | gate | first-party | | `external-job` | feature | full | `>=1.7.0` | `plan:post`, `execute:wave:post` | contribution | first-party | diff --git a/gsd-core/bin/lib/capability-registry.cjs b/gsd-core/bin/lib/capability-registry.cjs index bde61caf9..a08ab1b2e 100644 --- a/gsd-core/bin/lib/capability-registry.cjs +++ b/gsd-core/bin/lib/capability-registry.cjs @@ -515,7 +515,8 @@ const capabilities = { "router": "routeClaudeOrchestrationCommand", "subcommands": [ "detect-backend", - "emit-workflow" + "emit-workflow", + "resolve-wave-dispatch" ] } ], @@ -545,11 +546,11 @@ const capabilities = { "steps": [], "contributions": [ { - "point": "execute:wave:post", + "point": "execute:wave:pre", "into": "executor", "fragment": { - "path": "fragments/execute-wave-post.md", - "inline": "# Claude orchestration — Workflow execution backend (BETA)\n\n> Injected at `execute:wave:post` `into: executor` only when\n> `claude_orchestration.enabled` is true. Default-off; `onError: skip`.\n\n## When this contribution is active\n\nThe Claude orchestration capability is **default-off and BETA**. It activates only\nwhen ALL of the following hold:\n\n1. `claude_orchestration.enabled` is `true` in `.planning/config.json`, AND\n2. the active runtime is **Claude Code** (the Workflow tool is Claude / Agent\n SDK-specific), AND\n3. `claude_orchestration.execution_backend` resolves to `workflow` — either\n explicitly, or via `auto` — **and** the Agent SDK version is\n `>= claude_orchestration.min_agent_sdk_version` (default `0.3.149`). The SDK\n floor applies in both `auto` and `workflow` modes (fail-closed: a pre-release\n or older SDK never activates the preview backend).\n\nDetection is fail-closed: any miss degrades to **inline, manual, one-agent-per-\nmessage dispatch** — exactly today's behaviour. On a non-Claude runtime this\ncontribution is a no-op.\n\n## What the executor does when the Workflow backend is active\n\nInstead of the orchestrator fanning out one `Agent(subagent_type=gsd-executor,\nisolation=worktree, run_in_background=true)` per message (which on Claude Code\ncannot nest further subagents — #853 — and so degrades to sequential inline\nexecution), execute-phase **emits a generated Workflow script** and lets the main\nloop orchestrate it:\n\n- **waves → one or more sequential `parallel()` barriers** — each wave is a\n barrier group; when plans within a wave share `files_modified`, they are split\n into separate sequential stages within that wave's barrier (the next wave\n still waits for the previous wave to complete).\n- **plans → `agent(brief, { agentType: 'gsd-executor', isolation: 'worktree' })`**\n — the SAME executor agent and worktree isolation the inline path uses, so the\n produced `SUMMARY.md` and commits are identical.\n- **`files_modified` overlap → separate sequential stages** — two plans that\n touch the same file are placed in different stages within the wave (the same\n overlap rule execute-phase already applies inline).\n- **`resumeFromRunId`** — wired to the phase run id, so an interrupted phase\n resumes without re-running completed plans.\n- **`budget(tokens)`** — a shared token pool across the whole phase when the\n orchestrator passes a `budgetTokens` value to `emitWorkflowScript` (it is a\n function parameter, not a config key; the orchestrator decides the budget).\n\nThe emitter is a pure function exposed through the capability command surface:\n`gsd-tools claude-orchestration emit-workflow --waves --run-id \n[--phase-dir ] [--budget ]` (or `require('gsd-core/bin/lib/claude-orchestration.cjs').emitWorkflowScript`\ndirectly). It maps the phase's wave/plan manifest to the Workflow script string\nand never invokes the Workflow tool itself; the orchestrator runs the emitted\nscript. Detection is resolved by the orchestrator calling the pure\n`detectWorkflowBackend` with the LIVE host descriptor (the CLI\n`gsd-tools claude-orchestration detect-backend` is a simulation harness that\nassumes a capable host unless `--no-nested-dispatch` is passed — it does not probe\nthe real runtime; the orchestrator supplies the real descriptor).\n\n## Fallback contract\n\nIf detection resolves to `inline` (tool absent, SDK too old, runtime not Claude,\nor the capability disabled), execute-phase MUST proceed with the standard inline\nwave dispatch. The executor MUST NOT assume parallelism, a shared budget, or\nresume-from-run-id semantics in that mode.\n" + "path": "fragments/execute-wave-pre.md", + "inline": "# Claude orchestration — Workflow execution backend (BETA)\n\n> Injected at `execute:wave:pre` `into: executor` only when\n> `claude_orchestration.enabled` is true. Default-off; `onError: skip`.\n\n## When this contribution is active\n\nThe Claude orchestration capability is **default-off and BETA**. It activates only\nwhen ALL of the following hold:\n\n1. `claude_orchestration.enabled` is `true` in `.planning/config.json`, AND\n2. the active runtime is **Claude Code** (the Workflow tool is Claude / Agent\n SDK-specific), AND\n3. `claude_orchestration.execution_backend` resolves to `workflow` — either\n explicitly, or via `auto` — **and** the Agent SDK version is\n `>= claude_orchestration.min_agent_sdk_version` (default `0.3.149`). The SDK\n floor applies in both `auto` and `workflow` modes (fail-closed: a pre-release\n or older SDK never activates the preview backend).\n\nDetection is fail-closed: any miss degrades to **inline, manual, one-agent-per-\nmessage dispatch** — exactly today's behaviour. On a non-Claude runtime this\ncontribution is a no-op.\n\n## Why `execute:wave:pre` (not `execute:wave:post`)\n\nThis is a **dispatch-backend selector** — it decides HOW a wave's executor agents\nare spawned. That decision has to be made BEFORE the wave's `Agent()` calls in\n`execute-phase.md` step 3, not after the wave has already finished (#2285). The\ncapability previously registered at `execute:wave:post`, which fires only after\nworktree merge/post-merge tests/tracking updates — by then the wave was already\ndispatched inline, so the contribution was structurally unable to change how\ndispatch happened. This fragment is injected at the point that actually precedes\ndispatch.\n\n## What the orchestrator does when the Workflow backend is active\n\nBefore spawning executor agents for the current wave (execute-phase.md step 3),\nresolve the dispatch backend through the single composed CLI seam:\n\n```bash\ngsd-tools claude-orchestration resolve-wave-dispatch \\\n --waves \"$WAVE_MANIFEST_PATH\" --run-id \"$PHASE_RUN_ID\" \\\n --runtime \"$RUNTIME\" \\\n ${AGENT_SDK_VERSION:+--agent-sdk-version \"$AGENT_SDK_VERSION\"} \\\n --phase-dir \"$PHASE_DIR\" --raw\n```\n\nThis composes `detectWorkflowBackend` (the gate ladder above) with\n`emitWorkflowScript` (the wave→plan mapping below) in ONE call — the pure\nfunction backing it is `resolveWaveDispatch` in\n`gsd-core/bin/lib/claude-orchestration.cjs`. Response shape:\n`{ backend: 'inline'|'workflow', reason, script?, summary? }`.\n\n### Manifest construction (`$WAVE_MANIFEST_PATH`, `$PHASE_RUN_ID`, `$PHASE_DIR`, `$AGENT_SDK_VERSION`)\n\nThese are NOT pre-existing execute-phase.md variables — the orchestrator builds\nthem at this step, from data it already has in-context from `discover_and_group_plans`\n(the `PLAN_INDEX` JSON) and step 2.5 (the per-plan `USE_WORKTREES_FOR_PLAN` decision):\n\n1. **`$PHASE_DIR`** — reuse `{phase_dir}` from the `INIT` bundle (already loaded\n in the `initialize` step). No new value needed.\n\n2. **`$PHASE_RUN_ID`** — a stable identifier for THIS phase-execution attempt, so\n `resumeFromRunId` can resume an interrupted run without re-dispatching plans\n the Workflow tool already completed. Construct it deterministically —\n `execute-{phase_number}-{phase_slug}` — from `INIT`'s `phase_number`/`phase_slug`\n (both are already validated identifiers used elsewhere in this workflow, so\n they satisfy `emitWorkflowScript`'s `isScriptableIdentifier` check). Do NOT\n mint a new random id per wave — the SAME `$PHASE_RUN_ID` is reused for every\n wave in the phase so the Workflow tool can correctly track cross-wave resume\n state.\n\n3. **`$WAVE_MANIFEST_PATH`** — a fresh temp file for THIS wave's manifest (one\n wave = one `waves` array with a single entry, matching the wave-by-wave\n dispatch loop; do not batch multiple waves into one manifest — waves are\n dispatched in wave order, not all at once):\n\n ```bash\n WAVE_MANIFEST_PATH=$(mktemp \"${TMPDIR:-/tmp}/gsd-wave-dispatch-XXXXXX\") && mv \"$WAVE_MANIFEST_PATH\" \"$WAVE_MANIFEST_PATH.json\" && WAVE_MANIFEST_PATH=\"$WAVE_MANIFEST_PATH.json\"\n ```\n\n Then **use the Write tool** (not a bash/jq pipeline — the orchestrator already\n has every field parsed in-context) to write the manifest JSON to\n `$WAVE_MANIFEST_PATH`:\n\n ```json\n {\n \"waves\": [\n {\n \"id\": \"wave-{N}\",\n \"plans\": [\n {\n \"id\": \"{plan_id}\",\n \"brief\": \"{the SAME ... prompt block step 3 builds for this plan's inline Agent() call}\",\n \"files_modified\": [\"{from PLAN_INDEX.plans[].files_modified for this plan}\"],\n \"use_worktree\": {true unless step 2.5 set USE_WORKTREES_FOR_PLAN=false for this plan}\n }\n ]\n }\n ]\n }\n ```\n\n - **`id`** — the plan id from `PLAN_INDEX`, e.g. `\"01-01\"`.\n - **`brief`** — MUST carry the same task content as step 3's inline `Agent()`\n prompt (the ``/``/``/\n `` block, with `{plan_number}`/`{phase_number}`/\n `{phase_name}` substituted) — a short summary here would NOT reproduce\n step 3's behavior and would violate the \"identical artifacts\" contract.\n - **`files_modified`** — copy verbatim from the plan's `PLAN_INDEX` entry.\n - **`use_worktree`** — `true` for every plan UNLESS step 2.5's per-plan\n worktree gate (`execute-phase/steps/per-plan-worktree-gate.md`) set\n `USE_WORKTREES_FOR_PLAN=false` for that plan (submodule-touching plan, or\n project-level `USE_WORKTREES=false`) — in which case pass `false` here so\n `emitWorkflowScript` omits `isolation: \"worktree\"` for that plan (#2772 /\n #2285 finding 1). **Never** hardcode `true` — that would force worktree\n isolation on a plan the inline path explicitly keeps out of worktrees.\n\n4. **`$AGENT_SDK_VERSION`** — see below; OMIT when unknown (fails closed).\n\n**Agent SDK version:** the orchestrator has no scriptable (bash-computable) way\nto introspect the live Agent SDK version. When it can determine the version\n(e.g. from a host-exposed value it can read directly), pass\n`--agent-sdk-version`. When it cannot, OMIT the flag — `resolveWaveDispatch`'s\ngate 5 (`agent_sdk_version_unknown`) then fails closed to `inline` by design;\nthis is not a bug, it is the same fail-closed posture documented above applied\nto a real absence of information.\n\n**If `backend == \"workflow\"`:** run the emitted `script` via the Workflow tool\nfor THIS wave instead of the per-message `Agent()` loop in step 3. The script\ncomposes the SAME `gsd-executor` agent type the inline path uses, with\nworktree isolation applied PER PLAN from the manifest's `use_worktree` field\n(see `emitWorkflowScript`):\n\n- **waves → one or more sequential `parallel()` barriers** — each wave is a\n barrier group; when plans within a wave share `files_modified`, they are split\n into separate sequential stages within that wave's barrier.\n- **plans → `agent(brief, { agentType: 'gsd-executor', isolation: 'worktree' })`**\n when `use_worktree` is not `false`, or `agent(brief, { agentType: 'gsd-executor' })`\n (no isolation) when it is — so the produced `SUMMARY.md` and commits are\n identical to inline dispatch, INCLUDING the inline path's submodule safety\n gate (#2772 / #2285 finding 1).\n- **`files_modified` overlap → separate sequential stages** — the same overlap\n rule execute-phase already applies inline (step 1 of the wave loop).\n- **`resumeFromRunId`** — wired to the phase run id, so an interrupted phase\n resumes without re-running completed plans.\n\nThe orchestrator still runs steps 4–5.8 (wait for completion, worktree cleanup,\npost-merge gate, tracking update) exactly as it does for inline dispatch — the\nWorkflow backend only replaces HOW agents are spawned for this wave, not what\nhappens after they return.\n\n**If `backend == \"inline\"`** (any gate miss, or `resolve-wave-dispatch` itself\nunavailable/erroring): proceed to step 3's standard per-message `Agent()`\ndispatch — the default, byte-identical-to-today path. `onError: skip` on this\ncontribution means a `resolve-wave-dispatch` command failure is treated exactly\nlike an `inline` result, never as a fatal wave error.\n\n## Fallback contract\n\nDetection is fail-closed end-to-end: capability disabled, non-Claude runtime,\n`execution_backend:\"inline\"`, missing/incapable host descriptor, unknown or\nbelow-floor Agent SDK version, or an `emitWorkflowScript` failure on a malformed\nwave manifest — ANY of these degrades to `backend:\"inline\"` and execute-phase's\nstandard inline dispatch (step 3) runs unmodified. The Workflow backend never\npartially activates; the executor MUST NOT assume parallelism, a shared budget,\nor resume-from-run-id semantics when `backend == \"inline\"`.\n" }, "produces": [], "consumes": [ @@ -3333,20 +3334,15 @@ const byLoopPoint = { "gates": [] }, "execute:wave:pre": { - "steps": [], - "contributions": [], - "gates": [] - }, - "execute:wave:post": { "steps": [], "contributions": [ { "capId": "claude-orchestration", - "point": "execute:wave:post", + "point": "execute:wave:pre", "into": "executor", "fragment": { - "path": "fragments/execute-wave-post.md", - "inline": "# Claude orchestration — Workflow execution backend (BETA)\n\n> Injected at `execute:wave:post` `into: executor` only when\n> `claude_orchestration.enabled` is true. Default-off; `onError: skip`.\n\n## When this contribution is active\n\nThe Claude orchestration capability is **default-off and BETA**. It activates only\nwhen ALL of the following hold:\n\n1. `claude_orchestration.enabled` is `true` in `.planning/config.json`, AND\n2. the active runtime is **Claude Code** (the Workflow tool is Claude / Agent\n SDK-specific), AND\n3. `claude_orchestration.execution_backend` resolves to `workflow` — either\n explicitly, or via `auto` — **and** the Agent SDK version is\n `>= claude_orchestration.min_agent_sdk_version` (default `0.3.149`). The SDK\n floor applies in both `auto` and `workflow` modes (fail-closed: a pre-release\n or older SDK never activates the preview backend).\n\nDetection is fail-closed: any miss degrades to **inline, manual, one-agent-per-\nmessage dispatch** — exactly today's behaviour. On a non-Claude runtime this\ncontribution is a no-op.\n\n## What the executor does when the Workflow backend is active\n\nInstead of the orchestrator fanning out one `Agent(subagent_type=gsd-executor,\nisolation=worktree, run_in_background=true)` per message (which on Claude Code\ncannot nest further subagents — #853 — and so degrades to sequential inline\nexecution), execute-phase **emits a generated Workflow script** and lets the main\nloop orchestrate it:\n\n- **waves → one or more sequential `parallel()` barriers** — each wave is a\n barrier group; when plans within a wave share `files_modified`, they are split\n into separate sequential stages within that wave's barrier (the next wave\n still waits for the previous wave to complete).\n- **plans → `agent(brief, { agentType: 'gsd-executor', isolation: 'worktree' })`**\n — the SAME executor agent and worktree isolation the inline path uses, so the\n produced `SUMMARY.md` and commits are identical.\n- **`files_modified` overlap → separate sequential stages** — two plans that\n touch the same file are placed in different stages within the wave (the same\n overlap rule execute-phase already applies inline).\n- **`resumeFromRunId`** — wired to the phase run id, so an interrupted phase\n resumes without re-running completed plans.\n- **`budget(tokens)`** — a shared token pool across the whole phase when the\n orchestrator passes a `budgetTokens` value to `emitWorkflowScript` (it is a\n function parameter, not a config key; the orchestrator decides the budget).\n\nThe emitter is a pure function exposed through the capability command surface:\n`gsd-tools claude-orchestration emit-workflow --waves --run-id \n[--phase-dir ] [--budget ]` (or `require('gsd-core/bin/lib/claude-orchestration.cjs').emitWorkflowScript`\ndirectly). It maps the phase's wave/plan manifest to the Workflow script string\nand never invokes the Workflow tool itself; the orchestrator runs the emitted\nscript. Detection is resolved by the orchestrator calling the pure\n`detectWorkflowBackend` with the LIVE host descriptor (the CLI\n`gsd-tools claude-orchestration detect-backend` is a simulation harness that\nassumes a capable host unless `--no-nested-dispatch` is passed — it does not probe\nthe real runtime; the orchestrator supplies the real descriptor).\n\n## Fallback contract\n\nIf detection resolves to `inline` (tool absent, SDK too old, runtime not Claude,\nor the capability disabled), execute-phase MUST proceed with the standard inline\nwave dispatch. The executor MUST NOT assume parallelism, a shared budget, or\nresume-from-run-id semantics in that mode.\n" + "path": "fragments/execute-wave-pre.md", + "inline": "# Claude orchestration — Workflow execution backend (BETA)\n\n> Injected at `execute:wave:pre` `into: executor` only when\n> `claude_orchestration.enabled` is true. Default-off; `onError: skip`.\n\n## When this contribution is active\n\nThe Claude orchestration capability is **default-off and BETA**. It activates only\nwhen ALL of the following hold:\n\n1. `claude_orchestration.enabled` is `true` in `.planning/config.json`, AND\n2. the active runtime is **Claude Code** (the Workflow tool is Claude / Agent\n SDK-specific), AND\n3. `claude_orchestration.execution_backend` resolves to `workflow` — either\n explicitly, or via `auto` — **and** the Agent SDK version is\n `>= claude_orchestration.min_agent_sdk_version` (default `0.3.149`). The SDK\n floor applies in both `auto` and `workflow` modes (fail-closed: a pre-release\n or older SDK never activates the preview backend).\n\nDetection is fail-closed: any miss degrades to **inline, manual, one-agent-per-\nmessage dispatch** — exactly today's behaviour. On a non-Claude runtime this\ncontribution is a no-op.\n\n## Why `execute:wave:pre` (not `execute:wave:post`)\n\nThis is a **dispatch-backend selector** — it decides HOW a wave's executor agents\nare spawned. That decision has to be made BEFORE the wave's `Agent()` calls in\n`execute-phase.md` step 3, not after the wave has already finished (#2285). The\ncapability previously registered at `execute:wave:post`, which fires only after\nworktree merge/post-merge tests/tracking updates — by then the wave was already\ndispatched inline, so the contribution was structurally unable to change how\ndispatch happened. This fragment is injected at the point that actually precedes\ndispatch.\n\n## What the orchestrator does when the Workflow backend is active\n\nBefore spawning executor agents for the current wave (execute-phase.md step 3),\nresolve the dispatch backend through the single composed CLI seam:\n\n```bash\ngsd-tools claude-orchestration resolve-wave-dispatch \\\n --waves \"$WAVE_MANIFEST_PATH\" --run-id \"$PHASE_RUN_ID\" \\\n --runtime \"$RUNTIME\" \\\n ${AGENT_SDK_VERSION:+--agent-sdk-version \"$AGENT_SDK_VERSION\"} \\\n --phase-dir \"$PHASE_DIR\" --raw\n```\n\nThis composes `detectWorkflowBackend` (the gate ladder above) with\n`emitWorkflowScript` (the wave→plan mapping below) in ONE call — the pure\nfunction backing it is `resolveWaveDispatch` in\n`gsd-core/bin/lib/claude-orchestration.cjs`. Response shape:\n`{ backend: 'inline'|'workflow', reason, script?, summary? }`.\n\n### Manifest construction (`$WAVE_MANIFEST_PATH`, `$PHASE_RUN_ID`, `$PHASE_DIR`, `$AGENT_SDK_VERSION`)\n\nThese are NOT pre-existing execute-phase.md variables — the orchestrator builds\nthem at this step, from data it already has in-context from `discover_and_group_plans`\n(the `PLAN_INDEX` JSON) and step 2.5 (the per-plan `USE_WORKTREES_FOR_PLAN` decision):\n\n1. **`$PHASE_DIR`** — reuse `{phase_dir}` from the `INIT` bundle (already loaded\n in the `initialize` step). No new value needed.\n\n2. **`$PHASE_RUN_ID`** — a stable identifier for THIS phase-execution attempt, so\n `resumeFromRunId` can resume an interrupted run without re-dispatching plans\n the Workflow tool already completed. Construct it deterministically —\n `execute-{phase_number}-{phase_slug}` — from `INIT`'s `phase_number`/`phase_slug`\n (both are already validated identifiers used elsewhere in this workflow, so\n they satisfy `emitWorkflowScript`'s `isScriptableIdentifier` check). Do NOT\n mint a new random id per wave — the SAME `$PHASE_RUN_ID` is reused for every\n wave in the phase so the Workflow tool can correctly track cross-wave resume\n state.\n\n3. **`$WAVE_MANIFEST_PATH`** — a fresh temp file for THIS wave's manifest (one\n wave = one `waves` array with a single entry, matching the wave-by-wave\n dispatch loop; do not batch multiple waves into one manifest — waves are\n dispatched in wave order, not all at once):\n\n ```bash\n WAVE_MANIFEST_PATH=$(mktemp \"${TMPDIR:-/tmp}/gsd-wave-dispatch-XXXXXX\") && mv \"$WAVE_MANIFEST_PATH\" \"$WAVE_MANIFEST_PATH.json\" && WAVE_MANIFEST_PATH=\"$WAVE_MANIFEST_PATH.json\"\n ```\n\n Then **use the Write tool** (not a bash/jq pipeline — the orchestrator already\n has every field parsed in-context) to write the manifest JSON to\n `$WAVE_MANIFEST_PATH`:\n\n ```json\n {\n \"waves\": [\n {\n \"id\": \"wave-{N}\",\n \"plans\": [\n {\n \"id\": \"{plan_id}\",\n \"brief\": \"{the SAME ... prompt block step 3 builds for this plan's inline Agent() call}\",\n \"files_modified\": [\"{from PLAN_INDEX.plans[].files_modified for this plan}\"],\n \"use_worktree\": {true unless step 2.5 set USE_WORKTREES_FOR_PLAN=false for this plan}\n }\n ]\n }\n ]\n }\n ```\n\n - **`id`** — the plan id from `PLAN_INDEX`, e.g. `\"01-01\"`.\n - **`brief`** — MUST carry the same task content as step 3's inline `Agent()`\n prompt (the ``/``/``/\n `` block, with `{plan_number}`/`{phase_number}`/\n `{phase_name}` substituted) — a short summary here would NOT reproduce\n step 3's behavior and would violate the \"identical artifacts\" contract.\n - **`files_modified`** — copy verbatim from the plan's `PLAN_INDEX` entry.\n - **`use_worktree`** — `true` for every plan UNLESS step 2.5's per-plan\n worktree gate (`execute-phase/steps/per-plan-worktree-gate.md`) set\n `USE_WORKTREES_FOR_PLAN=false` for that plan (submodule-touching plan, or\n project-level `USE_WORKTREES=false`) — in which case pass `false` here so\n `emitWorkflowScript` omits `isolation: \"worktree\"` for that plan (#2772 /\n #2285 finding 1). **Never** hardcode `true` — that would force worktree\n isolation on a plan the inline path explicitly keeps out of worktrees.\n\n4. **`$AGENT_SDK_VERSION`** — see below; OMIT when unknown (fails closed).\n\n**Agent SDK version:** the orchestrator has no scriptable (bash-computable) way\nto introspect the live Agent SDK version. When it can determine the version\n(e.g. from a host-exposed value it can read directly), pass\n`--agent-sdk-version`. When it cannot, OMIT the flag — `resolveWaveDispatch`'s\ngate 5 (`agent_sdk_version_unknown`) then fails closed to `inline` by design;\nthis is not a bug, it is the same fail-closed posture documented above applied\nto a real absence of information.\n\n**If `backend == \"workflow\"`:** run the emitted `script` via the Workflow tool\nfor THIS wave instead of the per-message `Agent()` loop in step 3. The script\ncomposes the SAME `gsd-executor` agent type the inline path uses, with\nworktree isolation applied PER PLAN from the manifest's `use_worktree` field\n(see `emitWorkflowScript`):\n\n- **waves → one or more sequential `parallel()` barriers** — each wave is a\n barrier group; when plans within a wave share `files_modified`, they are split\n into separate sequential stages within that wave's barrier.\n- **plans → `agent(brief, { agentType: 'gsd-executor', isolation: 'worktree' })`**\n when `use_worktree` is not `false`, or `agent(brief, { agentType: 'gsd-executor' })`\n (no isolation) when it is — so the produced `SUMMARY.md` and commits are\n identical to inline dispatch, INCLUDING the inline path's submodule safety\n gate (#2772 / #2285 finding 1).\n- **`files_modified` overlap → separate sequential stages** — the same overlap\n rule execute-phase already applies inline (step 1 of the wave loop).\n- **`resumeFromRunId`** — wired to the phase run id, so an interrupted phase\n resumes without re-running completed plans.\n\nThe orchestrator still runs steps 4–5.8 (wait for completion, worktree cleanup,\npost-merge gate, tracking update) exactly as it does for inline dispatch — the\nWorkflow backend only replaces HOW agents are spawned for this wave, not what\nhappens after they return.\n\n**If `backend == \"inline\"`** (any gate miss, or `resolve-wave-dispatch` itself\nunavailable/erroring): proceed to step 3's standard per-message `Agent()`\ndispatch — the default, byte-identical-to-today path. `onError: skip` on this\ncontribution means a `resolve-wave-dispatch` command failure is treated exactly\nlike an `inline` result, never as a fatal wave error.\n\n## Fallback contract\n\nDetection is fail-closed end-to-end: capability disabled, non-Claude runtime,\n`execution_backend:\"inline\"`, missing/incapable host descriptor, unknown or\nbelow-floor Agent SDK version, or an `emitWorkflowScript` failure on a malformed\nwave manifest — ANY of these degrades to `backend:\"inline\"` and execute-phase's\nstandard inline dispatch (step 3) runs unmodified. The Workflow backend never\npartially activates; the executor MUST NOT assume parallelism, a shared budget,\nor resume-from-run-id semantics when `backend == \"inline\"`.\n" }, "produces": [], "consumes": [ @@ -3354,7 +3350,13 @@ const byLoopPoint = { ], "when": "claude_orchestration.enabled", "onError": "skip" - }, + } + ], + "gates": [] + }, + "execute:wave:post": { + "steps": [], + "contributions": [ { "capId": "external-job", "point": "execute:wave:post", diff --git a/gsd-core/bin/lib/claude-orchestration-command-router.cjs b/gsd-core/bin/lib/claude-orchestration-command-router.cjs index a07ad64c2..cf54eedae 100644 --- a/gsd-core/bin/lib/claude-orchestration-command-router.cjs +++ b/gsd-core/bin/lib/claude-orchestration-command-router.cjs @@ -22,7 +22,20 @@ * emit-workflow --waves --run-id [--phase-dir ] [--budget ] * Reads a wave/plan manifest JSON file and emits the generated Workflow * script + summary. The manifest shape matches emitWorkflowScript's input: - * { waves: [{ id, plans: [{ id, brief, files_modified: string[] }] }] }. + * { waves: [{ id, plans: [{ id, brief, files_modified: string[], use_worktree?: boolean }] }] }. + * `use_worktree` defaults to true; pass `false` for a plan the inline path + * (execute-phase.md step 2.5) would also keep out of worktree isolation + * (submodule-touching plans — #2772 / #2285 finding 1). + * + * resolve-wave-dispatch --waves --run-id [--runtime ] + * [--agent-sdk-version ] [--no-nested-dispatch] [--phase-dir ] + * [--budget ] + * #2285 — the single composed seam a PRE-wave dispatch-backend selector + * (`execute:wave:pre`) uses: resolves detect-backend + emit-workflow in + * ONE call. Emits { backend: 'inline'|'workflow', reason, script?, summary? }. + * Fail-closed identically to detect-backend/emit-workflow individually — + * any gate miss, or an emit failure on a malformed --waves manifest, + * resolves to 'inline' with no script. */ var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; @@ -36,30 +49,26 @@ const core = require("./claude-orchestration.cjs"); // eslint-disable-next-line @typescript-eslint/no-require-imports const configLoader = require("./config-loader.cjs"); const { output } = io; -const { detectWorkflowBackend, emitWorkflowScript } = core; +const { detectWorkflowBackend, emitWorkflowScript, resolveWaveDispatch } = core; const CAPABLE_HOST = { dispatch: { nested: true, background: true } }; function usage(error) { - error('Usage: gsd-tools claude-orchestration [...]\n' + + error('Usage: gsd-tools claude-orchestration [...]\n' + ' detect-backend [--runtime ] [--agent-sdk-version ] [--no-nested-dispatch]\n' + - ' emit-workflow --waves --run-id [--phase-dir ] [--budget ]'); + ' emit-workflow --waves --run-id [--phase-dir ] [--budget ]\n' + + ' resolve-wave-dispatch --waves --run-id [--runtime ] [--agent-sdk-version ] [--no-nested-dispatch] [--phase-dir ] [--budget ]'); } function argValue(args, flag) { const i = args.indexOf(flag); return i !== -1 && i + 1 < args.length ? args[i + 1] : undefined; } /** - * Detect whether the Workflow backend should activate for the current/given - * runtime. Reads `claude_orchestration.*` from the project config; runtime and - * SDK version come from flags (the orchestrator already knows these) or env. + * Resolve the `claude_orchestration.*` config slice from the project config + * (federated keys are merged by loadConfig as a nested object), flattened into + * the dotted-key shape `detectWorkflowBackend`/`resolveWaveDispatch` expect. A + * config read failure degrades to an empty slice — it must not break the core + * loop. Shared by `detect-backend` and `resolve-wave-dispatch`. */ -function cmdDetectBackend(args, cwd, raw) { - const runtimeId = argValue(args, '--runtime') || process.env['GSD_RUNTIME'] || 'unknown'; - const agentSdkVersion = argValue(args, '--agent-sdk-version'); - const noNested = args.includes('--no-nested-dispatch'); - const hostIntegration = noNested ? { dispatch: { nested: false, background: true } } : CAPABLE_HOST; - // Resolve the claude_orchestration.* slice from the project config (federated - // keys are merged by loadConfig as a nested object). A config read failure - // degrades to inline — it must not break the core loop. +function resolveFlatClaudeOrchestrationConfig(cwd) { let claudeSlice = {}; try { const loaded = configLoader.loadConfig(cwd); @@ -71,11 +80,56 @@ function cmdDetectBackend(args, cwd, raw) { catch { claudeSlice = {}; } - // Flatten the nested slice into the dotted-key shape detectWorkflowBackend expects. const flatConfig = {}; for (const k of Object.keys(claudeSlice)) { flatConfig['claude_orchestration.' + k] = claudeSlice[k]; } + return flatConfig; +} +/** + * Resolve `--runtime`/`--agent-sdk-version`/`--no-nested-dispatch` into the + * `{ runtimeId, hostIntegration, agentSdkVersion }` triple both `detect-backend` + * and `resolve-wave-dispatch` pass to the pure detection seam. + */ +function resolveDetectionArgs(args) { + const runtimeId = argValue(args, '--runtime') || process.env['GSD_RUNTIME'] || 'unknown'; + const agentSdkVersion = argValue(args, '--agent-sdk-version'); + const noNested = args.includes('--no-nested-dispatch'); + const hostIntegration = noNested ? { dispatch: { nested: false, background: true } } : CAPABLE_HOST; + return { runtimeId, hostIntegration, agentSdkVersion }; +} +/** + * Read and parse a `--waves ` manifest file. + * + * #2285 finding 2: a real read/parse failure (`ok:false`) is DISTINCT from a + * manifest that parsed fine but has no top-level `waves` key (`ok:true, waves: + * undefined`) — collapsing both into the same sentinel made the missing-key + * case exit 0 with ZERO output (fail-silent), breaking the "exit 0 => parseable + * JSON verdict" contract callers rely on. Only the `ok:false` (read/parse threw) + * case calls `error(...)` and should short-circuit the caller; `ok:true` with a + * missing/malformed `waves` value must flow through to `emitWorkflowScript`'s + * own validation (matching how `{"waves": null}` already behaves) so the caller + * emits an explicit, non-empty verdict instead of silently doing nothing. + */ +function readWavesManifest(wavesPath, error) { + try { + const content = node_fs_1.default.readFileSync(node_path_1.default.resolve(wavesPath), 'utf8'); + const parsed = JSON.parse(content); + return { ok: true, waves: parsed['waves'] }; + } + catch (e) { + error('could not read/parse --waves file "' + wavesPath + '": ' + (e instanceof Error ? e.message : String(e))); + return { ok: false }; + } +} +/** + * Detect whether the Workflow backend should activate for the current/given + * runtime. Reads `claude_orchestration.*` from the project config; runtime and + * SDK version come from flags (the orchestrator already knows these) or env. + */ +function cmdDetectBackend(args, cwd, raw) { + const { runtimeId, hostIntegration, agentSdkVersion } = resolveDetectionArgs(args); + const flatConfig = resolveFlatClaudeOrchestrationConfig(cwd); const result = detectWorkflowBackend({ runtimeId, hostIntegration, config: flatConfig, agentSdkVersion }); output(result, raw); } @@ -95,22 +149,15 @@ function cmdEmitWorkflow(args, _cwd, raw, error) { error('emit-workflow requires --run-id '); return; } - let waves; - try { - const content = node_fs_1.default.readFileSync(node_path_1.default.resolve(wavesPath), 'utf8'); - const parsed = JSON.parse(content); - waves = parsed['waves']; - } - catch (e) { - error('emit-workflow: could not read/parse --waves file "' + wavesPath + '": ' + (e instanceof Error ? e.message : String(e))); - return; - } + const read = readWavesManifest(wavesPath, (msg) => error('emit-workflow: ' + msg)); + if (!read.ok) + return; // read/parse failure — error() already surfaced it loudly above const budgetTokens = budgetRaw !== undefined ? parseInt(budgetRaw, 10) : undefined; const budget = (typeof budgetTokens === 'number' && !Number.isNaN(budgetTokens)) ? budgetTokens : undefined; const result = emitWorkflowScript({ phaseDir, runId, - waves: waves, + waves: read.waves, budgetTokens: budget, }); if (!result.ok) { @@ -119,6 +166,44 @@ function cmdEmitWorkflow(args, _cwd, raw, error) { } output({ script: result.script, summary: result.summary }, raw); } +/** + * #2285 — the single composed seam a PRE-wave dispatch-backend selector + * (`execute:wave:pre`) uses: resolves `detect-backend` + `emit-workflow` in + * ONE call via `resolveWaveDispatch`. Emits + * `{ backend: 'inline'|'workflow', reason, script?, summary? }`. + */ +function cmdResolveWaveDispatch(args, cwd, raw, error) { + const wavesPath = argValue(args, '--waves'); + const runId = argValue(args, '--run-id'); + const phaseDir = argValue(args, '--phase-dir') || '.planning/phases/current'; + const budgetRaw = argValue(args, '--budget'); + if (!wavesPath) { + error('resolve-wave-dispatch requires --waves '); + return; + } + if (!runId) { + error('resolve-wave-dispatch requires --run-id '); + return; + } + const read = readWavesManifest(wavesPath, (msg) => error('resolve-wave-dispatch: ' + msg)); + if (!read.ok) + return; // read/parse failure — error() already surfaced it loudly above + const { runtimeId, hostIntegration, agentSdkVersion } = resolveDetectionArgs(args); + const flatConfig = resolveFlatClaudeOrchestrationConfig(cwd); + const budgetTokens = budgetRaw !== undefined ? parseInt(budgetRaw, 10) : undefined; + const budget = (typeof budgetTokens === 'number' && !Number.isNaN(budgetTokens)) ? budgetTokens : undefined; + const result = resolveWaveDispatch({ + runtimeId, + hostIntegration, + config: flatConfig, + agentSdkVersion, + phaseDir, + runId, + waves: read.waves, + budgetTokens: budget, + }); + output(result, raw); +} function routeClaudeOrchestrationCommand(opts) { const { args, cwd, raw, error } = opts; // args[0] is the family ('claude-orchestration'); the subcommand is args[1]. @@ -129,6 +214,9 @@ function routeClaudeOrchestrationCommand(opts) { else if (subcommand === 'emit-workflow') { cmdEmitWorkflow(args, cwd, raw, error); } + else if (subcommand === 'resolve-wave-dispatch') { + cmdResolveWaveDispatch(args, cwd, raw, error); + } else { usage(error); } diff --git a/gsd-core/bin/lib/claude-orchestration.cjs b/gsd-core/bin/lib/claude-orchestration.cjs index 956fdc2ed..95264bdfd 100644 --- a/gsd-core/bin/lib/claude-orchestration.cjs +++ b/gsd-core/bin/lib/claude-orchestration.cjs @@ -16,15 +16,20 @@ * → { ok:true, script, summary } | { ok:false, reason } * Maps GSD's wave/plan model 1:1 onto Workflow primitives: * wave → sequential `parallel()` stage barriers, - * plan → `agent(brief, { agentType:'gsd-executor', isolation:'worktree' })`, + * plan → `agent(brief, { agentType:'gsd-executor', isolation:'worktree' })` + * — UNLESS the plan's `use_worktree` is explicitly `false`, in which case + * `isolation` is omitted entirely for that plan (#2772 / #2285 finding 1: + * a submodule-touching plan must never be forced into worktree isolation + * the inline path (execute-phase.md step 2.5) would keep it out of), * files_modified overlap → forces plans into separate sequential stages * (the same overlap rule execute-phase already applies inline), * resumeFromRunId → wired to the phase run id, * budgetTokens → a shared token pool. - * The emitted script composes the SAME gsd-executor agent and worktree - * isolation the inline path uses, so it produces the same artifacts/commits - * (criterion 2). It is a generated string consumed by the orchestrator; this - * module never invokes the Workflow tool itself. + * The emitted script composes the SAME gsd-executor agent the inline path + * uses, with per-plan worktree isolation mirroring the inline path's own + * per-plan decision, so it produces the same artifacts/commits (criterion 2). + * It is a generated string consumed by the orchestrator; this module never + * invokes the Workflow tool itself. * * Design laws: * - Gall's Law: ship a small working slice that composes existing primitives @@ -259,6 +264,17 @@ function partitionStages(plans) { function quoteString(s) { return JSON.stringify(s); } +/** + * Render the `agent()` options object for a single plan — `isolation: "worktree"` + * ONLY when the plan's `use_worktree` is not explicitly `false` (#2772 / #2285 + * finding 1). This is the single place that decides worktree isolation for the + * Workflow backend; it must never diverge from the inline path's per-plan gate. + */ +function agentOptions(p) { + return p.use_worktree === false + ? '{ agentType: "gsd-executor" }' + : '{ agentType: "gsd-executor", isolation: "worktree" }'; +} /** * True if `s` is a safe identifier/path token to interpolate into the generated * script WITHOUT requiring a string-literal context — i.e. it contains no @@ -318,6 +334,9 @@ function emitWorkflowScript(input) { if (!isScriptableIdentifier(p.id)) { return { ok: false, reason: 'waves[' + i + '].plans[' + j + '].id must not contain newlines/quotes/backslash/control chars' }; } + if (p.use_worktree !== undefined && typeof p.use_worktree !== 'boolean') { + return { ok: false, reason: 'waves[' + i + '].plans[' + j + '].use_worktree must be a boolean if present' }; + } if (seenIds.has(p.id)) { return { ok: false, reason: 'waves[' + i + '] has duplicate plan id "' + p.id + '"' }; } @@ -336,8 +355,9 @@ function emitWorkflowScript(input) { lines.push('// GSD Workflow script — generated by the claude-orchestration capability (#1143)'); lines.push('// phase: ' + phaseDir); lines.push('// BETA: preview-grade; on any failure the orchestrator falls back to inline dispatch.'); - lines.push('// Composes the SAME gsd-executor agent + worktree isolation as the inline path,'); - lines.push('// so artifacts (SUMMARY.md) and commits are produced identically.'); + lines.push('// Composes the SAME gsd-executor agent as the inline path, so artifacts (SUMMARY.md)'); + lines.push('// and commits are produced identically. Worktree isolation is per-plan (use_worktree)'); + lines.push('// and mirrors execute-phase.md step 2.5\'s submodule gate exactly (#2772 / #2285).'); lines.push('resumeFromRunId(' + quoteString(runId) + ')'); if (budgetTokens !== null) { lines.push('budget(' + budgetTokens + ')'); @@ -361,13 +381,13 @@ function emitWorkflowScript(input) { if (stagePlans.length === 1) { const p = stagePlans[0]; lines.push('parallel('); - lines.push(' agent(' + quoteString(p.brief) + ', { agentType: "gsd-executor", isolation: "worktree" })'); + lines.push(' agent(' + quoteString(p.brief) + ', ' + agentOptions(p) + ')'); lines.push(')'); } else { lines.push('parallel('); for (const p of stagePlans) { - lines.push(' agent(' + quoteString(p.brief) + ', { agentType: "gsd-executor", isolation: "worktree" }),'); + lines.push(' agent(' + quoteString(p.brief) + ', ' + agentOptions(p) + '),'); } // Replace trailing comma on the last agent line with nothing. const lastIdx = lines.length - 1; @@ -393,9 +413,64 @@ function emitWorkflowScript(input) { }, }; } +/** + * #2285 — single composed decision seam for a PRE-wave dispatch-backend selector + * (e.g. the `execute:wave:pre` claude-orchestration contribution). Composes + * `detectWorkflowBackend` (gate ladder) with `emitWorkflowScript` (wave→plan + * mapping) into ONE call so the orchestrator (and its CLI wrapper, + * `claude-orchestration resolve-wave-dispatch`) never has to re-implement the + * two-step "detect, then maybe emit" sequencing. + * + * Fail-closed at every layer, matching the two composed functions: + * - `detectWorkflowBackend` resolving anything other than `'workflow'` → + * `inline` immediately; `emitWorkflowScript` is never invoked (no wasted + * work, no risk of a bad emit masking a correct inline fallback). + * - `detectWorkflowBackend` resolves `'workflow'` but `emitWorkflowScript` + * fails (`ok:false` — e.g. a malformed wave manifest) → `inline`, carrying + * the emit failure reason so the caller can surface it. Never a partial or + * broken script. + * + * This is the designated non-CLI-router, non-test caller of + * `detectWorkflowBackend` and `emitWorkflowScript` — the standalone CLI + * subcommands (`detect-backend`, `emit-workflow`) remain for inspection/ + * debugging, but the orchestrator's real per-wave dispatch decision goes + * through this seam. + * + * Never throws on bad input. + */ +function resolveWaveDispatch(input) { + if (input === null || input === undefined || typeof input !== 'object') { + return { backend: 'inline', reason: 'invalid_input' }; + } + const detected = detectWorkflowBackend({ + runtimeId: input.runtimeId, + hostIntegration: input.hostIntegration, + config: input.config, + agentSdkVersion: input.agentSdkVersion, + }); + if (detected.backend !== 'workflow') { + return { backend: 'inline', reason: detected.reason }; + } + const emitted = emitWorkflowScript({ + phaseDir: input.phaseDir, + waves: input.waves, + runId: input.runId, + budgetTokens: input.budgetTokens, + }); + if (!emitted.ok) { + return { backend: 'inline', reason: 'emit_failed: ' + emitted.reason }; + } + return { + backend: 'workflow', + reason: detected.reason, + script: emitted.script, + summary: emitted.summary, + }; +} module.exports = { detectWorkflowBackend, emitWorkflowScript, + resolveWaveDispatch, compareSemver, isValidSemver, WORKFLOW_TOOL_FLOOR_VERSION, diff --git a/gsd-core/workflows/execute-phase.md b/gsd-core/workflows/execute-phase.md index 667bbccd5..232b84630 100644 --- a/gsd-core/workflows/execute-phase.md +++ b/gsd-core/workflows/execute-phase.md @@ -115,7 +115,7 @@ fi ``` `isolation="worktree"` is a Claude-Code-specific agent primitive; no other runtime can honor it (Codex maps subagents to `spawn_agent`, others prohibit or omit worktree binding). Failing closed prevents main-checkout edits while the workflow believes agents are isolated. -If the project uses git submodules, worktree isolation is unsafe **only when a plan touches a submodule path** — the executor commit protocol cannot correctly handle submodule commits inside isolated worktrees. The previous behavior unconditionally disabled worktree isolation whenever `.gitmodules` existed, which penalised every plan in a submodule project even when the plan was nowhere near a submodule. Compute submodule paths once and intersect them per-plan with the plan's declared `files_modified` frontmatter. +If the project uses git submodules, worktree isolation is unsafe **only when a plan touches a submodule path** — the executor commit protocol cannot correctly handle submodule commits inside isolated worktrees. Compute submodule paths once and intersect them per-plan with the plan's declared `files_modified` frontmatter. ```bash # Parse submodule paths from .gitmodules once (empty if no .gitmodules). @@ -277,12 +277,6 @@ checkpoints between tasks. The user can review, modify, or redirect work at any 3. After all plans: proceed to verification (same as normal mode). -**Benefits of interactive mode:** -- No subagent overhead — dramatically lower token usage -- User catches mistakes early — saves costly verification cycles -- Maintains GSD's planning/tracking structure -- Best for: small phases, bug fixes, verification gaps, learning GSD - **Skip to handle_branching step** (interactive plans execute inline after grouping). @@ -553,7 +547,7 @@ increases monotonically across waves. `{status}` is `complete` (success), ``` - Bad: "Executing terrain generation plan" - - Good: "Procedural terrain generator using Perlin noise — creates height maps, biome zones, and collision meshes. Required before vehicle physics can interact with ground." + - Good: "Procedural terrain generator using Perlin noise — creates height maps and biome zones. Required before vehicle physics." 2.5. **Per-plan worktree decision (run for each plan in this wave BEFORE its dispatch):** @@ -561,6 +555,14 @@ increases monotonically across waves. `{status}` is `complete` (success), The dispatch branches in step 3 below MUST gate on `USE_WORKTREES_FOR_PLAN` for the current plan, not on the project-level `USE_WORKTREES`. +2.75. **Execute:wave:pre capability dispatch:** + + ```bash + WAVE_PRE_HOOKS_JSON=$(gsd_run loop render-hooks execute:wave:pre --raw) + ``` + + If a contribution's `activeHooks` entry provides an alternate wave dispatch, follow it instead of step 3's inline loop; otherwise proceed to step 3. + 3. **Spawn executor agents:** **Emit a plan-start heartbeat (literal line, no tool call) immediately before diff --git a/src/claude-orchestration-command-router.cts b/src/claude-orchestration-command-router.cts index cabc96435..02a0e576d 100644 --- a/src/claude-orchestration-command-router.cts +++ b/src/claude-orchestration-command-router.cts @@ -21,7 +21,20 @@ * emit-workflow --waves --run-id [--phase-dir ] [--budget ] * Reads a wave/plan manifest JSON file and emits the generated Workflow * script + summary. The manifest shape matches emitWorkflowScript's input: - * { waves: [{ id, plans: [{ id, brief, files_modified: string[] }] }] }. + * { waves: [{ id, plans: [{ id, brief, files_modified: string[], use_worktree?: boolean }] }] }. + * `use_worktree` defaults to true; pass `false` for a plan the inline path + * (execute-phase.md step 2.5) would also keep out of worktree isolation + * (submodule-touching plans — #2772 / #2285 finding 1). + * + * resolve-wave-dispatch --waves --run-id [--runtime ] + * [--agent-sdk-version ] [--no-nested-dispatch] [--phase-dir ] + * [--budget ] + * #2285 — the single composed seam a PRE-wave dispatch-backend selector + * (`execute:wave:pre`) uses: resolves detect-backend + emit-workflow in + * ONE call. Emits { backend: 'inline'|'workflow', reason, script?, summary? }. + * Fail-closed identically to detect-backend/emit-workflow individually — + * any gate miss, or an emit failure on a malformed --waves manifest, + * resolves to 'inline' with no script. */ import fs from 'node:fs'; @@ -34,7 +47,7 @@ import core = require('./claude-orchestration.cjs'); import configLoader = require('./config-loader.cjs'); const { output } = io; -const { detectWorkflowBackend, emitWorkflowScript } = core; +const { detectWorkflowBackend, emitWorkflowScript, resolveWaveDispatch } = core; const CAPABLE_HOST = { dispatch: { nested: true, background: true } }; @@ -47,9 +60,10 @@ interface RouterOpts { function usage(error: (msg: string, reason?: string) => void): void { error( - 'Usage: gsd-tools claude-orchestration [...]\n' + + 'Usage: gsd-tools claude-orchestration [...]\n' + ' detect-backend [--runtime ] [--agent-sdk-version ] [--no-nested-dispatch]\n' + - ' emit-workflow --waves --run-id [--phase-dir ] [--budget ]', + ' emit-workflow --waves --run-id [--phase-dir ] [--budget ]\n' + + ' resolve-wave-dispatch --waves --run-id [--runtime ] [--agent-sdk-version ] [--no-nested-dispatch] [--phase-dir ] [--budget ]', ); } @@ -59,19 +73,13 @@ function argValue(args: string[], flag: string): string | undefined { } /** - * Detect whether the Workflow backend should activate for the current/given - * runtime. Reads `claude_orchestration.*` from the project config; runtime and - * SDK version come from flags (the orchestrator already knows these) or env. + * Resolve the `claude_orchestration.*` config slice from the project config + * (federated keys are merged by loadConfig as a nested object), flattened into + * the dotted-key shape `detectWorkflowBackend`/`resolveWaveDispatch` expect. A + * config read failure degrades to an empty slice — it must not break the core + * loop. Shared by `detect-backend` and `resolve-wave-dispatch`. */ -function cmdDetectBackend(args: string[], cwd: string, raw: boolean): void { - const runtimeId = argValue(args, '--runtime') || process.env['GSD_RUNTIME'] || 'unknown'; - const agentSdkVersion = argValue(args, '--agent-sdk-version'); - const noNested = args.includes('--no-nested-dispatch'); - const hostIntegration = noNested ? { dispatch: { nested: false, background: true } } : CAPABLE_HOST; - - // Resolve the claude_orchestration.* slice from the project config (federated - // keys are merged by loadConfig as a nested object). A config read failure - // degrades to inline — it must not break the core loop. +function resolveFlatClaudeOrchestrationConfig(cwd: string): Record { let claudeSlice: Record = {}; try { const loaded = configLoader.loadConfig(cwd); @@ -83,12 +91,63 @@ function cmdDetectBackend(args: string[], cwd: string, raw: boolean): void { claudeSlice = {}; } - // Flatten the nested slice into the dotted-key shape detectWorkflowBackend expects. const flatConfig: Record = {}; for (const k of Object.keys(claudeSlice)) { flatConfig['claude_orchestration.' + k] = claudeSlice[k]; } + return flatConfig; +} +/** + * Resolve `--runtime`/`--agent-sdk-version`/`--no-nested-dispatch` into the + * `{ runtimeId, hostIntegration, agentSdkVersion }` triple both `detect-backend` + * and `resolve-wave-dispatch` pass to the pure detection seam. + */ +function resolveDetectionArgs(args: string[]): { runtimeId: string; hostIntegration: { dispatch: { nested: boolean; background: boolean } }; agentSdkVersion: string | undefined } { + const runtimeId = argValue(args, '--runtime') || process.env['GSD_RUNTIME'] || 'unknown'; + const agentSdkVersion = argValue(args, '--agent-sdk-version'); + const noNested = args.includes('--no-nested-dispatch'); + const hostIntegration = noNested ? { dispatch: { nested: false, background: true } } : CAPABLE_HOST; + return { runtimeId, hostIntegration, agentSdkVersion }; +} + +/** Discriminated result for readWavesManifest — see doc comment below. */ +type WavesReadResult = + | { ok: true; waves: unknown } + | { ok: false }; + +/** + * Read and parse a `--waves ` manifest file. + * + * #2285 finding 2: a real read/parse failure (`ok:false`) is DISTINCT from a + * manifest that parsed fine but has no top-level `waves` key (`ok:true, waves: + * undefined`) — collapsing both into the same sentinel made the missing-key + * case exit 0 with ZERO output (fail-silent), breaking the "exit 0 => parseable + * JSON verdict" contract callers rely on. Only the `ok:false` (read/parse threw) + * case calls `error(...)` and should short-circuit the caller; `ok:true` with a + * missing/malformed `waves` value must flow through to `emitWorkflowScript`'s + * own validation (matching how `{"waves": null}` already behaves) so the caller + * emits an explicit, non-empty verdict instead of silently doing nothing. + */ +function readWavesManifest(wavesPath: string, error: (msg: string, reason?: string) => void): WavesReadResult { + try { + const content = fs.readFileSync(path.resolve(wavesPath), 'utf8'); + const parsed = JSON.parse(content) as Record; + return { ok: true, waves: parsed['waves'] }; + } catch (e) { + error('could not read/parse --waves file "' + wavesPath + '": ' + (e instanceof Error ? e.message : String(e))); + return { ok: false }; + } +} + +/** + * Detect whether the Workflow backend should activate for the current/given + * runtime. Reads `claude_orchestration.*` from the project config; runtime and + * SDK version come from flags (the orchestrator already knows these) or env. + */ +function cmdDetectBackend(args: string[], cwd: string, raw: boolean): void { + const { runtimeId, hostIntegration, agentSdkVersion } = resolveDetectionArgs(args); + const flatConfig = resolveFlatClaudeOrchestrationConfig(cwd); const result = detectWorkflowBackend({ runtimeId, hostIntegration, config: flatConfig, agentSdkVersion }); output(result, raw); } @@ -111,15 +170,8 @@ function cmdEmitWorkflow(args: string[], _cwd: string, raw: boolean, error: (msg return; } - let waves: unknown; - try { - const content = fs.readFileSync(path.resolve(wavesPath), 'utf8'); - const parsed = JSON.parse(content) as Record; - waves = parsed['waves']; - } catch (e) { - error('emit-workflow: could not read/parse --waves file "' + wavesPath + '": ' + (e instanceof Error ? e.message : String(e))); - return; - } + const read = readWavesManifest(wavesPath, (msg) => error('emit-workflow: ' + msg)); + if (!read.ok) return; // read/parse failure — error() already surfaced it loudly above const budgetTokens = budgetRaw !== undefined ? parseInt(budgetRaw, 10) : undefined; const budget = (typeof budgetTokens === 'number' && !Number.isNaN(budgetTokens)) ? budgetTokens : undefined; @@ -127,7 +179,7 @@ function cmdEmitWorkflow(args: string[], _cwd: string, raw: boolean, error: (msg const result = emitWorkflowScript({ phaseDir, runId, - waves: waves as EmitInput['waves'], + waves: read.waves as EmitInput['waves'], budgetTokens: budget, }); @@ -138,9 +190,53 @@ function cmdEmitWorkflow(args: string[], _cwd: string, raw: boolean, error: (msg output({ script: result.script, summary: result.summary }, raw); } +/** + * #2285 — the single composed seam a PRE-wave dispatch-backend selector + * (`execute:wave:pre`) uses: resolves `detect-backend` + `emit-workflow` in + * ONE call via `resolveWaveDispatch`. Emits + * `{ backend: 'inline'|'workflow', reason, script?, summary? }`. + */ +function cmdResolveWaveDispatch(args: string[], cwd: string, raw: boolean, error: (msg: string, reason?: string) => void): void { + const wavesPath = argValue(args, '--waves'); + const runId = argValue(args, '--run-id'); + const phaseDir = argValue(args, '--phase-dir') || '.planning/phases/current'; + const budgetRaw = argValue(args, '--budget'); + + if (!wavesPath) { + error('resolve-wave-dispatch requires --waves '); + return; + } + if (!runId) { + error('resolve-wave-dispatch requires --run-id '); + return; + } + + const read = readWavesManifest(wavesPath, (msg) => error('resolve-wave-dispatch: ' + msg)); + if (!read.ok) return; // read/parse failure — error() already surfaced it loudly above + + const { runtimeId, hostIntegration, agentSdkVersion } = resolveDetectionArgs(args); + const flatConfig = resolveFlatClaudeOrchestrationConfig(cwd); + + const budgetTokens = budgetRaw !== undefined ? parseInt(budgetRaw, 10) : undefined; + const budget = (typeof budgetTokens === 'number' && !Number.isNaN(budgetTokens)) ? budgetTokens : undefined; + + const result = resolveWaveDispatch({ + runtimeId, + hostIntegration, + config: flatConfig, + agentSdkVersion, + phaseDir, + runId, + waves: read.waves as EmitInput['waves'], + budgetTokens: budget, + }); + + output(result, raw); +} + // Re-declared minimal input type for the cast above (avoids importing private types). interface EmitInput { - waves: Array<{ id: string; plans: Array<{ id: string; brief: string; files_modified: string[] }> }>; + waves: Array<{ id: string; plans: Array<{ id: string; brief: string; files_modified: string[]; use_worktree?: boolean }> }>; } function routeClaudeOrchestrationCommand(opts: RouterOpts): void { @@ -151,6 +247,8 @@ function routeClaudeOrchestrationCommand(opts: RouterOpts): void { cmdDetectBackend(args, cwd, raw); } else if (subcommand === 'emit-workflow') { cmdEmitWorkflow(args, cwd, raw, error); + } else if (subcommand === 'resolve-wave-dispatch') { + cmdResolveWaveDispatch(args, cwd, raw, error); } else { usage(error); } diff --git a/src/claude-orchestration.cts b/src/claude-orchestration.cts index 8e877edbf..a8a0cc914 100644 --- a/src/claude-orchestration.cts +++ b/src/claude-orchestration.cts @@ -15,15 +15,20 @@ * → { ok:true, script, summary } | { ok:false, reason } * Maps GSD's wave/plan model 1:1 onto Workflow primitives: * wave → sequential `parallel()` stage barriers, - * plan → `agent(brief, { agentType:'gsd-executor', isolation:'worktree' })`, + * plan → `agent(brief, { agentType:'gsd-executor', isolation:'worktree' })` + * — UNLESS the plan's `use_worktree` is explicitly `false`, in which case + * `isolation` is omitted entirely for that plan (#2772 / #2285 finding 1: + * a submodule-touching plan must never be forced into worktree isolation + * the inline path (execute-phase.md step 2.5) would keep it out of), * files_modified overlap → forces plans into separate sequential stages * (the same overlap rule execute-phase already applies inline), * resumeFromRunId → wired to the phase run id, * budgetTokens → a shared token pool. - * The emitted script composes the SAME gsd-executor agent and worktree - * isolation the inline path uses, so it produces the same artifacts/commits - * (criterion 2). It is a generated string consumed by the orchestrator; this - * module never invokes the Workflow tool itself. + * The emitted script composes the SAME gsd-executor agent the inline path + * uses, with per-plan worktree isolation mirroring the inline path's own + * per-plan decision, so it produces the same artifacts/commits (criterion 2). + * It is a generated string consumed by the orchestrator; this module never + * invokes the Workflow tool itself. * * Design laws: * - Gall's Law: ship a small working slice that composes existing primitives @@ -251,6 +256,21 @@ interface Plan { id: string; brief: string; files_modified: string[]; + /** + * #2772 / #2285 finding 1 — mirrors execute-phase.md step 2.5's + * `USE_WORKTREES_FOR_PLAN` (per-plan submodule-intersection + project-level + * `workflow.use_worktrees` gate). The inline dispatch path in step 3 omits + * `isolation="worktree"` for a plan that touches a submodule path (the + * executor commit protocol cannot correctly handle submodule commits inside + * an isolated worktree). The Workflow backend MUST honor the SAME per-plan + * decision — it must never force worktree isolation on a plan the inline + * path would keep out of worktrees. + * + * Optional, defaults to `true` (preserves prior behavior for callers that + * don't populate it — e.g. a manifest with no submodule paths at all). + * Only an explicit `false` omits `isolation: "worktree"` for that plan. + */ + use_worktree?: boolean; } interface Wave { @@ -331,6 +351,18 @@ function quoteString(s: string): string { return JSON.stringify(s); } +/** + * Render the `agent()` options object for a single plan — `isolation: "worktree"` + * ONLY when the plan's `use_worktree` is not explicitly `false` (#2772 / #2285 + * finding 1). This is the single place that decides worktree isolation for the + * Workflow backend; it must never diverge from the inline path's per-plan gate. + */ +function agentOptions(p: Plan): string { + return p.use_worktree === false + ? '{ agentType: "gsd-executor" }' + : '{ agentType: "gsd-executor", isolation: "worktree" }'; +} + /** * True if `s` is a safe identifier/path token to interpolate into the generated * script WITHOUT requiring a string-literal context — i.e. it contains no @@ -390,6 +422,9 @@ function emitWorkflowScript(input: EmitInput | null | undefined): EmitOk | EmitE if (!isScriptableIdentifier(p.id)) { return { ok: false, reason: 'waves[' + i + '].plans[' + j + '].id must not contain newlines/quotes/backslash/control chars' }; } + if (p.use_worktree !== undefined && typeof p.use_worktree !== 'boolean') { + return { ok: false, reason: 'waves[' + i + '].plans[' + j + '].use_worktree must be a boolean if present' }; + } if (seenIds.has(p.id)) { return { ok: false, reason: 'waves[' + i + '] has duplicate plan id "' + p.id + '"' }; } @@ -410,8 +445,9 @@ function emitWorkflowScript(input: EmitInput | null | undefined): EmitOk | EmitE lines.push('// GSD Workflow script — generated by the claude-orchestration capability (#1143)'); lines.push('// phase: ' + phaseDir); lines.push('// BETA: preview-grade; on any failure the orchestrator falls back to inline dispatch.'); - lines.push('// Composes the SAME gsd-executor agent + worktree isolation as the inline path,'); - lines.push('// so artifacts (SUMMARY.md) and commits are produced identically.'); + lines.push('// Composes the SAME gsd-executor agent as the inline path, so artifacts (SUMMARY.md)'); + lines.push('// and commits are produced identically. Worktree isolation is per-plan (use_worktree)'); + lines.push('// and mirrors execute-phase.md step 2.5\'s submodule gate exactly (#2772 / #2285).'); lines.push('resumeFromRunId(' + quoteString(runId) + ')'); if (budgetTokens !== null) { lines.push('budget(' + budgetTokens + ')'); @@ -438,12 +474,12 @@ function emitWorkflowScript(input: EmitInput | null | undefined): EmitOk | EmitE if (stagePlans.length === 1) { const p = stagePlans[0]; lines.push('parallel('); - lines.push(' agent(' + quoteString(p.brief) + ', { agentType: "gsd-executor", isolation: "worktree" })'); + lines.push(' agent(' + quoteString(p.brief) + ', ' + agentOptions(p) + ')'); lines.push(')'); } else { lines.push('parallel('); for (const p of stagePlans) { - lines.push(' agent(' + quoteString(p.brief) + ', { agentType: "gsd-executor", isolation: "worktree" }),'); + lines.push(' agent(' + quoteString(p.brief) + ', ' + agentOptions(p) + '),'); } // Replace trailing comma on the last agent line with nothing. const lastIdx = lines.length - 1; @@ -472,11 +508,99 @@ function emitWorkflowScript(input: EmitInput | null | undefined): EmitOk | EmitE }; } +// ─── resolveWaveDispatch ────────────────────────────────────────────────────── + +interface ResolveWaveDispatchInput { + runtimeId?: string; + hostIntegration?: HostIntegration | null; + config?: BackendConfig | null; + agentSdkVersion?: string; + phaseDir: string; + waves: Wave[]; + runId: string; + budgetTokens?: number; +} + +interface ResolveWaveDispatchInline { + backend: 'inline'; + reason: string; +} + +interface ResolveWaveDispatchWorkflow { + backend: 'workflow'; + reason: string; + script: string; + summary: EmitOk['summary']; +} + +type ResolveWaveDispatchResult = ResolveWaveDispatchInline | ResolveWaveDispatchWorkflow; + +/** + * #2285 — single composed decision seam for a PRE-wave dispatch-backend selector + * (e.g. the `execute:wave:pre` claude-orchestration contribution). Composes + * `detectWorkflowBackend` (gate ladder) with `emitWorkflowScript` (wave→plan + * mapping) into ONE call so the orchestrator (and its CLI wrapper, + * `claude-orchestration resolve-wave-dispatch`) never has to re-implement the + * two-step "detect, then maybe emit" sequencing. + * + * Fail-closed at every layer, matching the two composed functions: + * - `detectWorkflowBackend` resolving anything other than `'workflow'` → + * `inline` immediately; `emitWorkflowScript` is never invoked (no wasted + * work, no risk of a bad emit masking a correct inline fallback). + * - `detectWorkflowBackend` resolves `'workflow'` but `emitWorkflowScript` + * fails (`ok:false` — e.g. a malformed wave manifest) → `inline`, carrying + * the emit failure reason so the caller can surface it. Never a partial or + * broken script. + * + * This is the designated non-CLI-router, non-test caller of + * `detectWorkflowBackend` and `emitWorkflowScript` — the standalone CLI + * subcommands (`detect-backend`, `emit-workflow`) remain for inspection/ + * debugging, but the orchestrator's real per-wave dispatch decision goes + * through this seam. + * + * Never throws on bad input. + */ +function resolveWaveDispatch(input: ResolveWaveDispatchInput | null | undefined): ResolveWaveDispatchResult { + if (input === null || input === undefined || typeof input !== 'object') { + return { backend: 'inline', reason: 'invalid_input' }; + } + + const detected = detectWorkflowBackend({ + runtimeId: input.runtimeId, + hostIntegration: input.hostIntegration, + config: input.config, + agentSdkVersion: input.agentSdkVersion, + }); + + if (detected.backend !== 'workflow') { + return { backend: 'inline', reason: detected.reason }; + } + + const emitted = emitWorkflowScript({ + phaseDir: input.phaseDir, + waves: input.waves, + runId: input.runId, + budgetTokens: input.budgetTokens, + }); + + if (!emitted.ok) { + return { backend: 'inline', reason: 'emit_failed: ' + emitted.reason }; + } + + return { + backend: 'workflow', + reason: detected.reason, + script: emitted.script, + summary: emitted.summary, + }; +} + // ─── Exports ────────────────────────────────────────────────────────────────── export = { detectWorkflowBackend, emitWorkflowScript, + resolveWaveDispatch, compareSemver, isValidSemver, WORKFLOW_TOOL_FLOOR_VERSION, diff --git a/tests/claude-orchestration.test.cjs b/tests/claude-orchestration.test.cjs index 808501e1e..abc7f4453 100644 --- a/tests/claude-orchestration.test.cjs +++ b/tests/claude-orchestration.test.cjs @@ -475,6 +475,105 @@ describe('emitWorkflowScript', () => { }); }); +// ─── 3.5. Per-plan use_worktree (#2772 / #2285 finding 1) ───────────────────── +// +// The Workflow backend must NEVER force worktree isolation on a plan the +// inline path (execute-phase.md step 2.5's USE_WORKTREES_FOR_PLAN) keeps out +// of worktrees — e.g. a submodule-touching plan, where the executor commit +// protocol cannot correctly handle submodule commits inside an isolated +// worktree. `use_worktree` is the per-plan signal that threads that decision +// into the emitted script. + +describe('emitWorkflowScript — per-plan use_worktree (#2772 / #2285 finding 1)', () => { + test('[happy] use_worktree omitted (default) -> isolation: "worktree" (backward-compatible default)', () => { + const r = emitWorkflowScript(singleWaveManifest()); + assert.strictEqual(r.ok, true); + assert.match(r.script, /agent\("Implement the foo module", \{ agentType: "gsd-executor", isolation: "worktree" \}\)/); + }); + + test('[happy] use_worktree: true explicit -> isolation: "worktree" (same as default)', () => { + const r = emitWorkflowScript({ + phaseDir: '.p', runId: 'r', + waves: [{ id: 'w1', plans: [{ id: 'p1', brief: 'b', files_modified: ['a.cts'], use_worktree: true }] }], + }); + assert.strictEqual(r.ok, true); + assert.match(r.script, /agent\("b", \{ agentType: "gsd-executor", isolation: "worktree" \}\)/); + }); + + test('[negative] use_worktree: false -> isolation OMITTED entirely for that plan', () => { + const r = emitWorkflowScript({ + phaseDir: '.p', runId: 'r', + waves: [{ id: 'w1', plans: [{ id: 'p1', brief: 'submodule plan', files_modified: ['vendor/lib.c'], use_worktree: false }] }], + }); + assert.strictEqual(r.ok, true); + assert.match(r.script, /agent\("submodule plan", \{ agentType: "gsd-executor" \}\)/); + assert.ok(!/agent\("submodule plan"[^)]*isolation/.test(r.script), 'isolation must not appear for this plan\'s agent() call'); + }); + + test('[happy] mixed wave: one worktree plan + one non-worktree plan in the SAME parallel() batch — each carries its own isolation independently', () => { + const r = emitWorkflowScript({ + phaseDir: '.p', runId: 'r', + waves: [{ id: 'w1', plans: [ + { id: 'p1', brief: 'normal plan', files_modified: ['src/a.ts'] }, + { id: 'p2', brief: 'submodule plan', files_modified: ['vendor/b.c'], use_worktree: false }, + ] }], + }); + assert.strictEqual(r.ok, true); + // Both plans have disjoint files_modified -> coalesce into ONE parallel() stage. + assert.strictEqual(r.summary.stagesByWave[0].length, 1, 'non-overlapping plans share one stage'); + assert.match(r.script, /agent\("normal plan", \{ agentType: "gsd-executor", isolation: "worktree" \}\)/); + assert.match(r.script, /agent\("submodule plan", \{ agentType: "gsd-executor" \}\)/); + assert.ok(!/agent\("submodule plan"[^)]*isolation/.test(r.script), 'the submodule plan must never gain isolation from being batched with a worktree plan'); + }); + + test('[negative] use_worktree with a non-boolean value -> ok:false (strict typing, no silent coercion)', () => { + const r = emitWorkflowScript({ + phaseDir: '.p', runId: 'r', + waves: [{ id: 'w1', plans: [{ id: 'p1', brief: 'b', files_modified: ['a.cts'], use_worktree: 'false' }] }], + }); + assert.strictEqual(r.ok, false); + assert.match(r.reason, /use_worktree/); + }); + + test('property: use_worktree never flips to isolation:"worktree" when explicitly false, across random plan shapes', () => { + fc.assert(fc.property( + fc.array( + fc.record({ + id: fc.integer({ min: 0, max: 999 }).map((n) => 'p' + n), + brief: fc.string({ minLength: 1, maxLength: 20 }).filter((s) => !/[\r\n"\\]/.test(s)), + useWorktree: fc.boolean(), + }), + { minLength: 1, maxLength: 4 }, + ).filter((plans) => new Set(plans.map((p) => p.id)).size === plans.length), // unique ids + (planSpecs) => { + const waves = [{ + id: 'w1', + plans: planSpecs.map((p, i) => ({ + id: p.id, + brief: p.brief, + files_modified: ['src/file' + i + '.cts'], // disjoint -> no overlap-driven staging noise + use_worktree: p.useWorktree, + })), + }]; + const r = emitWorkflowScript({ phaseDir: '.p', runId: 'r', waves }); + assert.strictEqual(r.ok, true); + for (const p of planSpecs) { + const briefEsc = JSON.stringify(p.brief); + const idx = r.script.indexOf('agent(' + briefEsc + ','); + assert.ok(idx !== -1, 'agent() call for plan must exist'); + const lineEnd = r.script.indexOf('\n', idx); + const line = r.script.slice(idx, lineEnd === -1 ? undefined : lineEnd); + if (p.useWorktree === false) { + assert.ok(!line.includes('isolation'), 'use_worktree:false must never carry isolation'); + } else { + assert.ok(line.includes('isolation: "worktree"'), 'use_worktree:true must carry isolation: "worktree"'); + } + } + }, + )); + }); +}); + // ─── 4. Capability declaration validation ───────────────────────────────────── describe('capability declaration (capabilities/claude-orchestration/capability.json)', () => { @@ -520,16 +619,19 @@ describe('capability declaration (capabilities/claude-orchestration/capability.j assert.strictEqual(slice.default, 'auto'); }); - test('registers at WIRED points only (execute:wave:post, plan:post)', () => { + test('registers at WIRED points only (execute:wave:pre, plan:post)', () => { const cap = loadCap(); const points = cap.contributions.map((c) => c.point); for (const p of points) { assert.ok( - ['discuss:pre', 'discuss:post', 'plan:pre', 'plan:post', 'execute:post', 'execute:wave:post', 'verify:post', 'ship:pre', 'ship:post'].includes(p), + ['discuss:pre', 'discuss:post', 'plan:pre', 'plan:post', 'execute:pre', 'execute:wave:pre', 'execute:post', 'verify:post', 'ship:pre', 'ship:post'].includes(p), 'contribution point ' + p + ' must be a wired point', ); } - assert.ok(points.includes('execute:wave:post'), 'registers the execute wave hook'); + // #2285: the dispatch-backend selector moved from execute:wave:post (fires + // AFTER the wave already dispatched inline — too late to select a backend) + // to execute:wave:pre (fires BEFORE step 3's Agent() dispatch). + assert.ok(points.includes('execute:wave:pre'), 'registers the pre-wave dispatch-selector hook'); assert.ok(points.includes('plan:post'), 'declares plan:* ownership for ultraplan (criterion 5)'); }); @@ -563,13 +665,21 @@ describe('registry integration', () => { assert.strictEqual(registry.configSchema['claude_orchestration.execution_backend'].default, 'auto'); }); - test('byLoopPoint[execute:wave:post].contributions includes our capability', () => { + test('byLoopPoint[execute:wave:pre].contributions includes our capability (#2285)', () => { + const { capMap } = loadAndValidate(new Set()); + const registry = buildRegistry(capMap); + const contribs = registry.byLoopPoint['execute:wave:pre'].contributions; + const ours = contribs.find((c) => c.capId === 'claude-orchestration'); + assert.ok(ours, 'our execute:wave:pre contribution is registered'); + assert.strictEqual(ours.into, 'executor'); + }); + + test('byLoopPoint[execute:wave:post] no longer carries our contribution (#2285 moved it to wave:pre)', () => { const { capMap } = loadAndValidate(new Set()); const registry = buildRegistry(capMap); const contribs = registry.byLoopPoint['execute:wave:post'].contributions; const ours = contribs.find((c) => c.capId === 'claude-orchestration'); - assert.ok(ours, 'our execute:wave:post contribution is registered'); - assert.strictEqual(ours.into, 'executor'); + assert.strictEqual(ours, undefined, 'claude-orchestration must not remain at execute:wave:post'); }); test('committed registry is in sync (gen-capability-registry --check)', () => { diff --git a/tests/execute-wave-post-gate-pipeline-e2e.test.cjs b/tests/execute-wave-post-gate-pipeline-e2e.test.cjs index c01f1903d..d92fcef6a 100644 --- a/tests/execute-wave-post-gate-pipeline-e2e.test.cjs +++ b/tests/execute-wave-post-gate-pipeline-e2e.test.cjs @@ -629,17 +629,29 @@ describe('F. Real registry execute:wave:post shape — guard against accidental `ui.safety-gate onError must be 'halt'; got ${uiGate.onError}`); }); - test('[happy] real registry: execute:wave:post has no steps and 3 contributions (claude-orchestration executor + external-job executor + mempalace capture-problems)', () => { + test('[happy] real registry: execute:wave:post has no steps and 2 contributions (external-job executor + mempalace capture-problems)', () => { const point = realRegistry.byLoopPoint['execute:wave:post']; assert.strictEqual(point.steps.length, 0, `execute:wave:post steps must be empty; got ${point.steps.length}`); - // #1143: claude-orchestration registers an execute:wave:post contribution - // providing the Workflow-tool backend guidance (default-off, claude-only). - assert.strictEqual(point.contributions.length, 3, - `execute:wave:post must have 3 contributions (claude-orchestration + external-job + mempalace); got ${point.contributions.length}`); + // #2285: claude-orchestration's dispatch-backend-selector contribution moved + // from execute:wave:post to execute:wave:pre — wave:post fires AFTER the + // wave already dispatched inline, too late to select a dispatch backend. + assert.strictEqual(point.contributions.length, 2, + `execute:wave:post must have 2 contributions (external-job + mempalace); got ${point.contributions.length}`); const capIds = point.contributions.map(c => c.capId).sort(); - assert.deepStrictEqual(capIds, ['claude-orchestration', 'external-job', 'mempalace'], - `execute:wave:post contributions must be claude-orchestration + external-job + mempalace; got ${capIds.join(',')}`); + assert.deepStrictEqual(capIds, ['external-job', 'mempalace'], + `execute:wave:post contributions must be external-job + mempalace; got ${capIds.join(',')}`); + }); + + test('[happy] real registry: execute:wave:pre has 1 contribution (claude-orchestration dispatch-backend selector, #2285)', () => { + const point = realRegistry.byLoopPoint['execute:wave:pre']; + assert.strictEqual(point.steps.length, 0, + `execute:wave:pre steps must be empty; got ${point.steps.length}`); + assert.strictEqual(point.contributions.length, 1, + `execute:wave:pre must have 1 contribution (claude-orchestration); got ${point.contributions.length}`); + const capIds = point.contributions.map(c => c.capId).sort(); + assert.deepStrictEqual(capIds, ['claude-orchestration'], + `execute:wave:pre contributions must be claude-orchestration; got ${capIds.join(',')}`); }); }); diff --git a/tests/fix-2285-claude-orchestration-wiring.test.cjs b/tests/fix-2285-claude-orchestration-wiring.test.cjs new file mode 100644 index 000000000..c99a8a84e --- /dev/null +++ b/tests/fix-2285-claude-orchestration-wiring.test.cjs @@ -0,0 +1,649 @@ +'use strict'; + +// allow-test-rule: source-text-is-the-product, see #2285 — reads gsd-core/workflows/execute-phase.md +// prose to verify the render-hooks call site + ordering. The workflow markdown IS the runtime +// contract executed by the orchestrator; there is no behavioral seam to drive this assertion +// through other than the rendered prose itself. + +/** + * fix-2285-claude-orchestration-wiring.test.cjs + * + * #2285 — the `claude-orchestration` capability (Workflow backend, #1143) was + * registered `active` but fully INERT: `detectWorkflowBackend`/`emitWorkflowScript` + * had zero callers outside their own CLI router, and execute-phase.md declared + * `execute:wave:pre` as a hook point in its frontmatter but never rendered it — + * the wave loop only ever dispatched `execute:pre`, `execute:wave:post`, and + * `execute:post`. `claude_orchestration.enabled:true` therefore had no effect on + * a real execute-phase run. + * + * Fix (Approach B): + * 1. execute-phase.md now renders `execute:wave:pre` immediately before each + * wave's agents are dispatched (step 2.75, before step 3's Agent() loop). + * 2. The claude-orchestration contribution moved from `execute:wave:post` + * (fires too late — after the wave already dispatched inline) to + * `execute:wave:pre` (fires before dispatch, where a backend selector + * actually has to run to matter). + * 3. `resolveWaveDispatch` in src/claude-orchestration.cts composes + * `detectWorkflowBackend` + `emitWorkflowScript` into ONE decision seam, + * giving both functions a real caller outside their CLI router and outside + * tests. It is also exposed via `gsd-tools claude-orchestration + * resolve-wave-dispatch`. + * + * This file drives the real seam (no source-grep on implementation files) and + * asserts the fail-closed contract: disabled or any gate miss => inline, + * byte-identical to today's dispatch shape. + */ + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const fc = require('fast-check'); + +const { + detectWorkflowBackend, + emitWorkflowScript, + resolveWaveDispatch, + WORKFLOW_TOOL_FLOOR_VERSION, +} = require('../gsd-core/bin/lib/claude-orchestration.cjs'); + +const { runGsdTools, createTempDir, cleanup } = require('./helpers.cjs'); + +const ROOT = path.resolve(__dirname, '..'); +const WORKFLOW_PATH = path.join(ROOT, 'gsd-core', 'workflows', 'execute-phase.md'); +const CAP_PATH = path.join(ROOT, 'capabilities', 'claude-orchestration', 'capability.json'); + +// ─── Fixtures ─────────────────────────────────────────────────────────────── + +/** A host-integration descriptor whose dispatch axis signals Workflow-tool capability. */ +const CAPABLE_HOST = { dispatch: { nested: true, background: true } }; +/** A descriptor that fails the nested/background dispatch gate. */ +const INCAPABLE_HOST = { dispatch: { nested: false, background: true } }; + +const ABOVE_FLOOR_SDK = '0.3.150'; +const AT_FLOOR_SDK = WORKFLOW_TOOL_FLOOR_VERSION; // '0.3.149' +const BELOW_FLOOR_SDK = '0.3.148'; + +function enabledConfig(overrides = {}) { + return { + 'claude_orchestration.enabled': true, + 'claude_orchestration.execution_backend': 'auto', + ...overrides, + }; +} + +function singleWave() { + return { + phaseDir: '.planning/phases/01-foo', + runId: 'run-2285-1', + waves: [ + { + id: 'w1', + plans: [ + { id: 'p1', brief: 'Implement the foo module', files_modified: ['src/foo.cts'] }, + ], + }, + ], + }; +} + +function baseInput(overrides = {}) { + return { + runtimeId: 'claude', + hostIntegration: CAPABLE_HOST, + agentSdkVersion: ABOVE_FLOOR_SDK, + config: enabledConfig(), + ...singleWave(), + ...overrides, + }; +} + +// ─── Section A: happy path — every gate satisfied → workflow backend ──────── + +describe('A. resolveWaveDispatch — enabled + all gates satisfied → workflow backend with emitted script', () => { + test('[happy] enabled, claude runtime, capable host, SDK above floor, auto backend → backend:"workflow"', () => { + const result = resolveWaveDispatch(baseInput()); + assert.strictEqual(result.backend, 'workflow'); + assert.strictEqual(result.reason, 'workflow_backend_active'); + assert.ok(typeof result.script === 'string' && result.script.length > 0, 'script must be a non-empty string'); + assert.match(result.script, /resumeFromRunId\("run-2285-1"\)/); + assert.match(result.script, /agentType: "gsd-executor", isolation: "worktree"/); + assert.ok(result.summary && result.summary.plans === 1, 'summary.plans must reflect the manifest'); + }); + + test('[happy] execution_backend explicitly "workflow" (not just "auto") also activates', () => { + const result = resolveWaveDispatch(baseInput({ + config: enabledConfig({ 'claude_orchestration.execution_backend': 'workflow' }), + })); + assert.strictEqual(result.backend, 'workflow'); + }); + + test('[bva] SDK version boundary: floor-1 → inline, floor exact → workflow, floor+1 → workflow', () => { + const below = resolveWaveDispatch(baseInput({ agentSdkVersion: BELOW_FLOOR_SDK })); + assert.strictEqual(below.backend, 'inline', 'below floor must be inline'); + assert.strictEqual(below.reason, 'agent_sdk_version_below_floor'); + + const at = resolveWaveDispatch(baseInput({ agentSdkVersion: AT_FLOOR_SDK })); + assert.strictEqual(at.backend, 'workflow', 'exactly at floor must activate workflow'); + + const above = resolveWaveDispatch(baseInput({ agentSdkVersion: ABOVE_FLOOR_SDK })); + assert.strictEqual(above.backend, 'workflow', 'above floor must activate workflow'); + }); +}); + +// ─── Section B: fail-closed contract — disabled / each gate individually failing → inline ── + +describe('B. resolveWaveDispatch — fail-closed contract: disabled or any gate miss → inline, matches detectWorkflowBackend 1:1', () => { + const GATE_MISS_CASES = [ + { + label: 'capability disabled', + overrides: { config: {} }, + expectedReason: 'capability_disabled', + }, + { + label: 'capability explicitly disabled', + overrides: { config: enabledConfig({ 'claude_orchestration.enabled': false }) }, + expectedReason: 'capability_disabled', + }, + { + label: 'runtime is not claude', + overrides: { runtimeId: 'codex' }, + expectedReason: 'runtime_not_claude', + }, + { + label: 'execution_backend explicitly "inline"', + overrides: { config: enabledConfig({ 'claude_orchestration.execution_backend': 'inline' }) }, + expectedReason: 'backend_inline', + }, + { + label: 'host descriptor incapable (nested:false)', + overrides: { hostIntegration: INCAPABLE_HOST }, + expectedReason: 'workflow_tool_unavailable', + }, + { + label: 'host descriptor missing entirely', + overrides: { hostIntegration: null }, + expectedReason: 'workflow_tool_unavailable', + }, + { + label: 'agent SDK version missing', + overrides: { agentSdkVersion: undefined }, + expectedReason: 'agent_sdk_version_unknown', + }, + { + label: 'agent SDK version malformed (not semver)', + overrides: { agentSdkVersion: 'not-a-version' }, + expectedReason: 'agent_sdk_version_unknown', + }, + { + label: 'agent SDK version below floor', + overrides: { agentSdkVersion: BELOW_FLOOR_SDK }, + expectedReason: 'agent_sdk_version_below_floor', + }, + ]; + + for (const { label, overrides, expectedReason } of GATE_MISS_CASES) { + test(`[negative] ${label} → backend:"inline", reason:"${expectedReason}"`, () => { + const input = baseInput(overrides); + const result = resolveWaveDispatch(input); + + assert.strictEqual(result.backend, 'inline', `${label}: must resolve to inline`); + assert.strictEqual(result.reason, expectedReason, `${label}: reason mismatch`); + + // Fail-closed CONTRACT: today's (byte-identical) inline dispatch carries no + // script/summary. Verify the shape never leaks emitter fields on a gate miss. + assert.deepStrictEqual( + Object.keys(result).sort(), + ['backend', 'reason'], + `${label}: inline result must be exactly {backend, reason}, got keys: ${Object.keys(result).join(',')}`, + ); + + // Parity: resolveWaveDispatch must not reimplement the gate ladder — its + // reason for a detect-side miss must be IDENTICAL to calling + // detectWorkflowBackend directly with the same gate-relevant fields. + const direct = detectWorkflowBackend({ + runtimeId: input.runtimeId, + hostIntegration: input.hostIntegration, + config: input.config, + agentSdkVersion: input.agentSdkVersion, + }); + assert.strictEqual(direct.backend, 'inline', `${label}: detectWorkflowBackend parity check must also be inline`); + assert.strictEqual(result.reason, direct.reason, `${label}: resolveWaveDispatch must surface detectWorkflowBackend's own reason verbatim`); + }); + } + + test('[negative] null/undefined/non-object input → inline, reason:"invalid_input" (never throws)', () => { + assert.deepStrictEqual(resolveWaveDispatch(null), { backend: 'inline', reason: 'invalid_input' }); + assert.deepStrictEqual(resolveWaveDispatch(undefined), { backend: 'inline', reason: 'invalid_input' }); + assert.deepStrictEqual(resolveWaveDispatch('not-an-object'), { backend: 'inline', reason: 'invalid_input' }); + }); + + test('[happy] a valid, dispatch-ready waves manifest never flips a gate-missed decision to workflow', () => { + // Prove the gate ladder short-circuits BEFORE emitWorkflowScript ever runs: + // even with a perfectly valid wave manifest, a disabled capability stays inline. + const result = resolveWaveDispatch(baseInput({ config: {}, ...singleWave() })); + assert.strictEqual(result.backend, 'inline'); + assert.strictEqual(result.reason, 'capability_disabled'); + }); +}); + +// ─── Section C: composition correctness — detect + emit have a real, non-CLI, non-test caller ── + +describe('C. resolveWaveDispatch composes detectWorkflowBackend + emitWorkflowScript (the seam itself)', () => { + test('[happy] resolveWaveDispatch is exported as a function from the core module', () => { + assert.strictEqual(typeof resolveWaveDispatch, 'function'); + }); + + test('[happy] on a workflow-hit, the emitted script/summary are IDENTICAL to calling emitWorkflowScript directly with the same wave data', () => { + const input = baseInput(); + const composed = resolveWaveDispatch(input); + assert.strictEqual(composed.backend, 'workflow'); + + const directEmit = emitWorkflowScript({ + phaseDir: input.phaseDir, + waves: input.waves, + runId: input.runId, + }); + assert.strictEqual(directEmit.ok, true); + assert.strictEqual(composed.script, directEmit.script, 'resolveWaveDispatch must not re-implement emission — script must match emitWorkflowScript byte-for-byte'); + assert.deepStrictEqual(composed.summary, directEmit.summary); + }); + + test('[negative] detect-hit but a malformed wave manifest (emit failure) → inline, carrying emitWorkflowScript\'s own failure reason', () => { + const input = baseInput({ waves: [] }); // emitWorkflowScript rejects empty waves + const result = resolveWaveDispatch(input); + assert.strictEqual(result.backend, 'inline'); + + const directEmit = emitWorkflowScript({ phaseDir: input.phaseDir, waves: input.waves, runId: input.runId }); + assert.strictEqual(directEmit.ok, false); + assert.strictEqual(result.reason, 'emit_failed: ' + directEmit.reason, 'the emit failure reason must be surfaced verbatim, prefixed'); + + // Still byte-identical inline shape — no partial/broken script ever leaks. + assert.deepStrictEqual(Object.keys(result).sort(), ['backend', 'reason']); + }); + + test('[happy] the CLI subcommand `claude-orchestration resolve-wave-dispatch` is ALSO a caller and matches the pure function output', () => { + const tmp = createTempDir('fix-2285-'); + try { + fs.mkdirSync(path.join(tmp, '.planning'), { recursive: true }); + fs.writeFileSync( + path.join(tmp, '.planning', 'config.json'), + JSON.stringify({ claude_orchestration: { enabled: true, execution_backend: 'auto' } }), + ); + const wavesPath = path.join(tmp, 'waves.json'); + fs.writeFileSync(wavesPath, JSON.stringify({ waves: singleWave().waves })); + + const res = runGsdTools([ + 'claude-orchestration', 'resolve-wave-dispatch', + '--waves', wavesPath, + '--run-id', 'run-2285-1', + '--phase-dir', '.planning/phases/01-foo', + '--runtime', 'claude', + '--agent-sdk-version', ABOVE_FLOOR_SDK, + '--raw', + ], tmp); + assert.strictEqual(res.success, true, 'CLI command must succeed; stderr: ' + (res.error || '')); + const parsed = JSON.parse(res.output); + + const direct = resolveWaveDispatch(baseInput()); + assert.strictEqual(parsed.backend, direct.backend); + assert.strictEqual(parsed.script, direct.script); + assert.deepStrictEqual(parsed.summary, direct.summary); + } finally { + cleanup(tmp); + } + }); + + test('[negative] CLI subcommand fails closed to inline exactly like the pure function when disabled', () => { + const tmp = createTempDir('fix-2285-off-'); + try { + fs.mkdirSync(path.join(tmp, '.planning'), { recursive: true }); + fs.writeFileSync(path.join(tmp, '.planning', 'config.json'), '{}'); + const wavesPath = path.join(tmp, 'waves.json'); + fs.writeFileSync(wavesPath, JSON.stringify({ waves: singleWave().waves })); + + const res = runGsdTools([ + 'claude-orchestration', 'resolve-wave-dispatch', + '--waves', wavesPath, '--run-id', 'run-x', '--raw', + ], tmp); + assert.strictEqual(res.success, true, 'CLI command must succeed (fail-closed, not error); stderr: ' + (res.error || '')); + const parsed = JSON.parse(res.output); + assert.strictEqual(parsed.backend, 'inline'); + assert.strictEqual(parsed.reason, 'capability_disabled'); + assert.deepStrictEqual(Object.keys(parsed).sort(), ['backend', 'reason']); + } finally { + cleanup(tmp); + } + }); + + test('property: for ANY input, resolveWaveDispatch never throws, backend is always "inline"|"workflow", and "inline" results carry exactly {backend, reason}', () => { + fc.assert(fc.property( + fc.record({ + runtimeId: fc.oneof(fc.constant('claude'), fc.constant('codex'), fc.constant(undefined), fc.string()), + agentSdkVersion: fc.oneof(fc.constant(ABOVE_FLOOR_SDK), fc.constant(BELOW_FLOOR_SDK), fc.constant(undefined), fc.string()), + enabled: fc.boolean(), + capableHost: fc.boolean(), + backendPref: fc.constantFrom('auto', 'workflow', 'inline'), + }), + ({ runtimeId, agentSdkVersion, enabled, capableHost, backendPref }) => { + const input = { + runtimeId, + hostIntegration: capableHost ? CAPABLE_HOST : INCAPABLE_HOST, + agentSdkVersion, + config: { + 'claude_orchestration.enabled': enabled, + 'claude_orchestration.execution_backend': backendPref, + }, + ...singleWave(), + }; + const result = resolveWaveDispatch(input); + assert.ok(result.backend === 'inline' || result.backend === 'workflow'); + if (result.backend === 'inline') { + assert.deepStrictEqual(Object.keys(result).sort(), ['backend', 'reason']); + } else { + assert.ok(typeof result.script === 'string' && result.script.length > 0); + } + }, + )); + }); +}); + +// ─── Section D: capability declaration now targets execute:wave:pre ───────── + +describe('D. capability.json declares the contribution at execute:wave:pre (#2285)', () => { + test('[happy] contribution point is execute:wave:pre, not execute:wave:post', () => { + const cap = JSON.parse(fs.readFileSync(CAP_PATH, 'utf8')); + const wavePreContrib = cap.contributions.find((c) => c.point === 'execute:wave:pre'); + assert.ok(wavePreContrib, 'capability.json must declare a contribution at execute:wave:pre'); + assert.strictEqual(wavePreContrib.into, 'executor'); + assert.strictEqual(wavePreContrib.when, 'claude_orchestration.enabled'); + assert.strictEqual(wavePreContrib.onError, 'skip'); + assert.strictEqual(wavePreContrib.fragment.path, 'fragments/execute-wave-pre.md'); + + const wavePostContrib = cap.contributions.find((c) => c.point === 'execute:wave:post'); + assert.strictEqual(wavePostContrib, undefined, 'the capability must no longer contribute at execute:wave:post'); + }); + + test('[happy] the declared fragment file exists on disk', () => { + const fragPath = path.join(ROOT, 'capabilities', 'claude-orchestration', 'fragments', 'execute-wave-pre.md'); + assert.ok(fs.existsSync(fragPath), 'fragments/execute-wave-pre.md must exist'); + const content = fs.readFileSync(fragPath, 'utf8'); + assert.match(content, /execute:wave:pre/); + assert.match(content, /resolve-wave-dispatch/); + }); +}); + +// ─── Section E: source-contract guard — execute-phase.md renders execute:wave:pre BEFORE dispatch ── + +describe('E. execute-phase.md actually renders execute:wave:pre (the dead hook is now live)', () => { + test('[happy] execute-phase.md invokes `loop render-hooks execute:wave:pre`', () => { + const doc = fs.readFileSync(WORKFLOW_PATH, 'utf8'); + assert.ok( + /loop render-hooks execute:wave:pre/.test(doc), + 'execute-phase.md must dispatch execute:wave:pre hooks (was declared in frontmatter but never rendered — #2285)', + ); + }); + + test('[happy] the execute:wave:pre render-hooks call site appears BEFORE the wave\'s Agent() dispatch (pre-wave, not post)', () => { + const doc = fs.readFileSync(WORKFLOW_PATH, 'utf8'); + const preHooksIdx = doc.indexOf('loop render-hooks execute:wave:pre'); + // Anchor on the actual per-wave dispatch call (step 3), not the generic + // `subagent_type="gsd-executor"` mention in near the + // top of the file — that mention predates the wave loop entirely and would + // give a false "before" reading. + const agentDispatchIdx = doc.indexOf('description="Execute plan {plan_number}'); + assert.ok(preHooksIdx !== -1, 'execute:wave:pre render-hooks call site must exist'); + assert.ok(agentDispatchIdx !== -1, 'the gsd-executor Agent() dispatch call site (step 3) must exist'); + assert.ok( + preHooksIdx < agentDispatchIdx, + `execute:wave:pre render-hooks (idx ${preHooksIdx}) must appear BEFORE the wave's Agent() dispatch (idx ${agentDispatchIdx}) — it is a pre-wave hook`, + ); + }); + + test('[happy] the frontmatter still declares all four execute:* points (regression guard)', () => { + const doc = fs.readFileSync(WORKFLOW_PATH, 'utf8'); + const frontmatterMatch = doc.match(/points:\s*(.+)/); + assert.ok(frontmatterMatch, 'frontmatter must declare a points: line'); + for (const point of ['execute:pre', 'execute:wave:pre', 'execute:wave:post', 'execute:post']) { + assert.ok(frontmatterMatch[1].includes(point), `frontmatter points: line must include ${point}`); + } + }); + + test('[happy] execute:wave:post is still rendered too (regression guard — did not accidentally remove the post-wave gate dispatch)', () => { + const doc = fs.readFileSync(WORKFLOW_PATH, 'utf8'); + assert.ok( + /loop render-hooks execute:wave:post/.test(doc), + 'execute-phase.md must still dispatch execute:wave:post hooks (drift/ui gates unaffected by #2285)', + ); + }); +}); + +// ─── Section F: orthogonal-review finding 1 — submodule plans never forced into worktree isolation ── +// +// #2772 / #2285 finding 1: emitWorkflowScript previously hardcoded +// `isolation: "worktree"` for EVERY plan. execute-phase.md step 2.5 computes +// USE_WORKTREES_FOR_PLAN per plan specifically to keep submodule-touching +// plans OUT of worktree isolation (the executor commit protocol cannot +// correctly handle submodule commits inside an isolated worktree). The +// Workflow backend must honor the SAME per-plan decision via `use_worktree`. + +function waveWithSubmodulePlan() { + return { + phaseDir: '.planning/phases/01-foo', + runId: 'run-2285-submodule', + waves: [ + { + id: 'w1', + plans: [ + { id: 'p1', brief: 'normal plan', files_modified: ['src/a.ts'] }, + { id: 'p2', brief: 'submodule plan', files_modified: ['vendor/lib.c'], use_worktree: false }, + ], + }, + ], + }; +} + +describe('F. Workflow backend never forces worktree isolation on a submodule / use_worktree:false plan', () => { + test('[happy] resolveWaveDispatch (pure seam): the submodule plan\'s agent() call carries NO isolation, the normal plan\'s does', () => { + const result = resolveWaveDispatch(baseInput({ ...waveWithSubmodulePlan() })); + assert.strictEqual(result.backend, 'workflow'); + assert.match(result.script, /agent\("normal plan", \{ agentType: "gsd-executor", isolation: "worktree" \}\)/); + assert.match(result.script, /agent\("submodule plan", \{ agentType: "gsd-executor" \}\)/); + assert.ok( + !/agent\("submodule plan"[^)]*isolation/.test(result.script), + 'the submodule-touching plan must NEVER be emitted with forced worktree isolation', + ); + }); + + test('[happy] CLI `resolve-wave-dispatch`: same per-plan guarantee end-to-end through the subprocess', () => { + const tmp = createTempDir('fix-2285-submodule-'); + try { + fs.mkdirSync(path.join(tmp, '.planning'), { recursive: true }); + fs.writeFileSync( + path.join(tmp, '.planning', 'config.json'), + JSON.stringify({ claude_orchestration: { enabled: true, execution_backend: 'auto' } }), + ); + const wavesPath = path.join(tmp, 'waves.json'); + fs.writeFileSync(wavesPath, JSON.stringify({ waves: waveWithSubmodulePlan().waves })); + + const res = runGsdTools([ + 'claude-orchestration', 'resolve-wave-dispatch', + '--waves', wavesPath, + '--run-id', 'run-2285-submodule', + '--phase-dir', '.planning/phases/01-foo', + '--runtime', 'claude', + '--agent-sdk-version', ABOVE_FLOOR_SDK, + '--raw', + ], tmp); + assert.strictEqual(res.success, true, 'CLI command must succeed; stderr: ' + (res.error || '')); + const parsed = JSON.parse(res.output); + assert.strictEqual(parsed.backend, 'workflow'); + assert.match(parsed.script, /agent\("submodule plan", \{ agentType: "gsd-executor" \}\)/); + assert.ok(!/agent\("submodule plan"[^)]*isolation/.test(parsed.script)); + } finally { + cleanup(tmp); + } + }); + + test('[negative] use_worktree defaults to true when omitted — a manifest with NO submodule info stays backward-compatible', () => { + const result = resolveWaveDispatch(baseInput()); + assert.strictEqual(result.backend, 'workflow'); + assert.match(result.script, /isolation: "worktree"/, 'default (no use_worktree field) must still isolate — backward compatible'); + }); +}); + +// ─── Section G: orthogonal-review finding 2 — missing top-level `waves` key must never silently exit 0 ── +// +// readWavesManifest previously collapsed "read/parse threw" and "parsed OK but +// no top-level `waves` key" into the same `undefined` sentinel. The call sites' +// `if (waves === undefined) return;` made the missing-key case exit 0 with ZERO +// output — fail-silent, breaking the "exit 0 => parseable JSON verdict" contract. +// A missing key must now flow through to emitWorkflowScript's own validation, +// exactly like an explicit `{"waves": null}` manifest already does. + +describe('G. missing top-level `waves` key never silently exits 0 with no output', () => { + function projectWithEnabledCapability(prefix) { + const tmp = createTempDir(prefix); + fs.mkdirSync(path.join(tmp, '.planning'), { recursive: true }); + fs.writeFileSync( + path.join(tmp, '.planning', 'config.json'), + JSON.stringify({ claude_orchestration: { enabled: true, execution_backend: 'auto' } }), + ); + return tmp; + } + + test('[negative] resolve-wave-dispatch with a {"notwaves":[]} manifest → non-empty JSON verdict (NOT silent exit 0)', () => { + const tmp = projectWithEnabledCapability('fix-2285-missingkey-resolve-'); + try { + const wavesPath = path.join(tmp, 'waves.json'); + fs.writeFileSync(wavesPath, JSON.stringify({ notwaves: [] })); + + const res = runGsdTools([ + 'claude-orchestration', 'resolve-wave-dispatch', + '--waves', wavesPath, '--run-id', 'run-x', + '--runtime', 'claude', '--agent-sdk-version', ABOVE_FLOOR_SDK, + '--raw', + ], tmp); + + assert.strictEqual(res.success, true, 'command must exit 0 (fail-closed to inline, not error); stderr: ' + (res.error || '')); + assert.ok(res.output.length > 0, 'FAIL-SILENT REGRESSION: missing waves key must NOT produce empty stdout on exit 0'); + const parsed = JSON.parse(res.output); + assert.strictEqual(parsed.backend, 'inline'); + assert.match(parsed.reason, /waves must be a non-empty array/, 'reason must surface emitWorkflowScript\'s own validation message'); + } finally { + cleanup(tmp); + } + }); + + test('[negative] resolve-wave-dispatch: {"notwaves":[]} and {"waves": null} produce the IDENTICAL verdict (parity)', () => { + const tmp = projectWithEnabledCapability('fix-2285-missingkey-parity-'); + try { + const missingKeyPath = path.join(tmp, 'missing.json'); + fs.writeFileSync(missingKeyPath, JSON.stringify({ notwaves: [] })); + const nullWavesPath = path.join(tmp, 'null.json'); + fs.writeFileSync(nullWavesPath, JSON.stringify({ waves: null })); + + const argsFor = (p) => [ + 'claude-orchestration', 'resolve-wave-dispatch', + '--waves', p, '--run-id', 'run-x', + '--runtime', 'claude', '--agent-sdk-version', ABOVE_FLOOR_SDK, + '--raw', + ]; + const missingRes = runGsdTools(argsFor(missingKeyPath), tmp); + const nullRes = runGsdTools(argsFor(nullWavesPath), tmp); + assert.strictEqual(missingRes.success, true); + assert.strictEqual(nullRes.success, true); + assert.deepStrictEqual(JSON.parse(missingRes.output), JSON.parse(nullRes.output), 'a missing `waves` key must behave identically to an explicit `waves: null`'); + } finally { + cleanup(tmp); + } + }); + + test('[negative] emit-workflow with a {"notwaves":[]} manifest → loud non-zero exit (NOT silent exit 0)', () => { + const tmp = createTempDir('fix-2285-missingkey-emit-'); + try { + const wavesPath = path.join(tmp, 'waves.json'); + fs.writeFileSync(wavesPath, JSON.stringify({ notwaves: [] })); + + const res = runGsdTools([ + 'claude-orchestration', 'emit-workflow', + '--waves', wavesPath, '--run-id', 'run-x', + ], tmp); + + assert.strictEqual(res.success, false, 'FAIL-SILENT REGRESSION: missing waves key must produce a loud, non-zero-exit error, not a silent success'); + assert.ok(res.exitCode !== 0, 'non-zero exit'); + assert.match(res.error || '', /waves must be a non-empty array/); + } finally { + cleanup(tmp); + } + }); + + test('[happy] a genuinely malformed (unparseable) --waves file still fails loudly, unaffected by the fix', () => { + const tmp = createTempDir('fix-2285-badjson-'); + try { + const wavesPath = path.join(tmp, 'waves.json'); + fs.writeFileSync(wavesPath, 'not json at all'); + + const res = runGsdTools([ + 'claude-orchestration', 'resolve-wave-dispatch', + '--waves', wavesPath, '--run-id', 'run-x', '--raw', + ], tmp); + assert.strictEqual(res.success, false, 'a real parse failure must still error'); + assert.match(res.error || '', /could not read\/parse --waves file/); + } finally { + cleanup(tmp); + } + }); +}); + +// ─── Section H: orthogonal-review finding 3 — manifest construction guidance is concrete ── + +describe('H. the execute:wave:pre fragment documents concrete manifest construction (finding 3)', () => { + test('[happy] the fragment explains how to build WAVE_MANIFEST_PATH, PHASE_RUN_ID, and per-plan use_worktree', () => { + const fragPath = path.join(ROOT, 'capabilities', 'claude-orchestration', 'fragments', 'execute-wave-pre.md'); + const content = fs.readFileSync(fragPath, 'utf8'); + assert.match(content, /Manifest construction/, 'fragment must have concrete manifest-construction guidance, not just reference undefined vars'); + assert.match(content, /PHASE_RUN_ID/); + assert.match(content, /WAVE_MANIFEST_PATH/); + assert.match(content, /use_worktree/); + assert.match(content, /USE_WORKTREES_FOR_PLAN/, 'must tie use_worktree back to step 2.5\'s per-plan decision'); + }); + + test('[happy] execute-phase.md step 2.75 stays minimal — manifest/use_worktree detail lives ONLY in the fragment (#1168 byte-budget conformance)', () => { + // Per the ADR-857 Phase 6 conformance gate (tests/phase6-capstone-conformance.test.cjs), + // the host loop must stay small — optional-feature detail (manifest construction, + // per-plan use_worktree carry-through) belongs in the capability fragment, not the + // host workflow. Step 2.75 is intentionally just a render-hooks call + a one-line + // "follow the contribution or fall through to step 3" instruction. + const doc = fs.readFileSync(WORKFLOW_PATH, 'utf8'); + const stepStart = doc.indexOf('2.75. **Execute:wave:pre capability dispatch:**'); + const stepEnd = doc.indexOf('\n3. **Spawn executor agents:**', stepStart); + assert.ok(stepStart !== -1 && stepEnd !== -1, 'step 2.75 must exist and precede step 3'); + const stepBody = doc.slice(stepStart, stepEnd); + assert.match(stepBody, /loop render-hooks execute:wave:pre/, 'step 2.75 must still render the hook point'); + assert.doesNotMatch(stepBody, /use_worktree/, 'manifest-construction detail (use_worktree) must live in the fragment, not the host step'); + assert.doesNotMatch(stepBody, /USE_WORKTREES_FOR_PLAN/, 'per-plan worktree gate detail must live in the fragment, not the host step'); + }); + + test('[happy] execute-phase.md is below the ADR-857 Phase 6 pre-phase-6 byte ceiling (#1168), with margin', () => { + const { lfByteCount } = require('../scripts/workflow-size.cjs'); + const bytes = lfByteCount(WORKFLOW_PATH); + assert.ok(bytes < 93600, `execute-phase.md must stay below the frozen pre-phase-6 ceiling (93600); got ${bytes}`); + assert.ok(bytes <= 93400, `execute-phase.md should carry a comfortable margin (<=93400) so minor future edits don't re-trip the gate; got ${bytes}`); + }); +}); + +// ─── Section I: orthogonal-review finding 4 — stale doc fixed ─────────────── + +describe('I. docs/explanation/claude-orchestration-capability.md reflects the execute:wave:pre move (finding 4)', () => { + test('[happy] the doc no longer claims the capability registers at execute:wave:post', () => { + const docPath = path.join(ROOT, 'docs', 'explanation', 'claude-orchestration-capability.md'); + const content = fs.readFileSync(docPath, 'utf8'); + assert.match(content, /execute:wave:pre/, 'doc must mention execute:wave:pre as the wired point'); + assert.ok( + !/execute:wave:post.*\(into the executor\)/.test(content), + 'doc must not still claim the wired point is execute:wave:post', + ); + }); +}); diff --git a/tests/fixtures/golden-install-parity/antigravity.json b/tests/fixtures/golden-install-parity/antigravity.json index 3089db641..7fabff4fa 100644 --- a/tests/fixtures/golden-install-parity/antigravity.json +++ b/tests/fixtures/golden-install-parity/antigravity.json @@ -233,7 +233,7 @@ "gsd-core/workflows/docs-update.md": "8e986e26d0e6e1a0", "gsd-core/workflows/edit-phase.md": "fc932e82ba1f585a", "gsd-core/workflows/eval-review.md": "eb4040eaa5b8497f", - "gsd-core/workflows/execute-phase.md": "894d0e9efed72258", + "gsd-core/workflows/execute-phase.md": "a349bfdfcdf2c011", "gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md": "55d0706e80a2554a", "gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md": "7ebb7d1af6082028", "gsd-core/workflows/execute-phase/steps/post-merge-gate.md": "9b7107b31b60a3b9", diff --git a/tests/fixtures/golden-install-parity/augment.json b/tests/fixtures/golden-install-parity/augment.json index c3f16592a..f5e1840af 100644 --- a/tests/fixtures/golden-install-parity/augment.json +++ b/tests/fixtures/golden-install-parity/augment.json @@ -304,7 +304,7 @@ "gsd-core/workflows/docs-update.md": "f35922d15b7061c9", "gsd-core/workflows/edit-phase.md": "966a3eadd1bebc04", "gsd-core/workflows/eval-review.md": "f898936e2cfe4130", - "gsd-core/workflows/execute-phase.md": "7d42e81188b3613a", + "gsd-core/workflows/execute-phase.md": "42bb004d479f7706", "gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md": "4e265392b3f2ba0e", "gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md": "7ebb7d1af6082028", "gsd-core/workflows/execute-phase/steps/post-merge-gate.md": "811b6d8489571581", diff --git a/tests/fixtures/golden-install-parity/claude-local.json b/tests/fixtures/golden-install-parity/claude-local.json index 331e1817b..d3893f1ae 100644 --- a/tests/fixtures/golden-install-parity/claude-local.json +++ b/tests/fixtures/golden-install-parity/claude-local.json @@ -303,7 +303,7 @@ "gsd-core/workflows/docs-update.md": "cd753783ab95da00", "gsd-core/workflows/edit-phase.md": "dbbb6191f5a8b65e", "gsd-core/workflows/eval-review.md": "086a1f2b3c11462c", - "gsd-core/workflows/execute-phase.md": "e5bc721629beaa01", + "gsd-core/workflows/execute-phase.md": "a0f26f223218812d", "gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md": "6d38bfd540030da4", "gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md": "7ebb7d1af6082028", "gsd-core/workflows/execute-phase/steps/post-merge-gate.md": "611b2be3bd133eb1", diff --git a/tests/fixtures/golden-install-parity/claude.json b/tests/fixtures/golden-install-parity/claude.json index cfc788311..e9e87b37c 100644 --- a/tests/fixtures/golden-install-parity/claude.json +++ b/tests/fixtures/golden-install-parity/claude.json @@ -232,7 +232,7 @@ "gsd-core/workflows/docs-update.md": "63082608d3ae92be", "gsd-core/workflows/edit-phase.md": "8323bfe10faa0c0a", "gsd-core/workflows/eval-review.md": "f59e8329dae1e528", - "gsd-core/workflows/execute-phase.md": "5231186012da87a2", + "gsd-core/workflows/execute-phase.md": "a5ca7da9e551bf4c", "gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md": "facb0e816d87a0c7", "gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md": "7ebb7d1af6082028", "gsd-core/workflows/execute-phase/steps/post-merge-gate.md": "811b6d8489571581", diff --git a/tests/fixtures/golden-install-parity/cline.json b/tests/fixtures/golden-install-parity/cline.json index e161d2107..4d0ac889d 100644 --- a/tests/fixtures/golden-install-parity/cline.json +++ b/tests/fixtures/golden-install-parity/cline.json @@ -236,7 +236,7 @@ "gsd-core/workflows/docs-update.md": "39f288623a8f6f32", "gsd-core/workflows/edit-phase.md": "9c9fadc047c61d74", "gsd-core/workflows/eval-review.md": "3e1d7829ed2ed494", - "gsd-core/workflows/execute-phase.md": "6213f0d6e64dc524", + "gsd-core/workflows/execute-phase.md": "7cafa358f645936a", "gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md": "67ebc93f51968cb6", "gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md": "7ebb7d1af6082028", "gsd-core/workflows/execute-phase/steps/post-merge-gate.md": "82e6cfe1e1b1ec0e", diff --git a/tests/fixtures/golden-install-parity/codebuddy.json b/tests/fixtures/golden-install-parity/codebuddy.json index 7b7a4123a..1a5dd5dc1 100644 --- a/tests/fixtures/golden-install-parity/codebuddy.json +++ b/tests/fixtures/golden-install-parity/codebuddy.json @@ -304,7 +304,7 @@ "gsd-core/workflows/docs-update.md": "f35922d15b7061c9", "gsd-core/workflows/edit-phase.md": "966a3eadd1bebc04", "gsd-core/workflows/eval-review.md": "f898936e2cfe4130", - "gsd-core/workflows/execute-phase.md": "f17719f6b780cd2b", + "gsd-core/workflows/execute-phase.md": "4d6f9e033792619a", "gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md": "4e265392b3f2ba0e", "gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md": "7ebb7d1af6082028", "gsd-core/workflows/execute-phase/steps/post-merge-gate.md": "811b6d8489571581", diff --git a/tests/fixtures/golden-install-parity/codex.json b/tests/fixtures/golden-install-parity/codex.json index 203fae673..973e0f618 100644 --- a/tests/fixtures/golden-install-parity/codex.json +++ b/tests/fixtures/golden-install-parity/codex.json @@ -339,7 +339,7 @@ "gsd-core/workflows/docs-update.md": "e255317df939e302", "gsd-core/workflows/edit-phase.md": "e592a4d85ce5380f", "gsd-core/workflows/eval-review.md": "63d0d0670b54c244", - "gsd-core/workflows/execute-phase.md": "b15bbe2f8a8d8d02", + "gsd-core/workflows/execute-phase.md": "8176e734cd927891", "gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md": "f4cacd27d37bac65", "gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md": "7ebb7d1af6082028", "gsd-core/workflows/execute-phase/steps/post-merge-gate.md": "811b6d8489571581", diff --git a/tests/fixtures/golden-install-parity/copilot.json b/tests/fixtures/golden-install-parity/copilot.json index b640f22ef..1e4133696 100644 --- a/tests/fixtures/golden-install-parity/copilot.json +++ b/tests/fixtures/golden-install-parity/copilot.json @@ -234,7 +234,7 @@ "gsd-core/workflows/docs-update.md": "9292cfa3c52c434e", "gsd-core/workflows/edit-phase.md": "8667c28b22b1599f", "gsd-core/workflows/eval-review.md": "81c8e72ba3862856", - "gsd-core/workflows/execute-phase.md": "80a1e0722f72dbb1", + "gsd-core/workflows/execute-phase.md": "75bc8795af213c29", "gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md": "63b712920f21a40f", "gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md": "7ebb7d1af6082028", "gsd-core/workflows/execute-phase/steps/post-merge-gate.md": "1b73ab2fc47c9dbf", diff --git a/tests/fixtures/golden-install-parity/cursor.json b/tests/fixtures/golden-install-parity/cursor.json index 88712fe22..1ee36be06 100644 --- a/tests/fixtures/golden-install-parity/cursor.json +++ b/tests/fixtures/golden-install-parity/cursor.json @@ -304,7 +304,7 @@ "gsd-core/workflows/docs-update.md": "e86d7d7e2e3dac6d", "gsd-core/workflows/edit-phase.md": "8323bfe10faa0c0a", "gsd-core/workflows/eval-review.md": "a86279dd98dd5c03", - "gsd-core/workflows/execute-phase.md": "a761ee37b32a6fe2", + "gsd-core/workflows/execute-phase.md": "f67b5d55c3e7dc4b", "gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md": "facb0e816d87a0c7", "gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md": "7ebb7d1af6082028", "gsd-core/workflows/execute-phase/steps/post-merge-gate.md": "811b6d8489571581", diff --git a/tests/fixtures/golden-install-parity/hermes.json b/tests/fixtures/golden-install-parity/hermes.json index 348704ed1..8261b0186 100644 --- a/tests/fixtures/golden-install-parity/hermes.json +++ b/tests/fixtures/golden-install-parity/hermes.json @@ -233,7 +233,7 @@ "gsd-core/workflows/docs-update.md": "ba4cf926fc463cbe", "gsd-core/workflows/edit-phase.md": "7f27003f20e88fb8", "gsd-core/workflows/eval-review.md": "f510e5762212dc6f", - "gsd-core/workflows/execute-phase.md": "e99263e6cfe3bdb2", + "gsd-core/workflows/execute-phase.md": "3205cca9fe364951", "gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md": "14a27cc0828f59d3", "gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md": "26ee34c543926402", "gsd-core/workflows/execute-phase/steps/post-merge-gate.md": "c9ad17d6cc6dfe45", diff --git a/tests/fixtures/golden-install-parity/kilo.json b/tests/fixtures/golden-install-parity/kilo.json index 2bf982251..d93ba9396 100644 --- a/tests/fixtures/golden-install-parity/kilo.json +++ b/tests/fixtures/golden-install-parity/kilo.json @@ -304,7 +304,7 @@ "gsd-core/workflows/docs-update.md": "79afaaf19fd527cc", "gsd-core/workflows/edit-phase.md": "8323bfe10faa0c0a", "gsd-core/workflows/eval-review.md": "926eda8bbee28b23", - "gsd-core/workflows/execute-phase.md": "43c8c218f8933cc8", + "gsd-core/workflows/execute-phase.md": "4babf365129ad14b", "gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md": "facb0e816d87a0c7", "gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md": "7ebb7d1af6082028", "gsd-core/workflows/execute-phase/steps/post-merge-gate.md": "811b6d8489571581", diff --git a/tests/fixtures/golden-install-parity/kimi.json b/tests/fixtures/golden-install-parity/kimi.json index a92fab751..5d690f133 100644 --- a/tests/fixtures/golden-install-parity/kimi.json +++ b/tests/fixtures/golden-install-parity/kimi.json @@ -297,7 +297,7 @@ "gsd-core/workflows/docs-update.md": "f35922d15b7061c9", "gsd-core/workflows/edit-phase.md": "966a3eadd1bebc04", "gsd-core/workflows/eval-review.md": "f898936e2cfe4130", - "gsd-core/workflows/execute-phase.md": "39def0423fb4eb45", + "gsd-core/workflows/execute-phase.md": "2629630eb4a94a6b", "gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md": "4e265392b3f2ba0e", "gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md": "7ebb7d1af6082028", "gsd-core/workflows/execute-phase/steps/post-merge-gate.md": "811b6d8489571581", diff --git a/tests/fixtures/golden-install-parity/opencode.json b/tests/fixtures/golden-install-parity/opencode.json index 8fc769d81..0d473e07b 100644 --- a/tests/fixtures/golden-install-parity/opencode.json +++ b/tests/fixtures/golden-install-parity/opencode.json @@ -304,7 +304,7 @@ "gsd-core/workflows/docs-update.md": "850366c2ef8fb780", "gsd-core/workflows/edit-phase.md": "1876c855fb0a0a39", "gsd-core/workflows/eval-review.md": "5394694d29ad7543", - "gsd-core/workflows/execute-phase.md": "2a1c33e0bda26211", + "gsd-core/workflows/execute-phase.md": "895f8e910b32ebd4", "gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md": "1804215577f1ad35", "gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md": "7ebb7d1af6082028", "gsd-core/workflows/execute-phase/steps/post-merge-gate.md": "baa2c401af10a80a", diff --git a/tests/fixtures/golden-install-parity/pi.json b/tests/fixtures/golden-install-parity/pi.json index 0e472f916..594bd91fa 100644 --- a/tests/fixtures/golden-install-parity/pi.json +++ b/tests/fixtures/golden-install-parity/pi.json @@ -200,7 +200,7 @@ "gsd-core/workflows/docs-update.md": "f35922d15b7061c9", "gsd-core/workflows/edit-phase.md": "966a3eadd1bebc04", "gsd-core/workflows/eval-review.md": "f898936e2cfe4130", - "gsd-core/workflows/execute-phase.md": "8f6dc95cc8030259", + "gsd-core/workflows/execute-phase.md": "353711104c33d19f", "gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md": "4e265392b3f2ba0e", "gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md": "7ebb7d1af6082028", "gsd-core/workflows/execute-phase/steps/post-merge-gate.md": "811b6d8489571581", diff --git a/tests/fixtures/golden-install-parity/qwen.json b/tests/fixtures/golden-install-parity/qwen.json index 7017e7e27..b79261ff2 100644 --- a/tests/fixtures/golden-install-parity/qwen.json +++ b/tests/fixtures/golden-install-parity/qwen.json @@ -233,7 +233,7 @@ "gsd-core/workflows/docs-update.md": "45d2f0d173c84e07", "gsd-core/workflows/edit-phase.md": "0fb5e0123cfc6f36", "gsd-core/workflows/eval-review.md": "6dee8a1e40ececd4", - "gsd-core/workflows/execute-phase.md": "38370aa7653a1c5b", + "gsd-core/workflows/execute-phase.md": "840ce6d4ff582aed", "gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md": "36af8d91e4ae8b9c", "gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md": "7ebb7d1af6082028", "gsd-core/workflows/execute-phase/steps/post-merge-gate.md": "98db1ba4c39cd784", diff --git a/tests/fixtures/golden-install-parity/trae.json b/tests/fixtures/golden-install-parity/trae.json index 367c924ff..98ff03b1d 100644 --- a/tests/fixtures/golden-install-parity/trae.json +++ b/tests/fixtures/golden-install-parity/trae.json @@ -233,7 +233,7 @@ "gsd-core/workflows/docs-update.md": "f13571f08e083356", "gsd-core/workflows/edit-phase.md": "7facd0faa33c8cad", "gsd-core/workflows/eval-review.md": "37d545d4f0db4927", - "gsd-core/workflows/execute-phase.md": "8d197109e4d60522", + "gsd-core/workflows/execute-phase.md": "cadbd19b2a828111", "gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md": "c985a30317a1aa6b", "gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md": "7ebb7d1af6082028", "gsd-core/workflows/execute-phase/steps/post-merge-gate.md": "0a9e915170c7121c", diff --git a/tests/fixtures/golden-install-parity/windsurf.json b/tests/fixtures/golden-install-parity/windsurf.json index 070219b58..9a8ddd406 100644 --- a/tests/fixtures/golden-install-parity/windsurf.json +++ b/tests/fixtures/golden-install-parity/windsurf.json @@ -233,7 +233,7 @@ "gsd-core/workflows/docs-update.md": "70f73cc8c27e0ca0", "gsd-core/workflows/edit-phase.md": "c0ae7d0063f3e789", "gsd-core/workflows/eval-review.md": "b28be79ef29f16fd", - "gsd-core/workflows/execute-phase.md": "7d88881ded1b575e", + "gsd-core/workflows/execute-phase.md": "46a363f3f33cc596", "gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md": "47ae5482f8e64100", "gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md": "7ebb7d1af6082028", "gsd-core/workflows/execute-phase/steps/post-merge-gate.md": "15bca39a75c664be", diff --git a/tests/fixtures/golden-install-parity/zcode.json b/tests/fixtures/golden-install-parity/zcode.json index 9bf8e5a85..59b3baa42 100644 --- a/tests/fixtures/golden-install-parity/zcode.json +++ b/tests/fixtures/golden-install-parity/zcode.json @@ -304,7 +304,7 @@ "gsd-core/workflows/docs-update.md": "f35922d15b7061c9", "gsd-core/workflows/edit-phase.md": "966a3eadd1bebc04", "gsd-core/workflows/eval-review.md": "f898936e2cfe4130", - "gsd-core/workflows/execute-phase.md": "55c0e6f659e27ff2", + "gsd-core/workflows/execute-phase.md": "4bd16cf990e3ee63", "gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md": "4e265392b3f2ba0e", "gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md": "7ebb7d1af6082028", "gsd-core/workflows/execute-phase/steps/post-merge-gate.md": "811b6d8489571581", diff --git a/tests/slurm-adapter.test.cjs b/tests/slurm-adapter.test.cjs index 38a162015..6fbc3f85e 100644 --- a/tests/slurm-adapter.test.cjs +++ b/tests/slurm-adapter.test.cjs @@ -2,9 +2,12 @@ process.env.GSD_TEST_MODE = '1'; // Refinements for issue #1164 (PR #1998 follow-up): -// - A: document the execute:wave:post choice (wave:pre is declared but not -// dispatched by execute-phase.md; wiring it is a core-loop change #1164 -// puts out of scope). +// - A: document the execute:wave:post choice (at the time of #1164, wave:pre +// was declared but not dispatched by execute-phase.md; wiring it was a +// core-loop change #1164 put out of scope. #2285 later wired wave:pre — +// see gsd-core/workflows/execute-phase.md step 2.75 and the +// claude-orchestration capability, which moved to wave:pre for that +// reason). // - B: external_job.artifact_dir is now consumed by the adapter (was declared // but unused). // - C: external_job.submit_timeout_ms / poll_timeout_ms are now read from diff --git a/tests/workflow-size-baseline.json b/tests/workflow-size-baseline.json index 801761198..270be8aed 100644 --- a/tests/workflow-size-baseline.json +++ b/tests/workflow-size-baseline.json @@ -24,7 +24,7 @@ "docs-update.md": 55706, "edit-phase.md": 12927, "eval-review.md": 9967, - "execute-phase.md": 93583, + "execute-phase.md": 93363, "execute-plan.md": 33113, "explore.md": 10541, "extract-learnings.md": 12893,