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) <noreply@anthropic.com>
This commit is contained in:
5
.changeset/quick-ibex-bark.md
Normal file
5
.changeset/quick-ibex-bark.md
Normal file
@@ -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)
|
||||
@@ -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": [
|
||||
|
||||
@@ -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 <manifest.json> --run-id <id>
|
||||
[--phase-dir <dir>] [--budget <n>]` (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.
|
||||
167
capabilities/claude-orchestration/fragments/execute-wave-pre.md
Normal file
167
capabilities/claude-orchestration/fragments/execute-wave-pre.md
Normal file
@@ -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 <objective>...<success_criteria> 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 `<objective>`/`<execution_context>`/`<files_to_read>`/
|
||||
`<success_criteria>` 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"`.
|
||||
@@ -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("<id>")` |
|
||||
| Phase token cap | `budget(<tokens>)` |
|
||||
|
||||
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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -22,7 +22,20 @@
|
||||
* emit-workflow --waves <path> --run-id <id> [--phase-dir <dir>] [--budget <n>]
|
||||
* 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 <path> --run-id <id> [--runtime <id>]
|
||||
* [--agent-sdk-version <ver>] [--no-nested-dispatch] [--phase-dir <dir>]
|
||||
* [--budget <n>]
|
||||
* #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 <detect-backend|emit-workflow> [...]\n' +
|
||||
error('Usage: gsd-tools claude-orchestration <detect-backend|emit-workflow|resolve-wave-dispatch> [...]\n' +
|
||||
' detect-backend [--runtime <id>] [--agent-sdk-version <ver>] [--no-nested-dispatch]\n' +
|
||||
' emit-workflow --waves <path> --run-id <id> [--phase-dir <dir>] [--budget <n>]');
|
||||
' emit-workflow --waves <path> --run-id <id> [--phase-dir <dir>] [--budget <n>]\n' +
|
||||
' resolve-wave-dispatch --waves <path> --run-id <id> [--runtime <id>] [--agent-sdk-version <ver>] [--no-nested-dispatch] [--phase-dir <dir>] [--budget <n>]');
|
||||
}
|
||||
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 <path>` 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 <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 <path>');
|
||||
return;
|
||||
}
|
||||
if (!runId) {
|
||||
error('resolve-wave-dispatch requires --run-id <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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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).
|
||||
</step>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -21,7 +21,20 @@
|
||||
* emit-workflow --waves <path> --run-id <id> [--phase-dir <dir>] [--budget <n>]
|
||||
* 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 <path> --run-id <id> [--runtime <id>]
|
||||
* [--agent-sdk-version <ver>] [--no-nested-dispatch] [--phase-dir <dir>]
|
||||
* [--budget <n>]
|
||||
* #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 <detect-backend|emit-workflow> [...]\n' +
|
||||
'Usage: gsd-tools claude-orchestration <detect-backend|emit-workflow|resolve-wave-dispatch> [...]\n' +
|
||||
' detect-backend [--runtime <id>] [--agent-sdk-version <ver>] [--no-nested-dispatch]\n' +
|
||||
' emit-workflow --waves <path> --run-id <id> [--phase-dir <dir>] [--budget <n>]',
|
||||
' emit-workflow --waves <path> --run-id <id> [--phase-dir <dir>] [--budget <n>]\n' +
|
||||
' resolve-wave-dispatch --waves <path> --run-id <id> [--runtime <id>] [--agent-sdk-version <ver>] [--no-nested-dispatch] [--phase-dir <dir>] [--budget <n>]',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<string, unknown> {
|
||||
let claudeSlice: Record<string, unknown> = {};
|
||||
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<string, unknown> = {};
|
||||
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 <path>` 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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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 <path>');
|
||||
return;
|
||||
}
|
||||
if (!runId) {
|
||||
error('resolve-wave-dispatch requires --run-id <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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)', () => {
|
||||
|
||||
@@ -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(',')}`);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
649
tests/fix-2285-claude-orchestration-wiring.test.cjs
Normal file
649
tests/fix-2285-claude-orchestration-wiring.test.cjs
Normal file
@@ -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 <runtime_compatibility> 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',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
2
tests/fixtures/golden-install-parity/pi.json
vendored
2
tests/fixtures/golden-install-parity/pi.json
vendored
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user