@opengsd/gsd-core 1.7.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 (261) 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 +45 -1
  4. package/README.md +2 -0
  5. package/agents/gsd-code-fixer.md +1 -1
  6. package/agents/gsd-codebase-mapper.md +1 -1
  7. package/agents/gsd-debug-session-manager.md +78 -4
  8. package/agents/gsd-debugger.md +87 -29
  9. package/agents/gsd-executor.md +49 -9
  10. package/agents/gsd-intel-updater.md +3 -3
  11. package/agents/gsd-phase-researcher.md +4 -2
  12. package/agents/gsd-plan-checker.md +20 -0
  13. package/agents/gsd-planner.md +44 -59
  14. package/agents/gsd-project-researcher.md +2 -2
  15. package/agents/gsd-ui-auditor.md +0 -40
  16. package/agents/gsd-verifier.md +2 -2
  17. package/bin/install.js +1338 -135
  18. package/commands/gsd/ai-integration-phase.md +1 -1
  19. package/commands/gsd/mempalace-capture.md +9 -5
  20. package/commands/gsd/new-milestone.md +1 -1
  21. package/commands/gsd/plan-phase.md +5 -3
  22. package/commands/gsd/plan-review-convergence.md +7 -2
  23. package/gsd-core/bin/gsd-tools.cjs +2690 -2472
  24. package/gsd-core/bin/lib/adapter-imperative.cjs +8 -1
  25. package/gsd-core/bin/lib/agent-command-router.cjs +20 -5
  26. package/gsd-core/bin/lib/api-coverage.cjs +360 -53
  27. package/gsd-core/bin/lib/audit.cjs +8 -8
  28. package/gsd-core/bin/lib/broken-windows.cjs +716 -0
  29. package/gsd-core/bin/lib/capability-command-router.cjs +733 -0
  30. package/gsd-core/bin/lib/capability-consent.cjs +40 -1
  31. package/gsd-core/bin/lib/capability-lifecycle.cjs +58 -0
  32. package/gsd-core/bin/lib/capability-loader.cjs +23 -1
  33. package/gsd-core/bin/lib/capability-registry.cjs +1450 -160
  34. package/gsd-core/bin/lib/capability-trust.cjs +468 -33
  35. package/gsd-core/bin/lib/capability-validator.cjs +882 -6
  36. package/gsd-core/bin/lib/capability-writer.cjs +6 -1
  37. package/gsd-core/bin/lib/check-command-router.cjs +140 -27
  38. package/gsd-core/bin/lib/cjs-command-router-adapter.cjs +15 -0
  39. package/gsd-core/bin/lib/claude-orchestration-command-router.cjs +209 -31
  40. package/gsd-core/bin/lib/claude-orchestration.cjs +203 -25
  41. package/gsd-core/bin/lib/command-aliases.cjs +14 -0
  42. package/gsd-core/bin/lib/commands.cjs +326 -21
  43. package/gsd-core/bin/lib/config-loader.cjs +214 -30
  44. package/gsd-core/bin/lib/config.cjs +158 -22
  45. package/gsd-core/bin/lib/core-utils.cjs +6 -1
  46. package/gsd-core/bin/lib/decisions.cjs +32 -8
  47. package/gsd-core/bin/lib/docs.cjs +6 -0
  48. package/gsd-core/bin/lib/estimate-cli.cjs +336 -0
  49. package/gsd-core/bin/lib/external-descriptor-trust.cjs +14 -2
  50. package/gsd-core/bin/lib/frontmatter.cjs +125 -15
  51. package/gsd-core/bin/lib/gap-checker.cjs +17 -2
  52. package/gsd-core/bin/lib/host-integration.cjs +215 -8
  53. package/gsd-core/bin/lib/init.cjs +155 -66
  54. package/gsd-core/bin/lib/install-engine.cjs +299 -23
  55. package/gsd-core/bin/lib/install-profiles.cjs +239 -1
  56. package/gsd-core/bin/lib/installer-migrations/005-opencode-baseline-commands-dir.cjs +146 -0
  57. package/gsd-core/bin/lib/installer-migrations/006-pi-extension-cjs-to-js.cjs +91 -0
  58. package/gsd-core/bin/lib/installer-migrations.cjs +44 -5
  59. package/gsd-core/bin/lib/markdown-sectionizer.cjs +107 -0
  60. package/gsd-core/bin/lib/milestone.cjs +248 -14
  61. package/gsd-core/bin/lib/model-catalog.cjs +69 -4
  62. package/gsd-core/bin/lib/model-resolver.cjs +189 -7
  63. package/gsd-core/bin/lib/observability/logger.cjs +7 -2
  64. package/gsd-core/bin/lib/onboard-projection.cjs +11 -8
  65. package/gsd-core/bin/lib/phase-command-router.cjs +10 -1
  66. package/gsd-core/bin/lib/phase-estimation.cjs +398 -0
  67. package/gsd-core/bin/lib/phase-id.cjs +304 -9
  68. package/gsd-core/bin/lib/phase.cjs +258 -17
  69. package/gsd-core/bin/lib/plan-drift-guard.cjs +1 -1
  70. package/gsd-core/bin/lib/plan-scan.cjs +70 -2
  71. package/gsd-core/bin/lib/planning-workspace.cjs +9 -2
  72. package/gsd-core/bin/lib/profile-output.cjs +34 -8
  73. package/gsd-core/bin/lib/review-lane-descriptor.cjs +927 -0
  74. package/gsd-core/bin/lib/review-lane-invocation.cjs +348 -0
  75. package/gsd-core/bin/lib/review-lane-runner.cjs +594 -0
  76. package/gsd-core/bin/lib/review-reviewer-selection.cjs +114 -32
  77. package/gsd-core/bin/lib/roadmap-parser.cjs +61 -10
  78. package/gsd-core/bin/lib/roadmap.cjs +23 -7
  79. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +38 -5
  80. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +23 -9
  81. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +156 -0
  82. package/gsd-core/bin/lib/runtime-name-policy.cjs +15 -2
  83. package/gsd-core/bin/lib/smart-entry.cjs +70 -5
  84. package/gsd-core/bin/lib/state-document.cjs +171 -24
  85. package/gsd-core/bin/lib/state-transition.cjs +50 -11
  86. package/gsd-core/bin/lib/state.cjs +206 -32
  87. package/gsd-core/bin/lib/surface.cjs +51 -9
  88. package/gsd-core/bin/lib/uat-predicate.cjs +6 -4
  89. package/gsd-core/bin/lib/uat.cjs +428 -11
  90. package/gsd-core/bin/lib/ui-consideration-probe.cjs +2 -2
  91. package/gsd-core/bin/lib/unusable-input.cjs +216 -0
  92. package/gsd-core/bin/lib/validate.cjs +44 -8
  93. package/gsd-core/bin/lib/verification.cjs +163 -31
  94. package/gsd-core/bin/lib/verify.cjs +348 -42
  95. package/gsd-core/bin/lib/worktree-safety.cjs +360 -15
  96. package/gsd-core/bin/shared/config-defaults.manifest.json +1 -0
  97. package/gsd-core/bin/shared/config-schema.manifest.json +4 -15
  98. package/gsd-core/bin/shared/model-catalog.json +5 -0
  99. package/gsd-core/bin/shared/runtime-aliases.manifest.json +5 -0
  100. package/gsd-core/references/api-coverage.md +37 -7
  101. package/gsd-core/references/checkpoints.md +1 -1
  102. package/gsd-core/references/common-bug-patterns.md +13 -0
  103. package/gsd-core/references/context-budget.md +40 -0
  104. package/gsd-core/references/debugger-bug-taxonomy.md +111 -0
  105. package/gsd-core/references/debugger-fix-acceptance.md +157 -0
  106. package/gsd-core/references/debugger-philosophy.md +1 -0
  107. package/gsd-core/references/debugger-prevention.md +98 -0
  108. package/gsd-core/references/debugger-rca-branching.md +98 -0
  109. package/gsd-core/references/debugger-repro-hardening.md +130 -0
  110. package/gsd-core/references/debugger-sbfl.md +110 -0
  111. package/gsd-core/references/debugger-semantic-recall.md +81 -0
  112. package/gsd-core/references/execute-phase-quota-recovery.md +55 -0
  113. package/gsd-core/references/execute-phase-requirement-revert.md +8 -0
  114. package/gsd-core/references/execute-phase-response-language.md +7 -0
  115. package/gsd-core/references/gate-prompts.md +6 -3
  116. package/gsd-core/references/model-profile-resolution.md +64 -13
  117. package/gsd-core/references/offer-next.md +88 -0
  118. package/gsd-core/references/planner-antipatterns.md +6 -0
  119. package/gsd-core/references/planner-mvp-mode.md +12 -13
  120. package/gsd-core/references/planner-preconditions.md +156 -0
  121. package/gsd-core/references/planner-reversibility.md +132 -0
  122. package/gsd-core/references/planning-config.md +2 -1
  123. package/gsd-core/references/reviewer-instances.md +28 -19
  124. package/gsd-core/references/runtime-aware-dispatch.md +42 -0
  125. package/gsd-core/references/skeleton-template.md +1 -1
  126. package/gsd-core/references/thinking-models-planning.md +3 -1
  127. package/gsd-core/references/ui-consideration-probe.md +2 -2
  128. package/gsd-core/references/worktree-branch-check.md +4 -4
  129. package/gsd-core/templates/DEBUG.md +5 -3
  130. package/gsd-core/templates/summary-minimal.md +4 -0
  131. package/gsd-core/templates/summary-standard.md +4 -0
  132. package/gsd-core/templates/summary.md +7 -0
  133. package/gsd-core/workflows/add-phase.md +2 -0
  134. package/gsd-core/workflows/add-tests.md +3 -1
  135. package/gsd-core/workflows/add-todo.md +32 -1
  136. package/gsd-core/workflows/ai-integration-phase.md +8 -6
  137. package/gsd-core/workflows/audit-fix.md +6 -2
  138. package/gsd-core/workflows/audit-milestone.md +8 -0
  139. package/gsd-core/workflows/autonomous.md +19 -15
  140. package/gsd-core/workflows/check-todos.md +5 -3
  141. package/gsd-core/workflows/cleanup.md +7 -1
  142. package/gsd-core/workflows/code-review-fix.md +14 -6
  143. package/gsd-core/workflows/code-review.md +93 -24
  144. package/gsd-core/workflows/complete-milestone.md +3 -0
  145. package/gsd-core/workflows/debug.md +35 -7
  146. package/gsd-core/workflows/diagnose-issues.md +5 -1
  147. package/gsd-core/workflows/discovery-phase.md +7 -0
  148. package/gsd-core/workflows/discuss-phase/modes/advisor.md +2 -4
  149. package/gsd-core/workflows/discuss-phase/modes/auto.md +0 -6
  150. package/gsd-core/workflows/discuss-phase/templates/context.md +16 -2
  151. package/gsd-core/workflows/discuss-phase-assumptions.md +18 -9
  152. package/gsd-core/workflows/discuss-phase.md +2 -2
  153. package/gsd-core/workflows/do.md +7 -1
  154. package/gsd-core/workflows/docs-update.md +9 -0
  155. package/gsd-core/workflows/eval-review.md +4 -1
  156. package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +4 -0
  157. package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +160 -0
  158. package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +4 -4
  159. package/gsd-core/workflows/execute-phase/steps/regression-gate.md +2 -2
  160. package/gsd-core/workflows/execute-phase.md +110 -149
  161. package/gsd-core/workflows/execute-plan.md +20 -8
  162. package/gsd-core/workflows/explore.md +4 -0
  163. package/gsd-core/workflows/extract-learnings.md +21 -0
  164. package/gsd-core/workflows/graduation.md +3 -0
  165. package/gsd-core/workflows/health.md +7 -1
  166. package/gsd-core/workflows/help/modes/full.md +9 -5
  167. package/gsd-core/workflows/import.md +11 -2
  168. package/gsd-core/workflows/inbox.md +7 -0
  169. package/gsd-core/workflows/ingest-docs.md +19 -10
  170. package/gsd-core/workflows/manager.md +3 -1
  171. package/gsd-core/workflows/map-codebase.md +17 -10
  172. package/gsd-core/workflows/mvp-phase.md +3 -0
  173. package/gsd-core/workflows/new-milestone.md +79 -23
  174. package/gsd-core/workflows/new-project.md +28 -19
  175. package/gsd-core/workflows/new-workspace.md +3 -1
  176. package/gsd-core/workflows/next.md +5 -2
  177. package/gsd-core/workflows/onboard.md +3 -0
  178. package/gsd-core/workflows/plan-phase.md +56 -51
  179. package/gsd-core/workflows/plan-review-convergence.md +61 -12
  180. package/gsd-core/workflows/plant-seed.md +3 -0
  181. package/gsd-core/workflows/profile-user.md +7 -1
  182. package/gsd-core/workflows/progress.md +31 -3
  183. package/gsd-core/workflows/quick.md +33 -10
  184. package/gsd-core/workflows/remove-workspace.md +3 -0
  185. package/gsd-core/workflows/review.md +172 -585
  186. package/gsd-core/workflows/scan.md +10 -2
  187. package/gsd-core/workflows/secure-phase.md +13 -2
  188. package/gsd-core/workflows/settings-integrations.md +3 -0
  189. package/gsd-core/workflows/settings.md +3 -0
  190. package/gsd-core/workflows/ship.md +88 -11
  191. package/gsd-core/workflows/sketch.md +3 -0
  192. package/gsd-core/workflows/smart-entry.md +4 -1
  193. package/gsd-core/workflows/spike.md +7 -1
  194. package/gsd-core/workflows/ui-phase.md +11 -2
  195. package/gsd-core/workflows/ui-review.md +11 -1
  196. package/gsd-core/workflows/undo.md +7 -0
  197. package/gsd-core/workflows/update.md +106 -5
  198. package/gsd-core/workflows/validate-phase.md +13 -2
  199. package/gsd-core/workflows/verify-phase.md +2 -2
  200. package/gsd-core/workflows/verify-work.md +15 -4
  201. package/hooks/dist/gsd-context-monitor.js +27 -9
  202. package/hooks/dist/gsd-cursor-session-start.js +6 -2
  203. package/hooks/dist/gsd-cursor-stop.js +6 -2
  204. package/hooks/dist/gsd-cursor-subagent-start.js +6 -2
  205. package/hooks/dist/gsd-graphify-update.sh +9 -0
  206. package/hooks/dist/gsd-phase-boundary.sh +14 -2
  207. package/hooks/dist/gsd-prompt-guard.js +101 -2
  208. package/hooks/dist/gsd-read-guard.js +100 -2
  209. package/hooks/dist/gsd-read-injection-scanner.js +109 -2
  210. package/hooks/dist/gsd-statusline.js +97 -9
  211. package/hooks/dist/gsd-workflow-guard.js +110 -6
  212. package/hooks/dist/gsd-worktree-path-guard.js +132 -8
  213. package/hooks/dist/lib/cursor-workspace.js +74 -0
  214. package/hooks/gsd-context-monitor.js +27 -9
  215. package/hooks/gsd-cursor-session-start.js +6 -2
  216. package/hooks/gsd-cursor-stop.js +6 -2
  217. package/hooks/gsd-cursor-subagent-start.js +6 -2
  218. package/hooks/gsd-graphify-update.sh +9 -0
  219. package/hooks/gsd-phase-boundary.sh +14 -2
  220. package/hooks/gsd-prompt-guard.js +101 -2
  221. package/hooks/gsd-read-guard.js +100 -2
  222. package/hooks/gsd-read-injection-scanner.js +109 -2
  223. package/hooks/gsd-statusline.js +97 -9
  224. package/hooks/gsd-workflow-guard.js +110 -6
  225. package/hooks/gsd-worktree-path-guard.js +132 -8
  226. package/hooks/lib/cursor-workspace.js +74 -0
  227. package/package.json +10 -8
  228. package/pi/gsd.cjs +34 -3
  229. package/scripts/changeset/lint.cjs +1 -0
  230. package/scripts/changeset/parse.cjs +26 -0
  231. package/scripts/check-coverage-gate.cjs +51 -0
  232. package/scripts/check-glossary-refs.cjs +244 -0
  233. package/scripts/ci-rebase-check.cjs +48 -4
  234. package/scripts/ci-test-scope.cjs +67 -17
  235. package/scripts/gen-adr-index.cjs +528 -0
  236. package/scripts/gen-capability-matrix.cjs +26 -2
  237. package/scripts/gen-capability-registry.cjs +132 -34
  238. package/scripts/gen-emitted-baseline.cjs +145 -0
  239. package/scripts/gen-test-timings.cjs +201 -0
  240. package/scripts/lint-compiled-artifact-sync.cjs +146 -0
  241. package/scripts/lint-emitted-drift-ack.cjs +149 -0
  242. package/scripts/lint-fix-has-regression-test.cjs +131 -0
  243. package/scripts/lint-portable-timeout.cjs +140 -0
  244. package/scripts/lint-resolution-provenance.cjs +9 -0
  245. package/scripts/lint-test-file-count.allowlist.json +1 -0
  246. package/scripts/mutation-matrix.cjs +4 -0
  247. package/scripts/prompt-injection-scan.sh +6 -0
  248. package/scripts/registry-schema.cjs +57 -8
  249. package/scripts/release-notes/conventional-title.cjs +19 -1
  250. package/scripts/release-notes/format-github-release-notes.cjs +7 -3
  251. package/scripts/release-tarball-smoke.cjs +18 -11
  252. package/scripts/run-tests.cjs +420 -58
  253. package/scripts/workflow-size.cjs +16 -8
  254. package/skills/gsd-ai-integration-phase/SKILL.md +1 -1
  255. package/skills/gsd-mempalace-capture/SKILL.md +9 -5
  256. package/skills/gsd-new-milestone/SKILL.md +1 -1
  257. package/skills/gsd-plan-phase/SKILL.md +5 -3
  258. package/skills/gsd-plan-review-convergence/SKILL.md +7 -2
  259. package/vscode/package.json +1 -1
  260. package/scripts/gen-golden-install-parity-zcode.cjs +0 -77
  261. package/scripts/update-size-baseline.cjs +0 -68
