* test(#2686): failing-first parity guard for Workflow-backend model threading The Workflow backend emitted every agent() call with no model, so model_overrides / model_policy / model_profile were silently inert on that path while the inline path honored them (ADR-1411). Neither existing suite contained the string 'model' at all. The centrepiece derives BOTH sides from resolveModelInternal(cwd,'gsd-executor') rather than hardcoding either, so it asserts backend parity rather than a fixed string. Also covers: omit-on-inherit/empty (#2517), byte-identical output when nothing resolves, the #2772/#2285 per-plan worktree gate, adversarial model ids reaching the code generator, the #2285 composed seam, CLI config-defaulting, and a fast-check round-trip property. RED expected: no model key is emitted anywhere, and --executor-model does not exist. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DPq9ovaovP2UvSVLjD4Lso * fix(#2686): thread the resolved executor model into the Workflow backend The Workflow backend emitted every agent() call with no model at all, so model_overrides / model_policy / model_profile_overrides / model_profile were silently inert on that path while the inline path honored all of them. The model was not dropped at the last step — it was absent from the whole seam: agentOptions() took no model, EmitInput had no field to carry one, and ResolveWaveDispatchInput (the #2285 seam the orchestrator actually calls) could not forward one. The generated script asserted the parity it broke. VERIFY-FIRST, which #2686 flags as the question that decides the fix: the Workflow tool's agent() DOES accept a per-call model. Its documented signature is agent(prompt, opts?: { label?, phase?, schema?, model?, effort?, isolation?, agentType? }) so fix branch 1 applies and branch 2 (declare model routing unavailable) is ruled out. ADR-1143:24's option enumeration omitting `model` is an incomplete enumeration, not a decision to exclude it. - agentOptions(p, executorModel) emits `model` only when it is a non-empty string that is not "inherit" (#2517: an empty model 404s on runtimes without native tier aliases). A non-string is a malformed config: omit, never throw. - executorModel threaded through EmitInput and ResolveWaveDispatchInput. - The CLI resolves gsd-executor from project config by DEFAULT rather than requiring a flag, reading the same source the inline path reads. An orchestrator that never learns about a new flag would otherwise silently keep the old bug. --executor-model exists only to pin/override. - ADR-1411 provenance: the generated header now states which model was applied, or that none resolved and why. A fallback must be a visible value. Compatibility: when nothing resolves, the emitted options object is byte-identical to before, so every existing caller and assertion is unaffected. Behavior change (Hyrum's Law): opted-in users move from session inheritance to the catalog-resolved executor model. Adding a `model` key also changes agent() opts, which invalidates the cached prefix of any in-flight resumeFromRunId run — a one-time re-execution. Both disclosed in the changeset. Fixes #2686 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DPq9ovaovP2UvSVLjD4Lso * fix(#2686): reject script-breaking model ids and share the emit predicate The isolated adversarial review found a BLOCKER in my own provenance comment, proven by execution (the emitted script exited 42 from an injected statement). 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 (JSON.stringify) is sufficient for the `model: "..."` object literal but NOT for the `// model: ...` provenance line I added: a raw U+2028 in a model id closed the comment and made the rest of the line live top-level code. The value is reachable from `.planning/config.json` (model_overrides / model_policy), which `mapClaudeOverrideForRuntime` passes through verbatim on any non-claude runtime — attacker-influenceable in a cloned repo. `emitWorkflowScript` now rejects a string executorModel carrying any character in UNSCRIPTABLE_CHAR_RE — the same class `isScriptableIdentifier` already applied to phaseDir/runId, which is proof the codebase knew this hazard. Rejection is ok:false with a reason rather than a silent drop, and resolveWaveDispatch maps an emit failure to the inline backend WITH that reason, so the degradation is visible. A non-string stays on the existing defensive path (omit, never throw) — that is malformed config, not an injection attempt. Also from the reviews: - The predicate deciding "is this model emittable" was duplicated between the emission and the comment asserting it. Extracted to emittableModel() so a generated comment can never claim something the generator did not do — the exact failure class #2686 was filed for. - That predicate now trims and lower-cases before comparing, closing a real #2517-class gap: " " and "INHERIT" were previously emitted verbatim. - The adversarial test was pass-always against this very vulnerability — it asserted only that JSON.stringify appeared. Replaced with the real contract (rejection) plus an execution-level check that no LineTerminator survives into the comment. A raw U+2028 had also been committed into that test's fixture array where a tab was intended; both are now explicit \u escapes. - optionsOf in the test was /\{[^}]*\}/, which truncated at any brace a generated model contained — silently not testing what it claimed. Now brace- and string-aware. Stale-test corrections in tests/fix-2285-*: three assertions froze the exact options literal `{ agentType: "gsd-executor" }`. The object legitimately gained an optional additive `model` key, so they now assert the invariant they exist to protect (agentType present, isolation absent) rather than a frozen literal. The CLI-vs-pure equality test pins --executor-model on both sides; otherwise it compared a config-resolved CLI run against a pure call given no model. CONTEXT.md glossary updated for the changed emitWorkflowScript signature and the new rejection rule (CLAUDE.md: the glossary is a PR gate for core-module changes). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DPq9ovaovP2UvSVLjD4Lso * test(#2686): fix the options extractor and model the rejection path Two defects in my own test helper, caught by the full matrix: - optionsOf anchored on /\(\s*\{/ — a '(' immediately followed by '{'. The emitted shape is agent("brief", { ... }), so that never matched and the helper returned an empty array, making every assertion over it vacuously true. It now anchors on agent( and takes the first balanced, string-aware {...} after it. - The fast-check property predated the security fix and asserted ok:true for any generated string. Strings carrying an unscriptable character are now rejected, so the property models the real three-way contract: unscriptable -> ok:false; trims to empty or 'inherit' (any case) -> omitted; otherwise -> emitted as the trimmed value. Verified locally against the built module: omit values clean, both plans carry the model on the parity path, property passes 500 runs at seed 42. Test file re-scanned for raw hazardous codepoints — zero; the U+2028/U+2029 cases are explicit \u escapes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DPq9ovaovP2UvSVLjD4Lso * test(#2686): scope no-control-regex on the mirrored unscriptable-char class The class is the point of the assertion — those bytes are exactly what must be rejected — so the rule is disabled at that line rather than the class weakened. UNSCRIPTABLE_CHAR_RE is not exported from src/claude-orchestration.cts, hence the mirror. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DPq9ovaovP2UvSVLjD4Lso * chore(#2686): backfill changeset PR number Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DPq9ovaovP2UvSVLjD4Lso --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
583 lines
28 KiB
JavaScript
583 lines
28 KiB
JavaScript
"use strict";
|
|
/**
|
|
* 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) {
|
|
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, b) {
|
|
if (!isValidSemver(a) || !isValidSemver(b))
|
|
return -1;
|
|
// Split numeric triple from pre-release/build metadata.
|
|
const parseTriple = (s) => {
|
|
const core = s.split('-')[0].split('+')[0].split('.');
|
|
return [parseInt(core[0], 10), parseInt(core[1], 10), parseInt(core[2], 10)];
|
|
};
|
|
const hasPre = (s) => s.indexOf('-') !== -1;
|
|
const preIdentifiers = (s) => (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;
|
|
}
|
|
/** Inline result shorthand. */
|
|
function inline(reason, available = false) {
|
|
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) {
|
|
if (input === null || input === undefined || typeof input !== 'object') {
|
|
return inline('capability_disabled');
|
|
}
|
|
const cfg = (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.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' };
|
|
}
|
|
/**
|
|
* 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) {
|
|
const stages = [];
|
|
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) {
|
|
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) {
|
|
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, executorModel) {
|
|
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) {
|
|
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) {
|
|
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 <id>")` 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 = [];
|
|
// `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('');
|
|
const stagesByWave = [];
|
|
let totalPlans = 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;
|
|
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));
|
|
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.
|
|
lines.push('await parallel([');
|
|
for (const p of stagePlans) {
|
|
lines.push(' () => agent(' + quoteString(p.brief) + ', ' + agentOptions(p, executorModel) + '),');
|
|
}
|
|
lines.push('])');
|
|
}
|
|
if (wi < waves.length - 1)
|
|
lines.push('');
|
|
}
|
|
lines.push('// Each agent writes SUMMARY.md on its worktree branch; commits land there');
|
|
lines.push('// and are merged by the orchestrator exactly as in inline wave dispatch.');
|
|
const script = lines.join('\n');
|
|
return {
|
|
ok: true,
|
|
script,
|
|
summary: {
|
|
waves: waves.length,
|
|
plans: totalPlans,
|
|
stagesByWave,
|
|
resumeRunId: runId,
|
|
budgetTokens,
|
|
},
|
|
};
|
|
}
|
|
/**
|
|
* #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,
|
|
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,
|
|
};
|
|
}
|
|
module.exports = {
|
|
detectWorkflowBackend,
|
|
emitWorkflowScript,
|
|
resolveWaveDispatch,
|
|
compareSemver,
|
|
isValidSemver,
|
|
WORKFLOW_TOOL_FLOOR_VERSION,
|
|
BACKEND_VALUES,
|
|
WORKFLOW_RUNTIME,
|
|
};
|