@opengsd/gsd-core 1.8.0 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (174) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.opencode/plugins/gsd-core.js +31 -1
  4. package/agents/gsd-code-fixer.md +1 -1
  5. package/agents/gsd-codebase-mapper.md +1 -1
  6. package/agents/gsd-debug-session-manager.md +36 -0
  7. package/agents/gsd-executor.md +20 -7
  8. package/agents/gsd-intel-updater.md +3 -3
  9. package/agents/gsd-phase-researcher.md +4 -2
  10. package/agents/gsd-plan-checker.md +20 -0
  11. package/agents/gsd-planner.md +15 -23
  12. package/agents/gsd-project-researcher.md +2 -2
  13. package/agents/gsd-ui-auditor.md +0 -40
  14. package/bin/install.js +186 -55
  15. package/commands/gsd/plan-review-convergence.md +5 -1
  16. package/gsd-core/bin/gsd-tools.cjs +849 -2
  17. package/gsd-core/bin/lib/api-coverage.cjs +22 -8
  18. package/gsd-core/bin/lib/audit.cjs +8 -8
  19. package/gsd-core/bin/lib/capability-consent.cjs +40 -1
  20. package/gsd-core/bin/lib/capability-lifecycle.cjs +58 -0
  21. package/gsd-core/bin/lib/capability-loader.cjs +23 -1
  22. package/gsd-core/bin/lib/capability-registry.cjs +1353 -132
  23. package/gsd-core/bin/lib/capability-trust.cjs +468 -33
  24. package/gsd-core/bin/lib/capability-validator.cjs +882 -6
  25. package/gsd-core/bin/lib/check-command-router.cjs +12 -2
  26. package/gsd-core/bin/lib/cjs-command-router-adapter.cjs +15 -0
  27. package/gsd-core/bin/lib/claude-orchestration-command-router.cjs +102 -12
  28. package/gsd-core/bin/lib/claude-orchestration.cjs +125 -22
  29. package/gsd-core/bin/lib/commands.cjs +246 -18
  30. package/gsd-core/bin/lib/config-loader.cjs +200 -28
  31. package/gsd-core/bin/lib/config.cjs +90 -5
  32. package/gsd-core/bin/lib/estimate-cli.cjs +336 -0
  33. package/gsd-core/bin/lib/frontmatter.cjs +125 -15
  34. package/gsd-core/bin/lib/host-integration.cjs +215 -8
  35. package/gsd-core/bin/lib/init.cjs +44 -19
  36. package/gsd-core/bin/lib/install-engine.cjs +1 -0
  37. package/gsd-core/bin/lib/milestone.cjs +5 -5
  38. package/gsd-core/bin/lib/model-catalog.cjs +51 -1
  39. package/gsd-core/bin/lib/observability/logger.cjs +7 -2
  40. package/gsd-core/bin/lib/phase-command-router.cjs +10 -1
  41. package/gsd-core/bin/lib/phase-estimation.cjs +398 -0
  42. package/gsd-core/bin/lib/phase-id.cjs +278 -5
  43. package/gsd-core/bin/lib/phase.cjs +57 -5
  44. package/gsd-core/bin/lib/plan-drift-guard.cjs +1 -1
  45. package/gsd-core/bin/lib/plan-scan.cjs +1 -1
  46. package/gsd-core/bin/lib/planning-workspace.cjs +9 -2
  47. package/gsd-core/bin/lib/profile-output.cjs +34 -8
  48. package/gsd-core/bin/lib/review-lane-descriptor.cjs +927 -0
  49. package/gsd-core/bin/lib/review-lane-invocation.cjs +348 -0
  50. package/gsd-core/bin/lib/review-lane-runner.cjs +594 -0
  51. package/gsd-core/bin/lib/review-reviewer-selection.cjs +114 -32
  52. package/gsd-core/bin/lib/roadmap-parser.cjs +54 -6
  53. package/gsd-core/bin/lib/roadmap.cjs +10 -4
  54. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +31 -4
  55. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +1 -1
  56. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +140 -0
  57. package/gsd-core/bin/lib/runtime-name-policy.cjs +15 -2
  58. package/gsd-core/bin/lib/smart-entry.cjs +1 -1
  59. package/gsd-core/bin/lib/state-document.cjs +164 -20
  60. package/gsd-core/bin/lib/state-transition.cjs +28 -10
  61. package/gsd-core/bin/lib/state.cjs +141 -21
  62. package/gsd-core/bin/lib/uat-predicate.cjs +6 -4
  63. package/gsd-core/bin/lib/uat.cjs +9 -7
  64. package/gsd-core/bin/lib/ui-consideration-probe.cjs +2 -2
  65. package/gsd-core/bin/lib/unusable-input.cjs +216 -0
  66. package/gsd-core/bin/lib/validate.cjs +32 -0
  67. package/gsd-core/bin/lib/verification.cjs +51 -14
  68. package/gsd-core/bin/lib/verify.cjs +128 -20
  69. package/gsd-core/bin/lib/worktree-safety.cjs +360 -15
  70. package/gsd-core/bin/shared/config-defaults.manifest.json +1 -0
  71. package/gsd-core/bin/shared/config-schema.manifest.json +1 -13
  72. package/gsd-core/bin/shared/model-catalog.json +5 -0
  73. package/gsd-core/bin/shared/runtime-aliases.manifest.json +5 -0
  74. package/gsd-core/references/context-budget.md +40 -0
  75. package/gsd-core/references/gate-prompts.md +6 -3
  76. package/gsd-core/references/model-profile-resolution.md +64 -13
  77. package/gsd-core/references/offer-next.md +88 -0
  78. package/gsd-core/references/planning-config.md +2 -1
  79. package/gsd-core/references/reviewer-instances.md +28 -21
  80. package/gsd-core/references/runtime-aware-dispatch.md +42 -0
  81. package/gsd-core/references/ui-consideration-probe.md +2 -2
  82. package/gsd-core/references/worktree-branch-check.md +4 -4
  83. package/gsd-core/templates/summary-minimal.md +4 -0
  84. package/gsd-core/templates/summary-standard.md +4 -0
  85. package/gsd-core/templates/summary.md +7 -0
  86. package/gsd-core/workflows/ai-integration-phase.md +4 -4
  87. package/gsd-core/workflows/audit-fix.md +4 -0
  88. package/gsd-core/workflows/audit-milestone.md +8 -0
  89. package/gsd-core/workflows/autonomous.md +19 -15
  90. package/gsd-core/workflows/check-todos.md +2 -2
  91. package/gsd-core/workflows/code-review-fix.md +14 -6
  92. package/gsd-core/workflows/code-review.md +76 -19
  93. package/gsd-core/workflows/debug.md +10 -2
  94. package/gsd-core/workflows/diagnose-issues.md +4 -0
  95. package/gsd-core/workflows/discuss-phase/modes/advisor.md +2 -4
  96. package/gsd-core/workflows/discuss-phase/modes/auto.md +0 -6
  97. package/gsd-core/workflows/discuss-phase-assumptions.md +15 -9
  98. package/gsd-core/workflows/discuss-phase.md +2 -2
  99. package/gsd-core/workflows/docs-update.md +8 -0
  100. package/gsd-core/workflows/eval-review.md +1 -1
  101. package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +4 -0
  102. package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +160 -0
  103. package/gsd-core/workflows/execute-phase.md +85 -115
  104. package/gsd-core/workflows/execute-plan.md +5 -4
  105. package/gsd-core/workflows/explore.md +4 -0
  106. package/gsd-core/workflows/extract-learnings.md +21 -0
  107. package/gsd-core/workflows/help/modes/full.md +3 -3
  108. package/gsd-core/workflows/import.md +4 -1
  109. package/gsd-core/workflows/ingest-docs.md +4 -0
  110. package/gsd-core/workflows/map-codebase.md +13 -6
  111. package/gsd-core/workflows/new-milestone.md +10 -2
  112. package/gsd-core/workflows/new-project.md +11 -4
  113. package/gsd-core/workflows/next.md +5 -2
  114. package/gsd-core/workflows/plan-phase.md +42 -46
  115. package/gsd-core/workflows/plan-review-convergence.md +18 -14
  116. package/gsd-core/workflows/progress.md +1 -1
  117. package/gsd-core/workflows/quick.md +14 -3
  118. package/gsd-core/workflows/review.md +146 -575
  119. package/gsd-core/workflows/scan.md +9 -1
  120. package/gsd-core/workflows/secure-phase.md +10 -2
  121. package/gsd-core/workflows/ship.md +41 -11
  122. package/gsd-core/workflows/smart-entry.md +1 -1
  123. package/gsd-core/workflows/ui-phase.md +8 -1
  124. package/gsd-core/workflows/ui-review.md +8 -1
  125. package/gsd-core/workflows/update.md +104 -5
  126. package/gsd-core/workflows/validate-phase.md +10 -2
  127. package/gsd-core/workflows/verify-work.md +8 -1
  128. package/hooks/dist/gsd-cursor-session-start.js +6 -2
  129. package/hooks/dist/gsd-cursor-stop.js +6 -2
  130. package/hooks/dist/gsd-cursor-subagent-start.js +6 -2
  131. package/hooks/dist/gsd-graphify-update.sh +9 -0
  132. package/hooks/dist/gsd-phase-boundary.sh +14 -2
  133. package/hooks/dist/gsd-prompt-guard.js +101 -2
  134. package/hooks/dist/gsd-read-guard.js +100 -2
  135. package/hooks/dist/gsd-read-injection-scanner.js +109 -2
  136. package/hooks/dist/gsd-statusline.js +9 -6
  137. package/hooks/dist/gsd-workflow-guard.js +110 -6
  138. package/hooks/dist/gsd-worktree-path-guard.js +132 -8
  139. package/hooks/dist/lib/cursor-workspace.js +74 -0
  140. package/hooks/gsd-cursor-session-start.js +6 -2
  141. package/hooks/gsd-cursor-stop.js +6 -2
  142. package/hooks/gsd-cursor-subagent-start.js +6 -2
  143. package/hooks/gsd-graphify-update.sh +9 -0
  144. package/hooks/gsd-phase-boundary.sh +14 -2
  145. package/hooks/gsd-prompt-guard.js +101 -2
  146. package/hooks/gsd-read-guard.js +100 -2
  147. package/hooks/gsd-read-injection-scanner.js +109 -2
  148. package/hooks/gsd-statusline.js +9 -6
  149. package/hooks/gsd-workflow-guard.js +110 -6
  150. package/hooks/gsd-worktree-path-guard.js +132 -8
  151. package/hooks/lib/cursor-workspace.js +74 -0
  152. package/package.json +7 -7
  153. package/pi/gsd.cjs +26 -1
  154. package/scripts/check-coverage-gate.cjs +51 -0
  155. package/scripts/check-glossary-refs.cjs +24 -0
  156. package/scripts/ci-test-scope.cjs +67 -17
  157. package/scripts/gen-adr-index.cjs +6 -4
  158. package/scripts/gen-capability-matrix.cjs +26 -2
  159. package/scripts/gen-capability-registry.cjs +132 -34
  160. package/scripts/gen-emitted-baseline.cjs +145 -0
  161. package/scripts/lint-compiled-artifact-sync.cjs +146 -0
  162. package/scripts/lint-emitted-drift-ack.cjs +149 -0
  163. package/scripts/lint-fix-has-regression-test.cjs +131 -0
  164. package/scripts/lint-resolution-provenance.cjs +9 -0
  165. package/scripts/mutation-matrix.cjs +4 -0
  166. package/scripts/prompt-injection-scan.sh +6 -0
  167. package/scripts/registry-schema.cjs +57 -8
  168. package/scripts/release-notes/conventional-title.cjs +19 -1
  169. package/scripts/release-notes/format-github-release-notes.cjs +7 -3
  170. package/scripts/workflow-size.cjs +16 -8
  171. package/skills/gsd-plan-review-convergence/SKILL.md +5 -1
  172. package/vscode/package.json +1 -1
  173. package/scripts/gen-golden-install-parity-zcode.cjs +0 -77
  174. package/scripts/update-size-baseline.cjs +0 -68
