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

@@ -0,0 +1,5 @@
---
type: Fixed
pr: 3670
---
**`/gsd-ingest-docs`, `/gsd-import`, `/gsd-audit-fix`, `/gsd-profile-user`, and `/gsd-docs-update` now honor model routing for their subagents** — the doc classifier/synthesizer/verifier, roadmapper, plan-checker, fix executor, and user-profiler subagents (plus the debugger spawned by the `diagnose-issues` workflow behind `/gsd-verify-work`) ran on the calling session's model, silently ignoring `dynamic_routing`/`model_profile` tier config. Each workflow now resolves the per-agent model and passes it on the spawn (omitting it when it resolves to inherit/empty per #2517). (#3602)

View File

@@ -98,11 +98,20 @@ For each **auto-fixable** finding (up to `--max`, ordered by severity desc):
> **Runtime-aware dispatch (#2508 Phase 4).** GSD workflows dispatch specialized subagents by role. Before dispatching on a built-in-only runtime (kimi-code — three built-ins only), resolve the role to a built-in via `gsd_run query resolve-dispatch-type --requested <role> --raw`. On named-dispatch runtimes (Claude/OpenCode/…) the role is returned unchanged; on kimi-code it maps to `coder`/`explore`/`plan` by role-suffix. The persona rides `${AGENT_SKILLS_<ROLE>}` (Phase 3) regardless. See @gsd-core/references/runtime-aware-dispatch.md.
```bash
EXECUTOR_MODEL=$(gsd_run query resolve-model gsd-executor --raw)
```
<!-- #2517 model-omit-on-inherit -->
> **Model omission (#2517).** Omit the `model` parameter entirely when the value it would carry (`EXECUTOR_MODEL`) is `"inherit"` or empty. An empty value 404s on runtimes without native tier aliases — the default on non-Claude runtimes. Omitting it inherits the orchestrator's model. See @gsd-core/references/model-profile-resolution.md.
**a. Spawn executor agent** (runs in a subagent — no output until it returns, ~1–5 min; expected, not a freeze)**:**
```
Agent(
prompt="Fix finding {ID}: {description}. Files: {file_refs}. Make the minimal change to resolve this specific finding. Do not refactor surrounding code.",
subagent_type="gsd-executor"
subagent_type="gsd-executor",
model="{EXECUTOR_MODEL}"
)
```

View File

@@ -124,6 +124,9 @@ fi
# Re-record after the base-check degrade, immediately before the spawn below, so the
# #3045 sentinel matches the dispatch the guard is about to see (#3045).
gsd_run query dispatch-isolation --raw --force-isolation "$ISOLATION" >/dev/null 2>&1 || true
# Model resolution for the debugger spawns below (#3602).
DEBUGGER_MODEL=$(gsd_run query resolve-model gsd-debugger --raw)
```
**Spawn debug agents — parallel only when each one is isolated:**
@@ -157,10 +160,15 @@ placeholder, not a shell variable.
> **Runtime-aware dispatch (#2508 Phase 4).** GSD workflows dispatch specialized subagents by role. Before dispatching on a built-in-only runtime (kimi-code — three built-ins only), resolve the role to a built-in via `gsd_run query resolve-dispatch-type --requested <role> --raw`. On named-dispatch runtimes (Claude/OpenCode/…) the role is returned unchanged; on kimi-code it maps to `coder`/`explore`/`plan` by role-suffix. The persona rides `${AGENT_SKILLS_<ROLE>}` (Phase 3) regardless. See @gsd-core/references/runtime-aware-dispatch.md.
<!-- #2517 model-omit-on-inherit -->
> **Model omission (#2517).** Omit the `model` parameter entirely when the value it would carry (`DEBUGGER_MODEL`) is `"inherit"` or empty. An empty value 404s on runtimes without native tier aliases — the default on non-Claude runtimes. Omitting it inherits the orchestrator's model. See @gsd-core/references/model-profile-resolution.md.
```
Agent(
prompt=filled_debug_subagent_prompt + "\n\n" + WORKTREE_GUARD + "\n\n<required_reading>\n- {phase_dir}/{phase_num}-UAT.md\n- {state_path}\n</required_reading>\n${AGENT_SKILLS_DEBUGGER}",
subagent_type="gsd-debugger",
model="{DEBUGGER_MODEL}",
{harnessFlag}
description="Debug: {truth_short}"
)

View File

@@ -22,10 +22,11 @@ AGENT_SKILLS=$(gsd_run query agent-skills gsd-doc-writer)
# only the section_manifest field (gates dispatch_monorepo_packages).
INIT_DOCS_UPDATE=$(gsd_run query init.docs-update)
if [[ "$INIT_DOCS_UPDATE" == @file:* ]]; then INIT_DOCS_UPDATE=$(cat "${INIT_DOCS_UPDATE#@file:}"); fi
DOC_VERIFIER_MODEL=$(gsd_run query resolve-model gsd-doc-verifier --raw)
```
Extract from init JSON:
- `doc_writer_model` — model string to pass to each spawned agent (never hardcode a model name)
- `doc_writer_model` — model string for the doc-writer spawns (never hardcode a model name); the doc-verifier spawn resolves its own `DOC_VERIFIER_MODEL`
- `commit_docs` — whether to commit generated files when done
- `existing_docs` — array of `{path, has_gsd_marker}` objects for existing Markdown files
- `project_type` — object with boolean signals: `has_package_json`, `has_api_routes`, `has_cli_bin`, `is_open_source`, `has_deploy_config`, `is_monorepo`, `has_tests`
@@ -394,7 +395,7 @@ Use `run_in_background=true` for all three to enable parallel execution.
<!-- #2517 model-omit-on-inherit -->
> **Model omission (#2517).** Omit the `model` parameter entirely when the value it would carry (`doc_writer_model`) is `"inherit"` or empty. An empty value 404s on runtimes without native tier aliases — the default on non-Claude runtimes. Omitting it inherits the orchestrator's model. See @gsd-core/references/model-profile-resolution.md.
> **Model omission (#2517).** Omit the `model` parameter entirely when the value it would carry (`doc_writer_model`, `DOC_VERIFIER_MODEL`) is `"inherit"` or empty. An empty value 404s on runtimes without native tier aliases — the default on non-Claude runtimes. Omitting it inherits the orchestrator's model. See @gsd-core/references/model-profile-resolution.md.
```
Agent(
@@ -932,7 +933,7 @@ Continue to scan_for_secrets.
Invoke the gsd-doc-verifier agent in read-only mode for each file in `existing_docs` from the init JSON:
1. For each doc in `existing_docs`:
a. Spawn `gsd-doc-verifier` (or invoke sequentially if Task tool is unavailable) with:
a. Spawn `gsd-doc-verifier` (or invoke sequentially if Task tool is unavailable), passing `model="{DOC_VERIFIER_MODEL}"` as the Task/Agent call's `model` parameter — not part of the `<verify_assignment>` prompt — so `dynamic_routing`/`model_profile` tiers apply instead of the caller's session model (#3602). Omit the parameter entirely when the value is `"inherit"` or empty (#2517). Each spawn carries:
```xml
<verify_assignment>
doc_path: {doc.path}

View File

@@ -204,13 +204,22 @@ Delegate validation to gsd-plan-checker:
Print: "Delegating to gsd-plan-checker (runs in a subagent — no output until it returns, ~1–5 min; expected, not a freeze)"
```bash
CHECKER_MODEL=$(gsd_run query resolve-model gsd-plan-checker --raw)
```
<!-- #2508 runtime-aware-dispatch -->
> **Runtime-aware dispatch (#2508 Phase 4).** GSD workflows dispatch specialized subagents by role. Before dispatching on a built-in-only runtime (kimi-code — three built-ins only), resolve the role to a built-in via `gsd_run query resolve-dispatch-type --requested <role> --raw`. On named-dispatch runtimes (Claude/OpenCode/…) the role is returned unchanged; on kimi-code it maps to `coder`/`explore`/`plan` by role-suffix. The persona rides `${AGENT_SKILLS_<ROLE>}` (Phase 3) regardless. See @gsd-core/references/runtime-aware-dispatch.md.
<!-- #2517 model-omit-on-inherit -->
> **Model omission (#2517).** Omit the `model` parameter entirely when the value it would carry (`CHECKER_MODEL`) is `"inherit"` or empty. An empty value 404s on runtimes without native tier aliases — the default on non-Claude runtimes. Omitting it inherits the orchestrator's model. See @gsd-core/references/model-profile-resolution.md.
```
Agent({
subagent_type: "gsd-plan-checker",
model: "{CHECKER_MODEL}",
prompt: "Validate: ${phase_dir}/{plan}-PLAN.md — check frontmatter completeness, task structure, and GSD conventions. Report any issues."
})
```

View File

@@ -56,6 +56,9 @@ _GSD_SHIM_NAME="gsd-tools.cjs"; _GSD_RUNTIME_ROOT="${RUNTIME_DIR:-$(git rev-pars
RESPONSE_LANGUAGE=$(gsd_run query config-get response_language --default "" 2>/dev/null || echo "")
INIT=$(gsd_run init ingest-docs)
if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi
CLASSIFIER_MODEL=$(gsd_run query resolve-model gsd-doc-classifier --raw)
SYNTHESIZER_MODEL=$(gsd_run query resolve-model gsd-doc-synthesizer --raw)
ROADMAPPER_MODEL=$(gsd_run query resolve-model gsd-roadmapper --raw)
```
**If `response_language` is set:** All user-facing questions, prompts, and explanations in this workflow MUST be presented in `{response_language}`. Technical terms, code, file paths, and subagent prompts stay in English — only user-facing output is translated.
@@ -174,6 +177,10 @@ mkdir -p .planning/intel/classifications/
For each discovered doc, spawn `gsd-doc-classifier` in parallel. In Claude Code, issue all Task calls in a single message with multiple tool uses so the harness runs them concurrently. For Copilot / sequential runtimes, fall back to sequential dispatch.
<!-- #2517 model-omit-on-inherit -->
> **Model omission (#2517).** Omit the `model` parameter entirely when the value it would carry (`CLASSIFIER_MODEL`, `SYNTHESIZER_MODEL`, `ROADMAPPER_MODEL`) is `"inherit"` or empty. An empty value 404s on runtimes without native tier aliases — the default on non-Claude runtimes. Omitting it inherits the orchestrator's model. See @gsd-core/references/model-profile-resolution.md.
Per-spawn prompt fields:
- `FILEPATH` — absolute path to the doc
- `OUTPUT_DIR` — `{intel_dir}/classifications` (absolute — from `init ingest-docs`; #2376: a spawned classifier's own cwd may differ from the orchestrator's)
@@ -181,6 +188,8 @@ Per-spawn prompt fields:
- `MANIFEST_PRECEDENCE` — the precedence integer from the manifest if present, else omit
- `<required_reading>` — `agents/gsd-doc-classifier.md` (the agent definition itself)
**Model on every classifier spawn (#3602):** `model="{CLASSIFIER_MODEL}"` is a parameter of each Task/Agent call — not a prompt field, never folded into the prompt text — so `dynamic_routing`/`model_profile` tiers apply instead of the caller's session model. Omit the parameter per the rule above when the value is `"inherit"` or empty.
Collect the one-line confirmations from each classifier. If any classifier errors out, surface the error and abort without touching `.planning/` further.
</step>
@@ -196,6 +205,7 @@ Spawn `gsd-doc-synthesizer` once (runs in a subagent — no output until it retu
```
Agent({
subagent_type: "gsd-doc-synthesizer",
model: "{SYNTHESIZER_MODEL}",
prompt: "
CLASSIFICATIONS_DIR: {intel_dir}/classifications
INTEL_DIR: {intel_dir}
@@ -263,6 +273,7 @@ Delegate to `gsd-roadmapper` (runs in a subagent — no output until it returns,
```
Agent({
subagent_type: "gsd-roadmapper",
model: "{ROADMAPPER_MODEL}",
prompt: "
Mode: new-project-from-ingest
Intel: {intel_dir}/SYNTHESIS.md (entry point)

View File

@@ -166,9 +166,17 @@ Display: "✓ Sampled N messages from M projects"
Display: "◆ Analyzing patterns..."
```bash
PROFILER_MODEL=$(gsd_run query resolve-model gsd-user-profiler --raw)
```
<!-- #2517 model-omit-on-inherit -->
> **Model omission (#2517).** Omit the `model` parameter entirely when the value it would carry (`PROFILER_MODEL`) is `"inherit"` or empty. An empty value 404s on runtimes without native tier aliases — the default on non-Claude runtimes. Omitting it inherits the orchestrator's model. See @gsd-core/references/model-profile-resolution.md.
**Spawn gsd-user-profiler agent using Task tool:**
Use the Task tool to spawn the `gsd-user-profiler` agent. Provide it with:
Use the Task tool to spawn the `gsd-user-profiler` agent, passing `model="{PROFILER_MODEL}"` (omit the parameter per the rule above when the value is `"inherit"` or empty). Provide it with:
- The sampled JSONL file path from profile-sample output
- The user-profiling reference doc at `$HOME/.claude/gsd-core/references/user-profiling.md`

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',
);
});