package/bin/install.js CHANGED
@@ -168,15 +168,28 @@ const DEFAULT_RUNTIME = 'claude';
168
168
  const GSD_CLAUDE_ALLOW_PERMISSIONS = Object.freeze([
169
169
  'Bash(npx gsd-core *)',
170
170
  'Read(.planning/*)',
171
- 'Write(.planning/*)',
171
+ 'Edit(.planning/*)',
172
172
  'Read(STATE.md)',
173
- 'Write(STATE.md)',
173
+ 'Edit(STATE.md)',
174
174
  ]);
175
175
  const GSD_CLAUDE_DENY_PERMISSIONS = Object.freeze([
176
176
  'Read(.env)',
177
177
  'Read(.env.*)',
178
178
  'Read(.secrets)',
179
179
  ]);
180
+ // #2278 — Stale allow-rule forms from before the fix. Claude Code has no
181
+ // standalone `Write` permission gate: file-editing tools (Write/Edit/
182
+ // NotebookEdit) are gated collectively via `Edit(pattern)`. The original
183
+ // `Write(.planning/*)` / `Write(STATE.md)` entries were therefore silently
184
+ // unmatched (never granted anything) and Claude Code additionally surfaces a
185
+ // session-start warning about unmatched permission rules. This list lets
186
+ // mergeClaudePermissions and uninstall cleanup retire those stale entries on
187
+ // existing installs while the current GSD_CLAUDE_ALLOW_PERMISSIONS above
188
+ // carries the working `Edit(...)` forms.
189
+ const GSD_CLAUDE_LEGACY_ALLOW_PERMISSIONS = Object.freeze([
190
+ 'Write(.planning/*)',
191
+ 'Write(STATE.md)',
192
+ ]);
180
193
 
181
194
  /**
182
195
  * Merge GSD-owned permission entries into a Claude Code settings object.
@@ -185,6 +198,12 @@ const GSD_CLAUDE_DENY_PERMISSIONS = Object.freeze([
185
198
  * entries are appended only if not already present. No other permission sub-keys
186
199
  * (ask, disableBypassPermissionsMode, etc.) are touched.
187
200
  *
201
+ * Migration (#2278): before adding the current GSD_CLAUDE_ALLOW_PERMISSIONS,
202
+ * any stale GSD_CLAUDE_LEGACY_ALLOW_PERMISSIONS entry (e.g. the unmatched
203
+ * `Write(...)` forms from before the fix) is removed from permissions.allow,
204
+ * so existing installs end up with the working `Edit(...)` forms instead of
205
+ * both the dead legacy entry and its replacement sitting side by side.
206
+ *
188
207
  * Defensive: if settings is not a plain object, returns immediately without
189
208
  * throwing. If permissions.allow / permissions.deny exist but are not arrays
190
209
  * (malformed settings), they are replaced with valid arrays.
@@ -205,6 +224,10 @@ function mergeClaudePermissions(settings) {
205
224
  settings.permissions.deny = [];
206
225
  }
207
226
 
227
+ settings.permissions.allow = settings.permissions.allow.filter(
228
+ (e) => !GSD_CLAUDE_LEGACY_ALLOW_PERMISSIONS.includes(e)
229
+ );
230
+
208
231
  for (const entry of GSD_CLAUDE_ALLOW_PERMISSIONS) {
209
232
  if (!settings.permissions.allow.includes(entry)) {
210
233
  settings.permissions.allow.push(entry);
@@ -302,7 +325,13 @@ const GSD_WINDSURF_HOOK_SCRIPTS = [
302
325
 
303
326
  // GSD-managed files under hooks/lib/ (helpers required by gsd-*.sh hooks).
304
327
  // git-cmd.js does not start with "gsd-" (shared classifier for #3129), gsd-graphify-rebuild.sh does.
305
- const GSD_HOOK_LIB_FILES = ['git-cmd.js', 'gsd-graphify-rebuild.sh'];
328
+ // cursor-workspace.js (#2587) is required by the Cursor lifecycle hooks. Those
329
+ // are staged individually by writeCursorHooksJson (Cursor sets
330
+ // hostBehaviors.skipSharedHooksInstall, so it never reaches the bulk hooks/lib
331
+ // copy below) — that function stages this helper alongside them. Listing it
332
+ // here keeps uninstall and the manifest managing it for every OTHER runtime
333
+ // that does receive hooks/lib.
334
+ const GSD_HOOK_LIB_FILES = ['git-cmd.js', 'gsd-graphify-rebuild.sh', 'cursor-workspace.js'];
306
335
 
307
336
  const CODEX_AGENT_SANDBOX = {
308
337
  'gsd-executor': 'workspace-write',
@@ -352,6 +381,7 @@ const {
352
381
  } = require(path.join(_gsdLibDir, 'model-catalog.cjs'));
353
382
  const {
354
383
  resolveTierEntry: gsdResolveTierEntry,
384
+ CLAUDE_AGENT_ALIASES,
355
385
  } = require(path.join(_gsdLibDir, 'model-resolver.cjs'));
356
386
 
357
387
  // #2071 — install-time effort resolution (readGsdEffectiveEffortConfig /
@@ -390,6 +420,32 @@ try {
390
420
  _capabilityRegistry = undefined;
391
421
  }
392
422
 
423
+ // #2322 BLOCKER 2: `_capabilityRegistry` above is the FROZEN first-party registry
424
+ // (capability-registry.cjs, built at publish time) — it never reflects an
425
+ // INSTALLED third-party overlay capability, so a fresh `gsd install` could never
426
+ // stage an installed third-party capability's skill regardless of registration,
427
+ // even on the DEFAULT `--profile full`. `_installedCapabilityRegistry` composes
428
+ // the overlay via capability-loader's `loadRegistry({includeInstalled:true})` —
429
+ // the SAME call capability-writer.cts's `capability set --runtime` path already
430
+ // uses — so a fresh install and a post-install `capability set` agree on
431
+ // third-party skill availability. Used ONLY for skill-profile resolution and
432
+ // runtime-artifact-layout staging below; `_capabilityRegistry` (frozen) remains
433
+ // the source for gsd-core's OWN runtime/host-behavior descriptors (unaffected —
434
+ // those are always first-party). A load failure degrades to the frozen
435
+ // `_capabilityRegistry` (no overlay data -> no third-party skills staged; never
436
+ // a crash and never a scan-and-guess fallback).
437
+ let _installedCapabilityRegistry;
438
+ try {
439
+ const _capabilityLoader = require(path.join(_gsdLibDir, 'capability-loader.cjs'));
440
+ _installedCapabilityRegistry = _capabilityLoader.loadRegistry({
441
+ includeInstalled: true,
442
+ cwd: process.cwd(),
443
+ gsdHome: process.env['GSD_HOME'],
444
+ });
445
+ } catch (_) {
446
+ _installedCapabilityRegistry = _capabilityRegistry;
447
+ }
448
+
393
449
  // Fail-safe floor for the reference host's #338-privacy-critical behaviors, used
394
450
  // ONLY when the first-party capability registry cannot be loaded (a broken bundle).
395
451
  // Without it, a registry-load failure would make `_hostBehaviors('claude')` return
@@ -441,6 +497,25 @@ function _hostBehaviors(runtime) {
441
497
  return _resolveHostBehaviors(runtime, _capabilityRegistry);
442
498
  }
443
499
 
500
+ /**
501
+ * Read a runtime's documentation-sourced `hostIntegration.dispatch` axes
502
+ * (ADR-1239 Phase A — `capabilities/<runtime>/capability.json`
503
+ * `runtime.hostIntegration.dispatch`): `{namedDispatch, nested, maxDepth,
504
+ * background, backgroundDispatch, subagentToolkit}`. These are validated,
505
+ * closed-vocabulary FACTS about what the runtime's real dispatch primitive
506
+ * supports (never inferred) — see `docs/reference/host-integration-capability-
507
+ * matrix.md` for citations. Unlike `_hostBehaviors` (install *policy*), this is
508
+ * the negotiated *capability* surface; #2284 is its first content-projection
509
+ * consumer (previously read only by `shouldFlattenDispatch`). Returns `{}` if
510
+ * the registry or the runtime's descriptor is unavailable, so callers must
511
+ * treat every axis as absent/unknown (fail-closed) rather than assume a value.
512
+ */
513
+ function _hostIntegrationDispatch(runtime) {
514
+ const cap = _capabilityRegistry && _capabilityRegistry.runtimes && _capabilityRegistry.runtimes[runtime];
515
+ const dispatch = cap && cap.runtime && cap.runtime.hostIntegration && cap.runtime.hostIntegration.dispatch;
516
+ return dispatch || {};
517
+ }
518
+
444
519
  /**
445
520
  * Resolve the ACTUAL on-disk skills-install directory for a runtime, honoring a
446
521
  * skills-kind `home` override (ADR-1239 upgrade 3 / #2088: e.g. Codex skills ->
@@ -510,6 +585,7 @@ const {
510
585
  _installNativePluginIfDeclared,
511
586
  _copyStaged,
512
587
  hasExistingSymlinkBetween,
588
+ isSymlinkedDestOptIn,
513
589
  preserveUserArtifacts,
514
590
  restoreUserArtifacts,
515
591
  migrateLegacyDevPreferencesToSkill,
@@ -557,7 +633,7 @@ if (hasMinimal && _profileArgRaw) {
557
633
 
558
634
  function selectRuntimesFromArgs(runtimeArgs) {
559
635
  if (runtimeArgs.includes('--all')) {
560
- return ['claude', 'kimi', 'kilo', 'opencode', 'pi', 'codex', 'copilot', 'antigravity', 'cursor', 'windsurf', 'augment', 'trae', 'qwen', 'hermes', 'codebuddy', 'cline', 'zcode'];
636
+ return ['claude', 'kimi', 'kimi-code', 'kilo', 'opencode', 'pi', 'codex', 'copilot', 'antigravity', 'cursor', 'windsurf', 'augment', 'trae', 'qwen', 'hermes', 'codebuddy', 'cline', 'zcode'];
561
637
  }
562
638
  if (runtimeArgs.includes('--both')) {
563
639
  return ['claude', 'opencode'];
@@ -578,6 +654,7 @@ function selectRuntimesFromArgs(runtimeArgs) {
578
654
  if (runtimeArgs.includes('--qwen')) selected.push('qwen');
579
655
  if (runtimeArgs.includes('--hermes')) selected.push('hermes');
580
656
  if (runtimeArgs.includes('--kimi')) selected.push('kimi');
657
+ if (runtimeArgs.includes('--kimi-code')) selected.push('kimi-code');
581
658
  if (runtimeArgs.includes('--codebuddy')) selected.push('codebuddy');
582
659
  if (runtimeArgs.includes('--cline')) selected.push('cline');
583
660
  if (runtimeArgs.includes('--zcode')) selected.push('zcode');
@@ -587,6 +664,62 @@ function selectRuntimesFromArgs(runtimeArgs) {
587
664
  // Runtime selection - can be set by flags or interactive prompt
588
665
  let selectedRuntimes = selectRuntimesFromArgs(args);
589
666
 
667
+ // #2505 Phase 5: Kimi variant disambiguation (#2513). Kimi CLI (Python, ~/.kimi/)
668
+ // and Kimi Code (Node, ~/.kimi-code/) are two distinct Moonshot products that
669
+ // share the "kimi" brand. Probe for each product's config.toml and warn when
670
+ // the selected runtime doesn't match the detected install — catches the common
671
+ // "ran --kimi --global but actually on Kimi Code" mistake that produced inert
672
+ // YAMLs and empty agent-skills before the Phase 1 descriptor split.
673
+ function disambiguateKimiVariant(runtimes) {
674
+ const home = os.homedir();
675
+ const hasKimiCli = fs.existsSync(path.join(home, '.kimi', 'config.toml'));
676
+ const hasKimiCode = fs.existsSync(path.join(home, '.kimi-code', 'config.toml'));
677
+ const notices = [];
678
+ if (runtimes.includes('kimi') && hasKimiCode && !hasKimiCli) {
679
+ notices.push({
680
+ kind: 'wrong-variant',
681
+ selected: 'kimi',
682
+ detected: 'kimi-code',
683
+ message: `Detected ~/.kimi-code/config.toml (Kimi Code, Node CLI) but not ~/.kimi/config.toml (Kimi CLI, Python). You selected --kimi but appear to be on Kimi Code. Re-run with --kimi-code for a working install. (Kimi CLI = Python kimi-cli with named subagents; Kimi Code = Node CLI with coder/explore/plan built-ins only.)`,
684
+ });
685
+ }
686
+ if (runtimes.includes('kimi-code') && hasKimiCli && !hasKimiCode) {
687
+ notices.push({
688
+ kind: 'wrong-variant',
689
+ selected: 'kimi-code',
690
+ detected: 'kimi',
691
+ message: `Detected ~/.kimi/config.toml (Kimi CLI, Python) but not ~/.kimi-code/config.toml (Kimi Code, Node CLI). You selected --kimi-code but appear to be on Kimi CLI. Re-run with --kimi for a working install.`,
692
+ });
693
+ }
694
+ // Distinct-entry descriptions when either Kimi variant is selected.
695
+ if (runtimes.includes('kimi')) {
696
+ notices.push({
697
+ kind: 'description',
698
+ runtime: 'kimi',
699
+ message: 'Kimi CLI (Python kimi-cli): named subagents via YAML, config at ~/.kimi/, hooks via ~/.kimi/config.toml [[hooks]].',
700
+ });
701
+ }
702
+ if (runtimes.includes('kimi-code')) {
703
+ notices.push({
704
+ kind: 'description',
705
+ runtime: 'kimi-code',
706
+ message: 'Kimi Code (Node CLI): three built-in subagents (coder/explore/plan), Agent Skills at ~/.kimi-code/skills/, config at ~/.kimi-code/.',
707
+ });
708
+ }
709
+ return notices;
710
+ }
711
+
712
+ if (selectedRuntimes.includes('kimi') || selectedRuntimes.includes('kimi-code')) {
713
+ const kimiNotices = disambiguateKimiVariant(selectedRuntimes);
714
+ for (const notice of kimiNotices) {
715
+ if (notice.kind === 'wrong-variant') {
716
+ console.error(`${yellow}⚠ Kimi variant mismatch (${notice.selected} → ${notice.detected}).${reset} ${notice.message}`);
717
+ } else if (notice.kind === 'description') {
718
+ console.log(`${dim} ${notice.runtime}: ${notice.message}${reset}`);
719
+ }
720
+ }
721
+ }
722
+
590
723
  // #1928: Google sunset Gemini CLI on 2026-06-18; Antigravity CLI is its
591
724
  // official successor. `--gemini` is no longer a valid runtime selector —
592
725
  // selectRuntimesFromArgs above no longer recognizes it, so it never lands in
@@ -2368,6 +2501,56 @@ function extractFrontmatterField(frontmatter, fieldName) {
2368
2501
  return match[1].trim().replace(/^['"]|['"]$/g, '');
2369
2502
  }
2370
2503
 
2504
+ // #2284 finding (b): the `<runtime_compatibility>` block appearing in
2505
+ // gsd-core/workflows/{plan-phase,execute-phase}.md is a runtime-COMPARISON
2506
+ // table ("**Claude Code:** Uses `Agent(...)`" / "a backgrounded Claude Code
2507
+ // agent" / "top-level Claude Code") — every "Claude Code" mention inside it
2508
+ // is a COMPARED-RUNTIME LABEL, not a host self-reference. The brand swap
2509
+ // below (`Claude Code` → the installing runtime's own display name) is
2510
+ // meant only for host self-references; applying it inside this block
2511
+ // mislabels the comparison (e.g. Windsurf installs would read "**Windsurf:**
2512
+ // Uses `Agent(...)`" describing what is actually Claude Code's behavior).
2513
+ // This is cross-cutting across every runtime that brand-swaps workflow
2514
+ // content (cursor/windsurf/trae/cline/codebuddy hardcoded; qwen/hermes
2515
+ // descriptor-driven via hostBehaviors.brandingRewrites) — confirmed to
2516
+ // reproduce on unmodified Windsurf, not Hermes-specific.
2517
+ const RUNTIME_COMPATIBILITY_BLOCK_RE = /<runtime_compatibility>[\s\S]*?<\/runtime_compatibility>/g;
2518
+
2519
+ /**
2520
+ * Rewrite bare "Claude Code" self-references in workflow content to
2521
+ * `brandName`, EXCEPT inside `<runtime_compatibility>...</runtime_compatibility>`
2522
+ * blocks, which are left byte-for-byte verbatim. Every other content
2523
+ * transform in a runtime's `.md` converter (tool-name renames, path
2524
+ * rewrites, etc.) is unaffected — only this literal brand-name swap is
2525
+ * protected-region-aware, since only it risks mislabeling a
2526
+ * runtime-comparison table.
2527
+ *
2528
+ * Implementation: SPLIT `content` on the protected-block regex, brand-swap
2529
+ * only the GAP text between (and around) matches, then rejoin gap+block
2530
+ * alternately. No placeholder/sentinel token of any kind is substituted in
2531
+ * — a prior version used a sentinel-token mask/restore, which is exactly the
2532
+ * kind of invisible landmine this rewrite eliminates (a sentinel string, no
2533
+ * matter how obscure, is a theoretical collision risk with real content and
2534
+ * is easy to silently reintroduce in a future edit without it showing in a
2535
+ * diff). Behavior-identical to the removed sentinel-token version — verified
2536
+ * via `npm run gen:golden` producing zero further diff.
2537
+ */
2538
+ function applyClaudeCodeBrandSwap(content, brandName) {
2539
+ if (!brandName) return content;
2540
+ let result = '';
2541
+ let lastIndex = 0;
2542
+ RUNTIME_COMPATIBILITY_BLOCK_RE.lastIndex = 0; // reset shared global-regex state before each use
2543
+ let m;
2544
+ while ((m = RUNTIME_COMPATIBILITY_BLOCK_RE.exec(content))) {
2545
+ const gap = content.slice(lastIndex, m.index);
2546
+ result += gap.replace(/\bClaude Code\b/g, brandName);
2547
+ result += m[0]; // protected block, verbatim — never brand-swapped
2548
+ lastIndex = m.index + m[0].length;
2549
+ }
2550
+ result += content.slice(lastIndex).replace(/\bClaude Code\b/g, brandName);
2551
+ return result;
2552
+ }
2553
+
2371
2554
  // Tool name mapping from Claude Code to Cursor CLI
