Files
msd-core/gsd-core/bin/lib/claude-orchestration-command-router.cjs
Tom Boucher 09477f925e fix(#2686): thread the resolved executor model into the Workflow backend (#2715)
* 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>
2026-07-27 16:24:21 -04:00

315 lines
14 KiB
JavaScript

"use strict";
/**
* Claude orchestration command router — CLI dispatcher for
* `gsd-tools claude-orchestration <subcommand>`.
*
* #1143 — thin CLI adapter over the pure `claude-orchestration.cjs` module.
* Lets execute-phase (or any orchestrator) invoke the Workflow-backend
* detection and the Workflow-script emitter through the standard capability
* command surface (ADR-959) instead of a bare `require()`.
*
* Router signature: { args, cwd, raw, error } — identical to the other host
* routers; discovered by dispatchCapabilityCommand via the registry's
* commandFamilies index.
*
* Subcommands:
* detect-backend [--runtime <id>] [--agent-sdk-version <ver>] [--no-nested-dispatch]
* Resolves whether the Workflow backend should activate. Both flags are
* OPTIONAL (#2590): `--runtime` falls back to the canonical
* `GSD_RUNTIME > config.runtime > 'claude'` chain, and
* `--agent-sdk-version` to `GSD_AGENT_SDK_VERSION` then the installed
* @anthropic-ai/claude-agent-sdk version. Reads the
* `claude_orchestration.*` keys from .planning/config.json. Emits
* { available, backend, reason }.
*
* emit-workflow --waves <path> --run-id <id> [--phase-dir <dir>] [--budget <n>] [--executor-model <id>]
* 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[], 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>] [--executor-model <id>]
* #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 };
};
const node_fs_1 = __importDefault(require("node:fs"));
const node_path_1 = __importDefault(require("node:path"));
// eslint-disable-next-line @typescript-eslint/no-require-imports
const io = require("./io.cjs");
// eslint-disable-next-line @typescript-eslint/no-require-imports
const core = require("./claude-orchestration.cjs");
// eslint-disable-next-line @typescript-eslint/no-require-imports
const configLoader = require("./config-loader.cjs");
// eslint-disable-next-line @typescript-eslint/no-require-imports
const runtimeSlash = require("./runtime-slash.cjs");
// eslint-disable-next-line @typescript-eslint/no-require-imports -- model-resolver.cjs is an export= CommonJS module
const modelResolver = require("./model-resolver.cjs");
const { output } = io;
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|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>] [--executor-model <id>]\n' +
' resolve-wave-dispatch --waves <path> --run-id <id> [--runtime <id>] [--agent-sdk-version <ver>] [--no-nested-dispatch] [--phase-dir <dir>] [--budget <n>] [--executor-model <id>]');
}
function argValue(args, flag) {
const i = args.indexOf(flag);
return i !== -1 && i + 1 < args.length ? args[i + 1] : undefined;
}
/**
* 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 resolveFlatClaudeOrchestrationConfig(cwd) {
let claudeSlice = {};
try {
const loaded = configLoader.loadConfig(cwd);
const slice = loaded['claude_orchestration'];
if (slice && typeof slice === 'object' && !Array.isArray(slice)) {
claudeSlice = slice;
}
}
catch {
claudeSlice = {};
}
const flatConfig = {};
for (const k of Object.keys(claudeSlice)) {
flatConfig['claude_orchestration.' + k] = claudeSlice[k];
}
return flatConfig;
}
/**
* Resolve the installed Agent SDK version (#2590).
*
* The `execute:wave:pre` fragment claimed the orchestrator "has no scriptable
* way to introspect the live Agent SDK version" and told callers to omit the
* flag — so gate 5 returned `agent_sdk_version_unknown` on every automated run
* and the Workflow backend never activated, while `capability state` still
* reported it `active: true`. That claim is true for BASH, but this router runs
* in Node: the installed package's own package.json is authoritative and
* requires no flag at all.
*
* Resolution is side-effect-free and fails closed to undefined (gate 5 then
* declines, exactly as before) rather than guessing a version.
*/
const AGENT_SDK_PKG = node_path_1.default.join('@anthropic-ai', 'claude-agent-sdk', 'package.json');
function resolveInstalledAgentSdkVersion(cwd) {
// Walk node_modules up the tree by hand rather than require.resolve: the SDK's
// `exports` map does not expose './package.json', so require.resolve throws
// ERR_PACKAGE_PATH_NOT_EXPORTED. Reading the file directly is exports-map
// independent and cannot execute package code.
for (const start of [cwd, __dirname]) {
let dir;
try {
dir = node_path_1.default.resolve(start);
}
catch {
continue;
}
for (;;) {
try {
const pkgPath = node_path_1.default.join(dir, 'node_modules', AGENT_SDK_PKG);
if (node_fs_1.default.existsSync(pkgPath)) {
const parsed = JSON.parse(node_fs_1.default.readFileSync(pkgPath, 'utf8'));
if (typeof parsed.version === 'string' && parsed.version.length > 0)
return parsed.version;
}
}
catch { /* unreadable/malformed — keep walking */ }
const parent = node_path_1.default.dirname(dir);
if (parent === dir)
break;
dir = parent;
}
}
return undefined;
}
/**
* 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, cwd) {
// #2590: the old fallback chain was `--runtime > GSD_RUNTIME > 'unknown'`,
// diverging from the canonical `GSD_RUNTIME > config.runtime > 'claude'` used
// by runtime-slash.resolveRuntime — so ANY manual invocation without
// --runtime reported `runtime_not_claude` on a perfectly ordinary Claude
// project. Delegate to the canonical resolver instead of re-deriving it.
const runtimeId = argValue(args, '--runtime') || runtimeSlash.resolveRuntime(cwd || null);
// Explicit flag wins (lets a caller pin a version); then the environment;
// then the actually-installed SDK.
const agentSdkVersion = argValue(args, '--agent-sdk-version')
|| process.env['GSD_AGENT_SDK_VERSION']
|| resolveInstalledAgentSdkVersion(cwd || process.cwd());
const noNested = args.includes('--no-nested-dispatch');
const hostIntegration = noNested ? { dispatch: { nested: false, background: true } } : CAPABLE_HOST;
return { runtimeId, hostIntegration, agentSdkVersion };
}
/**
* #2686 — resolve the `gsd-executor` model this dispatch should carry.
*
* Defaults from the project config rather than requiring a flag. The Workflow
* backend previously emitted no model at all, so `model_overrides` /
* `model_policy` / `model_profile` were silently inert on that path while the
* inline path honored them. Reading the same source the inline path reads is
* what makes the two backends agree by construction: an orchestrator that never
* learns about a new flag would otherwise silently keep the old bug.
*
* `--executor-model` exists only to pin/override. Resolution is side-effect-free
* and fails closed to `undefined` (emission then omits the key, i.e. exactly the
* pre-#2686 output) rather than guessing a model.
*/
function resolveExecutorModel(args, cwd) {
const pinned = argValue(args, '--executor-model');
if (pinned !== undefined)
return pinned;
try {
const resolved = modelResolver.resolveModelInternal(cwd, 'gsd-executor');
return typeof resolved === 'string' ? resolved : undefined;
}
catch {
return undefined;
}
}
/**
* 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, cwd);
const flatConfig = resolveFlatClaudeOrchestrationConfig(cwd);
const result = detectWorkflowBackend({ runtimeId, hostIntegration, config: flatConfig, agentSdkVersion });
output(result, raw);
}
/**
* Emit a Workflow script from a wave/plan manifest file.
*/
function cmdEmitWorkflow(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('emit-workflow requires --waves <path>');
return;
}
if (!runId) {
error('emit-workflow requires --run-id <id>');
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: read.waves,
budgetTokens: budget,
executorModel: resolveExecutorModel(args, cwd),
});
if (!result.ok) {
error('emit-workflow: ' + result.reason);
return;
}
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, cwd);
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,
executorModel: resolveExecutorModel(args, cwd),
});
output(result, raw);
}
function routeClaudeOrchestrationCommand(opts) {
const { args, cwd, raw, error } = opts;
// args[0] is the family ('claude-orchestration'); the subcommand is args[1].
const subcommand = args[1];
if (subcommand === 'detect-backend') {
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);
}
}
module.exports = { routeClaudeOrchestrationCommand };