From 7cf6a079fa90b468cf0d1798dfca3c6ce835bf96 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Wed, 19 Aug 2026 12:35:45 -0400 Subject: [PATCH] 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 --- .changeset/bold-lynx-cheer.md | 5 + gsd-core/workflows/audit-fix.md | 11 +- gsd-core/workflows/diagnose-issues.md | 8 + gsd-core/workflows/docs-update.md | 7 +- gsd-core/workflows/import.md | 9 + gsd-core/workflows/ingest-docs.md | 11 + gsd-core/workflows/profile-user.md | 10 +- .../2658-trae-instruction-file-path.json | 2 +- .../3576-references-canonical-cites.json | 2 +- ...02-workflow-subagent-model-resolution.json | 17 ++ tests/model-omit-when-inherit-guard.test.cjs | 239 ++++++++++++++++++ 11 files changed, 314 insertions(+), 7 deletions(-) create mode 100644 .changeset/bold-lynx-cheer.md create mode 100644 tests/emitted-drift-acks/3602-workflow-subagent-model-resolution.json diff --git a/.changeset/bold-lynx-cheer.md b/.changeset/bold-lynx-cheer.md new file mode 100644 index 000000000..8ad934c52 --- /dev/null +++ b/.changeset/bold-lynx-cheer.md @@ -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) diff --git a/gsd-core/workflows/audit-fix.md b/gsd-core/workflows/audit-fix.md index f92aa8e8f..50366fffe 100644 --- a/gsd-core/workflows/audit-fix.md +++ b/gsd-core/workflows/audit-fix.md @@ -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 --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_}` (Phase 3) regardless. See @gsd-core/references/runtime-aware-dispatch.md. +```bash +EXECUTOR_MODEL=$(gsd_run query resolve-model gsd-executor --raw) +``` + + + +> **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}" ) ``` diff --git a/gsd-core/workflows/diagnose-issues.md b/gsd-core/workflows/diagnose-issues.md index ac0b2c43d..da92142b9 100644 --- a/gsd-core/workflows/diagnose-issues.md +++ b/gsd-core/workflows/diagnose-issues.md @@ -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 --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_}` (Phase 3) regardless. See @gsd-core/references/runtime-aware-dispatch.md. + + +> **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\n- {phase_dir}/{phase_num}-UAT.md\n- {state_path}\n\n${AGENT_SKILLS_DEBUGGER}", subagent_type="gsd-debugger", + model="{DEBUGGER_MODEL}", {harnessFlag} description="Debug: {truth_short}" ) diff --git a/gsd-core/workflows/docs-update.md b/gsd-core/workflows/docs-update.md index a20b9e426..9e87f0504 100644 --- a/gsd-core/workflows/docs-update.md +++ b/gsd-core/workflows/docs-update.md @@ -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. -> **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 `` 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 doc_path: {doc.path} diff --git a/gsd-core/workflows/import.md b/gsd-core/workflows/import.md index 649c7224f..f2bfa463e 100644 --- a/gsd-core/workflows/import.md +++ b/gsd-core/workflows/import.md @@ -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) +``` + > **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 --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_}` (Phase 3) regardless. See @gsd-core/references/runtime-aware-dispatch.md. + + +> **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." }) ``` diff --git a/gsd-core/workflows/ingest-docs.md b/gsd-core/workflows/ingest-docs.md index 2af6457fe..1c54899a3 100644 --- a/gsd-core/workflows/ingest-docs.md +++ b/gsd-core/workflows/ingest-docs.md @@ -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. + + +> **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 - `` — `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. @@ -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) diff --git a/gsd-core/workflows/profile-user.md b/gsd-core/workflows/profile-user.md index 9a2cbb6d6..88addea26 100644 --- a/gsd-core/workflows/profile-user.md +++ b/gsd-core/workflows/profile-user.md @@ -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) +``` + + + +> **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` diff --git a/tests/emitted-drift-acks/2658-trae-instruction-file-path.json b/tests/emitted-drift-acks/2658-trae-instruction-file-path.json index c35993835..f65f9bcb3 100644 --- a/tests/emitted-drift-acks/2658-trae-instruction-file-path.json +++ b/tests/emitted-drift-acks/2658-trae-instruction-file-path.json @@ -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 -> tag rename (15 chars/block, tag-token-only delta). \u2014 #3576 append: bare `references/.md` cites repaired to the canonical `gsd-core/references/.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 -> tag rename (15 chars/block, tag-token-only delta). \u2014 #3576 append: bare `references/.md` cites repaired to the canonical `gsd-core/references/.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 -> tag rename (15 chars/block, tag-token-only delta)." } } \ No newline at end of file diff --git a/tests/emitted-drift-acks/3576-references-canonical-cites.json b/tests/emitted-drift-acks/3576-references-canonical-cites.json index a618e244b..659369532 100644 --- a/tests/emitted-drift-acks/3576-references-canonical-cites.json +++ b/tests/emitted-drift-acks/3576-references-canonical-cites.json @@ -1,7 +1,7 @@ { "version": 1, "paths": { - "import.md": "#3576: four bare `references/.md` cites repaired to the canonical `gsd-core/references/.md` form (+36 bytes, 4 \u00d7 9). Dead-pointer fix; no content change.", + "import.md": "#3576: four bare `references/.md` cites repaired to the canonical `gsd-core/references/.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/.md` cites repaired to the canonical `gsd-core/references/.md` form (+18 bytes, 2 \u00d7 9). Dead-pointer fix; no content change." } } \ No newline at end of file diff --git a/tests/emitted-drift-acks/3602-workflow-subagent-model-resolution.json b/tests/emitted-drift-acks/3602-workflow-subagent-model-resolution.json new file mode 100644 index 000000000..acd512700 --- /dev/null +++ b/tests/emitted-drift-acks/3602-workflow-subagent-model-resolution.json @@ -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." + } + } +} diff --git a/tests/model-omit-when-inherit-guard.test.cjs b/tests/model-omit-when-inherit-guard.test.cjs index 72e92b481..8dff1336d 100644 --- a/tests/model-omit-when-inherit-guard.test.cjs +++ b/tests/model-omit-when-inherit-guard.test.cjs @@ -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 ` 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 /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(/(? 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', + ); +});