2372
2555
  const claudeToCursorTools = {
2373
2556
  Bash: 'Shell',
@@ -2400,8 +2583,9 @@ function convertClaudeToCursorMarkdown(content) {
2400
2583
  // Remove Claude Code-specific bug workarounds before brand replacement
2401
2584
  converted = converted.replace(/\*\*Known Claude Code bug \(classifyHandoffIfNeeded\):\*\*[^\n]*\n/g, '');
2402
2585
  converted = converted.replace(/- \*\*classifyHandoffIfNeeded false failure:\*\*[^\n]*\n/g, '');
2403
- // Replace "Claude Code" brand references with "Cursor"
2404
- converted = converted.replace(/\bClaude Code\b/g, 'Cursor');
2586
+ // Replace "Claude Code" brand references with "Cursor" — #2284(b): skips
2587
+ // <runtime_compatibility> comparison-table content (protected region).
2588
+ converted = applyClaudeCodeBrandSwap(converted, 'Cursor');
2405
2589
  return converted;
2406
2590
  }
2407
2591
 
@@ -2445,7 +2629,13 @@ function convertClaudeCommandToCursorSkill(content, skillName) {
2445
2629
  const shortDescription = description.length > 180 ? `${description.slice(0, 177)}...` : description;
2446
2630
  const adapter = getCursorSkillAdapterHeader(skillName);
2447
2631
 
2448
- return `---\nname: ${yamlIdentifier(skillName)}\ndescription: ${yamlQuote(shortDescription)}\n---\n\n${adapter}\n\n${body.trimStart()}`;
2632
+ // #2341: mark user-invocable:false so the skill is NOT shown in Cursor's '/'
2633
+ // menu (it defaults to true). Cursor also writes a commands/ surface (#785),
2634
+ // and surfacing both duplicated every /gsd-* entry. This mirrors the #789
2635
+ // CodeBuddy de-dup: the commands/ surface is the sole '/' entry point; skills
2636
+ // stay model-invocable background knowledge. (user-invocable:false hides from
2637
+ // '/' while keeping model invocation — distinct from disable-model-invocation.)
2638
+ return `---\nname: ${yamlIdentifier(skillName)}\ndescription: ${yamlQuote(shortDescription)}\nuser-invocable: false\n---\n\n${adapter}\n\n${body.trimStart()}`;
2449
2639
  }
2450
2640
 
2451
2641
  /**
@@ -2533,8 +2723,9 @@ function convertClaudeToWindsurfMarkdown(content) {
2533
2723
  // Remove Claude Code-specific bug workarounds before brand replacement
2534
2724
  converted = converted.replace(/\*\*Known Claude Code bug \(classifyHandoffIfNeeded\):\*\*[^\n]*\n/g, '');
2535
2725
  converted = converted.replace(/- \*\*classifyHandoffIfNeeded false failure:\*\*[^\n]*\n/g, '');
2536
- // Replace "Claude Code" brand references with "Windsurf"
2537
- converted = converted.replace(/\bClaude Code\b/g, 'Windsurf');
2726
+ // Replace "Claude Code" brand references with "Windsurf" — #2284(b): skips
2727
+ // <runtime_compatibility> comparison-table content (protected region).
2728
+ converted = applyClaudeCodeBrandSwap(converted, 'Windsurf');
2538
2729
  return converted;
2539
2730
  }
2540
2731
 
@@ -2668,7 +2859,8 @@ function convertClaudeToTraeMarkdown(content) {
2668
2859
  converted = converted.replace(/\bCLAUDE_CONFIG_DIR\b/g, 'TRAE_CONFIG_DIR');
2669
2860
  converted = converted.replace(/\*\*Known Claude Code bug \(classifyHandoffIfNeeded\):\*\*[^\n]*\n/g, '');
2670
2861
  converted = converted.replace(/- \*\*classifyHandoffIfNeeded false failure:\*\*[^\n]*\n/g, '');
2671
- converted = converted.replace(/\bClaude Code\b/g, 'Trae');
2862
+ // #2284(b): skips <runtime_compatibility> comparison-table content (protected region).
2863
+ converted = applyClaudeCodeBrandSwap(converted, 'Trae');
2672
2864
  return converted;
2673
2865
  }
2674
2866
 
@@ -2740,7 +2932,8 @@ function convertClaudeToCodebuddyMarkdown(content) {
2740
2932
  converted = converted.replace(/\.claude\//g, '.codebuddy/');
2741
2933
  converted = converted.replace(/\*\*Known Claude Code bug \(classifyHandoffIfNeeded\):\*\*[^\n]*\n/g, '');
2742
2934
  converted = converted.replace(/- \*\*classifyHandoffIfNeeded false failure:\*\*[^\n]*\n/g, '');
2743
- converted = converted.replace(/\bClaude Code\b/g, 'CodeBuddy');
2935
+ // #2284(b): skips <runtime_compatibility> comparison-table content (protected region).
2936
+ converted = applyClaudeCodeBrandSwap(converted, 'CodeBuddy');
2744
2937
  return converted;
2745
2938
  }
2746
2939
 
@@ -2835,7 +3028,8 @@ function convertClaudeToCliineMarkdown(content) {
2835
3028
  converted = converted.replace(/\bCLAUDE_CONFIG_DIR\b/g, 'CLINE_CONFIG_DIR');
2836
3029
  converted = converted.replace(/\*\*Known Claude Code bug \(classifyHandoffIfNeeded\):\*\*[^\n]*\n/g, '');
2837
3030
  converted = converted.replace(/- \*\*classifyHandoffIfNeeded false failure:\*\*[^\n]*\n/g, '');
2838
- converted = converted.replace(/\bClaude Code\b/g, 'Cline');
3031
+ // #2284(b): skips <runtime_compatibility> comparison-table content (protected region).
3032
+ converted = applyClaudeCodeBrandSwap(converted, 'Cline');
2839
3033
  return converted;
2840
3034
  }
2841
3035
 
@@ -2888,6 +3082,825 @@ function convertClaudeCommandToClineSkill(content, skillName, runtime = null, cm
2888
3082
 
2889
3083
  // ── End Cline converters ─────────────────────────────────────────────────────
2890
3084
 
3085
+ // ── Hermes converters (#2284) ────────────────────────────────────────────────
3086
+ //
3087
+ // Hermes exposes `delegate_task` for subagent dispatch, not the Claude-shaped
3088
+ // `Agent(...)` tool the host-neutral `gsd-core/workflows/*.md` corpus assumes.
3089
+ // Prior to this fix, the hermes `.md` hook (RUNTIME_CONTENT_DISPATCH.hermes)
3090
+ // only brand-swapped "Claude Code" → "Hermes Agent" via
3091
+ // hostBehaviors.brandingRewrites, leaving the false "Agent tool IS available"
3092
+ // assertion and literal `Agent(...)` call syntax installed verbatim.
3093
+ //
3094
+ // `projectNamedDispatchToStructuralDelegate` is GENERIC projection machinery:
3095
+ // it branches ENTIRELY on the runtime's documentation-sourced
3096
+ // `hostIntegration.dispatch` facts (read via `_hostIntegrationDispatch`,
3097
+ // capabilities/<runtime>/capability.json — never hardcoded here) and a
3098
+ // `toolConfig` that supplies only the target primitive's own vocabulary (its
3099
+ // call name + native parameter names — not a capability claim; there is no
3100
+ // `dispatch` axis for "the call's own parameter names", so that vocabulary is
3101
+ // necessarily supplied by the caller, exactly as every other runtime's
3102
+ // converter supplies its own tool-name vocabulary, e.g. Trae's `Shell(`).
3103
+ //
3104
+ // Hermes-specific facts consumed (capabilities/hermes/capability.json,
3105
+ // docs/reference/host-integration-capability-matrix.md:244-249 — UNCHANGED by
3106
+ // this fix):
3107
+ // - dispatch.namedDispatch: false — Hermes's delegate_task has no named-
3108
+ // agent lookup ("Subagents are identified only by role ('leaf' or
3109
+ // 'orchestrator')"). GSD resolves the referenced gsd-* role itself
3110
+ // (fail-closed against the staged agents/ dir) and embeds the loaded
3111
+ // PROMPT CONTENT into the delegate_task payload.
3112
+ // - dispatch.background: true — `delegate_task(background=true)` "returns a
3113
+ // handle immediately"; Claude's `run_in_background=` maps onto Hermes's
3114
+ // own `background=` parameter, preserving the async-handle / no-busy-poll
3115
+ // / resume-on-completion wording already used throughout these workflows.
3116
+ // - dispatch.subagentToolkit: "read-only" / dispatch.maxDepth: 1 — dispatched
3117
+ // roles never themselves further delegate, so no nested-delegation
3118
+ // instruction is ever emitted toward them.
3119
+
3120
+ /**
3121
+ * Resolve the set of gsd-* role-prompt stems actually shipped in this
3122
+ * package's `agents/` directory (the FULL source set, not profile-staged —
3123
+ * `--minimal`/`--profile=core` intentionally excludes many agents from a
3124
+ * given install without those workflows being unreachable, so validating
3125
+ * against the profile-filtered subset would fail every restricted-profile
3126
+ * Hermes install; validating against the shipped source catches genuine
3127
+ * authoring bugs — a stale/typo'd role reference — without that regression).
3128
+ * Returns `null` if the directory cannot be resolved (fail-closed: callers
3129
+ * must refuse to install rather than skip validation).
3130
+ */
3131
+ function _resolveAvailableGsdRoles() {
3132
+ try {
3133
+ const agentsDir = path.join(__dirname, '..', 'agents');
3134
+ return new Set(
3135
+ fs.readdirSync(agentsDir, { withFileTypes: true })
3136
+ .filter((e) => e.isFile() && e.name.endsWith('.md'))
3137
+ .map((e) => e.name.slice(0, -3)),
3138
+ );
3139
+ } catch (_e) {
3140
+ return null;
3141
+ }
3142
+ }
3143
+
3144
+ /**
3145
+ * Fail-closed validation (#2284 AC: "Missing role prompts fail closed" /
3146
+ * "never emit a workflow referencing an unresolvable role"). A single literal
3147
+ * `gsd-*` role value must resolve to a real `agents/<role>.md` file — throws
3148
+ * an explicit Error otherwise, aborting the install (the standard
3149
+ * `copyWithPathReplacement` failure path already used for its own
3150
+ * confinement-violation throws). Called per extracted role value from EVERY
3151
+ * call-syntax form (`subagent_type=`, `subagent_type:`, post-rename
3152
+ * `gsd_role=`) — the check operates on the resolved value, independent of
3153
+ * which source syntax produced it. Non-literal / dynamic expressions (e.g.
3154
+ * `research_hook.ref.agent`) are not quoted strings and are never passed
3155
+ * here; they carry their own runtime resolution + fail-closed instruction via
3156
+ * the injected per-call resolution line.
3157
+ */
3158
+ function _assertRoleResolvable(role, availableRoles, runtime, sourceDescription) {
3159
+ if (!availableRoles) {
3160
+ throw new Error(
3161
+ `${runtime} workflow install: could not resolve the shipped agents/ directory to validate named-role ` +
3162
+ 'dispatch references — refusing to install (fail-closed, #2284)',
3163
+ );
3164
+ }
3165
+ if (role.startsWith('gsd-') && !availableRoles.has(role)) {
3166
+ throw new Error(
3167
+ `${runtime} workflow install: dispatch references role "${role}" via ${sourceDescription}, but no ` +
3168
+ `matching agents/${role}.md prompt file is shipped — refusing to install a workflow that dispatches ` +
3169
+ 'an unresolvable role (fail-closed, #2284)',
3170
+ );
3171
+ }
3172
+ }
3173
+
3174
+ /**
3175
+ * Segment `text` into 'code' and 'string' runs (recognizes `"..."`, `'...'`,
3176
+ * and Python-style `"""..."""`, with backslash-escaping). Required because
3177
+ * the real corpus embeds unescaped parens inside quoted prompt bodies (e.g.
3178
+ * discuss-phase-assumptions.md's `(e.g., "Technical Approach")` inside a
3179
+ * `"""`-quoted prompt) — naive paren/keyword scanning across raw text would
3180
+ * desync on these. Downstream call-span detection and header-token
3181
+ * extraction operate on a same-length MASK derived from this segmentation
3182
+ * (see `maskStringLiterals`) so string content can never be mistaken for
3183
+ * call structure.
3184
+ */
3185
+ function _segmentCodeAndStrings(text) {
3186
+ const segments = [];
3187
+ let i = 0;
3188
+ let segStart = 0;
3189
+ const flushCode = (end) => { if (end > segStart) segments.push({ type: 'code', start: segStart, end }); };
3190
+ while (i < text.length) {
3191
+ const ch = text[i];
3192
+ if (ch === '"' && text[i + 1] === '"' && text[i + 2] === '"') {
3193
+ flushCode(i);
3194
+ const strStart = i;
3195
+ i += 3;
3196
+ while (i < text.length && !(text[i] === '"' && text[i + 1] === '"' && text[i + 2] === '"')) {
3197
+ i += text[i] === '\\' ? 2 : 1;
3198
+ }
3199
+ i = Math.min(i + 3, text.length);
3200
+ segments.push({ type: 'string', start: strStart, end: i, quoteLen: 3 });
3201
+ segStart = i;
3202
+ continue;
3203
+ }
3204
+ // Only `"` is recognized as a single-char string delimiter — NOT `'`.
3205
+ // The corpus is markdown prose, not code: apostrophes are routine English
3206
+ // contractions/possessives ("install's", "don't") and treating them as
3207
+ // string delimiters would swallow everything up to the next unrelated
3208
+ // apostrophe as "inside a string" (verified against the real corpus —
3209
+ // this was a real, disqualifying bug during development of this fix).
3210
+ // Every real call-argument value in the corpus uses `"`/`"""` only.
3211
+ if (ch === '"') {
3212
+ flushCode(i);
3213
+ const strStart = i;
3214
+ i += 1;
3215
+ while (i < text.length && text[i] !== '"') {
3216
+ i += text[i] === '\\' ? 2 : 1;
3217
+ }
3218
+ i = Math.min(i + 1, text.length);
3219
+ segments.push({ type: 'string', start: strStart, end: i, quoteLen: 1 });
3220
+ segStart = i;
3221
+ continue;
3222
+ }
3223
+ i += 1;
3224
+ }
3225
+ flushCode(text.length);
3226
+ return segments;
3227
+ }
3228
+
3229
+ /**
3230
+ * Same-length mask of `text` with the INTERIOR of every string literal
3231
+ * replaced by a space (newlines preserved, so line-based regexes still work).
3232
+ * The delimiting quote character(s) themselves (`"`, `'`, `"""`) are kept
3233
+ * verbatim so a value-extraction regex like `key\s*[=:]\s*"[^"]*"` still
3234
+ * matches correctly against the mask — only the STRING CONTENT is blanked,
3235
+ * never the quote structure. Positions in the mask line up 1:1 with `text`,
3236
+ * so match indices/offsets found against the mask are valid offsets into the
3237
+ * original.
3238
+ */
3239
+ function maskStringLiterals(text) {
3240
+ let mask = '';
3241
+ for (const seg of _segmentCodeAndStrings(text)) {
3242
+ const slice = text.slice(seg.start, seg.end);
3243
+ if (seg.type === 'code') { mask += slice; continue; }
3244
+ const q = seg.quoteLen;
3245
+ if (slice.length <= q) { mask += slice; continue; } // truncated/unterminated — keep verbatim
3246
+ const closeLen = Math.min(q, slice.length - q);
3247
+ const open = slice.slice(0, q);
3248
+ const close = slice.slice(slice.length - closeLen);
3249
+ const interiorLen = slice.length - q - closeLen;
3250
+ const interior = interiorLen > 0 ? slice.slice(q, q + interiorLen) : '';
3251
+ mask += open + interior.replace(/[^\n]/g, ' ') + close;
3252
+ }
3253
+ return mask;
3254
+ }
3255
+
3256
+ /**
3257
+ * Locate every `<headWord>(` / `<headWord>({` call span in `text`.
3258
+ *
3259
+ * #2284 round-2 CRITICAL fix: this MUST NOT rely on whole-document quote
3260
+ * parity. A markdown workflow file mixes prose, ```bash code fences (full of
3261
+ * their own double-quoted strings), and shell quoting — there is no single
3262
+ * document-wide quote grammar, so a `"`-heavy bash `echo` upstream of a real
3263
+ * call (e.g. code-review.md's fenced `echo "..."` block before its
3264
+ * `Agent(subagent_type="gsd-code-reviewer", ...)` call) can desync a
3265
+ * CUMULATIVE quote-state scan, making the scanner believe the real call's
3266
+ * `Agent(` sits "inside a string" and silently skipping it entirely — the
3267
+ * call then survives completely unnormalized. (Reproduced and root-caused
3268
+ * against the real corpus.)
3269
+ *
3270
+ * Fixed shape: find each `<headWord>(` occurrence via a PLAIN literal-text
3271
+ * search (`indexOf`, immune to any prior document content), then run a
3272
+ * balanced paren-matching scan whose quote-tracking state STARTS FRESH AT
3273
+ * THE HEAD — local to this one call, never inherited from (or able to be
3274
+ * corrupted by) anything earlier in the document. Handles all three real
3275
+ * corpus shapes: multi-line one-key-per-line, single-line object-literal
3276
+ * (`Agent({ ... })`), and single-line compact
3277
+ * (`Agent(subagent_type="x", model="y", prompt="...")`) — including prompt
3278
+ * bodies containing their own unescaped `()`/`{}` (skipped via the SAME
3279
+ * span-local quote tracking, e.g. discuss-phase-assumptions.md's
3280
+ * `"""`-quoted parenthetical prose).
3281
+ *
3282
+ * Returns `[{start, end, hasBraceWrapper}]` — `start`/`end` bound the FULL
3283
+ * call INCLUDING the head word and the closing `)`/`})`.
3284
+ */
3285
+ function findDispatchCallSpans(text, headWord) {
3286
+ const spans = [];
3287
+ const headToken = `${headWord}(`;
3288
+ let searchFrom = 0;
3289
+ for (;;) {
3290
+ const start = text.indexOf(headToken, searchFrom);
3291
+ if (start === -1) break;
3292
+ const prevChar = start > 0 ? text[start - 1] : '';
3293
+ if (/[A-Za-z0-9_]/.test(prevChar)) { searchFrom = start + 1; continue; } // word-boundary guard
3294
+
3295
+ let i = start + headToken.length; // just past the '('
3296
+ let j = i;
3297
+ while (j < text.length && /\s/.test(text[j])) j++;
3298
+ const hasBraceWrapper = text[j] === '{';
3299
+
3300
+ // LOCAL scan — quote/paren state is fresh here, never inherited from
3301
+ // anything before `start` in the document.
3302
+ let parenDepth = 1;
3303
+ let inString = null; // null | '"' | 'triple'
3304
+ let end = -1;
3305
+ for (; i < text.length; i++) {
3306
+ const ch = text[i];
3307
+ if (inString) {
3308
+ if (ch === '\\') { i++; continue; }
3309
+ if (inString === 'triple') {
3310
+ if (ch === '"' && text[i + 1] === '"' && text[i + 2] === '"') { inString = null; i += 2; }
3311
+ continue;
3312
+ }
3313
+ if (ch === inString) inString = null;
3314
+ continue;
3315
+ }
3316
+ if (ch === '"' && text[i + 1] === '"' && text[i + 2] === '"') { inString = 'triple'; i += 2; continue; }
3317
+ if (ch === '"') { inString = '"'; continue; }
3318
+ if (ch === '(') { parenDepth++; continue; }
3319
+ if (ch === ')') {
3320
+ parenDepth--;
3321
+ if (parenDepth === 0) { end = i + 1; break; }
3322
+ continue;
3323
+ }
3324
+ }
3325
+ if (end === -1) { searchFrom = start + 1; continue; } // unterminated — skip past, keep scanning
3326
+ spans.push({ start, end, hasBraceWrapper });
3327
+ searchFrom = end;
3328
+ }
3329
+ return spans;
3330
+ }
3331
+
3332
+ /**
3333
+ * Remove a call argument's `[matchStart, matchEnd)` token from `spanText`,
3334
+ * consuming its surrounding comma/whitespace so no dangling `, ,` or trailing
3335
+ * comment survives. When the argument owns its whole line, the whole line
3336
+ * (including a trailing inline `# comment`) is removed; `consumeLeadingComments`
3337
+ * additionally removes contiguous comment-only lines immediately ABOVE it —
3338
+ * #2284 Finding 5: explanatory prose describing a now-removed conditional
3339
+ * (e.g. execute-phase.md's "# Only include model= when ...") must not survive
3340
+ * describing a branch that no longer exists. Inline (single-line-compact /
3341
+ * object-literal) occurrences instead eat one adjacent comma.
3342
+ */
3343
+ function _stripCallArgument(spanText, matchStart, matchEnd, { consumeLeadingComments = false } = {}) {
3344
+ let end = matchEnd;
3345
+ const afterRe = /^[ \t]*,?[ \t]*(#[^\n]*)?\r?\n?/;
3346
+ const afterMatch = afterRe.exec(spanText.slice(end));
3347
+ const hadTrailingComma = !!(afterMatch && /,/.test(afterMatch[0]));
3348
+ if (afterMatch) end += afterMatch[0].length;
3349
+
3350
+ let start = matchStart;
3351
+ const lineStart = spanText.lastIndexOf('\n', start - 1) + 1;
3352
+ const ownLine = /^[ \t]*$/.test(spanText.slice(lineStart, start));
3353
+ if (ownLine) {
3354
+ start = lineStart;
3355
+ if (consumeLeadingComments) {
3356
+ for (;;) {
3357
+ const prevLineStart = start > 0 ? spanText.lastIndexOf('\n', start - 2) + 1 : 0;
3358
+ const prevLine = spanText.slice(prevLineStart, start);
3359
+ if (/^[ \t]*#[^\n]*\r?\n$/.test(prevLine)) {
3360
+ start = prevLineStart;
3361
+ if (prevLineStart === 0) break;
3362
+ } else break;
3363
+ }
3364
+ }
3365
+ } else if (!hadTrailingComma) {
3366
+ // Inline form and this was the LAST arg (no trailing comma) — eat a
3367
+ // leading comma so the previous arg doesn't dangle one.
3368
+ const before = spanText.slice(0, start);
3369
+ const cm = /,[ \t]*$/.exec(before);
3370
+ if (cm) start -= cm[0].length;
3371
+ }
3372
+ return spanText.slice(0, start) + spanText.slice(end);
3373
+ }
3374
+
3375
+ /**
3376
+ * Replace a named-role argument token's `[matchStart, matchEnd)` span
3377
+ * (`subagent_type=`/`subagent_type:` + its value) with the projected
3378
+ * `gsd_role=` / role-prompt-resolution / structural-role argument group.
3379
+ * Preserves the pretty multi-line one-arg-per-line style when the original
3380
+ * token owned its own line; falls back to an inline, comma-joined group for
3381
+ * the single-line-compact and object-literal forms.
3382
+ */
3383
+ function _projectRoleArgument(spanText, matchStart, matchEnd, roleValueExpr, toolConfig, canOrchestrate) {
3384
+ const { namedRoleParam, promptContentParam, structuralRoleParam, leafRoleValue } = toolConfig;
3385
+ const lineStart = spanText.lastIndexOf('\n', matchStart - 1) + 1;
3386
+ const startsOwnLine = /^[ \t]*$/.test(spanText.slice(lineStart, matchStart));
3387
+
3388
+ // Consume an immediately-following separator comma (+ same-line whitespace/
3389
+ // newline) into `end` — never leave it dangling AFTER an injected trailing
3390
+ // `# comment` (a bare `,` after `#...` would sit on the comment's own line,
3391
+ // outside any real argument list).
3392
+ let end = matchEnd;
3393
+ const afterRe = /^[ \t]*,[ \t]*\r?\n?/;
3394
+ const afterMatch = afterRe.exec(spanText.slice(end));
3395
+ const hadTrailingComma = !!afterMatch;
3396
+ if (afterMatch) end += afterMatch[0].length;
3397
+ const ownLine = startsOwnLine && hadTrailingComma && /\n$/.test(afterMatch[0]);
3398
+
3399
+ const promptContentPhrase =
3400
+ `${promptContentParam}=<resolve ${roleValueExpr} against the active install's gsd-* role prompts and load ` +
3401
+ 'its contents; FAIL CLOSED with an explicit error if unresolved — never execute the role inline>';
3402
+
3403
+ let replacement;
3404
+ if (ownLine) {
3405
+ const indent = spanText.slice(lineStart, matchStart);
3406
+ const depthNote = canOrchestrate ? '' : ' # nested delegation is unavailable at this dispatch depth/toolkit';
3407
+ replacement =
3408
+ `${namedRoleParam}=${roleValueExpr},\n` +
3409
+ `${indent}${promptContentPhrase},\n` +
3410
+ `${indent}${structuralRoleParam}="${leafRoleValue}",${depthNote}\n`;
3411
+ } else {
3412
+ // Inline forms never carry a trailing `#` comment mid-argument-list (it
3413
+ // would silently "comment out" the remainder of the call), so the
3414
+ // depth/toolkit caveat is only ever emitted in the pretty own-line form.
3415
+ // Re-emit exactly the separator that originally followed this argument
3416
+ // (a comma if more args follow; nothing if it was the last one).
3417
+ replacement =
3418
+ `${namedRoleParam}=${roleValueExpr}, ${promptContentPhrase}, ${structuralRoleParam}="${leafRoleValue}"` +
3419
+ (hadTrailingComma ? ', ' : '');
3420
+ }
3421
+ return spanText.slice(0, matchStart) + replacement + spanText.slice(end);
3422
+ }
3423
+
3424
+ // Matches a `subagent_type`/`model` argument's key+delimiter+value across all
3425
+ // three corpus forms: quoted-string values ("gsd-planner", "{model}") and
3426
+ // bare dynamic-expression values (ref.agent, research_hook.ref.agent,
3427
+ // executor_model). The captured group is always the value (a suffix of the
3428
+ // whole match), so its start offset is `match.index + match[0].length -
3429
+ // match[1].length` — avoids needing the regex `d` (indices) flag.
3430
+ function _callArgValueRe(key) {
3431
+ return new RegExp(`\\b${key}\\s*[=:]\\s*("(?:[^"\\\\]|\\\\.)*"|[A-Za-z_][\\w.]*)`);
3432
+ }
3433
+
3434
+ /**
3435
+ * Returns the literal role name from a captured role-argument value EXPR
3436
+ * (e.g. `"gsd-planner"`) — or `null` when it is not a genuine static
3437
+ * literal: a bare dynamic expression (`ref.agent`), OR a quoted value that
3438
+ * still contains `{...}` template interpolation (the corpus's own
3439
+ * placeholder convention, e.g. `model="{researcher_model}"` — and,
3440
+ * critically, `subagent_type: "gsd-{agent}"` in
3441
+ * gsd-core/references/universal-anti-patterns.md, a DOCUMENTATION template
3442
+ * illustrating the naming pattern, never a concrete role to resolve).
3443
+ * Fail-closed validation only ever runs on a genuine static literal; a
3444
+ * template/dynamic value still gets the full role-prompt-resolution
3445
+ * projection treatment (the resolve+fail-closed instruction applies equally
3446
+ * once a template is substituted at runtime) — only the STATIC CHECK is
3447
+ * skipped, never the projection itself.
3448
+ */
3449
+ function _literalRoleValue(roleValueExpr) {
3450
+ const m = /^"([^"]*)"$/.exec(roleValueExpr);
3451
+ if (!m) return null;
3452
+ if (/[{}]/.test(m[1])) return null;
3453
+ return m[1];
3454
+ }
3455
+
3456
+ /**
3457
+ * `maskStringLiterals` PLUS `#`-to-end-of-line comment blanking (comments are
3458
+ * never string literals, so they survive string-masking as literal `#...`
3459
+ * text). Header-token searches (subagent_type/model/run_in_background) must
3460
+ * use THIS mask, not the string-only one — verified necessary against the
3461
+ * real corpus: execute-phase.md's explanatory comment "# Only include
3462
+ * model= when executor_model is..." literally contains the substring
3463
+ * "model= when", which a comment-blind `model` regex mismatches as a real
3464
+ * `model=when` argument, corrupting the comment AND missing the real
3465
+ * `model="{executor_model}"` line beneath it. Scoped to call-span text only
3466
+ * (never the whole document), so markdown `#`/`##` headings elsewhere are
3467
+ * unaffected.
3468
+ */
3469
+ function _maskStringsAndComments(text) {
3470
+ return maskStringLiterals(text).replace(/#[^\n]*/g, (m) => ' '.repeat(m.length));
3471
+ }
3472
+
3473
+ /**
3474
+ * Normalize ONE `Agent(...)`/`Agent({...})` call span (already isolated by
3475
+ * `findDispatchCallSpans`) onto the target's real dispatch primitive. Every
3476
+ * behavioral branch reads `dispatch` (the runtime's sourced
3477
+ * `hostIntegration.dispatch` facts) — none is hardcoded. Handles all three
3478
+ * corpus call-argument shapes uniformly via string-aware token location
3479
+ * (`maskStringLiterals` recomputed after each structural edit, since prior
3480
+ * edits shift offsets).
3481
+ */
3482
+ function _normalizeDispatchCallSpan(spanText, hasBraceWrapper, dispatch, toolConfig) {
3483
+ const namedDispatch = dispatch.namedDispatch === true;
3484
+ const backgroundCapable = dispatch.background === true;
3485
+ const canOrchestrate = dispatch.subagentToolkit === 'full'
3486
+ && (dispatch.maxDepth === -1 || (typeof dispatch.maxDepth === 'number' && dispatch.maxDepth > 1));
3487
+ const { toolName, backgroundParam, supportsPerCallModel, availableRoles, runtime } = toolConfig;
3488
+
3489
+ let text = spanText;
3490
+
3491
+ // 1. Named-role argument (subagent_type= / subagent_type:) — only when the
3492
+ // target has no native named-agent lookup (dispatch.namedDispatch).
3493
+ // Fail-closed validation runs on the extracted value REGARDLESS of
3494
+ // which source syntax produced it (#2284 requirement 2).
3495
+ if (!namedDispatch) {
3496
+ const roleRe = _callArgValueRe('subagent_type');
3497
+ const rm = roleRe.exec(_maskStringsAndComments(text));
3498
+ if (rm) {
3499
+ // Read the VALUE from the original (unmasked) text at the matched
3500
+ // offset — `rm[1]` was captured against the mask, whose string
3501
+ // INTERIOR is blanked, so it must never be used as the real value.
3502
+ const roleValueExpr = text.slice(rm.index + rm[0].length - rm[1].length, rm.index + rm[0].length);
3503
+ const literalRole = _literalRoleValue(roleValueExpr);
3504
+ if (literalRole !== null) {
3505
+ _assertRoleResolvable(literalRole, availableRoles, runtime, 'subagent_type');
3506
+ } else if (!availableRoles) {
3507
+ // No literal value to check, but a null availableRoles still means
3508
+ // the shipped agents/ dir couldn't be resolved at all — fail closed
3509
+ // unconditionally rather than silently install an unverifiable call.
3510
+ _assertRoleResolvable('', availableRoles, runtime, 'subagent_type');
3511
+ }
3512
+ text = _projectRoleArgument(text, rm.index, rm.index + rm[0].length, roleValueExpr, toolConfig, canOrchestrate);
3513
+ }
3514
+ }
3515
+
3516
+ // 2. Per-call model argument (model= / model:) — stripped entirely when the
3517
+ // target has no per-call model-selection parameter (there is no
3518
+ // `dispatch` axis for this — it is inherent tool vocabulary, like the
3519
+ // parameter names themselves). Also removes now-dead explanatory
3520
+ // comment lines directly above a `model=` line that owns its own line
3521
+ // (#2284 Finding 5).
3522
+ if (!supportsPerCallModel) {
3523
+ const modelRe = _callArgValueRe('model');
3524
+ const mm = modelRe.exec(_maskStringsAndComments(text));
3525
+ if (mm) {
3526
+ text = _stripCallArgument(text, mm.index, mm.index + mm[0].length, { consumeLeadingComments: true });
3527
+ }
3528
+ }
3529
+
3530
+ // 3. Background-dispatch flag (run_in_background= / run_in_background:) —
3531
+ // maps onto the target's own background parameter ONLY when documented
3532
+ // to support it; otherwise stripped rather than forwarding a parameter
3533
+ // the primitive doesn't accept.
3534
+ {
3535
+ const bgRe = /\brun_in_background\s*[=:]\s*(?:true|false)/;
3536
+ const bm = bgRe.exec(_maskStringsAndComments(text));
3537
+ if (bm) {
3538
+ if (backgroundCapable) {
3539
+ const matched = text.slice(bm.index, bm.index + bm[0].length);
3540
+ const replaced = matched.replace(/^run_in_background(\s*[=:]\s*)/, `${backgroundParam}$1`);
3541
+ text = text.slice(0, bm.index) + replaced + text.slice(bm.index + bm[0].length);
3542
+ } else {
3543
+ text = _stripCallArgument(text, bm.index, bm.index + bm[0].length);
3544
+ }
3545
+ }
3546
+ }
3547
+
3548
+ // 4. Call-syntax head rename + object-literal brace stripping. Hermes's
3549
+ // delegate_task is a flat kwarg call — `Agent({...})`'s wrapper braces
3550
+ // are dropped rather than carried through, so every projected call ends
3551
+ // up in the same flat shape regardless of source syntax.
3552
+ text = text.replace(/^Agent\(/, `${toolName}(`);
3553
+ if (hasBraceWrapper) {
3554
+ const openMask = maskStringLiterals(text);
3555
+ const braceOpenIdx = openMask.indexOf('{');
3556
+ if (braceOpenIdx !== -1) text = text.slice(0, braceOpenIdx) + text.slice(braceOpenIdx + 1);
3557
+ const closeMask = maskStringLiterals(text);
3558
+ const braceCloseIdx = closeMask.lastIndexOf('}');
3559
+ if (braceCloseIdx !== -1) text = text.slice(0, braceCloseIdx) + text.slice(braceCloseIdx + 1);
3560
+ }
3561
+
3562
+ return text;
3563
+ }
3564
+
3565
+ /**
3566
+ * Blank the interior (and delimiters) of every string literal inside
3567
+ * `spanText` to spaces — same length, newlines preserved — using a fresh,
3568
+ * LOCAL quote-tracking scan that starts at `spanText[0]` with NO inherited
3569
+ * state. This is deliberately the SAME state-machine shape as the
3570
+ * `inString`/`\\`/triple-quote handling inside `findDispatchCallSpans`
3571
+ * (double-quoted and `"""`-triple-quoted, backslash-escape aware) — reused
3572
+ * here so a call span's quoted argument VALUES (documentation prose, prompt
3573
+ * bodies) never masquerade as real call syntax, without EVER falling back to
3574
+ * a whole-document cumulative quote-parity mask (the round-2 defect
3575
+ * documented on `findDispatchCallSpans` above).
3576
+ */
3577
+ function _blankStringLiteralInteriors(spanText) {
3578
+ let out = '';
3579
+ let inString = null; // null | '"' | 'triple'
3580
+ for (let i = 0; i < spanText.length; i++) {
3581
+ const ch = spanText[i];
3582
+ if (inString) {
3583
+ if (ch === '\\') {
3584
+ out += ' ';
3585
+ i++;
3586
+ if (i < spanText.length) out += (spanText[i] === '\n') ? '\n' : ' ';
3587
+ continue;
3588
+ }
3589
+ if (inString === 'triple') {
3590
+ if (ch === '"' && spanText[i + 1] === '"' && spanText[i + 2] === '"') {
3591
+ inString = null;
3592
+ out += ' ';
3593
+ i += 2;
3594
+ continue;
3595
+ }
3596
+ out += (ch === '\n') ? '\n' : ' ';
3597
+ continue;
3598
+ }
3599
+ if (ch === inString) { inString = null; out += ' '; continue; }
3600
+ out += (ch === '\n') ? '\n' : ' ';
3601
+ continue;
3602
+ }
3603
+ if (ch === '"' && spanText[i + 1] === '"' && spanText[i + 2] === '"') {
3604
+ inString = 'triple';
3605
+ out += ' ';
3606
+ i += 2;
3607
+ continue;
3608
+ }
3609
+ if (ch === '"') { inString = '"'; out += ' '; continue; }
3610
+ out += ch;
3611
+ }
3612
+ return out;
3613
+ }
3614
+
3615
+ /**
3616
+ * Quote-aware view of `content` for the completeness checks below: for every
3617
+ * REAL call span located via `findDispatchCallSpans` (once per head word in
3618
+ * `headWords`), the string-literal ARGUMENT VALUES inside that span are
3619
+ * blanked via `_blankStringLiteralInteriors`; the call's own head word and
3620
+ * bare (unquoted) argument tokens are left untouched. `headWords` is
3621
+ * processed in order and each pass re-scans the PROGRESSIVELY-masked string
3622
+ * — `toolName` first, then `'Agent'` — so a spurious `Agent(` that
3623
+ * `findDispatchCallSpans('Agent')` would otherwise "find" purely because it
3624
+ * sits inside an outer call's quoted string (e.g. a `description="...Agent()
3625
+ * ...subagent_type=x"` argument value) has ALREADY been blanked away by the
3626
+ * outer `toolName` pass by the time the `'Agent'` pass runs, so it is never
3627
+ * mistaken for a real, independent call. A genuinely real (unquoted) `Agent(`
3628
+ * — including one nested as a raw, un-renamed argument value — survives every
3629
+ * pass and remains visible to the caller's regex checks.
3630
+ */
3631
+ function _maskQuotedRegionsWithinCallSpans(content, headWords) {
3632
+ let masked = content;
3633
+ for (const headWord of headWords) {
3634
+ const spans = findDispatchCallSpans(masked, headWord);
3635
+ for (let i = spans.length - 1; i >= 0; i--) {
3636
+ const { start, end } = spans[i];
3637
+ const maskedSpan = _blankStringLiteralInteriors(masked.slice(start, end));
3638
+ masked = masked.slice(0, start) + maskedSpan + masked.slice(end);
3639
+ }
3640
+ }
3641
+ return masked;
3642
+ }
3643
+
3644
+ /**
3645
+ * Post-projection guard (#2284 requirement 3 — belt-and-suspenders): after
3646
+ * projection, assert the corpus form the projection could not anticipate
3647
+ * never silently ships. Throws an explicit install error (fail-LOUD) rather
3648
+ * than let an unprojected/incompletely-projected dispatch call install.
3649
+ *
3650
+ * #2284 round-2 CRITICAL fix: this is an INDEPENDENT check — it does NOT use
3651
+ * `maskStringLiterals` over the whole document (the round-1 primitive whose
3652
+ * cumulative, document-wide quote-parity tracking was the root cause of the
3653
+ * round-2 defect: a `"`-heavy bash fence upstream of a real call desynced
3654
+ * quote state and made `findDispatchCallSpans` blind to that call, shipping
3655
+ * a Frankenstein `Agent(gsd_role="...", model="...")` with no detection).
3656
+ *
3657
+ * #2284 round-3 fix: a BLUNT, mask-free literal check over the whole
3658
+ * document (round-2's fix) over-throws — it cannot tell a real residual
3659
+ * `Agent(`/`subagent_type` call from the SAME text appearing INSIDE a quoted
3660
+ * string (documentation/prompt prose, e.g. `description="...Agent()..."`).
3661
+ * The completeness checks (residual `subagent_type` / literal `Agent(`) now
3662
+ * run against `_maskQuotedRegionsWithinCallSpans` — quote-aware, but scoped
3663
+ * strictly to already-correctly-bounded, per-occurrence-LOCAL call spans
3664
+ * (never a whole-document cumulative mask), so a real Frankenstein call
3665
+ * (unquoted, real call syntax) still fires while a same-text mention genuinely
3666
+ * inside a quoted string does not.
3667
+ *
3668
+ * The completeness checks also only apply when `namedDispatch` is false: when
3669
+ * `dispatch.namedDispatch === true`, `_normalizeDispatchCallSpan` step 1
3670
+ * INTENTIONALLY leaves `subagent_type` unprojected (the target primitive
3671
+ * resolves named agents itself) — a residual `subagent_type` in that case is
3672
+ * the correct, intended output, not a defect. (The call HEAD is still renamed
3673
+ * unconditionally regardless of `namedDispatch` — see step 4 there — so a
3674
+ * literal `Agent(` residual is gated the same way purely for symmetry with
3675
+ * the dispatch-facts-driven contract; it is never actually left unrenamed by
3676
+ * the projection in practice.)
3677
+ *
3678
+ * The model-leak check is unaffected by either fix above — it is orthogonal
3679
+ * to `namedDispatch` (gated only by `supportsPerCallModel`) and already
3680
+ * bounds each real call via the independently-fixed, per-occurrence-local,
3681
+ * non-cumulative `findDispatchCallSpans`, then does a raw substring check
3682
+ * within that bound.
3683
+ */
3684
+ function _assertProjectionComplete(content, toolConfig, namedDispatch = false) {
3685
+ const { toolName, runtime, supportsPerCallModel } = toolConfig;
3686
+
3687
+ if (!namedDispatch) {
3688
+ const quoteAware = _maskQuotedRegionsWithinCallSpans(content, [toolName, 'Agent']);
3689
+ if (/\bsubagent_type\s*[=:]/.test(quoteAware)) {
3690
+ throw new Error(
3691
+ `${runtime} workflow install: projection left a residual subagent_type reference — refusing to install ` +
3692
+ '(fail-closed post-projection guard, #2284)',
3693
+ );
3694
+ }
3695
+ if (/\bAgent\(/.test(quoteAware)) {
3696
+ throw new Error(
3697
+ `${runtime} workflow install: projection left literal Agent( call syntax — refusing to install ` +
3698
+ '(fail-closed post-projection guard, #2284)',
3699
+ );
3700
+ }
3701
+ }
3702
+
3703
+ if (!supportsPerCallModel) {
3704
+ for (const span of findDispatchCallSpans(content, toolName)) {
3705
+ const rawSpanText = content.slice(span.start, span.end);
3706
+ if (/\bmodel\s*[=:]/.test(rawSpanText)) {
3707
+ throw new Error(
3708
+ `${runtime} workflow install: projection left a leaked model= argument inside a ${toolName}(...) call ` +
3709
+ '— refusing to install (fail-closed post-projection guard, #2284)',
3710
+ );
3711
+ }
3712
+ }
3713
+ }
3714
+ }
3715
+
3716
+ /**
3717
+ * Project host-neutral `Agent(...)` named-subagent dispatch prose onto a
3718
+ * target runtime's real dispatch primitive. See the file-header comment above
3719
+ * for the governing rule: every behavioral branch reads `dispatch` (the
3720
+ * runtime's sourced `hostIntegration.dispatch` facts) — none is a hardcoded
3721
+ * assumption about a specific runtime. Handles all three real corpus call
3722
+ * forms (multi-line one-key-per-line, single-line object-literal, single-line
3723
+ * compact) via string-aware call-span detection rather than three independent
3724
+ * line-anchored regexes, and closes with a post-projection guard that fails
3725
+ * loud on any form it did not anticipate (#2284).
3726
+ *
3727
+ * @param {string} content
3728
+ * @param {{namedDispatch?: boolean, nested?: boolean, maxDepth?: number, background?: boolean, backgroundDispatch?: boolean, subagentToolkit?: string}} dispatch
3729
+ * @param {{toolName: string, namedRoleParam: string, promptContentParam: string, structuralRoleParam: string, leafRoleValue: string, backgroundParam: string, supportsPerCallModel: boolean, availableRoles: Set<string>|null, runtime: string}} toolConfig
3730
+ */
3731
+ function projectNamedDispatchToStructuralDelegate(content, dispatch, toolConfig) {
3732
+ const d = dispatch || {};
3733
+ const namedDispatch = d.namedDispatch === true;
3734
+ const backgroundCapable = d.background === true;
3735
+ const { toolName, promptContentParam } = toolConfig;
3736
+
3737
+ let converted = content;
3738
+
3739
+ // 1. The "Agent tool IS available" contract assertion (currently unique to
3740
+ // plan-phase.md, matched generically in case of future reuse elsewhere).
3741
+ const assertionRe = /The Agent tool IS available in a top-level ([^\n]+?) session\.\s+Always spawn\s+([\s\S]*?)\s+as separate Agent\(\) calls\./;
3742
+ converted = converted.replace(assertionRe, (_m, sessionName, roster) => {
3743
+ const rosterFlat = roster.replace(/\s+/g, ' ').trim();
3744
+ if (namedDispatch) {
3745
+ return `The \`${toolName}\` tool IS available in a top-level ${sessionName} session. Always dispatch ${rosterFlat} as separate \`${toolName}()\` calls.`;
3746
+ }
3747
+ return (
3748
+ `${sessionName} has no \`Agent\` tool. It exposes \`${toolName}\`, which dispatches by structural role — ` +
3749
+ 'it has no concept of a named subagent identity. GSD projects each named gsd-* role onto this primitive ' +
3750
+ `itself: resolve the role's prompt file from the active install, load its contents, and embed them in the ` +
3751
+ `\`${toolName}\` payload via \`${promptContentParam}\` as the dispatched task's operating instructions. ` +
3752
+ 'FAIL CLOSED — surface an explicit error and stop — if a referenced role prompt cannot be resolved; never ' +
3753
+ `execute the role inline as a substitute. In a top-level ${sessionName} session, always dispatch ` +
3754
+ `${rosterFlat} as separate \`${toolName}\` calls.`
3755
+ );
3756
+ });
3757
+
3758
+ // 1b. Dispatch-depth-availability prose immediately adjacent to a renamed
3759
+ // `Agent()` mention in the SAME sentence (plan-review-convergence.md
3760
+ // ~lines 108, 347, 355) — a bare "Agent" left un-renamed right next to
3761
+ // the projection's own `Agent()`→`${toolName}()` rename produced
3762
+ // self-contradictory installed text (e.g. "...delegate_task(...)...
3763
+ // with Agent available..."). Narrowly scoped to the EXACT known
3764
+ // phrases the projection itself creates the inconsistency beside —
3765
+ // never a broad bare-word `Agent` rename, which would corrupt
3766
+ // legitimate `Agent`-adjacent prose elsewhere in the corpus (role
3767
+ // names, "Agent Brief", agent-file references).
3768
+ converted = converted.replace(
3769
+ /\borchestrator runs at depth 0 with Agent available\b/g,
3770
+ `orchestrator runs at depth 0 with ${toolName} available`,
3771
+ );
3772
+ converted = converted.replace(
3773
+ /\(bug #936: depth-1 Agent has no Agent tool\)/g,
3774
+ `(bug #936: depth-1 ${toolName} has no nested ${toolName})`,
3775
+ );
3776
+
3777
+ // 2. Per-call model-selection prose ("Model resolution:" paragraph,
3778
+ // execute-phase.md) + inline backtick-quoted model-mention prose
3779
+ // examples (not live call sites) — only rewritten when the target
3780
+ // primitive has no per-call model parameter at all.
3781
+ if (!toolConfig.supportsPerCallModel) {
3782
+ const modelResolutionRe = /\*\*Model resolution:\*\* If `executor_model` is `"inherit"`, omit the `model=` parameter from all `Agent\(\)` calls — do NOT pass `model="inherit"` to Agent\. Omitting the `model=` parameter causes [^.]+\. Only set `model=` when `executor_model` is an explicit model name \(e\.g\., `"claude-sonnet-5"`, `"claude-opus-4-8"`\)\./;
3783
+ converted = converted.replace(
3784
+ modelResolutionRe,
3785
+ `**Model resolution:** \`${toolName}\` has no per-call model-selection parameter — every dispatched role ` +
3786
+ `always inherits the host session's active model. Never pass \`model=\` to \`${toolName}\`; drop the ` +
3787
+ '`executor_model` value entirely for this runtime.',
3788
+ );
3789
+ converted = converted.replace(/`model="[^"`\n]*"`,?\s*(?:and\s+)?/g, '');
3790
+ }
3791
+
3792
+ // 3. Background-dispatch PROSE mentions outside any real call span (e.g.
3793
+ // execute-phase.md:595,600 — `run_in_background: true` inline
3794
+ // documentation, not a call argument) — #2284 Finding 3. Only rewritten
3795
+ // when the target is documented to support background dispatch (a
3796
+ // prose mention of an unsupported capability would be equally
3797
+ // misleading as a real leaked argument).
3798
+ if (backgroundCapable) {
3799
+ converted = converted.replace(
3800
+ /\brun_in_background(\s*[=:]\s*(?:true|false))/g,
3801
+ `${toolConfig.backgroundParam}$1`,
3802
+ );
3803
+ }
3804
+
3805
+ // 4. Call-span-based normalization — the core of the fix. Every
3806
+ // `Agent(...)`/`Agent({...})` occurrence (all three corpus forms) is
3807
+ // located via string-aware balanced paren/brace matching, then
3808
+ // normalized as a unit; spans are rebuilt right-to-left so earlier
3809
+ // offsets stay valid while later ones are rewritten.
3810
+ const spans = findDispatchCallSpans(converted, 'Agent');
3811
+ for (let i = spans.length - 1; i >= 0; i--) {
3812
+ const { start, end, hasBraceWrapper } = spans[i];
3813
+ const rebuilt = _normalizeDispatchCallSpan(converted.slice(start, end), hasBraceWrapper, d, toolConfig);
3814
+ converted = converted.slice(0, start) + rebuilt + converted.slice(end);
3815
+ }
3816
+
3817
+ // 5. "Agent tool" capability mentions (conditions gating parallel vs.
3818
+ // sequential dispatch, e.g. map-codebase.md) → the real target primitive
3819
+ // name, which resolves these conditions accurately since it IS a real,
3820
+ // always-available dispatch primitive for this target.
3821
+ converted = converted.replace(/\bAgent tool\b/g, toolName);
3822
+
3823
+ // 5b. Catch-all: a `subagent_type` mention that is NOT part of any real
3824
+ // `Agent(...)` call span (e.g. map-codebase.md's inline documentation
3825
+ // prose ``Use Agent tool with `subagent_type="X"`, ...`` — disconnected
3826
+ // example syntax, not a live call). Renamed for the same accuracy the
3827
+ // real calls get; a literal quoted role value is still fail-closed
3828
+ // validated even though there is no call structure to inject
3829
+ // role-prompt/fail-closed guidance INTO.
3830
+ if (!namedDispatch) {
3831
+ converted = converted.replace(
3832
+ /\bsubagent_type(\s*[=:]\s*"[^"]*")/g,
3833
+ (_m, rest) => {
3834
+ const literalRole = _literalRoleValue(rest.replace(/^\s*[=:]\s*/, ''));
3835
+ if (literalRole !== null) {
3836
+ _assertRoleResolvable(literalRole, toolConfig.availableRoles, toolConfig.runtime, 'subagent_type (prose mention)');
3837
+ }
3838
+ return `${toolConfig.namedRoleParam}${rest}`;
3839
+ },
3840
+ );
3841
+ converted = converted.replace(/\bsubagent_type(\s*[=:])/g, `${toolConfig.namedRoleParam}$1`);
3842
+ }
3843
+
3844
+ // 5c. Safety net (#2284 requirement 2): the PRIMARY mechanism for
3845
+ // eliminating literal `Agent(` syntax is complete span detection (step
3846
+ // 4) — this unconditional final rename exists only so that even a call
3847
+ // span detection somehow misses at least loses its `Agent(` head
3848
+ // rather than shipping the literal Claude-shaped tool name verbatim.
3849
+ // A call caught only by this safety net is still INCOMPLETELY
3850
+ // normalized (no role/model handling) and gets caught by the
3851
+ // independent post-projection guard below via its OTHER invariants
3852
+ // (residual subagent_type / leaked model=), which this safety net does
3853
+ // not touch — the install still fails closed for a missed span.
3854
+ converted = converted.replace(/\bAgent\(/g, `${toolName}(`);
3855
+
3856
+ // 6. Post-projection guard (#2284 requirement 3): fail loud, never ship
3857
+ // silently, on any residual/leaked form the projection above did not
3858
+ // anticipate. `namedDispatch` gates the completeness checks — a
3859
+ // residual subagent_type is INTENTIONAL, not a defect, when the target
3860
+ // resolves named agents itself (see `_assertProjectionComplete`).
3861
+ _assertProjectionComplete(converted, toolConfig, namedDispatch);
3862
+
3863
+ return converted;
3864
+ }
3865
+
3866
+ const HERMES_DISPATCH_TOOL_CONFIG = Object.freeze({
3867
+ toolName: 'delegate_task',
3868
+ namedRoleParam: 'gsd_role',
3869
+ promptContentParam: 'gsd_role_prompt',
3870
+ structuralRoleParam: 'role',
3871
+ leafRoleValue: 'leaf',
3872
+ backgroundParam: 'background',
3873
+ supportsPerCallModel: false,
3874
+ });
3875
+
3876
+ /**
3877
+ * Hermes `.md` content converter (#2284): brand-swap (unchanged behavior,
3878
+ * descriptor-driven per `hostBehaviors.brandingRewrites`) followed by the
3879
+ * generic named-dispatch → `delegate_task` projection above, driven by
3880
+ * `capabilities/hermes/capability.json`'s `hostIntegration.dispatch` (read
3881
+ * via `_hostIntegrationDispatch`, values UNCHANGED by this fix — they are
3882
+ * already documentation-sourced and correct).
3883
+ */
3884
+ function convertClaudeToHermesMarkdown(content, ctx) {
3885
+ const runtime = (ctx && ctx.runtime) || 'hermes';
3886
+ const b = _hostBehaviors(runtime).brandingRewrites;
3887
+ let converted = content;
3888
+ if (b) {
3889
+ converted = converted.replace(/CLAUDE\.md/g, b['CLAUDE.md']);
3890
+ // #2284(b): skips <runtime_compatibility> comparison-table content (protected region).
3891
+ converted = applyClaudeCodeBrandSwap(converted, b['Claude Code']);
3892
+ converted = converted.replace(/\.claude\//g, b['.claude/']);
3893
+ }
3894
+ const dispatch = _hostIntegrationDispatch(runtime);
3895
+ const toolConfig = Object.assign({}, HERMES_DISPATCH_TOOL_CONFIG, {
3896
+ availableRoles: _resolveAvailableGsdRoles(),
3897
+ runtime,
3898
+ });
3899
+ return projectNamedDispatchToStructuralDelegate(converted, dispatch, toolConfig);
3900
+ }
3901
+
3902
+ // ── End Hermes converters ────────────────────────────────────────────────────
3903
+
2891
3904
  function convertSlashCommandsToCodexSkillMentions(content) {
2892
3905
  // Colon-style /gsd: never appears as a filesystem path segment, so no boundary guard is needed (unlike the hyphen-style below).
2893
3906
  let converted = content.replace(/\/gsd:([a-z0-9-]+)/gi, (_, commandName) => {
@@ -3006,10 +4019,14 @@ Typed mapping (agent_type-capable schema only):
3006
4019
  inherited, or unsupported values; do not invent one-off effort literals in
3007
4020
  workflow prose.
3008
4021
  - \`fork_context: false\` by default — GSD agents load their own context via \`<files_to_read>\` blocks
3009
- - \`Task(isolation="worktree")\` / \`Agent(isolation="worktree")\` → no direct Codex mapping.
3010
- Codex \`spawn_agent\` does not create or bind a git worktree automatically.
3011
- Workflows that require this isolation must fail closed or use an explicit
3012
- manual worktree protocol before spawning (#3360).
4022
+ - \`Task(isolation="worktree")\` / \`Agent(isolation="worktree")\` → no direct \`spawn_agent\` mapping,
4023
+ but Codex declares \`dispatch.isolation: orchestrator-worktree\` (#2584). Codex
4024
+ \`spawn_agent\` still does not create or bind a git worktree; instead GSD itself
4025
+ creates the worktree and process-spawns the executor into it with
4026
+ \`codex exec --cd <dir>\`, performing every git operation on the executor's behalf
4027
+ (its \`workspace-write\` sandbox makes \`.git\` read-only). Workflows must therefore
4028
+ never fabricate a manual worktree protocol — route through the negotiated
4029
+ isolation adapter, which still fails closed for hosts declaring \`none\` (#3360).
3013
4030
 
3014
4031
  Generic-agent workaround (multi_agent_v1 schema — NO agent_type field):
3015
4032
  When only the generic \`multi_agent_v1\` schema is available, typed GSD agent dispatch
@@ -3089,6 +4106,36 @@ purpose: ${toSingleLine(description)}
3089
4106
  return `${cleanFrontmatter}\n\n${roleHeader}\n${body}`;
3090
4107
  }
3091
4108
 
4109
+ /**
4110
+ * #2310 — True if `model` is an Anthropic-flavored value that must never appear as a
4111
+ * Codex agent `.toml` `model`. Two forms: (a) a bare Claude Agent-tool tier alias
4112
+ * (opus/sonnet/haiku/fable — the canonical CLAUDE_AGENT_ALIASES, imported from
4113
+ * src/model-resolver.cts so it can't diverge); (b) any Claude model id in any provider
4114
+ * namespacing — `claude-*`, `anthropic/claude-*`, `us.anthropic.claude-*` (the forms the
4115
+ * catalog assigns to opencode/hermes/kilo, reachable on a Codex .toml via the runtime-
4116
+ * resolver path). No OpenAI/Codex model id contains "claude", so a case-insensitive
4117
+ * substring test is a safe, exhaustive guard for (b). Codex/ChatGPT rejects all of these.
4118
+ */
4119
+ function _isAnthropicFlavoredModel(model) {
4120
+ return typeof model === 'string' && (CLAUDE_AGENT_ALIASES.has(model) || model.toLowerCase().includes('claude'));
4121
+ }
4122
+
4123
+ // #2310 — dedupe stderr warnings so repeated agent emits don't spam (mirrors the
4124
+ // #2041/#1133 model-resolver warn-dedupe). Value is length-capped so an oversized
4125
+ // or secret-shaped override cannot leak in full to logs.
4126
+ const _codexModelOverrideDroppedWarned = new Set();
4127
+ function _warnCodexModelOverrideDropped(agentName, value) {
4128
+ const key = `${agentName}::${value}`;
4129
+ if (_codexModelOverrideDroppedWarned.has(key)) return;
4130
+ _codexModelOverrideDroppedWarned.add(key);
4131
+ const safe = String(value).length > 64 ? `${String(value).slice(0, 64)}…` : String(value);
4132
+ process.stderr.write(
4133
+ `gsd: warning — Codex agent "${agentName}" model "${safe}" is not a valid Codex model ` +
4134
+ `(Anthropic alias/id); dropping it so Codex uses a valid default. ` +
4135
+ `Set runtime:"codex" or pin a gpt-* model to route it.\n`,
4136
+ );
4137
+ }
4138
+
3092
4139
  /**
3093
4140
  * Generate a per-agent .toml config file for Codex.
3094
4141
  * Sets required agent metadata, sandbox_mode, and developer_instructions
@@ -3122,21 +4169,43 @@ function generateCodexAgentToml(agentName, agentContent, modelOverrides = null,
3122
4169
  // model_overrides is respected on Codex (which uses static TOML, not inline
3123
4170
  // Task() model parameters). See #2256.
3124
4171
  // Precedence: per-agent model_overrides > runtime-aware tier resolution (#2517).
3125
- const modelOverride = modelOverrides?.[resolvedName] || modelOverrides?.[agentName];
3126
- let hasPinnedModel = false;
3127
- if (modelOverride) {
3128
- lines.push(`model = ${JSON.stringify(modelOverride)}`);
3129
- hasPinnedModel = true;
3130
- } else if (runtimeResolver) {
4172
+ // #2310 — a Codex .toml `model` MUST be a real Codex/OpenAI model id. Codex is a
4173
+ // passive/session-only model host (ADR-1239): GSD cannot reliably route per-agent
4174
+ // tiers, and a bare GSD/Claude tier alias (opus/sonnet/haiku/fable) or a claude-*
4175
+ // id 400s on a ChatGPT-account Codex ("The 'sonnet' model is not supported when
4176
+ // using Codex with a ChatGPT account"). So: embed ONLY an explicit real-Codex
4177
+ // model pin from model_overrides; omit anything Anthropic-flavored so the agent
4178
+ // inherits the always-available session model. (Removing the runtime-resolver
4179
+ // per-tier embedding below is the ADR-2310 passive-posture epic.)
4180
+ const rawModelOverride = modelOverrides?.[resolvedName] || modelOverrides?.[agentName];
4181
+ let pinnedModel = null;
4182
+ if (rawModelOverride) {
4183
+ if (typeof rawModelOverride === 'string' && rawModelOverride && !_isAnthropicFlavoredModel(rawModelOverride)) {
4184
+ pinnedModel = rawModelOverride; // explicit real-Codex model pin → embed verbatim (#2256)
4185
+ } else {
4186
+ _warnCodexModelOverrideDropped(resolvedName, rawModelOverride); // alias/claude-* → omit
4187
+ }
4188
+ }
4189
+ if (!pinnedModel && runtimeResolver) {
3131
4190
  // #2517 — runtime-aware tier resolution. Embeds Codex-native model + reasoning_effort
3132
4191
  // from RUNTIME_PROFILE_MAP / model_profile_overrides for the configured tier.
4192
+ // (Superseded on the default path by the ADR-2310 passive-posture epic.)
3133
4193
  const entry = runtimeResolver.resolve(resolvedName) || runtimeResolver.resolve(agentName);
3134
- if (entry?.model) {
3135
- lines.push(`model = ${JSON.stringify(entry.model)}`);
3136
- hasPinnedModel = true;
3137
- // model is resolved here; reasoning_effort from catalog tier is REPLACED by the
3138
- // unified effort resolver below (#443). Do NOT emit entry.reasoning_effort here.
3139
- }
4194
+ if (entry?.model) pinnedModel = entry.model;
4195
+ }
4196
+ // #2310 — final safety gate: never emit an Anthropic-flavored model into a Codex
4197
+ // .toml, even from the runtime-resolver path (e.g. a defaults.json runtime that
4198
+ // does not match the codex install target).
4199
+ if (pinnedModel && _isAnthropicFlavoredModel(pinnedModel)) {
4200
+ _warnCodexModelOverrideDropped(resolvedName, pinnedModel);
4201
+ pinnedModel = null;
4202
+ }
4203
+ let hasPinnedModel = false;
4204
+ if (pinnedModel) {
4205
+ lines.push(`model = ${JSON.stringify(pinnedModel)}`);
4206
+ hasPinnedModel = true;
4207
+ // model is resolved here; reasoning_effort from catalog tier is REPLACED by the
4208
+ // unified effort resolver below (#443). Do NOT emit entry.reasoning_effort here.
3140
4209
  }
3141
4210
 
3142
4211
  // #443 — Unified effort for Codex .toml. Uses the same config-driven precedence chain
@@ -3364,14 +4433,26 @@ function _resolveMovedSkillsOldDir(runtime, targetDir, scope) {
3364
4433
 
3365
4434
  /**
3366
4435
  * Generate the GSD config block for Codex config.toml.
3367
- * @param {Array<{name: string, description: string}>} agents
3368
- */
3369
- function generateCodexConfigBlock(agents, targetDir) {
3370
- // Use absolute paths when targetDir is provided — Codex ≥0.116 requires
3371
- // AbsolutePathBuf for config_file and cannot resolve relative paths.
3372
- const agentsPrefix = targetDir
3373
- ? path.join(targetDir, 'agents').replace(/\\/g, '/')
3374
- : 'agents';
4436
+ *
4437
+ * #2406 — standalone per-agent TOMLs (written by installCodexConfig to
4438
+ * `$CODEX_HOME/agents/<name>.toml`) are auto-discovered by Codex and are the
4439
+ * SOLE canonical registration source for each role. This block therefore no
4440
+ * longer emits `[agents.<name>]` role tables that point `config_file` back at
4441
+ * those same standalone TOMLs — that was a second, redundant declaration of
4442
+ * the same role in one config layer, and Codex logged "Ignoring malformed
4443
+ * agent role definition: duplicate agent role name" once per agent as a
4444
+ * result. Only the bare `[agents]` dispatch-tuning scalar table is emitted
4445
+ * here; role name/description/model/reasoning-effort/sandbox settings remain
4446
+ * fully discoverable through the standalone TOML alone.
4447
+ * @param {Array<{name: string, description: string}>} _agents unused — kept
4448
+ * in the signature for call-site compatibility (installCodexConfig and
4449
+ * existing tests still pass it positionally); per-agent role tables are no
4450
+ * longer generated from it.
4451
+ * @param {string} [_targetDir] unused — the standalone-TOML `config_file`
4452
+ * path it used to resolve is no longer emitted here; kept for the same
4453
+ * call-site-compatibility reason as `_agents`.
4454
+ */
4455
+ function generateCodexConfigBlock(_agents, _targetDir) {
3375
4456
  const lines = [
3376
4457
  GSD_CODEX_MARKER,
3377
4458
  '',
@@ -3380,24 +4461,12 @@ function generateCodexConfigBlock(agents, targetDir) {
3380
4461
  // ADR-1239 upgrade 2 / #2088 — explicit dispatch tuning. Pin `max_depth` on the
3381
4462
  // `[agents]` (AgentsToml) table rather than relying on codex-cli's implicit
3382
4463
  // default, realizing the negotiated `dispatch.maxDepth: 1` axis. This bare
3383
- // `[agents]` scalar table coexists with the flattened `[agents.<name>]` role
3384
- // sub-tables below (validated by validateCodexConfigSchema, which permits a
3385
- // known-scalar-only `[agents]`). Emitted before the role tables so the parent
3386
- // table is opened first.
4464
+ // `[agents]` scalar table is validated by validateCodexConfigSchema, which
4465
+ // permits a known-scalar-only `[agents]`.
3387
4466
  lines.push('[agents]');
3388
4467
  lines.push(`max_depth = ${GSD_CODEX_AGENTS_MAX_DEPTH}`);
3389
4468
  lines.push('');
3390
4469
 
3391
- for (const { name, description } of agents) {
3392
- // #2727 — Codex 0.124.0 requires [agents.<name>] struct format, not [[agents]] sequence.
3393
- // [[agents]] (introduced in #2645) is rejected by codex-cli 0.124.0 with
3394
- // "invalid type: sequence, expected struct AgentsToml in `agents`".
3395
- lines.push(`[agents.${name}]`);
3396
- lines.push(`description = ${JSON.stringify(description)}`);
3397
- lines.push(`config_file = "${agentsPrefix}/${name}.toml"`);
3398
- lines.push('');
3399
- }
3400
-
3401
4470
  return lines.join('\n');
3402
4471
  }
3403
4472
 
@@ -5922,12 +6991,14 @@ function installCodexConfig(targetDir, agentsSrc, sandboxTier = 'codex-agent-san
5922
6991
  // Symlink-escape guard (parity with _copyStaged / copyWithPathReplacement): the
5923
6992
  // lexical gate above does not resolve symlinks, so a pre-existing config.toml or
5924
6993
  // agents/ symlink could redirect writes outside targetDir. Reject those.
6994
+ // #2393: honor GSD_ALLOW_SYMLINKED_DEST for intentional user-owned symlink layouts.
6995
+ const symlinkOptIn = isSymlinkedDestOptIn();
5925
6996
  if (
5926
- hasExistingSymlinkBetween(resolvedTargetRoot, configPath) ||
5927
- hasExistingSymlinkBetween(resolvedTargetRoot, path.resolve(agentsTomlDir))
6997
+ hasExistingSymlinkBetween(resolvedTargetRoot, configPath, { allowOptInFollow: symlinkOptIn }) ||
6998
+ hasExistingSymlinkBetween(resolvedTargetRoot, path.resolve(agentsTomlDir), { allowOptInFollow: symlinkOptIn })
5928
6999
  ) {
5929
7000
  throw new Error(
5930
- `installCodexConfig: a Codex config path under "${targetDir}" contains a symlink escaping the install root — refusing to write`,
7001
+ `installCodexConfig: a Codex config path under "${targetDir}" contains a symlink the install root does not trust — refusing to write. If this is an intentional user-owned symlink layout, re-run with GSD_ALLOW_SYMLINKED_DEST=1.`,
5931
7002
  );
5932
7003
  }
5933
7004
  fs.mkdirSync(agentsTomlDir, { recursive: true });
@@ -5978,9 +7049,9 @@ function installCodexConfig(targetDir, agentsSrc, sandboxTier = 'codex-agent-san
5978
7049
  // `name` containing path separators must not escape agents/ (which would let
5979
7050
  // it clobber config.toml or write elsewhere under the configHome).
5980
7051
  const agentTomlPath = assertDestWithinConfigHome(agentsTomlDir, `${name}.toml`);
5981
- if (hasExistingSymlinkBetween(resolvedTargetRoot, agentTomlPath)) {
7052
+ if (hasExistingSymlinkBetween(resolvedTargetRoot, agentTomlPath, { allowOptInFollow: symlinkOptIn })) {
5982
7053
  throw new Error(
5983
- `installCodexConfig: agent toml path "${agentTomlPath}" contains a symlink escaping the install root — refusing to write`,
7054
+ `installCodexConfig: agent toml path "${agentTomlPath}" contains a symlink the install root does not trust — refusing to write. If this is an intentional user-owned symlink layout, re-run with GSD_ALLOW_SYMLINKED_DEST=1.`,
5984
7055
  );
5985
7056
  }
5986
7057
  fs.writeFileSync(agentTomlPath, tomlContent);
@@ -6582,7 +7653,8 @@ const RUNTIME_CONTENT_DISPATCH = {
6582
7653
  const b = _hostBehaviors(ctx.runtime).brandingRewrites;
6583
7654
  if (b) {
6584
7655
  content = content.replace(/CLAUDE\.md/g, b['CLAUDE.md']);
6585
- content = content.replace(/\bClaude Code\b/g, b['Claude Code']);
7656
+ // #2284(b): skips <runtime_compatibility> comparison-table content (protected region).
7657
+ content = applyClaudeCodeBrandSwap(content, b['Claude Code']);
6586
7658
  content = content.replace(/\.claude\//g, b['.claude/']);
6587
7659
  }
6588
7660
  return content;
@@ -6599,16 +7671,13 @@ const RUNTIME_CONTENT_DISPATCH = {
6599
7671
  },
6600
7672
  },
6601
7673
  hermes: {
6602
- md: (content, ctx) => {
6603
- // Guarded (post-review #2092): see qwen entry above.
6604
- const b = _hostBehaviors(ctx.runtime).brandingRewrites;
6605
- if (b) {
6606
- content = content.replace(/CLAUDE\.md/g, b['CLAUDE.md']);
6607
- content = content.replace(/\bClaude Code\b/g, b['Claude Code']);
6608
- content = content.replace(/\.claude\//g, b['.claude/']);
6609
- }
6610
- return content;
6611
- },
7674
+ // #2284: brand-swap alone left the false "Agent tool IS available"
7675
+ // assertion + literal `Agent(...)` call syntax installed verbatim — see
7676
+ // convertClaudeToHermesMarkdown / projectNamedDispatchToStructuralDelegate
7677
+ // above (the Hermes converters section) for the full named-dispatch →
7678
+ // `delegate_task` projection, driven by capabilities/hermes/capability.json's
7679
+ // hostIntegration.dispatch facts.
7680
+ md: (content, ctx) => convertClaudeToHermesMarkdown(content, ctx),
6612
7681
  js: (content, ctx) => {
6613
7682
  const b = _hostBehaviors(ctx.runtime).brandingRewrites;
6614
7683
  if (b) {
@@ -6645,9 +7714,10 @@ function copyWithPathReplacement(srcDir, destDir, pathPrefix, runtime, isCommand
6645
7714
  }
6646
7715
  const resolvedConfinementRoot = path.resolve(confinementRoot);
6647
7716
  const resolvedDestDir = assertDestWithinConfigHome(confinementRoot, destDir);
6648
- if (hasExistingSymlinkBetween(resolvedConfinementRoot, resolvedDestDir)) {
7717
+ // #2393: honor GSD_ALLOW_SYMLINKED_DEST for intentional user-owned symlink layouts.
7718
+ if (hasExistingSymlinkBetween(resolvedConfinementRoot, resolvedDestDir, { allowOptInFollow: isSymlinkedDestOptIn() })) {
6649
7719
  throw new Error(
6650
- `copyWithPathReplacement: destDir "${destDir}" contains a symlink escaping the install root "${confinementRoot}" — refusing to write`,
7720
+ `copyWithPathReplacement: destDir "${destDir}" contains a symlink the install root "${confinementRoot}" does not trust — refusing to write. If this is an intentional user-owned symlink layout, re-run with GSD_ALLOW_SYMLINKED_DEST=1.`,
6651
7721
  );
6652
7722
  }
6653
7723
  // Use the validated absolute path for all writes below so the gate validates
@@ -7431,6 +8501,17 @@ function uninstall(isGlobal, runtime = DEFAULT_RUNTIME) {
7431
8501
  console.log(` ${green}✓${reset} Removed ${removedLibFiles} hooks/lib/ helper(s)`);
7432
8502
  }
7433
8503
  }
8504
+
8505
+ // #2717: remove the CommonJS marker GSD wrote into hooks/ for runtimes that
8506
+ // stage .js hooks via dedicated paths (cursor/windsurf/codex) — but ONLY if
8507
+ // it still carries GSD's exact content (a user-authored package.json is
8508
+ // never deleted). Safe no-op for runtimes whose marker lives at the config
8509
+ // root (the shared-bundle path) or that never received one.
8510
+ try {
8511
+ if (hooksSurface.removeCommonJsMarkerIfGsdOwned(hooksDir)) {
8512
+ console.log(` ${green}✓${reset} Removed GSD hooks/package.json (CommonJS marker)`);
8513
+ }
8514
+ } catch { /* best-effort */ }
7434
8515
  }
7435
8516
 
7436
8517
  // 4z. Remove the native plugin adapter (#1914, extended to Kilo by #2093).
@@ -7591,8 +8672,11 @@ function uninstall(isGlobal, runtime = DEFAULT_RUNTIME) {
7591
8672
  let permissionsModified = false;
7592
8673
  if (Array.isArray(settings.permissions.allow)) {
7593
8674
  const before = settings.permissions.allow.length;
8675
+ // #2278 — filter against the union of the current allow-rule forms
8676
+ // AND the retired legacy forms, so uninstall still cleans up
8677
+ // pre-fix installs that still carry the stale `Write(...)` entries.
7594
8678
  settings.permissions.allow = settings.permissions.allow.filter(
7595
- (e) => !GSD_CLAUDE_ALLOW_PERMISSIONS.includes(e)
8679
+ (e) => !GSD_CLAUDE_ALLOW_PERMISSIONS.includes(e) && !GSD_CLAUDE_LEGACY_ALLOW_PERMISSIONS.includes(e)
7596
8680
  );
7597
8681
  if (settings.permissions.allow.length !== before) {
7598
8682
  permissionsModified = true;
@@ -8303,7 +9387,7 @@ function resolveInstallRelativePath(baseDir, relPath) {
8303
9387
  if (fullPath !== root && !fullPath.startsWith(root + path.sep)) {
8304
9388
  return null;
8305
9389
  }
8306
- if (hasExistingSymlinkBetween(root, fullPath)) {
9390
+ if (hasExistingSymlinkBetween(root, fullPath, { allowOptInFollow: isSymlinkedDestOptIn() })) {
8307
9391
  return null;
8308
9392
  }
8309
9393
  return { relPath: normalized, fullPath };
@@ -8377,9 +9461,14 @@ function writeManifest(configDir, runtime = DEFAULT_RUNTIME, options = {}) {
8377
9461
  }
8378
9462
  }
8379
9463
  if (_hostBehaviors(runtime).flatCommandDir && fs.existsSync(opencodeCommandDir)) {
9464
+ // #2329: derive the manifest key prefix from the SAME descriptor value used
9465
+ // to compute opencodeCommandDir above, instead of a separately-hardcoded
9466
+ // literal — a divergence here would silently break the manifest even after
9467
+ // the destSubpath descriptor is corrected (Generative Fix Divergence guard).
9468
+ const flatCommandDirPrefix = _hostBehaviors(runtime).flatCommandDir || 'command';
8380
9469
  for (const file of fs.readdirSync(opencodeCommandDir)) {
8381
9470
  if (file.startsWith('gsd-') && file.endsWith('.md')) {
8382
- manifest.files['command/' + file] = fileHash(path.join(opencodeCommandDir, file));
9471
+ manifest.files[flatCommandDirPrefix + '/' + file] = fileHash(path.join(opencodeCommandDir, file));
8383
9472
  }
8384
9473
  }
8385
9474
  }
@@ -8974,14 +10063,24 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
8974
10063
  const _effectiveInstallMode = _isCoreProfileAlias ? 'minimal' : 'full';
8975
10064
  // Load the manifest and compute resolved profile for named profiles.
8976
10065
  // For --minimal/core: use an empty manifest (core profile has no transitive
8977
- // deps) to produce a resolvedProfile with the core skill set. Registry IS
8978
- // consulted so tier:core capability skills are included when registered.
10066
+ // deps) to produce a resolvedProfile with the core skill set. For core/
10067
+ // standard profiles, resolveProfile's `registry` arg IS consulted (via
10068
+ // _capabilitySkillsForMode) so tier:core/tier:standard capability skills are
10069
+ // unioned in when registered. #2322 correction: for the DEFAULT `full`
10070
+ // profile, resolveProfile short-circuits to the `{skills:'*'}` sentinel
10071
+ // BEFORE ever reading `registry` (there is nothing to union — '*' already
10072
+ // means "everything"), so the registry consultation that matters for `full`
10073
+ // happens LATER, at staging time (stageSkillsForRuntimeAsSkills's '*'
10074
+ // fill-in, resolveRuntimeArtifactLayout's `capabilityRegistry` param below) —
10075
+ // not here. `_installedCapabilityRegistry` (not the frozen `_capabilityRegistry`)
10076
+ // is passed so an INSTALLED third-party capability (not just a first-party
10077
+ // one) is honored on every profile, `full` included (#2322 blocker 2).
8979
10078
  const _commandsDir = path.join(src, 'commands', 'gsd');
8980
10079
  const _skillsManifest = _isCoreProfileAlias ? new Map() : loadSkillsManifest(_commandsDir);
8981
10080
  const _resolvedProfile = resolveProfile({
8982
10081
  modes: [_activeProfileName],
8983
10082
  manifest: _skillsManifest,
8984
- registry: _capabilityRegistry,
10083
+ registry: _installedCapabilityRegistry,
8985
10084
  });
8986
10085
  // Unified staging function: all profiles use stageSkillsForProfile with the
8987
10086
  // registry-aware _resolvedProfile (ADR-857 phase 4c cutover).
@@ -9318,6 +10417,45 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
9318
10417
  return Array.isArray(scopeLayout) && scopeLayout.length > 0;
9319
10418
  })();
9320
10419
 
10420
+ // #2624: write the .gsd-source marker. Extracted from its former late position so it can be
10421
+ // called BEFORE staging reads the marker (see the call site below). Scoped to the Claude-global
10422
+ // layout (issue #1477) — the only install path that ships the skills layout without a
10423
+ // commands/gsd source tree, so findInstallSourceRoot's walk-up has nothing to find and
10424
+ // /gsd-surface (list/status) throws without it. Points at the package's own commands/gsd
10425
+ // source. Guarded on source presence so a half-published package never writes a dangling
10426
+ // marker. Write failure is non-fatal (install proceeds; warn so /gsd-surface breakage is
10427
+ // diagnosable) — the same contract the late write had.
10428
+ function _writeGsdSourceMarker(runtime, targetDir, src, isGlobal) {
10429
+ if (_hostBehaviors(runtime).sourceMarkerFile && isGlobal) {
10430
+ const gsdSourceCommands = path.join(src, 'commands', 'gsd');
10431
+ if (fs.existsSync(gsdSourceCommands)) {
10432
+ try {
10433
+ // ADR-1239 Phase B write-confinement: the descriptor-sourced marker filename
10434
+ // must resolve under targetDir (parity with the other descriptor-driven writes).
10435
+ const _markerPath = assertDestWithinConfigHome(targetDir, _hostBehaviors(runtime).sourceMarkerFile);
10436
+ fs.writeFileSync(_markerPath, gsdSourceCommands + '\n', 'utf8');
10437
+ } catch (err) {
10438
+ // Non-fatal: install proceeds. But on the Claude-global layout walk-up
10439
+ // also fails (no commands/gsd source tree), so a silent write failure
10440
+ // still leaves /gsd-surface broken at runtime — warn so it's diagnosable.
10441
+ console.warn(` ${yellow}!${reset} Could not write .gsd-source marker (${err.message}); /gsd-surface list/status may fail`);
10442
+ }
10443
+ }
10444
+ }
10445
+ }
10446
+
10447
+ // #2624: write the .gsd-source marker BEFORE any staging reads it. The marker write
10448
+ // formerly lived AFTER staging; on an upgrade the marker still held the PREVIOUS
10449
+ // install's source path (e.g. an npx per-version cache dir that still exists on disk),
10450
+ // so findInstallSourceRoot(configDir) — called inside installRuntimeArtifacts below —
10451
+ // returned the stale path and every converted skill was generated from the OLD version's
10452
+ // commands/gsd, silently installing prior-version content with a self-consistent manifest
10453
+ // hash. Writing first closes the read-before-write hole for every findInstallSourceRoot
10454
+ // consumer (skills, commands, /gsd-surface, capability-state). Placed here (before the
10455
+ // _isSkillsRuntime branch) so it runs for every Claude-global install, matching the
10456
+ // original write's sourceMarkerFile && isGlobal guard exactly.
10457
+ _writeGsdSourceMarker(runtime, targetDir, src, isGlobal);
10458
+
9321
10459
  if (_isSkillsRuntime) {
9322
10460
  // Layout-driven install for skills-based runtimes (full and minimal modes)
9323
10461
  const scope = isGlobal ? 'global' : 'local';
@@ -9341,7 +10479,10 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
9341
10479
  resolveAttribution: getCommitAttribution,
9342
10480
  });
9343
10481
  } else {
9344
- installRuntimeArtifacts(runtime, targetDir, scope, _resolvedProfile, getCommitAttribution);
10482
+ // #2322: fallback path (adapter unavailable) — thread the composed
10483
+ // registry too, so this path stages third-party capability skills
10484
+ // identically to the primary adapter path above.
10485
+ installRuntimeArtifacts(runtime, targetDir, scope, _resolvedProfile, getCommitAttribution, _installedCapabilityRegistry);
9345
10486
  }
9346
10487
 
9347
10488
  // #1326 — Codex only: remove stale agents/openai.yaml sidecars from managed
@@ -9500,7 +10641,8 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
9500
10641
  } else if (_hostBehaviors(runtime).pluginOnlyInstall) {
9501
10642
  // pi (ADR-1239 / #2102 Stage 1): plugin-only install — pi's /gsd command is
9502
10643
  // registered programmatically by the native extension (pi/gsd.cjs →
9503
- // extensions/gsd.cjs, staged separately below) and dispatches in-process
10644
+ // extensions/gsd.js, staged separately below; the dest suffix must be
10645
+ // .ts/.js or pi's auto-discovery skips it silently — #2470) and dispatches in-process
9504
10646
  // through the embedded gsd-core command-routing hub. pi has no host-read
9505
10647
  // markdown surface (unlike Claude/OpenCode/etc., which scan commands/ or
9506
10648
  // command/ directories), so writing flat gsd-<cmd>.md files here would be
@@ -9602,35 +10744,11 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
9602
10744
  failures.push('gsd-core');
9603
10745
  }
9604
10746
 
9605
- // Write the .gsd-source marker so runtime source resolution succeeds at
9606
- // runtime (#1477). The Claude-global skills layout ships gsd-core/{bin,
9607
- // contexts,references,templates,workflows} but NOT the commands/gsd source
9608
- // tree, and _runLegacyUninstallCleanup actively removes any commands/gsd/
9609
- // for that scope — so findInstallSourceRoot's walk-up has nothing to find
9610
- // and /gsd-surface (list/status) throws. This is the writer half of the
9611
- // marker that runtime-artifact-layout.cjs's finders already read (the reader
9612
- // landed in #1476). It points at the package's own commands/gsd source.
9613
- // Scoped to the Claude-global layout (issue #1477) — the only install path
9614
- // that ships the skills layout without a commands/gsd source tree; every
9615
- // other runtime/scope deploys commands/gsd, so its walk-up already resolves
9616
- // and needs no marker. Guarded on source presence so a half-published
9617
- // package never writes a dangling marker.
9618
- if (_hostBehaviors(runtime).sourceMarkerFile && isGlobal) {
9619
- const gsdSourceCommands = path.join(src, 'commands', 'gsd');
9620
- if (fs.existsSync(gsdSourceCommands)) {
9621
- try {
9622
- // ADR-1239 Phase B write-confinement: the descriptor-sourced marker filename
9623
- // must resolve under targetDir (parity with the other descriptor-driven writes).
9624
- const _markerPath = assertDestWithinConfigHome(targetDir, _hostBehaviors(runtime).sourceMarkerFile);
9625
- fs.writeFileSync(_markerPath, gsdSourceCommands + '\n', 'utf8');
9626
- } catch (err) {
9627
- // Non-fatal: install proceeds. But on the Claude-global layout walk-up
9628
- // also fails (no commands/gsd source tree), so a silent write failure
9629
- // still leaves /gsd-surface broken at runtime — warn so it's diagnosable.
9630
- console.warn(` ${yellow}!${reset} Could not write .gsd-source marker (${err.message}); /gsd-surface list/status may fail`);
9631
- }
9632
- }
9633
- }
10747
+ // #2624: the .gsd-source marker is now written by _writeGsdSourceMarker()
10748
+ // BEFORE staging reads it (see the early call above the _isSkillsRuntime
10749
+ // block). The former write lived here — AFTER staging — which on an upgrade
10750
+ // let staging read a stale prior-version marker and silently install
10751
+ // old-version skill content. Moved up; this site intentionally left empty.
9634
10752
 
9635
10753
  // #1629 critical fix: Windsurf workflow wrappers (convertClaudeCommandToWindsurfWorkflow)
9636
10754
  // delegate to command bodies at <targetDir>/gsd-core/commands/gsd/${stem}.md via a
@@ -9910,6 +11028,22 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
9910
11028
  failures.push('VERSION');
9911
11029
  }
9912
11030
 
11031
+ // #2297: write a per-install runtime marker co-located with VERSION at
11032
+ // <install>/gsd-core/.gsd-runtime. It gives resolveModelInternal a reliable
11033
+ // "which runtime owns THIS install" signal in a no-project session (config.runtime
11034
+ // is null and GSD_RUNTIME is not exported), so the shared ~/.gsd/defaults.json
11035
+ // resolve_model_ids:"omit" policy (written below for non-alias runtimes only)
11036
+ // applies ONLY when a non-alias runtime is actually resolving — a Claude session
11037
+ // reads its own marker and keeps its tier aliases instead of inheriting another
11038
+ // runtime's install-order-dependent "omit". See src/model-resolver.cts.
11039
+ const runtimeMarkerDest = path.join(targetDir, 'gsd-core', '.gsd-runtime');
11040
+ fs.writeFileSync(runtimeMarkerDest, `${runtime}\n`);
11041
+ if (verifyFileInstalled(runtimeMarkerDest, '.gsd-runtime')) {
11042
+ console.log(` ${green}✓${reset} Wrote runtime marker (.gsd-runtime: ${runtime})`);
11043
+ } else {
11044
+ failures.push('.gsd-runtime');
11045
+ }
11046
+
9913
11047
  // Reusable: copy hooks/dist/ + hooks/lib/ into destRootDir, writing the
9914
11048
  // CommonJS package.json marker alongside them. Used below for the generic
9915
11049
  // configDir install path (guarded by hostBehaviors.skipSharedHooksInstall),
@@ -10023,13 +11157,15 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
10023
11157
  }
10024
11158
 
10025
11159
  // Gate hooks/lib/ install on the same set of runtimes that receive hooks/.
10026
- // Codex/Copilot/Cursor/Windsurf/Trae/Cline/Kilo do not use the shared
11160
+ // Codex/Copilot/Cursor/Windsurf/Trae/Cline do not use the shared
10027
11161
  // hooks/lib/ helpers (Cursor uses standalone .js hook scripts registered
10028
11162
  // via hooks.json — gated descriptor-driven via
10029
- // hostBehaviors.skipSharedHooksInstall, #2089; Cline likewise #2090; Kilo
10030
- // likewise #2093; Trae likewise #2094; Codex uses hooks.json directly;
10031
- // the others skip hooks entirely); Kilo and ZCode also skip hooks entirely
10032
- // (hooksSurface:'none' with no plugin surface — #1821). None of the
11163
+ // hostBehaviors.skipSharedHooksInstall, #2089; Cline likewise #2090;
11164
+ // Trae likewise #2094; Codex uses hooks.json directly;
11165
+ // the others skip hooks entirely); ZCode also skips hooks entirely
11166
+ // (hooksSurface:'none' with no plugin surface — #1821). Kilo is NOT
11167
+ // excluded since #2305: its native plugin adapter (#2093) spawns the
11168
+ // staged hooks/*.js scripts, same as OpenCode. None of the
10033
11169
  // excluded runtimes must receive the hooks/lib/ helpers — otherwise the
10034
11170
  // Codex comment downstream ("we deliberately do *not* copy hooks/lib/ for
10035
11171
  // Codex") is contradicted in practice. (Gating lives at the call sites
@@ -10045,18 +11181,21 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
10045
11181
  return hooksOk;
10046
11182
  }
10047
11183
 
10048
- // #1821: Kilo and ZCode declare hooksSurface:'none' AND have no plugin surface,
10049
- // so the staged hook scripts are dead weight for them — exclude both here.
11184
+ // #1821: ZCode declares hooksSurface:'none' AND has no plugin surface,
11185
+ // so the staged hook scripts are dead weight for it — excluded here.
10050
11186
  // OpenCode also declares hooksSurface:'none' but is deliberately NOT excluded:
10051
11187
  // its native plugin adapter (#1914, installed above under plugins/gsd-core.js)
10052
11188
  // spawns the staged hooks/*.js scripts via OpenCode's event bus and needs both
10053
- // them and the CommonJS package.json marker written below.
11189
+ // them and the CommonJS package.json marker written below. Kilo is the same
11190
+ // shape since #2093 (a nativePlugin spawning the staged hooks), so it must
11191
+ // NOT skip either — declaring skipSharedHooksInstall:true alongside a
11192
+ // nativePlugin left every guard the plugin spawns a silent no-op (#2305).
10054
11193
  // #2089: Cursor's exclusion is now descriptor-driven via
10055
11194
  // hostBehaviors.skipSharedHooksInstall (was hardcoded !isCursor).
10056
11195
  // #2090: Cline's exclusion is likewise descriptor-driven (cline declares
10057
11196
  // skipSharedHooksInstall:true) — the redundant `&& !isCline` was removed.
10058
- // #2093: Kilo's exclusion is likewise descriptor-driven (kilo declares
10059
- // skipSharedHooksInstall:true) — the redundant `&& !isKilo` was removed.
11197
+ // #2093/#2305: Kilo's former exclusion (descriptor-driven via
11198
+ // skipSharedHooksInstall:true) was removed in #2305 — see above.
10060
11199
  // #2094: Trae's exclusion is likewise descriptor-driven (trae declares
10061
11200
  // skipSharedHooksInstall:true) — the redundant `&& !isTrae` was removed.
10062
11201
  // #2101: ZCode's exclusion is likewise descriptor-driven (zcode declares
@@ -10275,7 +11414,15 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
10275
11414
  throw _earlyInstallErr;
10276
11415
  }
10277
11416
 
10278
- if (plan.installSurface === 'codex-toml' && !isMinimalMode(_effectiveInstallMode)) {
11417
+ // #2695: this branch runs for BOTH `core` (minimal) and `full` profiles.
11418
+ // Hooks are lightweight infrastructure (update-check + context monitor), not the
11419
+ // "full agent surface" that `core` deliberately omits. The config.toml / agent
11420
+ // generation below is still gated by its own inner `!isMinimalMode` guard, so
11421
+ // `core` enters the branch to receive the hook-file copy + hooks.json wiring but
11422
+ // does NOT get agent roles generated. Before #2695 the outer `!isMinimalMode`
11423
+ // here skipped the whole branch for `core`, so the registered parent hook pointed
11424
+ // at a worker/registry the same installer never delivered.
11425
+ if (plan.installSurface === 'codex-toml') {
10279
11426
  // Capture pre-install snapshots before ANY GSD mutation
10280
11427
  // (#2760 fix 3). On post-write schema-validation failure OR any throw
10281
11428
  // during the mutation sequence (write failure, merge throw, etc.) we
@@ -10458,9 +11605,18 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
10458
11605
 
10459
11606
  // Copy only the hook files that Codex actually registers via its hook configuration (#2153).
10460
11607
  // #772: added gsd-context-monitor.js for the new SubagentStart/Stop/PostToolUse events.
11608
+ // #2695: the parent gsd-check-update.js spawn()s gsd-check-update-worker.js, which
11609
+ // require()s managed-hooks-registry.cjs for MANAGED_HOOKS — so all four must be
11610
+ // installed/refreshed together for every profile, or Codex is wired to a dependency
11611
+ // chain the same installer never delivers.
10461
11612
  // We deliberately do *not* copy gsd-graphify-update.sh or hooks/lib/ for Codex
10462
11613
  // in this change (graphify auto-update support for Codex is out of scope for #3579).
10463
- const CODEX_HOOKS_TO_COPY = ['gsd-check-update.js', 'gsd-context-monitor.js'];
11614
+ const CODEX_HOOKS_TO_COPY = [
11615
+ 'gsd-check-update.js',
11616
+ 'gsd-check-update-worker.js',
11617
+ 'managed-hooks-registry.cjs',
11618
+ 'gsd-context-monitor.js',
11619
+ ];
10464
11620
  const codexHooksSrc = path.join(src, 'hooks', 'dist');
10465
11621
  if (fs.existsSync(codexHooksSrc)) {
10466
11622
  const codexHooksDest = path.join(targetDir, 'hooks');
@@ -10490,9 +11646,28 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
10490
11646
  content = content.replace(/\{\{GSD_VERSION\}\}/g, pkg.version);
10491
11647
  fs.writeFileSync(destFile, content);
10492
11648
  try { fs.chmodSync(destFile, 0o755); } catch (e) { /* Windows */ }
11649
+ } else {
11650
+ // #2695: raw byte-for-byte copy for allowlisted artifacts that carry
11651
+ // no {{GSD_VERSION}} placeholder and no runtime path token (e.g.
11652
+ // managed-hooks-registry.cjs, whose only `.claude` mention is inside a
11653
+ // doc comment). Version/path transforms would be a no-op at best and a
11654
+ // surprise at worst; the issue requires the registry copied verbatim.
11655
+ fs.copyFileSync(srcFile, destFile);
11656
+ try { fs.chmodSync(destFile, 0o755); } catch (e) { /* Windows */ }
10493
11657
  }
10494
11658
  }
10495
11659
  console.log(` ${green}✓${reset} Installed hooks (Codex)`);
11660
+ // #2717: write the CommonJS marker into hooks/ alongside the staged .js
11661
+ // scripts. Codex is excluded from installSharedHooksBundle by the
11662
+ // !isCodex gate, so it never received the marker the shared-bundle path
11663
+ // writes for the other runtimes. Without it, a ~/.codex/package.json
11664
+ // declaring {"type":"module"} makes Node load gsd-check-update.js /
11665
+ // gsd-context-monitor.js as ESM and their require() calls fail silently.
11666
+ // Reuses the same helper the Cursor/Windsurf writers call so the marker
11667
+ // content + user-file-preservation contract is identical everywhere.
11668
+ if (hooksSurface.ensureCommonJsMarker(codexHooksDest)) {
11669
+ console.log(` ${green}✓${reset} Wrote hooks/package.json (CommonJS mode)`);
11670
+ }
10496
11671
  }
10497
11672
 
10498
11673
  // Add Codex hooks (SessionStart for update checking) — requires codex_hooks feature flag
@@ -11221,6 +12396,19 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS
11221
12396
  fs.writeFileSync(defaultsPath, JSON.stringify(defaults, null, 2) + '\n');
11222
12397
  console.log(` ${green}✓${reset} Set resolve_model_ids: "omit" in ~/.gsd/defaults.json`);
11223
12398
  }
12399
+
12400
+ // #2395: also persist `runtime: <runtime>` for non-Claude runtimes, so
12401
+ // resolveRuntime() (precedence: GSD_RUNTIME env > config.runtime > 'claude')
12402
+ // resolves to the install's actual runtime identity out of the box — without
12403
+ // this, agent_runtime and every runtime-branded slash hint falls through to
12404
+ // the hard-coded 'claude' default. Mirrors the resolve_model_ids write above:
12405
+ // honor an explicit pre-existing value (any string), only default-populating
12406
+ // when absent. Claude is the resolveRuntime() fallback, so it needs no write.
12407
+ if (defaults.runtime === undefined || defaults.runtime === null || defaults.runtime === '') {
12408
+ defaults.runtime = runtime;
12409
+ fs.writeFileSync(defaultsPath, JSON.stringify(defaults, null, 2) + '\n');
12410
+ console.log(` ${green}✓${reset} Set runtime: "${runtime}" in ~/.gsd/defaults.json`);
12411
+ }
11224
12412
  } catch (e) {
11225
12413
  console.log(` ${yellow}⚠${reset} Could not write ~/.gsd/defaults.json: ${e.message}`);
11226
12414
  }
@@ -11331,16 +12519,17 @@ const runtimeMap = {
11331
12519
  '8': 'cursor',
11332
12520
  '9': 'hermes',
11333
12521
  '10': 'kimi',
11334
- '11': 'kilo',
11335
- '12': 'opencode',
11336
- '13': 'pi',
11337
- '14': 'qwen',
11338
- '15': 'trae',
11339
- '16': 'windsurf',
11340
- '17': 'zcode'
12522
+ '11': 'kimi-code',
12523
+ '12': 'kilo',
12524
+ '13': 'opencode',
12525
+ '14': 'pi',
12526
+ '15': 'qwen',
12527
+ '16': 'trae',
12528
+ '17': 'windsurf',
12529
+ '18': 'zcode'
11341
12530
  };
11342
- const allRuntimes = ['claude', 'antigravity', 'augment', 'cline', 'codebuddy', 'codex', 'copilot', 'cursor', 'hermes', 'kimi', 'kilo', 'opencode', 'pi', 'qwen', 'trae', 'windsurf', 'zcode'];
11343
- const ALL_RUNTIMES_OPTION = '18';
12531
+ const allRuntimes = ['claude', 'antigravity', 'augment', 'cline', 'codebuddy', 'codex', 'copilot', 'cursor', 'hermes', 'kimi', 'kimi-code', 'kilo', 'opencode', 'pi', 'qwen', 'trae', 'windsurf', 'zcode'];
12532
+ const ALL_RUNTIMES_OPTION = '19';
11344
12533
 
11345
12534
  /**
11346
12535
  * Build the runtime-selection prompt text shown by the interactive installer.
@@ -11358,14 +12547,15 @@ function buildRuntimePromptText() {
11358
12547
  ${cyan}8${reset}) Cursor ${dim}(~/.cursor)${reset}
11359
12548
  ${cyan}9${reset}) Hermes Agent ${dim}(~/.hermes)${reset}
11360
12549
  ${cyan}10${reset}) Kimi ${dim}(~/.config/agents, then ~/.agents if existing)${reset}
11361
- ${cyan}11${reset}) Kilo ${dim}(~/.config/kilo)${reset}
11362
- ${cyan}12${reset}) OpenCode ${dim}(~/.config/opencode)${reset}
11363
- ${cyan}13${reset}) pi ${dim}(~/.pi/agent)${reset}
11364
- ${cyan}14${reset}) Qwen Code ${dim}(~/.qwen)${reset}
11365
- ${cyan}15${reset}) Trae ${dim}(~/.trae)${reset}
11366
- ${cyan}16${reset}) Windsurf ${dim}(~/.codeium/windsurf)${reset}
11367
- ${cyan}17${reset}) ZCode ${dim}(~/.zcode)${reset}
11368
- ${cyan}18${reset}) All
12550
+ ${cyan}11${reset}) Kimi Code ${dim}(~/.kimi-code)${reset}
12551
+ ${cyan}12${reset}) Kilo ${dim}(~/.config/kilo)${reset}
12552
+ ${cyan}13${reset}) OpenCode ${dim}(~/.config/opencode)${reset}
12553
+ ${cyan}14${reset}) pi ${dim}(~/.pi/agent)${reset}
12554
+ ${cyan}15${reset}) Qwen Code ${dim}(~/.qwen)${reset}
12555
+ ${cyan}16${reset}) Trae ${dim}(~/.trae)${reset}
12556
+ ${cyan}17${reset}) Windsurf ${dim}(~/.codeium/windsurf)${reset}
12557
+ ${cyan}18${reset}) ZCode ${dim}(~/.zcode)${reset}
12558
+ ${cyan}19${reset}) All
11369
12559
 
11370
12560
  ${dim}Select multiple: 1,2,6 or 1 2 6${reset}
11371
12561
  `;
@@ -11376,7 +12566,7 @@ function buildRuntimePromptText() {
11376
12566
  * Pure function — exported so tests can verify split/dedupe/fallback behavior.
11377
12567
  * - Accepts comma- and/or whitespace-separated choices
11378
12568
  * - Deduplicates while preserving order
11379
- * - Maps option 16 ("All") to every runtime
12569
+ * - Maps option 19 ("All") to every runtime
11380
12570
  * - Falls back to ['claude'] when nothing valid is selected
11381
12571
  */
11382
12572
  function parseRuntimeInput(answer) {
@@ -12157,6 +13347,7 @@ module.exports = {
12157
13347
  // #768 — Claude Code permissions pre-population
12158
13348
  mergeClaudePermissions,
12159
13349
  GSD_CLAUDE_ALLOW_PERMISSIONS,
13350
+ GSD_CLAUDE_LEGACY_ALLOW_PERMISSIONS,
12160
13351
  GSD_CLAUDE_DENY_PERMISSIONS,
12161
13352
  GSD_CODEX_MARKER,
12162
13353
  CODEX_AGENT_SANDBOX,
@@ -12206,6 +13397,18 @@ module.exports = {
12206
13397
  convertClaudeToCliineMarkdown,
12207
13398
  convertClaudeCommandToClineSkill,
12208
13399
  convertClaudeAgentToClineAgent,
13400
+ // #2284(b) — cross-cutting branding protected-region helper
13401
+ applyClaudeCodeBrandSwap,
13402
+ // #2284 — Hermes named-dispatch → delegate_task projection
13403
+ convertClaudeToHermesMarkdown,
13404
+ projectNamedDispatchToStructuralDelegate,
13405
+ _hostIntegrationDispatch,
13406
+ _resolveAvailableGsdRoles,
13407
+ HERMES_DISPATCH_TOOL_CONFIG,
13408
+ maskStringLiterals,
13409
+ findDispatchCallSpans,
13410
+ _assertProjectionComplete,
13411
+ _normalizeDispatchCallSpan,
12209
13412
  buildClineRulesBody,
12210
13413
  buildClineAgentsMdBody,
12211
13414
  buildClinePreToolUseHook,