/** * Claude Orchestration Capability — Workflow-tool backend detection + emitter * * #1143 — adopts Claude Code's Workflow tool (the engine behind `/effort ultracode`) * as an optional, runtime-gated parallel-execution backend for the GSD loop. * * This module is the pure, testable core of the capability. It owns two seams: * * detectWorkflowBackend({ runtimeId, hostIntegration, config, agentSdkVersion }) * → { available: boolean, backend: 'workflow'|'inline', reason: string } * Fail-closed: every miss degrades to `inline` (today's behaviour), so the * core loop is byte-identical unless every gate opens. This is criteria 3 + 6. * * emitWorkflowScript({ phaseDir, waves, runId, budgetTokens? }) * → { 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' })` * — 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 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 * (gsd-executor + worktree isolation) rather than reinventing them. * - Greenspun's Tenth Rule (cited in #1143): adopt the Workflow tool's * barrier/pipeline/budget/resume semantics instead of hand-rolling them. * - Postel's Law: liberal in input (missing fields → inline), conservative in * output (workflow only when every gate opens). * - Fail-closed: an unknown version, a missing descriptor, or a disabled * toggle all resolve to `inline`, never to `workflow`. * * Zero external dependencies. Pure functions. Never throws on bad input. */ // ─── Constants ──────────────────────────────────────────────────────────────── /** * The Agent SDK version that introduced the Workflow tool (#1143 prior art). * Used as the default floor when config does not override it. A runtime reporting * an agentSdkVersion below this cannot host the Workflow backend. */ const WORKFLOW_TOOL_FLOOR_VERSION = '0.3.149'; /** Closed enum for the `claude_orchestration.execution_backend` config key. */ const BACKEND_VALUES = new Set(['auto', 'workflow', 'inline']); /** Only this runtime can host the Workflow tool (Claude Code / Agent SDK). */ const WORKFLOW_RUNTIME = 'claude'; // ─── Semver helpers ─────────────────────────────────────────────────────────── /** Official-ish strict SemVer 2.0.0 numeric triple (+ optional pre/build). */ const SEMVER_RE = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/; /** True for a syntactically valid semver string. */ function isValidSemver(s: unknown): s is string { return typeof s === 'string' && SEMVER_RE.test(s); } /** * Compare two semver strings. * Returns -1/0/1 in the usual sense. Garbage in either position → -1 (fail-closed: * an unparseable version is treated as "less than" any real floor, so detection * never accidentally enables the preview backend on an unknown SDK). * * Pre-release/build metadata are ignored for the comparison — only the numeric * major.minor.patch triple participates, matching how the Workflow-tool floor is * specified (a plain "0.3.149"). */ function compareSemver(a: string, b: string): number { if (!isValidSemver(a) || !isValidSemver(b)) return -1; // Split numeric triple from pre-release/build metadata. const parseTriple = (s: string): number[] => { const core = s.split('-')[0].split('+')[0].split('.'); return [parseInt(core[0], 10), parseInt(core[1], 10), parseInt(core[2], 10)]; }; const hasPre = (s: string): boolean => s.indexOf('-') !== -1; const preIdentifiers = (s: string): string[] => (s.split('-')[1] || '').split('+')[0].split('.').filter((x) => x.length > 0); const am = parseTriple(a); const bm = parseTriple(b); for (let i = 0; i < 3; i++) { if (am[i] < bm[i]) return -1; if (am[i] > bm[i]) return 1; } // Numeric triple is equal. SemVer 2.0.0 §11 precedence: // - a version WITH a pre-release tag is LOWER than the same triple WITHOUT one // (keeps the floor fail-closed for pre-release builds of the GA floor); // - two pre-releases of the same triple are ordered by their dot-separated // identifiers (numeric < alphanumeric; numeric compared numerically, // alphanumeric lexically; fewer identifiers < more). const aPre = hasPre(a); const bPre = hasPre(b); if (aPre && !bPre) return -1; if (!aPre && bPre) return 1; if (aPre && bPre) { const ai = preIdentifiers(a); const bi = preIdentifiers(b); const len = Math.min(ai.length, bi.length); for (let i = 0; i < len; i++) { const ax = ai[i]; const bx = bi[i]; const aNum = /^\d+$/.test(ax); const bNum = /^\d+$/.test(bx); if (aNum && bNum) { const an = parseInt(ax, 10); const bn = parseInt(bx, 10); if (an < bn) return -1; if (an > bn) return 1; } else if (aNum && !bNum) { return -1; // numeric identifiers always lower than alphanumeric } else if (!aNum && bNum) { return 1; } else { if (ax < bx) return -1; if (ax > bx) return 1; } } if (ai.length < bi.length) return -1; if (ai.length > bi.length) return 1; } return 0; } // ─── detectWorkflowBackend ──────────────────────────────────────────────────── interface HostIntegration { dispatch?: { nested?: boolean; background?: boolean; backgroundDispatch?: boolean; [k: string]: unknown; }; [k: string]: unknown; } interface BackendConfig { 'claude_orchestration.enabled'?: unknown; 'claude_orchestration.execution_backend'?: unknown; 'claude_orchestration.min_agent_sdk_version'?: unknown; [k: string]: unknown; } interface DetectInput { runtimeId?: string; hostIntegration?: HostIntegration | null; config?: BackendConfig | null; agentSdkVersion?: string; } interface DetectResult { available: boolean; backend: 'workflow' | 'inline'; reason: string; } /** Inline result shorthand. */ function inline(reason: string, available = false): DetectResult { return { available, backend: 'inline', reason }; } /** * Resolve whether the Workflow-tool backend should activate. * * Gate ladder (all must pass for `workflow`; first miss wins, fail-closed): * 1. capability enabled (claude_orchestration.enabled truthy) * 2. runtime is Claude (the only runtime that exposes the Workflow tool) * 3. execution_backend !== 'inline' * 4. host descriptor signals nested+background dispatch (Workflow-tool capable) * 5. agentSdkVersion is a known, valid semver * 6. agentSdkVersion >= the configured floor (default WORKFLOW_TOOL_FLOOR_VERSION) * 7. execution_backend === 'workflow' OR 'auto' (both reach here; 'inline' exited at 3) * * Never throws. Destructures defensively. */ function detectWorkflowBackend(input: DetectInput | null | undefined): DetectResult { if (input === null || input === undefined || typeof input !== 'object') { return inline('capability_disabled'); } const cfg: BackendConfig = (input.config !== null && input.config !== undefined && typeof input.config === 'object') ? input.config : {}; // 1. capability must be opted in (default-off — ships disabled). if (!cfg['claude_orchestration.enabled']) { return inline('capability_disabled'); } // 2. only Claude can host the Workflow tool. if (input.runtimeId !== WORKFLOW_RUNTIME) { return inline('runtime_not_claude'); } // 3. explicit inline opt-out short-circuits. let backendRaw = cfg['claude_orchestration.execution_backend']; if (typeof backendRaw !== 'string' || !BACKEND_VALUES.has(backendRaw)) { backendRaw = 'auto'; } if (backendRaw === 'inline') { return inline('backend_inline'); } // 4. the host dispatch descriptor must be the nesting-capable Claude-Code shape // (a proxy for Workflow-tool presence). This is Claude-specific and already // gated at step 2; `background:true` alone is true on several non-Claude hosts, // so the proxy is only meaningful after the runtime check above. Note: this is // NOT the canonical `shouldFlattenDispatch` rule (which keys on // `backgroundDispatch`); the Workflow backend works precisely because a single // tool-call orchestrates internally, sidestepping the backgroundDispatch:false // limitation. Missing/false/foreign descriptor → fail-closed. const hi = input.hostIntegration; if (hi === null || hi === undefined || typeof hi !== 'object' || Array.isArray(hi)) { return inline('workflow_tool_unavailable'); } const dispatch = (hi as { dispatch?: Record }).dispatch; if (typeof dispatch !== 'object' || dispatch === null || Array.isArray(dispatch)) { return inline('workflow_tool_unavailable'); } const nested = dispatch['nested']; const background = dispatch['background']; if (nested !== true || background !== true) { return inline('workflow_tool_unavailable'); } // 5. an unknown agentSdkVersion cannot be trusted to meet the floor. if (!isValidSemver(input.agentSdkVersion)) { return inline('agent_sdk_version_unknown'); } // 6. version floor (config override > default constant). const floorRaw = cfg['claude_orchestration.min_agent_sdk_version']; const floor = typeof floorRaw === 'string' && isValidSemver(floorRaw) ? floorRaw : WORKFLOW_TOOL_FLOOR_VERSION; if (compareSemver(input.agentSdkVersion, floor) < 0) { return inline('agent_sdk_version_below_floor'); } // 7. auto/workflow both reach the workflow backend once every gate passes. return { available: true, backend: 'workflow', reason: 'workflow_backend_active' }; } // ─── emitWorkflowScript ─────────────────────────────────────────────────────── 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 { id: string; plans: Plan[]; } interface EmitInput { phaseDir: string; waves: Wave[]; runId: string; budgetTokens?: number; /** * #2686 — the resolved `gsd-executor` model, threaded so this backend honors * model_overrides / model_policy / model_profile exactly as the inline path * does. Omitted from the emitted options when it is `"inherit"`, empty, or not * a string (#2517: an empty model 404s on runtimes without native tier * aliases). When absent the emitted script is byte-identical to pre-#2686. */ executorModel?: string; } interface EmitOk { ok: true; script: string; summary: { waves: number; plans: number; /** * #3302 — the number of record-agent entries the orchestrator must end up * with in WAVE_WORKTREE_MANIFEST after the run (plans with `use_worktree` * not `false`). The loud count check: fewer entries than this after the * Workflow run means metadata capture failed and the wave must HALT. */ worktreePlans: number; stagesByWave: string[][][]; // wave → stage → planId[] resumeRunId: string; budgetTokens: number | null; }; } interface EmitErr { ok: false; reason: string; } /** * Partition a wave's plans into a near-minimal number of sequential stages (via * greedy first-fit — not guaranteed optimal for arbitrary overlap graphs, but * correct: no two plans sharing a file ever cohabit a stage) such that no two * plans in the same stage share a modified file. Each plan goes into the earliest * stage where it does not overlap any plan already there. * * A plan with an EMPTY files_modified set declares no files; it overlaps nothing * and coalesces into stage 0 (same behavior as the inline path, which also cannot * guard against undeclared concurrent writes — declare filesModified accurately). * * This is the same overlap rule execute-phase applies inline — the only difference * is the execution vehicle (Workflow `parallel()` vs one-agent-per-message). */ function partitionStages(plans: Plan[]): string[][] { const stages: { plans: Plan[]; files: Set }[] = []; for (const plan of plans) { const fileSet = new Set(plan.files_modified); let placed = false; for (const stage of stages) { let overlap = false; for (const f of fileSet) { if (stage.files.has(f)) { overlap = true; break; } } if (!overlap) { stage.plans.push(plan); for (const f of fileSet) stage.files.add(f); placed = true; break; } } if (!placed) { stages.push({ plans: [plan], files: new Set(fileSet) }); } } return stages.map((s) => s.plans.map((p) => p.id)); } /** * Quote a free-text value for safe embedding as a JavaScript/Workflow double-quoted * string literal. Uses JSON.stringify so every JS-relevant escape (backslash, quote, * newline, tab, NUL, U+2028/U+2029, all control chars) is handled by the language * itself — there is no hand-rolled escape table to drift. Returns the value already * wrapped in its surrounding quotes. */ function quoteString(s: string): string { return JSON.stringify(s); } /** * #2686 — the single decision of whether a resolved executor model is emittable, * and what to emit. Shared by `agentOptions` (the emission) and the provenance * comment (the claim about it) so the two can never disagree — a generated * comment asserting something the generator does not actually do is the exact * failure #2686 was filed for. * * Returns the model to emit, or `undefined` for "emit nothing": * - non-string → malformed config; omit rather than throw, matching the * defensive typeof guard `mapClaudeOverrideForRuntime` * already carries in model-resolver for the same reason. * - empty/whitespace → #2517: emitting `model: ""` 404s on runtimes without * native tier aliases. Trimmed, so `" "` is also "none". * - "inherit" → same rule; matched case-insensitively after trimming, * since config is user-authored free text. * * NOTE it does NOT reject unscriptable characters — that is a hard input error, * not a silent omission, and is rejected up front by `emitWorkflowScript` so the * caller sees a reason instead of quietly losing their model routing. */ function emittableModel(executorModel: unknown): string | undefined { if (typeof executorModel !== 'string') return undefined; const trimmed = executorModel.trim(); if (trimmed.length === 0) return undefined; if (trimmed.toLowerCase() === 'inherit') return undefined; return trimmed; } /** * 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, executorModel?: unknown): string { const parts = ['agentType: "gsd-executor"']; if (p.use_worktree !== false) parts.push('isolation: "worktree"'); // #2686: carry the resolved executor model so this backend honors // model_overrides / model_policy / model_profile exactly as the inline path. const model = emittableModel(executorModel); if (model !== undefined) parts.push('model: ' + quoteString(model)); return '{ ' + parts.join(', ') + ' }'; } /** * 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 * character that could terminate a comment line (`\n`/`\r`), break out of a * string literal (`"` / `\`), or smuggle a NUL/control sequence. Used for * `phaseDir`, `runId`, `wave.id`, and `plan.id`, which are identifiers/paths and * must never legitimately contain such characters. Rejecting them at validation * (rather than silently flattening) keeps the emitted script faithful to input. */ const UNSCRIPTABLE_CHAR_RE = /[\r\n"\\\x00-\x1f\x7f\u2028\u2029]/; function isScriptableIdentifier(s: unknown): boolean { if (typeof s !== 'string' || s.length === 0) return false; return !UNSCRIPTABLE_CHAR_RE.test(s); } /** * Emit a Workflow script mapping the phase's wave/plan model onto Workflow * primitives. Pure and deterministic: identical input yields an identical string. * * Returns ok:false (never throws) on invalid input — empty waves, missing runId, * a wave with no plans, etc. */ function emitWorkflowScript(input: EmitInput | null | undefined): EmitOk | EmitErr { if (input === null || input === undefined || typeof input !== 'object') { return { ok: false, reason: 'invalid_input' }; } const { phaseDir, waves, runId, executorModel } = input; // Identifiers/paths interpolated into the generated script must be free of any // character that could terminate a comment, break out of a string literal, or // smuggle control bytes — reject up front (security: #1143 review Finding 1). if (!isScriptableIdentifier(phaseDir)) { return { ok: false, reason: 'phaseDir must be a non-empty string without newlines/quotes/backslash/control chars' }; } if (!isScriptableIdentifier(runId)) { return { ok: false, reason: 'runId must be a non-empty string without newlines/quotes/backslash/control chars' }; } // #2686 security: the resolved model is interpolated into BOTH an object // literal (safe under quoteString) and a `//` provenance comment (NOT safe // under quoteString — U+2028/U+2029 are LineTerminators that end a single-line // comment in every engine, so a hostile model id would make the rest of the // line live code). Reject the whole emission rather than silently dropping the // model: an unscriptable id is malformed input, and `resolveWaveDispatch` maps // an emit failure to the inline backend WITH a reason, so the user sees it. // Only a STRING carrying such a character is rejected. A non-string is a // malformed config rather than an injection attempt, and stays on the existing // defensive path: `emittableModel` omits it and emission proceeds. if (typeof executorModel === 'string' && UNSCRIPTABLE_CHAR_RE.test(executorModel)) { return { ok: false, reason: 'executorModel must not contain newlines/quotes/backslash/control/line-separator chars' }; } if (!Array.isArray(waves) || waves.length === 0) { return { ok: false, reason: 'waves must be a non-empty array' }; } // Wave ids must be unique ACROSS waves, not just plan ids within one (#2590). // Each wave emits a `phase("Wave ")` call plus a matching meta.phases // entry, and the Workflow tool matches phase titles by exact string — two // waves sharing an id would collapse into one progress group and misattribute // every agent in the second wave to the first. const seenWaveIds = new Set(); for (let i = 0; i < waves.length; i++) { const w = waves[i]; if (w === null || typeof w !== 'object' || typeof w.id !== 'string') { return { ok: false, reason: 'waves[' + i + '] must be { id, plans: non-empty[] }' }; } if (!isScriptableIdentifier(w.id)) { return { ok: false, reason: 'waves[' + i + '].id must not contain newlines/quotes/backslash/control chars' }; } if (seenWaveIds.has(w.id)) { return { ok: false, reason: 'duplicate wave id "' + w.id + '" — wave ids must be unique (phase titles must map 1:1)' }; } seenWaveIds.add(w.id); if (!Array.isArray(w.plans) || w.plans.length === 0) { return { ok: false, reason: 'waves[' + i + '] must have a non-empty plans array' }; } const seenIds = new Set(); for (let j = 0; j < w.plans.length; j++) { const p = w.plans[j]; if (p === null || typeof p !== 'object' || typeof p.id !== 'string' || typeof p.brief !== 'string' || !Array.isArray(p.files_modified)) { return { ok: false, reason: 'waves[' + i + '].plans[' + j + '] must be { id, brief, files_modified[] }' }; } 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 + '"' }; } seenIds.add(p.id); for (const f of p.files_modified) { if (typeof f !== 'string' || f.length === 0) { return { ok: false, reason: 'waves[' + i + '].plans[' + j + '].files_modified entries must be non-empty strings' }; } } } } const budgetTokens = (typeof input.budgetTokens === 'number' && Number.isFinite(input.budgetTokens) && input.budgetTokens > 0) ? Math.floor(input.budgetTokens) : null; const lines: string[] = []; // `export const meta = {…}` MUST be the first statement in the script — the // Workflow tool rejects the whole script otherwise (#2590). Leading comments // are not statements, but the meta block is emitted first regardless so the // contract holds under the strictest reading of "first statement". // // meta.phases must be a PURE LITERAL (no variables, calls, spreads, or // template interpolation), and its titles are matched EXACTLY against the // phase() calls emitted below. lines.push('export const meta = {'); lines.push(' name: ' + quoteString('gsd-execute-' + runId) + ','); lines.push(' description: ' + quoteString('GSD wave dispatch for ' + phaseDir) + ','); lines.push(' phases: ['); for (const w of waves) { lines.push(' { title: ' + quoteString('Wave ' + w.id) + ', detail: ' + quoteString(w.plans.length + ' plan(s)') + ' },'); } lines.push(' ],'); lines.push('}'); lines.push(''); 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 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).'); // #2686 / ADR-1411: state which model was applied — or that none resolved — // so an opted-in user can SEE the routing decision instead of having to read // the emitted options. A fallback must be a visible value, never silent. // // SECURITY: this is a `//` comment, and U+2028/U+2029 are ECMAScript // LineTerminators that END a single-line comment in every engine — the ES2019 // change legalized them inside string LITERALS only, so `quoteString` alone is // NOT sufficient here even though it is sufficient in the object literal // above. An unscriptable model id would otherwise close the comment and make // the remainder live top-level code. `emitWorkflowScript` rejects such ids // before reaching this point (see the validation above), which is what makes // interpolating here safe. const provenanceModel = emittableModel(executorModel); if (provenanceModel !== undefined) { lines.push('// model: ' + quoteString(provenanceModel) + ' (resolved for gsd-executor, same source as the inline path)'); } else { lines.push('// model: none applied — resolved to "inherit"/empty, so each agent inherits the'); lines.push('// orchestrator model (#2517: emitting an empty model 404s on some runtimes).'); } lines.push('//'); // resumeFromRunId is a Workflow TOOL INPUT parameter, not a script function — // calling it threw "resumeFromRunId is not defined" (#2590). The run id is // carried in summary.resumeRunId for the caller to pass as that input. lines.push('// resume: pass ' + quoteString(runId) + ' as the Workflow tool\'s resumeFromRunId input'); lines.push('// (it is a tool parameter, NOT a script function).'); if (budgetTokens !== null) { // `budget` is a read-only object ({ total, spent(), remaining() }) supplied // by the caller's token directive — a script cannot SET it, and `budget(n)` // threw "budget is not a function" (#2590). Recorded as intent only. lines.push('// budget: ' + budgetTokens + ' output tokens intended for this run; `budget` is'); lines.push('// read-only in a Workflow script — set it via the caller\'s token directive.'); } lines.push(''); // #3302: the generated script must hand the per-agent executor results back // to the orchestrator so it can feed the wave merge chain. Emitted right // after the header comments (meta stays the first statement): a helper that // extracts the executor's JSON (agents/gsd-executor.md // ) from one agent() result, plus the outcomes // accumulator the stage barriers below push into. `metadata` is null when // the result carried no parseable block — the LOUD-failure input the // orchestrator halts on for expects_worktree plans (never a silent skip). lines.push('// #3302: extract the executor-returned JSON so the'); lines.push('// orchestrator can record it into WAVE_WORKTREE_MANIFEST after the run'); lines.push('// (worktree.record-agent -> worktree.cleanup-wave, the same manifest-scoped'); lines.push('// merge chain inline dispatch feeds). null = absent/unparseable/interrupted.'); lines.push('function gsdWorktreeMetadata(agentResult) {'); lines.push(' if (typeof agentResult !== \'string\') return null;'); lines.push(' const m = agentResult.match(/([\\s\\S]*?)<\\/worktree_metadata>/);'); lines.push(' if (m === null) return null;'); lines.push(' try {'); lines.push(' const parsed = JSON.parse(m[1]);'); lines.push(' return (parsed !== null && typeof parsed === \'object\') ? parsed : null;'); lines.push(' } catch (e) {'); lines.push(' return null;'); lines.push(' }'); lines.push('}'); lines.push('const gsdAgentOutcomes = [];'); lines.push(''); const stagesByWave: string[][][] = []; let totalPlans = 0; let worktreePlans = 0; for (let wi = 0; wi < waves.length; wi++) { const wave = waves[wi]; const stages = partitionStages(wave.plans); stagesByWave.push(stages); totalPlans += wave.plans.length; for (const p of wave.plans) { if (p.use_worktree !== false) worktreePlans += 1; } lines.push('// Wave ' + wave.id); // Title must match this wave's meta.phases entry EXACTLY. lines.push('phase(' + quoteString('Wave ' + wave.id) + ')'); for (let si = 0; si < stages.length; si++) { const stagePlanIds = stages[si]; // Resolve back to plan objects for briefs (ids are unique within a wave — validated above). const stagePlans = stagePlanIds.map((id) => wave.plans.find((p) => p.id === id) as Plan); if (stages.length > 1) { lines.push('// Stage ' + si + (si > 0 ? ' (sequential — files_modified overlap)' : '')); } // parallel() takes an ARRAY OF THUNKS — `parallel(agent(…), agent(…))` // threw "parallel() expects an array of functions" (#2590). Passing // agent() results directly would also start every agent eagerly, before // parallel() could bound concurrency. // #3302: capture the barrier's resolved results (one per thunk, in thunk // order — the documented parallel() contract) so each plan's outcome can // be tagged and returned below. Discarding them stranded every // worktree-wf_* branch: the merge chain had no input (#3302). lines.push('const gsdStage_' + wi + '_' + si + ' = await parallel(['); for (const p of stagePlans) { lines.push(' () => agent(' + quoteString(p.brief) + ', ' + agentOptions(p, executorModel) + '),'); } lines.push('])'); // Positional tagging is decided at EMIT time from the validated manifest, // so attribution survives out-of-order completion and needs no runtime // introspection. expects_worktree mirrors agentOptions' own per-plan // decision (use_worktree !== false). lines.push('gsdAgentOutcomes.push('); for (let pi = 0; pi < stagePlans.length; pi++) { const p = stagePlans[pi]; const tail = pi < stagePlans.length - 1 ? ',' : ''; lines.push(' { plan: ' + quoteString(p.id) + ', expects_worktree: ' + (p.use_worktree !== false) + ', metadata: gsdWorktreeMetadata(gsdStage_' + wi + '_' + si + '[' + pi + ']) }' + tail); } lines.push(')'); } if (wi < waves.length - 1) lines.push(''); } // #3302: the script's top-level return value is what the Workflow tool hands // back to the orchestrator. One { plan, expects_worktree, metadata } entry // per dispatched plan; the orchestrator records every worktree entry via // `gsd_run query worktree.record-agent` and HALTS on a null metadata entry // for an expects_worktree plan (see the execute:wave:pre fragment) — a // silently-empty manifest is the exact #3302 failure mode. lines.push('return gsdAgentOutcomes'); const script = lines.join('\n'); return { ok: true, script, summary: { waves: waves.length, plans: totalPlans, // #3302: the number of record-agent entries the orchestrator must end up // with in WAVE_WORKTREE_MANIFEST after the run — the loud count check. worktreePlans, stagesByWave, resumeRunId: runId, budgetTokens, }, }; } // ─── resolveWaveDispatch ────────────────────────────────────────────────────── interface ResolveWaveDispatchInput { runtimeId?: string; hostIntegration?: HostIntegration | null; config?: BackendConfig | null; agentSdkVersion?: string; phaseDir: string; waves: Wave[]; runId: string; budgetTokens?: number; /** #2686 — forwarded verbatim to `emitWorkflowScript`. */ executorModel?: string; } 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, executorModel: input.executorModel, }); 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, BACKEND_VALUES, WORKFLOW_RUNTIME, };