fix(#3602): bind model resolution for every workflow subagent spawn (#3670)

* test(#3602): guard every spawned gsd-* subagent has a model resolution

* fix(#3602): bind model resolution for every workflow subagent spawn

* test(#3602): merge drift-ack entries into their owning fragments

* fix(#3602): address review findings - docs-update verifier binding, ack merge, guard residuals

* chore(#3602): backfill changeset pr number

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-08-19 12:35:45 -04:00
committed by GitHub
parent dae134b960
commit 7cf6a079fa
11 changed files with 314 additions and 7 deletions

View File

@@ -19,7 +19,7 @@
"gsd-core/workflows/update.md": "#2658: same CLAUDE.md replacement-target change as checkpoints.md above (bare directory '.trae/rules/' -> concrete file '.trae/rules/rules.md').",
"skills/gsd-ns-project/skills/profile-user/SKILL.md": "#2658: derived (via commands/gsd/profile-user.md, which mentions CLAUDE.md) through convertClaudeToTraeMarkdown; same replacement-target change as checkpoints.md above.",
"skills/gsd-ns-review/skills/code-review/SKILL.md": "#2658: derived (via commands/gsd/code-review.md, which mentions CLAUDE.md) through convertClaudeToTraeMarkdown; same replacement-target change as checkpoints.md above.",
"ingest-docs.md": "#2658: runtime-detection block gained a `/.trae/` path-based line and a `TRAE_CONFIG_DIR` env-var fallback line (the same trae-detection gap found in new-project.md, fixed here too since it is the identical defect in a sibling workflow). \u2014 #3423 append (epic #1891 F8): also grew with the <files_to_read> -> <required_reading> tag rename (15 chars/block, tag-token-only delta). \u2014 #3576 append: bare `references/<name>.md` cites repaired to the canonical `gsd-core/references/<name>.md` form (+36 bytes, 4 cite(s) \u00d7 9). Dead-pointer fix; no content change.",
"ingest-docs.md": "#2658: runtime-detection block gained a `/.trae/` path-based line and a `TRAE_CONFIG_DIR` env-var fallback line (the same trae-detection gap found in new-project.md, fixed here too since it is the identical defect in a sibling workflow). \u2014 #3423 append (epic #1891 F8): also grew with the <files_to_read> -> <required_reading> tag rename (15 chars/block, tag-token-only delta). \u2014 #3576 append: bare `references/<name>.md` cites repaired to the canonical `gsd-core/references/<name>.md` form (+36 bytes, 4 cite(s) \u00d7 9). Dead-pointer fix; no content change. \u2014 #3602 append: gains CLASSIFIER_MODEL/SYNTHESIZER_MODEL/ROADMAPPER_MODEL resolve-model bindings, a #2517 model-omit-on-inherit marker + rule block, and model= on all three subagent dispatch shapes (classifier prose fan-out, synthesizer Agent block, roadmapper Agent block) so dynamic_routing/model_profile tiers apply instead of the caller's session model.",
"new-project.md": "#2658: runtime-detection block gained a `/.trae/` path-based line and a `TRAE_CONFIG_DIR` env-var fallback line, so trae resolves to RUNTIME=trae instead of falling through to the claude default. \u2014 #3423 append (epic #1891 F8): also grew with the <files_to_read> -> <required_reading> tag rename (15 chars/block, tag-token-only delta)."
}
}

View File

@@ -1,7 +1,7 @@
{
"version": 1,
"paths": {
"import.md": "#3576: four bare `references/<name>.md` cites repaired to the canonical `gsd-core/references/<name>.md` form (+36 bytes, 4 \u00d7 9). Dead-pointer fix; no content change.",
"import.md": "#3576: four bare `references/<name>.md` cites repaired to the canonical `gsd-core/references/<name>.md` form (+36 bytes, 4 \u00d7 9). Dead-pointer fix; no content change. \u2014 #3602 append: gains a CHECKER_MODEL resolve-model binding, the #2517 marker + rule block, and model=\"{CHECKER_MODEL}\" on the gsd-plan-checker Agent block so the checker honors dynamic_routing/model_profile tiers.",
"gsd-doc-synthesizer.md": "#3576: two bare `references/<name>.md` cites repaired to the canonical `gsd-core/references/<name>.md` form (+18 bytes, 2 \u00d7 9). Dead-pointer fix; no content change."
}
}

View File

@@ -0,0 +1,17 @@
{
"version": 1,
"paths": {
"audit-fix.md": {
"reason": "#3602: the issue's file-level grep audit missed this file — its gsd-executor spawn is dispatch-shaped. Gains an EXECUTOR_MODEL resolve-model binding, the #2517 marker + rule block, and model= on the executor Agent block. Deliberate growth, not converter drift."
},
"diagnose-issues.md": {
"reason": "#3602: same class — gsd-debugger spawned with no model resolution. Gains a DEBUGGER_MODEL resolve-model assignment in the pre-spawn block, the #2517 marker + rule block, and model=\"{DEBUGGER_MODEL}\" on the debugger Agent block. Deliberate growth, not converter drift."
},
"profile-user.md": {
"reason": "#3602: same class via a Task-tool prose spawn the issue's Agent()-shaped audit could not see. Gains a PROFILER_MODEL resolve-model binding, the #2517 marker + rule block, and a model=\"{PROFILER_MODEL}\" pass/omit instruction on the Task-tool spawn. Deliberate growth, not converter drift."
},
"docs-update.md": {
"reason": "#3602 (isolated adversarial review finding): the --verify-only step's gsd-doc-verifier spawn had no model resolution — doc_writer_model covers only the writer spawns, and docs-init emits no verifier field. Gains a DOC_VERIFIER_MODEL resolve-model assignment in the init block, a model=\"{DOC_VERIFIER_MODEL}\" pass/omit instruction at the verifier spawn, and the #2517 blockquote's variable list extended. Deliberate growth, not converter drift."
}
}
}

View File

@@ -14,6 +14,7 @@ const fs = require('node:fs');
const path = require('node:path');
const fc = require('./helpers/fast-check-setup.cjs');
const { runGsdTools, createTempProject, cleanup, readWorkflowCombined } = require('./helpers.cjs');
const { escapeRegex } = require('../gsd-core/bin/lib/pattern.cjs');
const ROOT = path.resolve(__dirname, '..');
const WORKFLOWS = path.join(ROOT, 'gsd-core', 'workflows');
@@ -415,3 +416,241 @@ test('#2684: an unknown agent type resolves to an empty model, so dispatch must
cleanup(dir);
}
});
// ---------------------------------------------------------------------------
// #3602 — every spawned gsd-* subagent gets a model resolution.
//
// ingest-docs.md and import.md spawned their agents with ZERO model bindings,
// so every spawn silently inherited the caller's model regardless of
// dynamic_routing/model_profile config. audit-fix.md and diagnose-issues.md
// had the same defect; the issue's per-file grep audit missed them because
// their spawns are dispatch-shaped, not file-level greppable. This guard
// re-derives the audit from the corpus so a fifth file cannot join silently.
//
// An agent type is covered when the workflow either (a) resolves it directly —
// a `resolve-model <agent>` call appears in the file — or (b) dispatches with
// a `model=…` reference that is bound by one of the #2684 sources (shell
// assignment, declared parse field, or a key of an init surface the file
// queries). Route (b) cannot see WHICH agent a `planner_model`-style field
// answers — that mapping is conventional, not textual — so it covers the
// file's spawns as a group; that is the same altitude the issue's own audit
// operated at, and it is what lets this guard adopt without touching the
// ~20 compliant init-route workflows.
//
// Documented residuals (isolated adversarial review, #3602 PR):
// - Group coverage means a file binding ONE agent's model could later gain a
// SECOND, unbound spawn and still pass. Direct per-agent resolution (route a)
// is what this PR shipped for every file it touched; a per-site guard would
// need delimited Agent-block parsing the corpus's prose spawns don't have.
// - The prose collector misses "Delegate to `gsd-x`", "Invoke the gsd-x agent",
// and mid-sentence "then spawn `gsd-x`" shapes; every live instance of those
// today is also collected via a dispatch shape in the same combined body.
// - readWorkflowCombined inlines only <wf>/steps/ fragments, so dispatches in
// e.g. discuss-phase/modes/*.md are invisible here (all currently compliant).
// ---------------------------------------------------------------------------
/** Init-surface resolver for synthetic bodies: no surfaces, so binding must come
* from the text sources (shell assignment / parse line) alone. */
const noInit = () => null;
/** gsd-* agent types this workflow spawns, by any spawn shape the corpus uses.
*
* The prose shape collects an instruction ("spawn `gsd-x` in parallel",
* "**Spawn gsd-user-profiler agent using Task tool:**") but not a description of
* what a NESTED workflow does — plan-review-convergence.md runs plan-phase inline
* and its "inline plan-phase can spawn gsd-planner / plan-phase spawn gsd-planner"
* mentions describe plan-phase's dispatches, whose bindings live in plan-phase.md.
* Discriminator: a descriptive continuation is always preceded by a lowercase word
* ("it can spawn", "phase spawn"); an instruction follows a comma, `**`, punctuation,
* or line start. Mid-sentence imperatives ("then spawn `gsd-x`") are a known
* under-collection — the dispatch shape remains the primary collector.
*/
function spawnedAgentTypes(content) {
const dispatchShaped = [...content.matchAll(/subagent_type[=:]\s*"?(gsd-[a-z0-9-]+)"?/g)].map((m) => m[1]);
const proseShaped = [
...content.matchAll(/(?<![a-z] )\bspawn(?:s|ing)?\s+`?(gsd-[a-z0-9-]+)/gi),
].map((m) => m[1]);
return [...new Set([...dispatchShaped, ...proseShaped])];
}
/** Names referenced as `model=…` at dispatch sites: `"{X}"`, bare `X`, quoted `"X"`. */
function modelRefNames(content) {
const braced = [...content.matchAll(/model="\{([A-Za-z0-9_]+)\}"/g)].map((m) => m[1]);
// Bare/unquoted form (execute-plan.md: `model=executor_model`). A leading quote
// deliberately does not match here, so `model="haiku"` is not treated as a
// binding reference — a literal tier is a value, not a resolution.
const bare = [...content.matchAll(/model=([A-Za-z_][A-Za-z0-9_]*)/g)].map((m) => m[1]);
return [...new Set([...braced, ...bare])];
}
/** Every name the #2684 machinery treats as a binding source, for one body. */
function boundModelNames(content, resolveInit = initPayloadKeys) {
const bound = new Set([...shellAssignedNames(content), ...declaredParseNames(content)]);
for (const surface of queriedInitSurfaces(content)) {
const keys = resolveInit(surface);
if (keys) for (const k of keys) bound.add(k);
}
return bound;
}
/** Uncovered spawns in one body: `gsd-x` agent types with no resolution behind them. */
function uncoveredSpawnedAgents(content, resolveInit = initPayloadKeys) {
const agents = spawnedAgentTypes(content);
if (agents.length === 0) return [];
const bound = boundModelNames(content, resolveInit);
// A file that binds any *_model name (shell assignment, parse line, or init payload
// key) carries model resolution even when its dispatch sites live in an inline child
// workflow — plan-review-convergence.md parses planner_model/checker_model and runs
// plan-phase inline; plan-phase.md owns the model= sites.
const fileCarriesModelResolution =
modelRefNames(content).some((n) => bound.has(n)) || [...bound].some((n) => /_model$/i.test(n));
return agents.filter((a) => {
const direct = new RegExp(`resolve-model\\s+["'\`]?${escapeRegex(a)}["'\`]?`).test(content);
return !(direct || fileCarriesModelResolution);
});
}
test('#3602: every workflow that spawns a gsd-* subagent resolves a model for it', () => {
const findings = [];
let spawningFiles = 0;
let coveredSpawns = 0;
for (const file of fs.readdirSync(WORKFLOWS).filter((f) => f.endsWith('.md'))) {
const content = readWorkflowCombined(path.join(WORKFLOWS, file));
const agents = spawnedAgentTypes(content);
if (agents.length === 0) continue;
spawningFiles += 1;
const uncovered = uncoveredSpawnedAgents(content);
coveredSpawns += agents.length - uncovered.length;
for (const a of uncovered) {
findings.push(
`${file}: spawns ${a} with no model resolution — neither a \`resolve-model ${a}\` ` +
`binding nor a bound model= reference (#3602). The spawn silently inherits the ` +
`caller's model, ignoring dynamic_routing/model_profile.`,
);
}
}
// Non-vacuity: a spawn-collector that silently stops matching must fail, not pass.
assert.ok(
spawningFiles >= 20,
`expected >=20 spawning workflows, derived ${spawningFiles} — the derivation itself ` +
'is broken, so this guard proves nothing.',
);
assert.ok(
coveredSpawns >= 30,
`expected >=30 covered spawns, derived ${coveredSpawns} — the derivation itself ` +
'is broken, so this guard proves nothing.',
);
assert.deepEqual(findings, [], `spawns with no model resolution:\n ${findings.join('\n ')}`);
});
test('#3602: prose mentions that are not spawns are not flagged', () => {
const body = [
'## Anti-Patterns',
'',
'- Use `gsd-plan-checker` and `gsd-planner` — never the pbr ones',
'- Valid types: gsd-debugger — investigates bugs',
'',
].join('\n');
assert.deepEqual(
uncoveredSpawnedAgents(body, noInit),
[],
'an anti-pattern mention and a type listing are not spawns — flagging them is a false positive',
);
// Counter-case: the same agent name preceded by a spawn verb IS collected.
const spawnProse = 'For each doc, spawn `gsd-doc-classifier` in parallel.\n';
assert.deepEqual(
uncoveredSpawnedAgents(spawnProse, noInit),
['gsd-doc-classifier'],
'a prose spawn with no binding behind it must be reported — that is the #3602 shape',
);
});
test('#3602: a literal model= value is not a binding', () => {
const literal = [
'Agent(',
' prompt="fix it",',
' subagent_type="gsd-executor",',
' model="haiku"',
')',
'',
].join('\n');
assert.deepEqual(
uncoveredSpawnedAgents(literal, noInit),
['gsd-executor'],
'model="haiku" hardcodes a tier — it is a value, not a resolution, and must not count as coverage',
);
const bound = [
'EXECUTOR_MODEL=$(gsd_run query resolve-model gsd-executor --raw)',
'Agent(',
' prompt="fix it",',
' subagent_type="gsd-executor",',
' model="{EXECUTOR_MODEL}"',
')',
'',
].join('\n');
assert.deepEqual(
uncoveredSpawnedAgents(bound, noInit),
[],
'a shell-assigned resolve-model binding referenced at the dispatch site is coverage',
);
const bareForm = 'Parse from init JSON: `executor_model`.\nAgent(subagent_type="gsd-executor", model=executor_model)\n';
assert.deepEqual(
uncoveredSpawnedAgents(bareForm, noInit),
[],
'the bare model=executor_model notation (execute-plan.md) counts when the name is parse-declared',
);
});
test('#3602: spawn-site extraction round-trips (property)', () => {
const name = fc.stringMatching(/^gsd-[a-z0-9]{1,18}$/);
fc.assert(
fc.property(fc.array(name, { minLength: 1, maxLength: 10 }), (names) => {
const distinct = [...new Set(names)];
const render = (n) =>
n === names[0]
? `spawn \`${n}\` in parallel` // prose shape
: `subagent_type: "${n}"`; // dispatch shape
const body = names.map(render).join('\n');
assert.deepEqual([...spawnedAgentTypes(body)].sort(), [...distinct].sort());
}),
{ numRuns: 200 },
);
});
test('#3602: spawn coverage detection is CRLF-safe', () => {
const unbound = [
'For each doc, spawn `gsd-doc-classifier` in parallel.',
'Agent(subagent_type="gsd-doc-synthesizer")',
'',
].join('\n');
const unboundLf = uncoveredSpawnedAgents(unbound, noInit);
assert.deepEqual(
[...unboundLf].sort(),
['gsd-doc-classifier', 'gsd-doc-synthesizer'],
'with no binding anywhere in the file, both spawn shapes are uncovered',
);
assert.deepEqual(
uncoveredSpawnedAgents(unbound.replace(/\n/g, '\r\n'), noInit),
unboundLf,
'CRLF input must yield the same verdicts as LF (recurring class: #1658/#1668/#2206/#2449/#2450)',
);
const bound = [
'CLASSIFIER_MODEL=$(gsd_run query resolve-model gsd-doc-classifier --raw)',
'Agent(subagent_type="gsd-doc-synthesizer", model="{CLASSIFIER_MODEL}")',
'',
].join('\n');
const boundLf = uncoveredSpawnedAgents(bound, noInit);
assert.deepEqual(boundLf, [], 'a file-level binding covers the file\'s spawns as a group');
assert.deepEqual(
uncoveredSpawnedAgents(bound.replace(/\n/g, '\r\n'), noInit),
boundLf,
'CRLF input must yield the same verdicts as LF',
);
});