@@ -260,12 +260,22 @@ function loadDecisionExtraction(contextPath) {
260
260
  }
261
261
  function cmdDecisionCoveragePlan(projectDir, args, raw) {
262
262
  const phaseDir = args[2] ? resolvePath(args[2], projectDir) : '';
263
- const contextPath = args[3] ? resolvePath(args[3], projectDir) : '';
263
+ const contextArg = args[3];
264
+ const contextPath = contextArg ? resolvePath(contextArg, projectDir) : '';
264
265
  if (!gateEnabled(projectDir)) {
265
266
  output({ passed: true, skipped: true, reason: 'workflow.context_coverage_gate is false', total: 0, covered: 0, uncovered: [], message: 'Decision coverage gate disabled by config.' }, raw, undefined);
266
267
  return;
267
268
  }
268
- if (!contextPath || !node_fs_1.default.existsSync(contextPath)) {
269
+ // #2770: an EMPTY/MISSING contextPath argument is a CALLER ERROR (the workflow
270
+ // forgot to pass the path — e.g. a shell variable lost between Bash blocks), not
271
+ // evidence the phase has no CONTEXT.md. Fail closed (mirrors #1365 fail-loud) so a
272
+ // blocking gate cannot silently certify success on a caller mistake.
273
+ if (!contextArg || contextArg === '') {
274
+ output({ passed: false, skipped: false, reason: 'missing context path argument', total: 0, covered: 0, uncovered: [], message: 'Decision coverage gate called without a context path argument — the caller (e.g. the plan-phase workflow) must pass the CONTEXT.md path. An empty argument is a caller error, not evidence there is nothing to check (#2770).' }, raw, undefined);
275
+ return;
276
+ }
277
+ // A REAL path whose file genuinely does not exist is the LEGITIMATE green skip.
278
+ if (!node_fs_1.default.existsSync(contextPath)) {
269
279
  output({ passed: true, skipped: true, reason: 'CONTEXT.md missing', total: 0, covered: 0, uncovered: [], message: 'No CONTEXT.md - nothing to check.' }, raw, undefined);
270
280
  return;
271
281
  }
@@ -13,6 +13,17 @@
13
13
  // eslint-disable-next-line @typescript-eslint/no-require-imports
14
14
  const commandRoutingHub = require("./command-routing-hub.cjs");
15
15
  const { createHub, ERROR_KINDS } = commandRoutingHub;
16
+ // #2620 (ADR-0174 §6): the Hub defaults to a no-op logger and the live CLI
17
+ // dispatch path never injected the reference DispatchLogger, so GSD_AUDIT and
18
+ // config.audit.enabled were inert. Inject the reference logger ONLY when
19
+ // observability is opt-in enabled; when off, inject nothing so the Hub stays
20
+ // byte-for-byte silent (preserving the default dispatch output contract, incl.
21
+ // --json-errors). Enabling stderr-on-error unconditionally by default is a
22
+ // separate, blast-radius-bearing change (it adds a second stderr line to the
23
+ // --json-errors envelope) — deferred as its own follow-up.
24
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
25
+ const observabilityLogger = require("./observability/logger.cjs");
26
+ const { createDefaultLogger, isAuditEnabled } = observabilityLogger;
16
27
  // Phase 2 (#1646): import ERROR_REASON so the UnknownCommand translation can
17
28
  // pass `sdk_unknown_command` as the second arg to error(), preserving the
18
29
  // JSON-error envelope contract that capability routers' tests assert on.
@@ -73,6 +84,10 @@ function routeHubCommandFamily({ family, args, subcommands, handlers, defaultSub
73
84
  const hub = createHub({
74
85
  cjsRegistry: { [family]: registryHandlers },
75
86
  manifest: { [family]: available },
87
+ // #2620: wire the reference logger onto the live dispatch path (ADR-0174 §6),
88
+ // but only when observability is opt-in enabled — otherwise leave it unset
89
+ // so the Hub falls back to the no-op logger and stays byte-for-byte silent.
90
+ logger: isAuditEnabled() ? createDefaultLogger({ cwd }) : undefined,
76
91
  });
77
92
  const result = hub.dispatch({
78
93
  family,
@@ -14,12 +14,15 @@
14
14
  *
15
15
  * Subcommands:
16
16
  * detect-backend [--runtime <id>] [--agent-sdk-version <ver>] [--no-nested-dispatch]
17
- * Resolves whether the Workflow backend should activate. `--runtime`
18
- * defaults to the GSD_RUNTIME env var (or 'unknown'). Reads the
17
+ * Resolves whether the Workflow backend should activate. Both flags are
18
+ * OPTIONAL (#2590): `--runtime` falls back to the canonical
19
+ * `GSD_RUNTIME > config.runtime > 'claude'` chain, and
20
+ * `--agent-sdk-version` to `GSD_AGENT_SDK_VERSION` then the installed
21
+ * @anthropic-ai/claude-agent-sdk version. Reads the
19
22
  * `claude_orchestration.*` keys from .planning/config.json. Emits
20
23
  * { available, backend, reason }.
21
24
  *
22
- * emit-workflow --waves <path> --run-id <id> [--phase-dir <dir>] [--budget <n>]
25
+ * emit-workflow --waves <path> --run-id <id> [--phase-dir <dir>] [--budget <n>] [--executor-model <id>]
23
26
  * Reads a wave/plan manifest JSON file and emits the generated Workflow
24
27
  * script + summary. The manifest shape matches emitWorkflowScript's input:
25
28
  * { waves: [{ id, plans: [{ id, brief, files_modified: string[], use_worktree?: boolean }] }] }.
@@ -29,7 +32,7 @@
29
32
  *
30
33
  * resolve-wave-dispatch --waves <path> --run-id <id> [--runtime <id>]
31
34
  * [--agent-sdk-version <ver>] [--no-nested-dispatch] [--phase-dir <dir>]
32
- * [--budget <n>]
35
+ * [--budget <n>] [--executor-model <id>]
33
36
  * #2285 — the single composed seam a PRE-wave dispatch-backend selector
34
37
  * (`execute:wave:pre`) uses: resolves detect-backend + emit-workflow in
35
38
  * ONE call. Emits { backend: 'inline'|'workflow', reason, script?, summary? }.
@@ -48,14 +51,18 @@ const io = require("./io.cjs");
48
51
  const core = require("./claude-orchestration.cjs");
49
52
  // eslint-disable-next-line @typescript-eslint/no-require-imports
50
53
  const configLoader = require("./config-loader.cjs");
54
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
55
+ const runtimeSlash = require("./runtime-slash.cjs");
56
+ // eslint-disable-next-line @typescript-eslint/no-require-imports -- model-resolver.cjs is an export= CommonJS module
57
+ const modelResolver = require("./model-resolver.cjs");
51
58
  const { output } = io;
52
59
  const { detectWorkflowBackend, emitWorkflowScript, resolveWaveDispatch } = core;
53
60
  const CAPABLE_HOST = { dispatch: { nested: true, background: true } };
54
61
  function usage(error) {
55
62
  error('Usage: gsd-tools claude-orchestration <detect-backend|emit-workflow|resolve-wave-dispatch> [...]\n' +
56
63
  ' detect-backend [--runtime <id>] [--agent-sdk-version <ver>] [--no-nested-dispatch]\n' +
57
- ' emit-workflow --waves <path> --run-id <id> [--phase-dir <dir>] [--budget <n>]\n' +
58
- ' resolve-wave-dispatch --waves <path> --run-id <id> [--runtime <id>] [--agent-sdk-version <ver>] [--no-nested-dispatch] [--phase-dir <dir>] [--budget <n>]');
64
+ ' emit-workflow --waves <path> --run-id <id> [--phase-dir <dir>] [--budget <n>] [--executor-model <id>]\n' +
65
+ ' 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>]');
59
66
  }
60
67
  function argValue(args, flag) {
61
68
  const i = args.indexOf(flag);
@@ -86,18 +93,99 @@ function resolveFlatClaudeOrchestrationConfig(cwd) {
86
93
  }
87
94
  return flatConfig;
88
95
  }
96
+ /**
97
+ * Resolve the installed Agent SDK version (#2590).
98
+ *
99
+ * The `execute:wave:pre` fragment claimed the orchestrator "has no scriptable
100
+ * way to introspect the live Agent SDK version" and told callers to omit the
101
+ * flag — so gate 5 returned `agent_sdk_version_unknown` on every automated run
102
+ * and the Workflow backend never activated, while `capability state` still
103
+ * reported it `active: true`. That claim is true for BASH, but this router runs
104
+ * in Node: the installed package's own package.json is authoritative and
105
+ * requires no flag at all.
106
+ *
107
+ * Resolution is side-effect-free and fails closed to undefined (gate 5 then
108
+ * declines, exactly as before) rather than guessing a version.
109
+ */
110
+ const AGENT_SDK_PKG = node_path_1.default.join('@anthropic-ai', 'claude-agent-sdk', 'package.json');
111
+ function resolveInstalledAgentSdkVersion(cwd) {
112
+ // Walk node_modules up the tree by hand rather than require.resolve: the SDK's
113
+ // `exports` map does not expose './package.json', so require.resolve throws
114
+ // ERR_PACKAGE_PATH_NOT_EXPORTED. Reading the file directly is exports-map
115
+ // independent and cannot execute package code.
116
+ for (const start of [cwd, __dirname]) {
117
+ let dir;
118
+ try {
119
+ dir = node_path_1.default.resolve(start);
120
+ }
121
+ catch {
122
+ continue;
123
+ }
124
+ for (;;) {
125
+ try {
126
+ const pkgPath = node_path_1.default.join(dir, 'node_modules', AGENT_SDK_PKG);
127
+ if (node_fs_1.default.existsSync(pkgPath)) {
128
+ const parsed = JSON.parse(node_fs_1.default.readFileSync(pkgPath, 'utf8'));
129
+ if (typeof parsed.version === 'string' && parsed.version.length > 0)
130
+ return parsed.version;
131
+ }
132
+ }
133
+ catch { /* unreadable/malformed — keep walking */ }
134
+ const parent = node_path_1.default.dirname(dir);
135
+ if (parent === dir)
136
+ break;
137
+ dir = parent;
138
+ }
139
+ }
140
+ return undefined;
141
+ }
89
142
  /**
90
143
  * Resolve `--runtime`/`--agent-sdk-version`/`--no-nested-dispatch` into the
91
144
  * `{ runtimeId, hostIntegration, agentSdkVersion }` triple both `detect-backend`
92
145
  * and `resolve-wave-dispatch` pass to the pure detection seam.
93
146
  */
94
- function resolveDetectionArgs(args) {
95
- const runtimeId = argValue(args, '--runtime') || process.env['GSD_RUNTIME'] || 'unknown';
96
- const agentSdkVersion = argValue(args, '--agent-sdk-version');
147
+ function resolveDetectionArgs(args, cwd) {
148
+ // #2590: the old fallback chain was `--runtime > GSD_RUNTIME > 'unknown'`,
149
+ // diverging from the canonical `GSD_RUNTIME > config.runtime > 'claude'` used
150
+ // by runtime-slash.resolveRuntime — so ANY manual invocation without
151
+ // --runtime reported `runtime_not_claude` on a perfectly ordinary Claude
152
+ // project. Delegate to the canonical resolver instead of re-deriving it.
153
+ const runtimeId = argValue(args, '--runtime') || runtimeSlash.resolveRuntime(cwd || null);
154
+ // Explicit flag wins (lets a caller pin a version); then the environment;
155
+ // then the actually-installed SDK.
156
+ const agentSdkVersion = argValue(args, '--agent-sdk-version')
157
+ || process.env['GSD_AGENT_SDK_VERSION']
158
+ || resolveInstalledAgentSdkVersion(cwd || process.cwd());
97
159
  const noNested = args.includes('--no-nested-dispatch');
98
160
  const hostIntegration = noNested ? { dispatch: { nested: false, background: true } } : CAPABLE_HOST;
99
161
  return { runtimeId, hostIntegration, agentSdkVersion };
100
162
  }
163
+ /**
164
+ * #2686 — resolve the `gsd-executor` model this dispatch should carry.
165
+ *
166
+ * Defaults from the project config rather than requiring a flag. The Workflow
167
+ * backend previously emitted no model at all, so `model_overrides` /
168
+ * `model_policy` / `model_profile` were silently inert on that path while the
169
+ * inline path honored them. Reading the same source the inline path reads is
170
+ * what makes the two backends agree by construction: an orchestrator that never
171
+ * learns about a new flag would otherwise silently keep the old bug.
172
+ *
173
+ * `--executor-model` exists only to pin/override. Resolution is side-effect-free
174
+ * and fails closed to `undefined` (emission then omits the key, i.e. exactly the
175
+ * pre-#2686 output) rather than guessing a model.
176
+ */
177
+ function resolveExecutorModel(args, cwd) {
178
+ const pinned = argValue(args, '--executor-model');
179
+ if (pinned !== undefined)
180
+ return pinned;
181
+ try {
182
+ const resolved = modelResolver.resolveModelInternal(cwd, 'gsd-executor');
183
+ return typeof resolved === 'string' ? resolved : undefined;
184
+ }
185
+ catch {
186
+ return undefined;
187
+ }
188
+ }
101
189
  /**
102
190
  * Read and parse a `--waves <path>` manifest file.
103
191
  *
@@ -128,7 +216,7 @@ function readWavesManifest(wavesPath, error) {
128
216
  * SDK version come from flags (the orchestrator already knows these) or env.
129
217
  */
130
218
  function cmdDetectBackend(args, cwd, raw) {
131
- const { runtimeId, hostIntegration, agentSdkVersion } = resolveDetectionArgs(args);
219
+ const { runtimeId, hostIntegration, agentSdkVersion } = resolveDetectionArgs(args, cwd);
132
220
  const flatConfig = resolveFlatClaudeOrchestrationConfig(cwd);
133
221
  const result = detectWorkflowBackend({ runtimeId, hostIntegration, config: flatConfig, agentSdkVersion });
134
222
  output(result, raw);
@@ -136,7 +224,7 @@ function cmdDetectBackend(args, cwd, raw) {
136
224
  /**
137
225
  * Emit a Workflow script from a wave/plan manifest file.
138
226
  */
139
- function cmdEmitWorkflow(args, _cwd, raw, error) {
227
+ function cmdEmitWorkflow(args, cwd, raw, error) {
140
228
  const wavesPath = argValue(args, '--waves');
141
229
  const runId = argValue(args, '--run-id');
142
230
  const phaseDir = argValue(args, '--phase-dir') || '.planning/phases/current';
@@ -159,6 +247,7 @@ function cmdEmitWorkflow(args, _cwd, raw, error) {
159
247
  runId,
160
248
  waves: read.waves,
161
249
  budgetTokens: budget,
250
+ executorModel: resolveExecutorModel(args, cwd),
162
251
  });
163
252
  if (!result.ok) {
164
253
  error('emit-workflow: ' + result.reason);
@@ -188,7 +277,7 @@ function cmdResolveWaveDispatch(args, cwd, raw, error) {
188
277
  const read = readWavesManifest(wavesPath, (msg) => error('resolve-wave-dispatch: ' + msg));
189
278
  if (!read.ok)
190
279
  return; // read/parse failure — error() already surfaced it loudly above
191
- const { runtimeId, hostIntegration, agentSdkVersion } = resolveDetectionArgs(args);
280
+ const { runtimeId, hostIntegration, agentSdkVersion } = resolveDetectionArgs(args, cwd);
192
281
  const flatConfig = resolveFlatClaudeOrchestrationConfig(cwd);
193
282
  const budgetTokens = budgetRaw !== undefined ? parseInt(budgetRaw, 10) : undefined;
194
283
  const budget = (typeof budgetTokens === 'number' && !Number.isNaN(budgetTokens)) ? budgetTokens : undefined;
@@ -201,6 +290,7 @@ function cmdResolveWaveDispatch(args, cwd, raw, error) {
201
290
  runId,
202
291
  waves: read.waves,
203
292
  budgetTokens: budget,
293
+ executorModel: resolveExecutorModel(args, cwd),
204
294
  });
205
295
  output(result, raw);
206
296
  }
@@ -264,16 +264,52 @@ function partitionStages(plans) {
264
264
  function quoteString(s) {
265
265
  return JSON.stringify(s);
266
266
  }
267
+ /**
268
+ * #2686 — the single decision of whether a resolved executor model is emittable,
269
+ * and what to emit. Shared by `agentOptions` (the emission) and the provenance
270
+ * comment (the claim about it) so the two can never disagree — a generated
271
+ * comment asserting something the generator does not actually do is the exact
272
+ * failure #2686 was filed for.
273
+ *
274
+ * Returns the model to emit, or `undefined` for "emit nothing":
275
+ * - non-string → malformed config; omit rather than throw, matching the
276
+ * defensive typeof guard `mapClaudeOverrideForRuntime`
277
+ * already carries in model-resolver for the same reason.
278
+ * - empty/whitespace → #2517: emitting `model: ""` 404s on runtimes without
279
+ * native tier aliases. Trimmed, so `" "` is also "none".
280
+ * - "inherit" → same rule; matched case-insensitively after trimming,
281
+ * since config is user-authored free text.
282
+ *
283
+ * NOTE it does NOT reject unscriptable characters — that is a hard input error,
284
+ * not a silent omission, and is rejected up front by `emitWorkflowScript` so the
285
+ * caller sees a reason instead of quietly losing their model routing.
286
+ */
287
+ function emittableModel(executorModel) {
288
+ if (typeof executorModel !== 'string')
289
+ return undefined;
290
+ const trimmed = executorModel.trim();
291
+ if (trimmed.length === 0)
292
+ return undefined;
293
+ if (trimmed.toLowerCase() === 'inherit')
294
+ return undefined;
295
+ return trimmed;
296
+ }
267
297
  /**
268
298
  * Render the `agent()` options object for a single plan — `isolation: "worktree"`
269
299
  * ONLY when the plan's `use_worktree` is not explicitly `false` (#2772 / #2285
270
300
  * finding 1). This is the single place that decides worktree isolation for the
271
301
  * Workflow backend; it must never diverge from the inline path's per-plan gate.
272
302
  */
273
- function agentOptions(p) {
274
- return p.use_worktree === false
275
- ? '{ agentType: "gsd-executor" }'
276
- : '{ agentType: "gsd-executor", isolation: "worktree" }';
303
+ function agentOptions(p, executorModel) {
304
+ const parts = ['agentType: "gsd-executor"'];
305
+ if (p.use_worktree !== false)
306
+ parts.push('isolation: "worktree"');
307
+ // #2686: carry the resolved executor model so this backend honors
308
+ // model_overrides / model_policy / model_profile exactly as the inline path.
309
+ const model = emittableModel(executorModel);
310
+ if (model !== undefined)
311
+ parts.push('model: ' + quoteString(model));
312
+ return '{ ' + parts.join(', ') + ' }';
277
313
  }
278
314
  /**
279
315
  * True if `s` is a safe identifier/path token to interpolate into the generated
@@ -301,7 +337,7 @@ function emitWorkflowScript(input) {
301
337
  if (input === null || input === undefined || typeof input !== 'object') {
302
338
  return { ok: false, reason: 'invalid_input' };
303
339
  }
304
- const { phaseDir, waves, runId } = input;
340
+ const { phaseDir, waves, runId, executorModel } = input;
305
341
  // Identifiers/paths interpolated into the generated script must be free of any
306
342
  // character that could terminate a comment, break out of a string literal, or
307
343
  // smuggle control bytes — reject up front (security: #1143 review Finding 1).
@@ -311,9 +347,28 @@ function emitWorkflowScript(input) {
311
347
  if (!isScriptableIdentifier(runId)) {
312
348
  return { ok: false, reason: 'runId must be a non-empty string without newlines/quotes/backslash/control chars' };
313
349
  }
350
+ // #2686 security: the resolved model is interpolated into BOTH an object
351
+ // literal (safe under quoteString) and a `//` provenance comment (NOT safe
352
+ // under quoteString — U+2028/U+2029 are LineTerminators that end a single-line
353
+ // comment in every engine, so a hostile model id would make the rest of the
354
+ // line live code). Reject the whole emission rather than silently dropping the
355
+ // model: an unscriptable id is malformed input, and `resolveWaveDispatch` maps
356
+ // an emit failure to the inline backend WITH a reason, so the user sees it.
357
+ // Only a STRING carrying such a character is rejected. A non-string is a
358
+ // malformed config rather than an injection attempt, and stays on the existing
359
+ // defensive path: `emittableModel` omits it and emission proceeds.
360
+ if (typeof executorModel === 'string' && UNSCRIPTABLE_CHAR_RE.test(executorModel)) {
361
+ return { ok: false, reason: 'executorModel must not contain newlines/quotes/backslash/control/line-separator chars' };
362
+ }
314
363
  if (!Array.isArray(waves) || waves.length === 0) {
315
364
  return { ok: false, reason: 'waves must be a non-empty array' };
316
365
  }
366
+ // Wave ids must be unique ACROSS waves, not just plan ids within one (#2590).
367
+ // Each wave emits a `phase("Wave <id>")` call plus a matching meta.phases
368
+ // entry, and the Workflow tool matches phase titles by exact string — two
369
+ // waves sharing an id would collapse into one progress group and misattribute
370
+ // every agent in the second wave to the first.
371
+ const seenWaveIds = new Set();
317
372
  for (let i = 0; i < waves.length; i++) {
318
373
  const w = waves[i];
319
374
  if (w === null || typeof w !== 'object' || typeof w.id !== 'string') {
@@ -322,6 +377,10 @@ function emitWorkflowScript(input) {
322
377
  if (!isScriptableIdentifier(w.id)) {
323
378
  return { ok: false, reason: 'waves[' + i + '].id must not contain newlines/quotes/backslash/control chars' };
324
379
  }
380
+ if (seenWaveIds.has(w.id)) {
381
+ return { ok: false, reason: 'duplicate wave id "' + w.id + '" — wave ids must be unique (phase titles must map 1:1)' };
382
+ }
383
+ seenWaveIds.add(w.id);
325
384
  if (!Array.isArray(w.plans) || w.plans.length === 0) {
326
385
  return { ok: false, reason: 'waves[' + i + '] must have a non-empty plans array' };
327
386
  }
@@ -352,15 +411,63 @@ function emitWorkflowScript(input) {
352
411
  ? Math.floor(input.budgetTokens)
353
412
  : null;
354
413
  const lines = [];
414
+ // `export const meta = {…}` MUST be the first statement in the script — the
415
+ // Workflow tool rejects the whole script otherwise (#2590). Leading comments
416
+ // are not statements, but the meta block is emitted first regardless so the
417
+ // contract holds under the strictest reading of "first statement".
418
+ //
419
+ // meta.phases must be a PURE LITERAL (no variables, calls, spreads, or
420
+ // template interpolation), and its titles are matched EXACTLY against the
421
+ // phase() calls emitted below.
422
+ lines.push('export const meta = {');
423
+ lines.push(' name: ' + quoteString('gsd-execute-' + runId) + ',');
424
+ lines.push(' description: ' + quoteString('GSD wave dispatch for ' + phaseDir) + ',');
425
+ lines.push(' phases: [');
426
+ for (const w of waves) {
427
+ lines.push(' { title: ' + quoteString('Wave ' + w.id) + ', detail: '
428
+ + quoteString(w.plans.length + ' plan(s)') + ' },');
429
+ }
430
+ lines.push(' ],');
431
+ lines.push('}');
432
+ lines.push('');
355
433
  lines.push('// GSD Workflow script — generated by the claude-orchestration capability (#1143)');
356
434
  lines.push('// phase: ' + phaseDir);
357
435
  lines.push('// BETA: preview-grade; on any failure the orchestrator falls back to inline dispatch.');
358
436
  lines.push('// Composes the SAME gsd-executor agent as the inline path, so artifacts (SUMMARY.md)');
359
437
  lines.push('// and commits are produced identically. Worktree isolation is per-plan (use_worktree)');
360
438
  lines.push('// and mirrors execute-phase.md step 2.5\'s submodule gate exactly (#2772 / #2285).');
361
- lines.push('resumeFromRunId(' + quoteString(runId) + ')');
439
+ // #2686 / ADR-1411: state which model was applied — or that none resolved —
440
+ // so an opted-in user can SEE the routing decision instead of having to read
441
+ // the emitted options. A fallback must be a visible value, never silent.
442
+ //
443
+ // SECURITY: this is a `//` comment, and U+2028/U+2029 are ECMAScript
444
+ // LineTerminators that END a single-line comment in every engine — the ES2019
445
+ // change legalized them inside string LITERALS only, so `quoteString` alone is
446
+ // NOT sufficient here even though it is sufficient in the object literal
447
+ // above. An unscriptable model id would otherwise close the comment and make
448
+ // the remainder live top-level code. `emitWorkflowScript` rejects such ids
449
+ // before reaching this point (see the validation above), which is what makes
450
+ // interpolating here safe.
451
+ const provenanceModel = emittableModel(executorModel);
452
+ if (provenanceModel !== undefined) {
453
+ lines.push('// model: ' + quoteString(provenanceModel) + ' (resolved for gsd-executor, same source as the inline path)');
454
+ }
455
+ else {
456
+ lines.push('// model: none applied — resolved to "inherit"/empty, so each agent inherits the');
457
+ lines.push('// orchestrator model (#2517: emitting an empty model 404s on some runtimes).');
458
+ }
459
+ lines.push('//');
460
+ // resumeFromRunId is a Workflow TOOL INPUT parameter, not a script function —
461
+ // calling it threw "resumeFromRunId is not defined" (#2590). The run id is
462
+ // carried in summary.resumeRunId for the caller to pass as that input.
463
+ lines.push('// resume: pass ' + quoteString(runId) + ' as the Workflow tool\'s resumeFromRunId input');
464
+ lines.push('// (it is a tool parameter, NOT a script function).');
362
465
  if (budgetTokens !== null) {
363
- lines.push('budget(' + budgetTokens + ')');
466
+ // `budget` is a read-only object ({ total, spent(), remaining() }) supplied
467
+ // by the caller's token directive — a script cannot SET it, and `budget(n)`
468
+ // threw "budget is not a function" (#2590). Recorded as intent only.
469
+ lines.push('// budget: ' + budgetTokens + ' output tokens intended for this run; `budget` is');
470
+ lines.push('// read-only in a Workflow script — set it via the caller\'s token directive.');
364
471
  }
365
472
  lines.push('');
366
473
  const stagesByWave = [];
@@ -371,6 +478,8 @@ function emitWorkflowScript(input) {
371
478
  stagesByWave.push(stages);
372
479
  totalPlans += wave.plans.length;
373
480
  lines.push('// Wave ' + wave.id);
481
+ // Title must match this wave's meta.phases entry EXACTLY.
482
+ lines.push('phase(' + quoteString('Wave ' + wave.id) + ')');
374
483
  for (let si = 0; si < stages.length; si++) {
375
484
  const stagePlanIds = stages[si];
376
485
  // Resolve back to plan objects for briefs (ids are unique within a wave — validated above).
@@ -378,22 +487,15 @@ function emitWorkflowScript(input) {
378
487
  if (stages.length > 1) {
379
488
  lines.push('// Stage ' + si + (si > 0 ? ' (sequential — files_modified overlap)' : ''));
380
489
  }
381
- if (stagePlans.length === 1) {
382
- const p = stagePlans[0];
383
- lines.push('parallel(');
384
- lines.push(' agent(' + quoteString(p.brief) + ', ' + agentOptions(p) + ')');
385
- lines.push(')');
386
- }
387
- else {
388
- lines.push('parallel(');
389
- for (const p of stagePlans) {
390
- lines.push(' agent(' + quoteString(p.brief) + ', ' + agentOptions(p) + '),');
391
- }
392
- // Replace trailing comma on the last agent line with nothing.
393
- const lastIdx = lines.length - 1;
394
- lines[lastIdx] = lines[lastIdx].replace(/,$/, '');
395
- lines.push(')');
490
+ // parallel() takes an ARRAY OF THUNKS — `parallel(agent(…), agent(…))`
491
+ // threw "parallel() expects an array of functions" (#2590). Passing
492
+ // agent() results directly would also start every agent eagerly, before
493
+ // parallel() could bound concurrency.
494
+ lines.push('await parallel([');
495
+ for (const p of stagePlans) {
496
+ lines.push(' () => agent(' + quoteString(p.brief) + ', ' + agentOptions(p, executorModel) + '),');
396
497
  }
498
+ lines.push('])');
397
499
  }
398
500
  if (wi < waves.length - 1)
399
501
  lines.push('');
@@ -456,6 +558,7 @@ function resolveWaveDispatch(input) {
456
558
  waves: input.waves,
457
559
  runId: input.runId,
458
560
  budgetTokens: input.budgetTokens,
561
+ executorModel: input.executorModel,
459
562
  });
460
563
  if (!emitted.ok) {
461
564
  return { backend: 'inline', reason: 'emit_failed: ' + emitted.reason };