@opengsd/gsd-core 1.7.0 → 1.8.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 (165) 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 +14 -0
  4. package/README.md +2 -0
  5. package/agents/gsd-debug-session-manager.md +42 -4
  6. package/agents/gsd-debugger.md +87 -29
  7. package/agents/gsd-executor.md +29 -2
  8. package/agents/gsd-planner.md +29 -36
  9. package/agents/gsd-verifier.md +2 -2
  10. package/bin/install.js +1152 -80
  11. package/commands/gsd/ai-integration-phase.md +1 -1
  12. package/commands/gsd/mempalace-capture.md +9 -5
  13. package/commands/gsd/new-milestone.md +1 -1
  14. package/commands/gsd/plan-phase.md +5 -3
  15. package/commands/gsd/plan-review-convergence.md +3 -2
  16. package/gsd-core/bin/gsd-tools.cjs +1878 -2507
  17. package/gsd-core/bin/lib/adapter-imperative.cjs +8 -1
  18. package/gsd-core/bin/lib/agent-command-router.cjs +20 -5
  19. package/gsd-core/bin/lib/api-coverage.cjs +338 -45
  20. package/gsd-core/bin/lib/broken-windows.cjs +716 -0
  21. package/gsd-core/bin/lib/capability-command-router.cjs +733 -0
  22. package/gsd-core/bin/lib/capability-registry.cjs +155 -86
  23. package/gsd-core/bin/lib/capability-writer.cjs +6 -1
  24. package/gsd-core/bin/lib/check-command-router.cjs +128 -25
  25. package/gsd-core/bin/lib/claude-orchestration-command-router.cjs +115 -27
  26. package/gsd-core/bin/lib/claude-orchestration.cjs +84 -9
  27. package/gsd-core/bin/lib/command-aliases.cjs +14 -0
  28. package/gsd-core/bin/lib/commands.cjs +81 -4
  29. package/gsd-core/bin/lib/config-loader.cjs +14 -2
  30. package/gsd-core/bin/lib/config.cjs +69 -18
  31. package/gsd-core/bin/lib/core-utils.cjs +6 -1
  32. package/gsd-core/bin/lib/decisions.cjs +32 -8
  33. package/gsd-core/bin/lib/docs.cjs +6 -0
  34. package/gsd-core/bin/lib/external-descriptor-trust.cjs +14 -2
  35. package/gsd-core/bin/lib/gap-checker.cjs +17 -2
  36. package/gsd-core/bin/lib/init.cjs +111 -47
  37. package/gsd-core/bin/lib/install-engine.cjs +298 -23
  38. package/gsd-core/bin/lib/install-profiles.cjs +239 -1
  39. package/gsd-core/bin/lib/installer-migrations/005-opencode-baseline-commands-dir.cjs +146 -0
  40. package/gsd-core/bin/lib/installer-migrations/006-pi-extension-cjs-to-js.cjs +91 -0
  41. package/gsd-core/bin/lib/installer-migrations.cjs +44 -5
  42. package/gsd-core/bin/lib/markdown-sectionizer.cjs +107 -0
  43. package/gsd-core/bin/lib/milestone.cjs +246 -12
  44. package/gsd-core/bin/lib/model-catalog.cjs +19 -4
  45. package/gsd-core/bin/lib/model-resolver.cjs +189 -7
  46. package/gsd-core/bin/lib/onboard-projection.cjs +11 -8
  47. package/gsd-core/bin/lib/phase-id.cjs +26 -4
  48. package/gsd-core/bin/lib/phase.cjs +201 -12
  49. package/gsd-core/bin/lib/plan-scan.cjs +70 -2
  50. package/gsd-core/bin/lib/roadmap-parser.cjs +7 -4
  51. package/gsd-core/bin/lib/roadmap.cjs +13 -3
  52. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +7 -1
  53. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +22 -8
  54. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +16 -0
  55. package/gsd-core/bin/lib/smart-entry.cjs +69 -4
  56. package/gsd-core/bin/lib/state-document.cjs +7 -4
  57. package/gsd-core/bin/lib/state-transition.cjs +22 -1
  58. package/gsd-core/bin/lib/state.cjs +65 -11
  59. package/gsd-core/bin/lib/surface.cjs +51 -9
  60. package/gsd-core/bin/lib/uat.cjs +420 -5
  61. package/gsd-core/bin/lib/validate.cjs +12 -8
  62. package/gsd-core/bin/lib/verification.cjs +112 -17
  63. package/gsd-core/bin/lib/verify.cjs +220 -22
  64. package/gsd-core/bin/shared/config-schema.manifest.json +3 -2
  65. package/gsd-core/references/api-coverage.md +37 -7
  66. package/gsd-core/references/checkpoints.md +1 -1
  67. package/gsd-core/references/common-bug-patterns.md +13 -0
  68. package/gsd-core/references/debugger-bug-taxonomy.md +111 -0
  69. package/gsd-core/references/debugger-fix-acceptance.md +157 -0
  70. package/gsd-core/references/debugger-philosophy.md +1 -0
  71. package/gsd-core/references/debugger-prevention.md +98 -0
  72. package/gsd-core/references/debugger-rca-branching.md +98 -0
  73. package/gsd-core/references/debugger-repro-hardening.md +130 -0
  74. package/gsd-core/references/debugger-sbfl.md +110 -0
  75. package/gsd-core/references/debugger-semantic-recall.md +81 -0
  76. package/gsd-core/references/execute-phase-quota-recovery.md +55 -0
  77. package/gsd-core/references/execute-phase-requirement-revert.md +8 -0
  78. package/gsd-core/references/execute-phase-response-language.md +7 -0
  79. package/gsd-core/references/planner-antipatterns.md +6 -0
  80. package/gsd-core/references/planner-mvp-mode.md +12 -13
  81. package/gsd-core/references/planner-preconditions.md +156 -0
  82. package/gsd-core/references/planner-reversibility.md +132 -0
  83. package/gsd-core/references/reviewer-instances.md +9 -7
  84. package/gsd-core/references/skeleton-template.md +1 -1
  85. package/gsd-core/references/thinking-models-planning.md +3 -1
  86. package/gsd-core/templates/DEBUG.md +5 -3
  87. package/gsd-core/workflows/add-phase.md +2 -0
  88. package/gsd-core/workflows/add-tests.md +3 -1
  89. package/gsd-core/workflows/add-todo.md +32 -1
  90. package/gsd-core/workflows/ai-integration-phase.md +4 -2
  91. package/gsd-core/workflows/audit-fix.md +2 -2
  92. package/gsd-core/workflows/check-todos.md +3 -1
  93. package/gsd-core/workflows/cleanup.md +7 -1
  94. package/gsd-core/workflows/code-review.md +17 -5
  95. package/gsd-core/workflows/complete-milestone.md +3 -0
  96. package/gsd-core/workflows/debug.md +25 -5
  97. package/gsd-core/workflows/diagnose-issues.md +1 -1
  98. package/gsd-core/workflows/discovery-phase.md +7 -0
  99. package/gsd-core/workflows/discuss-phase/templates/context.md +16 -2
  100. package/gsd-core/workflows/discuss-phase-assumptions.md +3 -0
  101. package/gsd-core/workflows/do.md +7 -1
  102. package/gsd-core/workflows/docs-update.md +1 -0
  103. package/gsd-core/workflows/eval-review.md +3 -0
  104. package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +4 -4
  105. package/gsd-core/workflows/execute-phase/steps/regression-gate.md +2 -2
  106. package/gsd-core/workflows/execute-phase.md +25 -34
  107. package/gsd-core/workflows/execute-plan.md +15 -4
  108. package/gsd-core/workflows/graduation.md +3 -0
  109. package/gsd-core/workflows/health.md +7 -1
  110. package/gsd-core/workflows/help/modes/full.md +6 -2
  111. package/gsd-core/workflows/import.md +8 -2
  112. package/gsd-core/workflows/inbox.md +7 -0
  113. package/gsd-core/workflows/ingest-docs.md +15 -10
  114. package/gsd-core/workflows/manager.md +3 -1
  115. package/gsd-core/workflows/map-codebase.md +4 -4
  116. package/gsd-core/workflows/mvp-phase.md +3 -0
  117. package/gsd-core/workflows/new-milestone.md +69 -21
  118. package/gsd-core/workflows/new-project.md +17 -15
  119. package/gsd-core/workflows/new-workspace.md +3 -1
  120. package/gsd-core/workflows/onboard.md +3 -0
  121. package/gsd-core/workflows/plan-phase.md +14 -5
  122. package/gsd-core/workflows/plan-review-convergence.md +48 -3
  123. package/gsd-core/workflows/plant-seed.md +3 -0
  124. package/gsd-core/workflows/profile-user.md +7 -1
  125. package/gsd-core/workflows/progress.md +31 -3
  126. package/gsd-core/workflows/quick.md +19 -7
  127. package/gsd-core/workflows/remove-workspace.md +3 -0
  128. package/gsd-core/workflows/review.md +89 -73
  129. package/gsd-core/workflows/scan.md +1 -1
  130. package/gsd-core/workflows/secure-phase.md +3 -0
  131. package/gsd-core/workflows/settings-integrations.md +3 -0
  132. package/gsd-core/workflows/settings.md +3 -0
  133. package/gsd-core/workflows/ship.md +50 -3
  134. package/gsd-core/workflows/sketch.md +3 -0
  135. package/gsd-core/workflows/smart-entry.md +3 -0
  136. package/gsd-core/workflows/spike.md +7 -1
  137. package/gsd-core/workflows/ui-phase.md +3 -1
  138. package/gsd-core/workflows/ui-review.md +3 -0
  139. package/gsd-core/workflows/undo.md +7 -0
  140. package/gsd-core/workflows/update.md +2 -0
  141. package/gsd-core/workflows/validate-phase.md +3 -0
  142. package/gsd-core/workflows/verify-phase.md +2 -2
  143. package/gsd-core/workflows/verify-work.md +7 -3
  144. package/hooks/dist/gsd-context-monitor.js +27 -9
  145. package/hooks/dist/gsd-statusline.js +88 -3
  146. package/hooks/gsd-context-monitor.js +27 -9
  147. package/hooks/gsd-statusline.js +88 -3
  148. package/package.json +6 -4
  149. package/pi/gsd.cjs +8 -2
  150. package/scripts/changeset/lint.cjs +1 -0
  151. package/scripts/changeset/parse.cjs +26 -0
  152. package/scripts/check-glossary-refs.cjs +220 -0
  153. package/scripts/ci-rebase-check.cjs +48 -4
  154. package/scripts/gen-adr-index.cjs +526 -0
  155. package/scripts/gen-test-timings.cjs +201 -0
  156. package/scripts/lint-portable-timeout.cjs +140 -0
  157. package/scripts/lint-test-file-count.allowlist.json +1 -0
  158. package/scripts/release-tarball-smoke.cjs +18 -11
  159. package/scripts/run-tests.cjs +420 -58
  160. package/skills/gsd-ai-integration-phase/SKILL.md +1 -1
  161. package/skills/gsd-mempalace-capture/SKILL.md +9 -5
  162. package/skills/gsd-new-milestone/SKILL.md +1 -1
  163. package/skills/gsd-plan-phase/SKILL.md +5 -3
  164. package/skills/gsd-plan-review-convergence/SKILL.md +3 -2
  165. package/vscode/package.json +1 -1
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);
@@ -352,6 +375,7 @@ const {
352
375
  } = require(path.join(_gsdLibDir, 'model-catalog.cjs'));
353
376
  const {
354
377
  resolveTierEntry: gsdResolveTierEntry,
378
+ CLAUDE_AGENT_ALIASES,
355
379
  } = require(path.join(_gsdLibDir, 'model-resolver.cjs'));
356
380
 
357
381
  // #2071 — install-time effort resolution (readGsdEffectiveEffortConfig /
@@ -390,6 +414,32 @@ try {
390
414
  _capabilityRegistry = undefined;
391
415
  }
392
416
 
417
+ // #2322 BLOCKER 2: `_capabilityRegistry` above is the FROZEN first-party registry
418
+ // (capability-registry.cjs, built at publish time) — it never reflects an
419
+ // INSTALLED third-party overlay capability, so a fresh `gsd install` could never
420
+ // stage an installed third-party capability's skill regardless of registration,
421
+ // even on the DEFAULT `--profile full`. `_installedCapabilityRegistry` composes
422
+ // the overlay via capability-loader's `loadRegistry({includeInstalled:true})` —
423
+ // the SAME call capability-writer.cts's `capability set --runtime` path already
424
+ // uses — so a fresh install and a post-install `capability set` agree on
425
+ // third-party skill availability. Used ONLY for skill-profile resolution and
426
+ // runtime-artifact-layout staging below; `_capabilityRegistry` (frozen) remains
427
+ // the source for gsd-core's OWN runtime/host-behavior descriptors (unaffected —
428
+ // those are always first-party). A load failure degrades to the frozen
429
+ // `_capabilityRegistry` (no overlay data -> no third-party skills staged; never
430
+ // a crash and never a scan-and-guess fallback).
431
+ let _installedCapabilityRegistry;
432
+ try {
433
+ const _capabilityLoader = require(path.join(_gsdLibDir, 'capability-loader.cjs'));
434
+ _installedCapabilityRegistry = _capabilityLoader.loadRegistry({
435
+ includeInstalled: true,
436
+ cwd: process.cwd(),
437
+ gsdHome: process.env['GSD_HOME'],
438
+ });
439
+ } catch (_) {
440
+ _installedCapabilityRegistry = _capabilityRegistry;
441
+ }
442
+
393
443
  // Fail-safe floor for the reference host's #338-privacy-critical behaviors, used
394
444
  // ONLY when the first-party capability registry cannot be loaded (a broken bundle).
395
445
  // Without it, a registry-load failure would make `_hostBehaviors('claude')` return
@@ -441,6 +491,25 @@ function _hostBehaviors(runtime) {
441
491
  return _resolveHostBehaviors(runtime, _capabilityRegistry);
442
492
  }
443
493
 
494
+ /**
495
+ * Read a runtime's documentation-sourced `hostIntegration.dispatch` axes
496
+ * (ADR-1239 Phase A — `capabilities/<runtime>/capability.json`
497
+ * `runtime.hostIntegration.dispatch`): `{namedDispatch, nested, maxDepth,
498
+ * background, backgroundDispatch, subagentToolkit}`. These are validated,
499
+ * closed-vocabulary FACTS about what the runtime's real dispatch primitive
500
+ * supports (never inferred) — see `docs/reference/host-integration-capability-
501
+ * matrix.md` for citations. Unlike `_hostBehaviors` (install *policy*), this is
502
+ * the negotiated *capability* surface; #2284 is its first content-projection
503
+ * consumer (previously read only by `shouldFlattenDispatch`). Returns `{}` if
504
+ * the registry or the runtime's descriptor is unavailable, so callers must
505
+ * treat every axis as absent/unknown (fail-closed) rather than assume a value.
506
+ */
507
+ function _hostIntegrationDispatch(runtime) {
508
+ const cap = _capabilityRegistry && _capabilityRegistry.runtimes && _capabilityRegistry.runtimes[runtime];
509
+ const dispatch = cap && cap.runtime && cap.runtime.hostIntegration && cap.runtime.hostIntegration.dispatch;
510
+ return dispatch || {};
511
+ }
512
+
444
513
  /**
445
514
  * Resolve the ACTUAL on-disk skills-install directory for a runtime, honoring a
446
515
  * skills-kind `home` override (ADR-1239 upgrade 3 / #2088: e.g. Codex skills ->
@@ -510,6 +579,7 @@ const {
510
579
  _installNativePluginIfDeclared,
511
580
  _copyStaged,
512
581
  hasExistingSymlinkBetween,
582
+ isSymlinkedDestOptIn,
513
583
  preserveUserArtifacts,
514
584
  restoreUserArtifacts,
515
585
  migrateLegacyDevPreferencesToSkill,
@@ -2368,6 +2438,56 @@ function extractFrontmatterField(frontmatter, fieldName) {
2368
2438
  return match[1].trim().replace(/^['"]|['"]$/g, '');
2369
2439
  }
2370
2440
 
2441
+ // #2284 finding (b): the `<runtime_compatibility>` block appearing in
2442
+ // gsd-core/workflows/{plan-phase,execute-phase}.md is a runtime-COMPARISON
2443
+ // table ("**Claude Code:** Uses `Agent(...)`" / "a backgrounded Claude Code
2444
+ // agent" / "top-level Claude Code") — every "Claude Code" mention inside it
2445
+ // is a COMPARED-RUNTIME LABEL, not a host self-reference. The brand swap
2446
+ // below (`Claude Code` → the installing runtime's own display name) is
2447
+ // meant only for host self-references; applying it inside this block
2448
+ // mislabels the comparison (e.g. Windsurf installs would read "**Windsurf:**
2449
+ // Uses `Agent(...)`" describing what is actually Claude Code's behavior).
2450
+ // This is cross-cutting across every runtime that brand-swaps workflow
2451
+ // content (cursor/windsurf/trae/cline/codebuddy hardcoded; qwen/hermes
2452
+ // descriptor-driven via hostBehaviors.brandingRewrites) — confirmed to
2453
+ // reproduce on unmodified Windsurf, not Hermes-specific.
2454
+ const RUNTIME_COMPATIBILITY_BLOCK_RE = /<runtime_compatibility>[\s\S]*?<\/runtime_compatibility>/g;
2455
+
2456
+ /**
2457
+ * Rewrite bare "Claude Code" self-references in workflow content to
2458
+ * `brandName`, EXCEPT inside `<runtime_compatibility>...</runtime_compatibility>`
2459
+ * blocks, which are left byte-for-byte verbatim. Every other content
2460
+ * transform in a runtime's `.md` converter (tool-name renames, path
2461
+ * rewrites, etc.) is unaffected — only this literal brand-name swap is
2462
+ * protected-region-aware, since only it risks mislabeling a
2463
+ * runtime-comparison table.
2464
+ *
2465
+ * Implementation: SPLIT `content` on the protected-block regex, brand-swap
2466
+ * only the GAP text between (and around) matches, then rejoin gap+block
2467
+ * alternately. No placeholder/sentinel token of any kind is substituted in
2468
+ * — a prior version used a sentinel-token mask/restore, which is exactly the
2469
+ * kind of invisible landmine this rewrite eliminates (a sentinel string, no
2470
+ * matter how obscure, is a theoretical collision risk with real content and
2471
+ * is easy to silently reintroduce in a future edit without it showing in a
2472
+ * diff). Behavior-identical to the removed sentinel-token version — verified
2473
+ * via `npm run gen:golden` producing zero further diff.
2474
+ */
2475
+ function applyClaudeCodeBrandSwap(content, brandName) {
2476
+ if (!brandName) return content;
2477
+ let result = '';
2478
+ let lastIndex = 0;
2479
+ RUNTIME_COMPATIBILITY_BLOCK_RE.lastIndex = 0; // reset shared global-regex state before each use
2480
+ let m;
2481
+ while ((m = RUNTIME_COMPATIBILITY_BLOCK_RE.exec(content))) {
2482
+ const gap = content.slice(lastIndex, m.index);
2483
+ result += gap.replace(/\bClaude Code\b/g, brandName);
2484
+ result += m[0]; // protected block, verbatim — never brand-swapped
2485
+ lastIndex = m.index + m[0].length;
2486
+ }
2487
+ result += content.slice(lastIndex).replace(/\bClaude Code\b/g, brandName);
2488
+ return result;
2489
+ }
2490
+
2371
2491
  // Tool name mapping from Claude Code to Cursor CLI
2372
2492
  const claudeToCursorTools = {
2373
2493
  Bash: 'Shell',
@@ -2400,8 +2520,9 @@ function convertClaudeToCursorMarkdown(content) {
2400
2520
  // Remove Claude Code-specific bug workarounds before brand replacement
2401
2521
  converted = converted.replace(/\*\*Known Claude Code bug \(classifyHandoffIfNeeded\):\*\*[^\n]*\n/g, '');
2402
2522
  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');
2523
+ // Replace "Claude Code" brand references with "Cursor" — #2284(b): skips
2524
+ // <runtime_compatibility> comparison-table content (protected region).
2525
+ converted = applyClaudeCodeBrandSwap(converted, 'Cursor');
2405
2526
  return converted;
2406
2527
  }
2407
2528
 
@@ -2445,7 +2566,13 @@ function convertClaudeCommandToCursorSkill(content, skillName) {
2445
2566
  const shortDescription = description.length > 180 ? `${description.slice(0, 177)}...` : description;
2446
2567
  const adapter = getCursorSkillAdapterHeader(skillName);
2447
2568
 
2448
- return `---\nname: ${yamlIdentifier(skillName)}\ndescription: ${yamlQuote(shortDescription)}\n---\n\n${adapter}\n\n${body.trimStart()}`;
2569
+ // #2341: mark user-invocable:false so the skill is NOT shown in Cursor's '/'
2570
+ // menu (it defaults to true). Cursor also writes a commands/ surface (#785),
2571
+ // and surfacing both duplicated every /gsd-* entry. This mirrors the #789
2572
+ // CodeBuddy de-dup: the commands/ surface is the sole '/' entry point; skills
2573
+ // stay model-invocable background knowledge. (user-invocable:false hides from
2574
+ // '/' while keeping model invocation — distinct from disable-model-invocation.)
2575
+ return `---\nname: ${yamlIdentifier(skillName)}\ndescription: ${yamlQuote(shortDescription)}\nuser-invocable: false\n---\n\n${adapter}\n\n${body.trimStart()}`;
2449
2576
  }
2450
2577
 
2451
2578
  /**
@@ -2533,8 +2660,9 @@ function convertClaudeToWindsurfMarkdown(content) {
2533
2660
  // Remove Claude Code-specific bug workarounds before brand replacement
2534
2661
  converted = converted.replace(/\*\*Known Claude Code bug \(classifyHandoffIfNeeded\):\*\*[^\n]*\n/g, '');
2535
2662
  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');
2663
+ // Replace "Claude Code" brand references with "Windsurf" — #2284(b): skips
2664
+ // <runtime_compatibility> comparison-table content (protected region).
2665
+ converted = applyClaudeCodeBrandSwap(converted, 'Windsurf');
2538
2666
  return converted;
2539
2667
  }
2540
2668
 
@@ -2668,7 +2796,8 @@ function convertClaudeToTraeMarkdown(content) {
2668
2796
  converted = converted.replace(/\bCLAUDE_CONFIG_DIR\b/g, 'TRAE_CONFIG_DIR');
2669
2797
  converted = converted.replace(/\*\*Known Claude Code bug \(classifyHandoffIfNeeded\):\*\*[^\n]*\n/g, '');
2670
2798
  converted = converted.replace(/- \*\*classifyHandoffIfNeeded false failure:\*\*[^\n]*\n/g, '');
2671
- converted = converted.replace(/\bClaude Code\b/g, 'Trae');
2799
+ // #2284(b): skips <runtime_compatibility> comparison-table content (protected region).
2800
+ converted = applyClaudeCodeBrandSwap(converted, 'Trae');
2672
2801
  return converted;
2673
2802
  }
2674
2803
 
@@ -2740,7 +2869,8 @@ function convertClaudeToCodebuddyMarkdown(content) {
2740
2869
  converted = converted.replace(/\.claude\//g, '.codebuddy/');
2741
2870
  converted = converted.replace(/\*\*Known Claude Code bug \(classifyHandoffIfNeeded\):\*\*[^\n]*\n/g, '');
2742
2871
  converted = converted.replace(/- \*\*classifyHandoffIfNeeded false failure:\*\*[^\n]*\n/g, '');
2743
- converted = converted.replace(/\bClaude Code\b/g, 'CodeBuddy');
2872
+ // #2284(b): skips <runtime_compatibility> comparison-table content (protected region).
2873
+ converted = applyClaudeCodeBrandSwap(converted, 'CodeBuddy');
2744
2874
  return converted;
2745
2875
  }
2746
2876
 
@@ -2835,7 +2965,8 @@ function convertClaudeToCliineMarkdown(content) {
2835
2965
  converted = converted.replace(/\bCLAUDE_CONFIG_DIR\b/g, 'CLINE_CONFIG_DIR');
2836
2966
  converted = converted.replace(/\*\*Known Claude Code bug \(classifyHandoffIfNeeded\):\*\*[^\n]*\n/g, '');
2837
2967
  converted = converted.replace(/- \*\*classifyHandoffIfNeeded false failure:\*\*[^\n]*\n/g, '');
2838
- converted = converted.replace(/\bClaude Code\b/g, 'Cline');
2968
+ // #2284(b): skips <runtime_compatibility> comparison-table content (protected region).
2969
+ converted = applyClaudeCodeBrandSwap(converted, 'Cline');
2839
2970
  return converted;
2840
2971
  }
2841
2972
 
@@ -2888,6 +3019,825 @@ function convertClaudeCommandToClineSkill(content, skillName, runtime = null, cm
2888
3019
 
2889
3020
  // ── End Cline converters ─────────────────────────────────────────────────────
2890
3021
 
3022
+ // ── Hermes converters (#2284) ────────────────────────────────────────────────
3023
+ //
3024
+ // Hermes exposes `delegate_task` for subagent dispatch, not the Claude-shaped
3025
+ // `Agent(...)` tool the host-neutral `gsd-core/workflows/*.md` corpus assumes.
3026
+ // Prior to this fix, the hermes `.md` hook (RUNTIME_CONTENT_DISPATCH.hermes)
3027
+ // only brand-swapped "Claude Code" → "Hermes Agent" via
3028
+ // hostBehaviors.brandingRewrites, leaving the false "Agent tool IS available"
3029
+ // assertion and literal `Agent(...)` call syntax installed verbatim.
3030
+ //
3031
+ // `projectNamedDispatchToStructuralDelegate` is GENERIC projection machinery:
3032
+ // it branches ENTIRELY on the runtime's documentation-sourced
3033
+ // `hostIntegration.dispatch` facts (read via `_hostIntegrationDispatch`,
3034
+ // capabilities/<runtime>/capability.json — never hardcoded here) and a
3035
+ // `toolConfig` that supplies only the target primitive's own vocabulary (its
3036
+ // call name + native parameter names — not a capability claim; there is no
3037
+ // `dispatch` axis for "the call's own parameter names", so that vocabulary is
3038
+ // necessarily supplied by the caller, exactly as every other runtime's
3039
+ // converter supplies its own tool-name vocabulary, e.g. Trae's `Shell(`).
3040
+ //
3041
+ // Hermes-specific facts consumed (capabilities/hermes/capability.json,
3042
+ // docs/reference/host-integration-capability-matrix.md:244-249 — UNCHANGED by
3043
+ // this fix):
3044
+ // - dispatch.namedDispatch: false — Hermes's delegate_task has no named-
3045
+ // agent lookup ("Subagents are identified only by role ('leaf' or
3046
+ // 'orchestrator')"). GSD resolves the referenced gsd-* role itself
3047
+ // (fail-closed against the staged agents/ dir) and embeds the loaded
3048
+ // PROMPT CONTENT into the delegate_task payload.
3049
+ // - dispatch.background: true — `delegate_task(background=true)` "returns a
3050
+ // handle immediately"; Claude's `run_in_background=` maps onto Hermes's
3051
+ // own `background=` parameter, preserving the async-handle / no-busy-poll
3052
+ // / resume-on-completion wording already used throughout these workflows.
3053
+ // - dispatch.subagentToolkit: "read-only" / dispatch.maxDepth: 1 — dispatched
3054
+ // roles never themselves further delegate, so no nested-delegation
3055
+ // instruction is ever emitted toward them.
3056
+
3057
+ /**
3058
+ * Resolve the set of gsd-* role-prompt stems actually shipped in this
3059
+ * package's `agents/` directory (the FULL source set, not profile-staged —
3060
+ * `--minimal`/`--profile=core` intentionally excludes many agents from a
3061
+ * given install without those workflows being unreachable, so validating
3062
+ * against the profile-filtered subset would fail every restricted-profile
3063
+ * Hermes install; validating against the shipped source catches genuine
3064
+ * authoring bugs — a stale/typo'd role reference — without that regression).
3065
+ * Returns `null` if the directory cannot be resolved (fail-closed: callers
3066
+ * must refuse to install rather than skip validation).
3067
+ */
3068
+ function _resolveAvailableGsdRoles() {
3069
+ try {
3070
+ const agentsDir = path.join(__dirname, '..', 'agents');
3071
+ return new Set(
3072
+ fs.readdirSync(agentsDir, { withFileTypes: true })
3073
+ .filter((e) => e.isFile() && e.name.endsWith('.md'))
3074
+ .map((e) => e.name.slice(0, -3)),
3075
+ );
3076
+ } catch (_e) {
3077
+ return null;
3078
+ }
3079
+ }
3080
+
3081
+ /**
3082
+ * Fail-closed validation (#2284 AC: "Missing role prompts fail closed" /
3083
+ * "never emit a workflow referencing an unresolvable role"). A single literal
3084
+ * `gsd-*` role value must resolve to a real `agents/<role>.md` file — throws
3085
+ * an explicit Error otherwise, aborting the install (the standard
3086
+ * `copyWithPathReplacement` failure path already used for its own
3087
+ * confinement-violation throws). Called per extracted role value from EVERY
3088
+ * call-syntax form (`subagent_type=`, `subagent_type:`, post-rename
3089
+ * `gsd_role=`) — the check operates on the resolved value, independent of
3090
+ * which source syntax produced it. Non-literal / dynamic expressions (e.g.
3091
+ * `research_hook.ref.agent`) are not quoted strings and are never passed
3092
+ * here; they carry their own runtime resolution + fail-closed instruction via
3093
+ * the injected per-call resolution line.
3094
+ */
3095
+ function _assertRoleResolvable(role, availableRoles, runtime, sourceDescription) {
3096
+ if (!availableRoles) {
3097
+ throw new Error(
3098
+ `${runtime} workflow install: could not resolve the shipped agents/ directory to validate named-role ` +
3099
+ 'dispatch references — refusing to install (fail-closed, #2284)',
3100
+ );
3101
+ }
3102
+ if (role.startsWith('gsd-') && !availableRoles.has(role)) {
3103
+ throw new Error(
3104
+ `${runtime} workflow install: dispatch references role "${role}" via ${sourceDescription}, but no ` +
3105
+ `matching agents/${role}.md prompt file is shipped — refusing to install a workflow that dispatches ` +
3106
+ 'an unresolvable role (fail-closed, #2284)',
3107
+ );
3108
+ }
3109
+ }
3110
+
3111
+ /**
3112
+ * Segment `text` into 'code' and 'string' runs (recognizes `"..."`, `'...'`,
3113
+ * and Python-style `"""..."""`, with backslash-escaping). Required because
3114
+ * the real corpus embeds unescaped parens inside quoted prompt bodies (e.g.
3115
+ * discuss-phase-assumptions.md's `(e.g., "Technical Approach")` inside a
3116
+ * `"""`-quoted prompt) — naive paren/keyword scanning across raw text would
3117
+ * desync on these. Downstream call-span detection and header-token
3118
+ * extraction operate on a same-length MASK derived from this segmentation
3119
+ * (see `maskStringLiterals`) so string content can never be mistaken for
3120
+ * call structure.
3121
+ */
3122
+ function _segmentCodeAndStrings(text) {
3123
+ const segments = [];
3124
+ let i = 0;
3125
+ let segStart = 0;
3126
+ const flushCode = (end) => { if (end > segStart) segments.push({ type: 'code', start: segStart, end }); };
3127
+ while (i < text.length) {
3128
+ const ch = text[i];
3129
+ if (ch === '"' && text[i + 1] === '"' && text[i + 2] === '"') {
3130
+ flushCode(i);
3131
+ const strStart = i;
3132
+ i += 3;
3133
+ while (i < text.length && !(text[i] === '"' && text[i + 1] === '"' && text[i + 2] === '"')) {
3134
+ i += text[i] === '\\' ? 2 : 1;
3135
+ }
3136
+ i = Math.min(i + 3, text.length);
3137
+ segments.push({ type: 'string', start: strStart, end: i, quoteLen: 3 });
3138
+ segStart = i;
3139
+ continue;
3140
+ }
3141
+ // Only `"` is recognized as a single-char string delimiter — NOT `'`.
3142
+ // The corpus is markdown prose, not code: apostrophes are routine English
3143
+ // contractions/possessives ("install's", "don't") and treating them as
3144
+ // string delimiters would swallow everything up to the next unrelated
3145
+ // apostrophe as "inside a string" (verified against the real corpus —
3146
+ // this was a real, disqualifying bug during development of this fix).
3147
+ // Every real call-argument value in the corpus uses `"`/`"""` only.
3148
+ if (ch === '"') {
3149
+ flushCode(i);
3150
+ const strStart = i;
3151
+ i += 1;
3152
+ while (i < text.length && text[i] !== '"') {
3153
+ i += text[i] === '\\' ? 2 : 1;
3154
+ }
3155
+ i = Math.min(i + 1, text.length);
3156
+ segments.push({ type: 'string', start: strStart, end: i, quoteLen: 1 });
3157
+ segStart = i;
3158
+ continue;
3159
+ }
3160
+ i += 1;
3161
+ }
3162
+ flushCode(text.length);
3163
+ return segments;
3164
+ }
3165
+
3166
+ /**
3167
+ * Same-length mask of `text` with the INTERIOR of every string literal
3168
+ * replaced by a space (newlines preserved, so line-based regexes still work).
3169
+ * The delimiting quote character(s) themselves (`"`, `'`, `"""`) are kept
3170
+ * verbatim so a value-extraction regex like `key\s*[=:]\s*"[^"]*"` still
3171
+ * matches correctly against the mask — only the STRING CONTENT is blanked,
3172
+ * never the quote structure. Positions in the mask line up 1:1 with `text`,
3173
+ * so match indices/offsets found against the mask are valid offsets into the
3174
+ * original.
3175
+ */
3176
+ function maskStringLiterals(text) {
3177
+ let mask = '';
3178
+ for (const seg of _segmentCodeAndStrings(text)) {
3179
+ const slice = text.slice(seg.start, seg.end);
3180
+ if (seg.type === 'code') { mask += slice; continue; }
3181
+ const q = seg.quoteLen;
3182
+ if (slice.length <= q) { mask += slice; continue; } // truncated/unterminated — keep verbatim
3183
+ const closeLen = Math.min(q, slice.length - q);
3184
+ const open = slice.slice(0, q);
3185
+ const close = slice.slice(slice.length - closeLen);
3186
+ const interiorLen = slice.length - q - closeLen;
3187
+ const interior = interiorLen > 0 ? slice.slice(q, q + interiorLen) : '';
3188
+ mask += open + interior.replace(/[^\n]/g, ' ') + close;
3189
+ }
3190
+ return mask;
3191
+ }
3192
+
3193
+ /**
3194
+ * Locate every `<headWord>(` / `<headWord>({` call span in `text`.
3195
+ *
3196
+ * #2284 round-2 CRITICAL fix: this MUST NOT rely on whole-document quote
3197
+ * parity. A markdown workflow file mixes prose, ```bash code fences (full of
3198
+ * their own double-quoted strings), and shell quoting — there is no single
3199
+ * document-wide quote grammar, so a `"`-heavy bash `echo` upstream of a real
3200
+ * call (e.g. code-review.md's fenced `echo "..."` block before its
3201
+ * `Agent(subagent_type="gsd-code-reviewer", ...)` call) can desync a
3202
+ * CUMULATIVE quote-state scan, making the scanner believe the real call's
3203
+ * `Agent(` sits "inside a string" and silently skipping it entirely — the
3204
+ * call then survives completely unnormalized. (Reproduced and root-caused
3205
+ * against the real corpus.)
3206
+ *
3207
+ * Fixed shape: find each `<headWord>(` occurrence via a PLAIN literal-text
3208
+ * search (`indexOf`, immune to any prior document content), then run a
3209
+ * balanced paren-matching scan whose quote-tracking state STARTS FRESH AT
3210
+ * THE HEAD — local to this one call, never inherited from (or able to be
3211
+ * corrupted by) anything earlier in the document. Handles all three real
3212
+ * corpus shapes: multi-line one-key-per-line, single-line object-literal
3213
+ * (`Agent({ ... })`), and single-line compact
3214
+ * (`Agent(subagent_type="x", model="y", prompt="...")`) — including prompt
3215
+ * bodies containing their own unescaped `()`/`{}` (skipped via the SAME
3216
+ * span-local quote tracking, e.g. discuss-phase-assumptions.md's
3217
+ * `"""`-quoted parenthetical prose).
3218
+ *
3219
+ * Returns `[{start, end, hasBraceWrapper}]` — `start`/`end` bound the FULL
3220
+ * call INCLUDING the head word and the closing `)`/`})`.
3221
+ */
3222
+ function findDispatchCallSpans(text, headWord) {
3223
+ const spans = [];
3224
+ const headToken = `${headWord}(`;
3225
+ let searchFrom = 0;
3226
+ for (;;) {
3227
+ const start = text.indexOf(headToken, searchFrom);
3228
+ if (start === -1) break;
3229
+ const prevChar = start > 0 ? text[start - 1] : '';
3230
+ if (/[A-Za-z0-9_]/.test(prevChar)) { searchFrom = start + 1; continue; } // word-boundary guard
3231
+
3232
+ let i = start + headToken.length; // just past the '('
3233
+ let j = i;
3234
+ while (j < text.length && /\s/.test(text[j])) j++;
3235
+ const hasBraceWrapper = text[j] === '{';
3236
+
3237
+ // LOCAL scan — quote/paren state is fresh here, never inherited from
3238
+ // anything before `start` in the document.
3239
+ let parenDepth = 1;
3240
+ let inString = null; // null | '"' | 'triple'
3241
+ let end = -1;
3242
+ for (; i < text.length; i++) {
3243
+ const ch = text[i];
3244
+ if (inString) {
3245
+ if (ch === '\\') { i++; continue; }
3246
+ if (inString === 'triple') {
3247
+ if (ch === '"' && text[i + 1] === '"' && text[i + 2] === '"') { inString = null; i += 2; }
3248
+ continue;
3249
+ }
3250
+ if (ch === inString) inString = null;
3251
+ continue;
3252
+ }
3253
+ if (ch === '"' && text[i + 1] === '"' && text[i + 2] === '"') { inString = 'triple'; i += 2; continue; }
3254
+ if (ch === '"') { inString = '"'; continue; }
3255
+ if (ch === '(') { parenDepth++; continue; }
3256
+ if (ch === ')') {
3257
+ parenDepth--;
3258
+ if (parenDepth === 0) { end = i + 1; break; }
3259
+ continue;
3260
+ }
3261
+ }
3262
+ if (end === -1) { searchFrom = start + 1; continue; } // unterminated — skip past, keep scanning
3263
+ spans.push({ start, end, hasBraceWrapper });
3264
+ searchFrom = end;
3265
+ }
3266
+ return spans;
3267
+ }
3268
+
3269
+ /**
3270
+ * Remove a call argument's `[matchStart, matchEnd)` token from `spanText`,
3271
+ * consuming its surrounding comma/whitespace so no dangling `, ,` or trailing
3272
+ * comment survives. When the argument owns its whole line, the whole line
3273
+ * (including a trailing inline `# comment`) is removed; `consumeLeadingComments`
3274
+ * additionally removes contiguous comment-only lines immediately ABOVE it —
3275
+ * #2284 Finding 5: explanatory prose describing a now-removed conditional
3276
+ * (e.g. execute-phase.md's "# Only include model= when ...") must not survive
3277
+ * describing a branch that no longer exists. Inline (single-line-compact /
3278
+ * object-literal) occurrences instead eat one adjacent comma.
3279
+ */
3280
+ function _stripCallArgument(spanText, matchStart, matchEnd, { consumeLeadingComments = false } = {}) {
3281
+ let end = matchEnd;
3282
+ const afterRe = /^[ \t]*,?[ \t]*(#[^\n]*)?\r?\n?/;
3283
+ const afterMatch = afterRe.exec(spanText.slice(end));
3284
+ const hadTrailingComma = !!(afterMatch && /,/.test(afterMatch[0]));
3285
+ if (afterMatch) end += afterMatch[0].length;
3286
+
3287
+ let start = matchStart;
3288
+ const lineStart = spanText.lastIndexOf('\n', start - 1) + 1;
3289
+ const ownLine = /^[ \t]*$/.test(spanText.slice(lineStart, start));
3290
+ if (ownLine) {
3291
+ start = lineStart;
3292
+ if (consumeLeadingComments) {
3293
+ for (;;) {
3294
+ const prevLineStart = start > 0 ? spanText.lastIndexOf('\n', start - 2) + 1 : 0;
3295
+ const prevLine = spanText.slice(prevLineStart, start);
3296
+ if (/^[ \t]*#[^\n]*\r?\n$/.test(prevLine)) {
3297
+ start = prevLineStart;
3298
+ if (prevLineStart === 0) break;
3299
+ } else break;
3300
+ }
3301
+ }
3302
+ } else if (!hadTrailingComma) {
3303
+ // Inline form and this was the LAST arg (no trailing comma) — eat a
3304
+ // leading comma so the previous arg doesn't dangle one.
3305
+ const before = spanText.slice(0, start);
3306
+ const cm = /,[ \t]*$/.exec(before);
3307
+ if (cm) start -= cm[0].length;
3308
+ }
3309
+ return spanText.slice(0, start) + spanText.slice(end);
3310
+ }
3311
+
3312
+ /**
3313
+ * Replace a named-role argument token's `[matchStart, matchEnd)` span
3314
+ * (`subagent_type=`/`subagent_type:` + its value) with the projected
3315
+ * `gsd_role=` / role-prompt-resolution / structural-role argument group.
3316
+ * Preserves the pretty multi-line one-arg-per-line style when the original
3317
+ * token owned its own line; falls back to an inline, comma-joined group for
3318
+ * the single-line-compact and object-literal forms.
3319
+ */
3320
+ function _projectRoleArgument(spanText, matchStart, matchEnd, roleValueExpr, toolConfig, canOrchestrate) {
3321
+ const { namedRoleParam, promptContentParam, structuralRoleParam, leafRoleValue } = toolConfig;
3322
+ const lineStart = spanText.lastIndexOf('\n', matchStart - 1) + 1;
3323
+ const startsOwnLine = /^[ \t]*$/.test(spanText.slice(lineStart, matchStart));
3324
+
3325
+ // Consume an immediately-following separator comma (+ same-line whitespace/
3326
+ // newline) into `end` — never leave it dangling AFTER an injected trailing
3327
+ // `# comment` (a bare `,` after `#...` would sit on the comment's own line,
3328
+ // outside any real argument list).
3329
+ let end = matchEnd;
3330
+ const afterRe = /^[ \t]*,[ \t]*\r?\n?/;
3331
+ const afterMatch = afterRe.exec(spanText.slice(end));
3332
+ const hadTrailingComma = !!afterMatch;
3333
+ if (afterMatch) end += afterMatch[0].length;
3334
+ const ownLine = startsOwnLine && hadTrailingComma && /\n$/.test(afterMatch[0]);
3335
+
3336
+ const promptContentPhrase =
3337
+ `${promptContentParam}=<resolve ${roleValueExpr} against the active install's gsd-* role prompts and load ` +
3338
+ 'its contents; FAIL CLOSED with an explicit error if unresolved — never execute the role inline>';
3339
+
3340
+ let replacement;
3341
+ if (ownLine) {
3342
+ const indent = spanText.slice(lineStart, matchStart);
3343
+ const depthNote = canOrchestrate ? '' : ' # nested delegation is unavailable at this dispatch depth/toolkit';
3344
+ replacement =
3345
+ `${namedRoleParam}=${roleValueExpr},\n` +
3346
+ `${indent}${promptContentPhrase},\n` +
3347
+ `${indent}${structuralRoleParam}="${leafRoleValue}",${depthNote}\n`;
3348
+ } else {
3349
+ // Inline forms never carry a trailing `#` comment mid-argument-list (it
3350
+ // would silently "comment out" the remainder of the call), so the
3351
+ // depth/toolkit caveat is only ever emitted in the pretty own-line form.
3352
+ // Re-emit exactly the separator that originally followed this argument
3353
+ // (a comma if more args follow; nothing if it was the last one).
3354
+ replacement =
3355
+ `${namedRoleParam}=${roleValueExpr}, ${promptContentPhrase}, ${structuralRoleParam}="${leafRoleValue}"` +
3356
+ (hadTrailingComma ? ', ' : '');
3357
+ }
3358
+ return spanText.slice(0, matchStart) + replacement + spanText.slice(end);
3359
+ }
3360
+
3361
+ // Matches a `subagent_type`/`model` argument's key+delimiter+value across all
3362
+ // three corpus forms: quoted-string values ("gsd-planner", "{model}") and
3363
+ // bare dynamic-expression values (ref.agent, research_hook.ref.agent,
3364
+ // executor_model). The captured group is always the value (a suffix of the
3365
+ // whole match), so its start offset is `match.index + match[0].length -
3366
+ // match[1].length` — avoids needing the regex `d` (indices) flag.
3367
+ function _callArgValueRe(key) {
3368
+ return new RegExp(`\\b${key}\\s*[=:]\\s*("(?:[^"\\\\]|\\\\.)*"|[A-Za-z_][\\w.]*)`);
3369
+ }
3370
+
3371
+ /**
3372
+ * Returns the literal role name from a captured role-argument value EXPR
3373
+ * (e.g. `"gsd-planner"`) — or `null` when it is not a genuine static
3374
+ * literal: a bare dynamic expression (`ref.agent`), OR a quoted value that
3375
+ * still contains `{...}` template interpolation (the corpus's own
3376
+ * placeholder convention, e.g. `model="{researcher_model}"` — and,
3377
+ * critically, `subagent_type: "gsd-{agent}"` in
3378
+ * gsd-core/references/universal-anti-patterns.md, a DOCUMENTATION template
3379
+ * illustrating the naming pattern, never a concrete role to resolve).
3380
+ * Fail-closed validation only ever runs on a genuine static literal; a
3381
+ * template/dynamic value still gets the full role-prompt-resolution
3382
+ * projection treatment (the resolve+fail-closed instruction applies equally
3383
+ * once a template is substituted at runtime) — only the STATIC CHECK is
3384
+ * skipped, never the projection itself.
3385
+ */
3386
+ function _literalRoleValue(roleValueExpr) {
3387
+ const m = /^"([^"]*)"$/.exec(roleValueExpr);
3388
+ if (!m) return null;
3389
+ if (/[{}]/.test(m[1])) return null;
3390
+ return m[1];
3391
+ }
3392
+
3393
+ /**
3394
+ * `maskStringLiterals` PLUS `#`-to-end-of-line comment blanking (comments are
3395
+ * never string literals, so they survive string-masking as literal `#...`
3396
+ * text). Header-token searches (subagent_type/model/run_in_background) must
3397
+ * use THIS mask, not the string-only one — verified necessary against the
3398
+ * real corpus: execute-phase.md's explanatory comment "# Only include
3399
+ * model= when executor_model is..." literally contains the substring
3400
+ * "model= when", which a comment-blind `model` regex mismatches as a real
3401
+ * `model=when` argument, corrupting the comment AND missing the real
3402
+ * `model="{executor_model}"` line beneath it. Scoped to call-span text only
3403
+ * (never the whole document), so markdown `#`/`##` headings elsewhere are
3404
+ * unaffected.
3405
+ */
3406
+ function _maskStringsAndComments(text) {
3407
+ return maskStringLiterals(text).replace(/#[^\n]*/g, (m) => ' '.repeat(m.length));
3408
+ }
3409
+
3410
+ /**
3411
+ * Normalize ONE `Agent(...)`/`Agent({...})` call span (already isolated by
3412
+ * `findDispatchCallSpans`) onto the target's real dispatch primitive. Every
3413
+ * behavioral branch reads `dispatch` (the runtime's sourced
3414
+ * `hostIntegration.dispatch` facts) — none is hardcoded. Handles all three
3415
+ * corpus call-argument shapes uniformly via string-aware token location
3416
+ * (`maskStringLiterals` recomputed after each structural edit, since prior
3417
+ * edits shift offsets).
3418
+ */
3419
+ function _normalizeDispatchCallSpan(spanText, hasBraceWrapper, dispatch, toolConfig) {
3420
+ const namedDispatch = dispatch.namedDispatch === true;
3421
+ const backgroundCapable = dispatch.background === true;
3422
+ const canOrchestrate = dispatch.subagentToolkit === 'full'
3423
+ && (dispatch.maxDepth === -1 || (typeof dispatch.maxDepth === 'number' && dispatch.maxDepth > 1));
3424
+ const { toolName, backgroundParam, supportsPerCallModel, availableRoles, runtime } = toolConfig;
3425
+
3426
+ let text = spanText;
3427
+
3428
+ // 1. Named-role argument (subagent_type= / subagent_type:) — only when the
3429
+ // target has no native named-agent lookup (dispatch.namedDispatch).
3430
+ // Fail-closed validation runs on the extracted value REGARDLESS of
3431
+ // which source syntax produced it (#2284 requirement 2).
3432
+ if (!namedDispatch) {
3433
+ const roleRe = _callArgValueRe('subagent_type');
3434
+ const rm = roleRe.exec(_maskStringsAndComments(text));
3435
+ if (rm) {
3436
+ // Read the VALUE from the original (unmasked) text at the matched
3437
+ // offset — `rm[1]` was captured against the mask, whose string
3438
+ // INTERIOR is blanked, so it must never be used as the real value.
3439
+ const roleValueExpr = text.slice(rm.index + rm[0].length - rm[1].length, rm.index + rm[0].length);
3440
+ const literalRole = _literalRoleValue(roleValueExpr);
3441
+ if (literalRole !== null) {
3442
+ _assertRoleResolvable(literalRole, availableRoles, runtime, 'subagent_type');
3443
+ } else if (!availableRoles) {
3444
+ // No literal value to check, but a null availableRoles still means
3445
+ // the shipped agents/ dir couldn't be resolved at all — fail closed
3446
+ // unconditionally rather than silently install an unverifiable call.
3447
+ _assertRoleResolvable('', availableRoles, runtime, 'subagent_type');
3448
+ }
3449
+ text = _projectRoleArgument(text, rm.index, rm.index + rm[0].length, roleValueExpr, toolConfig, canOrchestrate);
3450
+ }
3451
+ }
3452
+
3453
+ // 2. Per-call model argument (model= / model:) — stripped entirely when the
3454
+ // target has no per-call model-selection parameter (there is no
3455
+ // `dispatch` axis for this — it is inherent tool vocabulary, like the
3456
+ // parameter names themselves). Also removes now-dead explanatory
3457
+ // comment lines directly above a `model=` line that owns its own line
3458
+ // (#2284 Finding 5).
3459
+ if (!supportsPerCallModel) {
3460
+ const modelRe = _callArgValueRe('model');
3461
+ const mm = modelRe.exec(_maskStringsAndComments(text));
3462
+ if (mm) {
3463
+ text = _stripCallArgument(text, mm.index, mm.index + mm[0].length, { consumeLeadingComments: true });
3464
+ }
3465
+ }
3466
+
3467
+ // 3. Background-dispatch flag (run_in_background= / run_in_background:) —
3468
+ // maps onto the target's own background parameter ONLY when documented
3469
+ // to support it; otherwise stripped rather than forwarding a parameter
3470
+ // the primitive doesn't accept.
3471
+ {
3472
+ const bgRe = /\brun_in_background\s*[=:]\s*(?:true|false)/;
3473
+ const bm = bgRe.exec(_maskStringsAndComments(text));
3474
+ if (bm) {
3475
+ if (backgroundCapable) {
3476
+ const matched = text.slice(bm.index, bm.index + bm[0].length);
3477
+ const replaced = matched.replace(/^run_in_background(\s*[=:]\s*)/, `${backgroundParam}$1`);
3478
+ text = text.slice(0, bm.index) + replaced + text.slice(bm.index + bm[0].length);
3479
+ } else {
3480
+ text = _stripCallArgument(text, bm.index, bm.index + bm[0].length);
3481
+ }
3482
+ }
3483
+ }
3484
+
3485
+ // 4. Call-syntax head rename + object-literal brace stripping. Hermes's
3486
+ // delegate_task is a flat kwarg call — `Agent({...})`'s wrapper braces
3487
+ // are dropped rather than carried through, so every projected call ends
3488
+ // up in the same flat shape regardless of source syntax.
3489
+ text = text.replace(/^Agent\(/, `${toolName}(`);
3490
+ if (hasBraceWrapper) {
3491
+ const openMask = maskStringLiterals(text);
3492
+ const braceOpenIdx = openMask.indexOf('{');
3493
+ if (braceOpenIdx !== -1) text = text.slice(0, braceOpenIdx) + text.slice(braceOpenIdx + 1);
3494
+ const closeMask = maskStringLiterals(text);
3495
+ const braceCloseIdx = closeMask.lastIndexOf('}');
3496
+ if (braceCloseIdx !== -1) text = text.slice(0, braceCloseIdx) + text.slice(braceCloseIdx + 1);
3497
+ }
3498
+
3499
+ return text;
3500
+ }
3501
+
3502
+ /**
3503
+ * Blank the interior (and delimiters) of every string literal inside
3504
+ * `spanText` to spaces — same length, newlines preserved — using a fresh,
3505
+ * LOCAL quote-tracking scan that starts at `spanText[0]` with NO inherited
3506
+ * state. This is deliberately the SAME state-machine shape as the
3507
+ * `inString`/`\\`/triple-quote handling inside `findDispatchCallSpans`
3508
+ * (double-quoted and `"""`-triple-quoted, backslash-escape aware) — reused
3509
+ * here so a call span's quoted argument VALUES (documentation prose, prompt
3510
+ * bodies) never masquerade as real call syntax, without EVER falling back to
3511
+ * a whole-document cumulative quote-parity mask (the round-2 defect
3512
+ * documented on `findDispatchCallSpans` above).
3513
+ */
3514
+ function _blankStringLiteralInteriors(spanText) {
3515
+ let out = '';
3516
+ let inString = null; // null | '"' | 'triple'
3517
+ for (let i = 0; i < spanText.length; i++) {
3518
+ const ch = spanText[i];
3519
+ if (inString) {
3520
+ if (ch === '\\') {
3521
+ out += ' ';
3522
+ i++;
3523
+ if (i < spanText.length) out += (spanText[i] === '\n') ? '\n' : ' ';
3524
+ continue;
3525
+ }
3526
+ if (inString === 'triple') {
3527
+ if (ch === '"' && spanText[i + 1] === '"' && spanText[i + 2] === '"') {
3528
+ inString = null;
3529
+ out += ' ';
3530
+ i += 2;
3531
+ continue;
3532
+ }
3533
+ out += (ch === '\n') ? '\n' : ' ';
3534
+ continue;
3535
+ }
3536
+ if (ch === inString) { inString = null; out += ' '; continue; }
3537
+ out += (ch === '\n') ? '\n' : ' ';
3538
+ continue;
3539
+ }
3540
+ if (ch === '"' && spanText[i + 1] === '"' && spanText[i + 2] === '"') {
3541
+ inString = 'triple';
3542
+ out += ' ';
3543
+ i += 2;
3544
+ continue;
3545
+ }
3546
+ if (ch === '"') { inString = '"'; out += ' '; continue; }
3547
+ out += ch;
3548
+ }
3549
+ return out;
3550
+ }
3551
+
3552
+ /**
3553
+ * Quote-aware view of `content` for the completeness checks below: for every
3554
+ * REAL call span located via `findDispatchCallSpans` (once per head word in
3555
+ * `headWords`), the string-literal ARGUMENT VALUES inside that span are
3556
+ * blanked via `_blankStringLiteralInteriors`; the call's own head word and
3557
+ * bare (unquoted) argument tokens are left untouched. `headWords` is
3558
+ * processed in order and each pass re-scans the PROGRESSIVELY-masked string
3559
+ * — `toolName` first, then `'Agent'` — so a spurious `Agent(` that
3560
+ * `findDispatchCallSpans('Agent')` would otherwise "find" purely because it
3561
+ * sits inside an outer call's quoted string (e.g. a `description="...Agent()
3562
+ * ...subagent_type=x"` argument value) has ALREADY been blanked away by the
3563
+ * outer `toolName` pass by the time the `'Agent'` pass runs, so it is never
3564
+ * mistaken for a real, independent call. A genuinely real (unquoted) `Agent(`
3565
+ * — including one nested as a raw, un-renamed argument value — survives every
3566
+ * pass and remains visible to the caller's regex checks.
3567
+ */
3568
+ function _maskQuotedRegionsWithinCallSpans(content, headWords) {
3569
+ let masked = content;
3570
+ for (const headWord of headWords) {
3571
+ const spans = findDispatchCallSpans(masked, headWord);
3572
+ for (let i = spans.length - 1; i >= 0; i--) {
3573
+ const { start, end } = spans[i];
3574
+ const maskedSpan = _blankStringLiteralInteriors(masked.slice(start, end));
3575
+ masked = masked.slice(0, start) + maskedSpan + masked.slice(end);
3576
+ }
3577
+ }
3578
+ return masked;
3579
+ }
3580
+
3581
+ /**
3582
+ * Post-projection guard (#2284 requirement 3 — belt-and-suspenders): after
3583
+ * projection, assert the corpus form the projection could not anticipate
3584
+ * never silently ships. Throws an explicit install error (fail-LOUD) rather
3585
+ * than let an unprojected/incompletely-projected dispatch call install.
3586
+ *
3587
+ * #2284 round-2 CRITICAL fix: this is an INDEPENDENT check — it does NOT use
3588
+ * `maskStringLiterals` over the whole document (the round-1 primitive whose
3589
+ * cumulative, document-wide quote-parity tracking was the root cause of the
3590
+ * round-2 defect: a `"`-heavy bash fence upstream of a real call desynced
3591
+ * quote state and made `findDispatchCallSpans` blind to that call, shipping
3592
+ * a Frankenstein `Agent(gsd_role="...", model="...")` with no detection).
3593
+ *
3594
+ * #2284 round-3 fix: a BLUNT, mask-free literal check over the whole
3595
+ * document (round-2's fix) over-throws — it cannot tell a real residual
3596
+ * `Agent(`/`subagent_type` call from the SAME text appearing INSIDE a quoted
3597
+ * string (documentation/prompt prose, e.g. `description="...Agent()..."`).
3598
+ * The completeness checks (residual `subagent_type` / literal `Agent(`) now
3599
+ * run against `_maskQuotedRegionsWithinCallSpans` — quote-aware, but scoped
3600
+ * strictly to already-correctly-bounded, per-occurrence-LOCAL call spans
3601
+ * (never a whole-document cumulative mask), so a real Frankenstein call
3602
+ * (unquoted, real call syntax) still fires while a same-text mention genuinely
3603
+ * inside a quoted string does not.
3604
+ *
3605
+ * The completeness checks also only apply when `namedDispatch` is false: when
3606
+ * `dispatch.namedDispatch === true`, `_normalizeDispatchCallSpan` step 1
3607
+ * INTENTIONALLY leaves `subagent_type` unprojected (the target primitive
3608
+ * resolves named agents itself) — a residual `subagent_type` in that case is
3609
+ * the correct, intended output, not a defect. (The call HEAD is still renamed
3610
+ * unconditionally regardless of `namedDispatch` — see step 4 there — so a
3611
+ * literal `Agent(` residual is gated the same way purely for symmetry with
3612
+ * the dispatch-facts-driven contract; it is never actually left unrenamed by
3613
+ * the projection in practice.)
3614
+ *
3615
+ * The model-leak check is unaffected by either fix above — it is orthogonal
3616
+ * to `namedDispatch` (gated only by `supportsPerCallModel`) and already
3617
+ * bounds each real call via the independently-fixed, per-occurrence-local,
3618
+ * non-cumulative `findDispatchCallSpans`, then does a raw substring check
3619
+ * within that bound.
3620
+ */
3621
+ function _assertProjectionComplete(content, toolConfig, namedDispatch = false) {
3622
+ const { toolName, runtime, supportsPerCallModel } = toolConfig;
3623
+
3624
+ if (!namedDispatch) {
3625
+ const quoteAware = _maskQuotedRegionsWithinCallSpans(content, [toolName, 'Agent']);
3626
+ if (/\bsubagent_type\s*[=:]/.test(quoteAware)) {
3627
+ throw new Error(
3628
+ `${runtime} workflow install: projection left a residual subagent_type reference — refusing to install ` +
3629
+ '(fail-closed post-projection guard, #2284)',
3630
+ );
3631
+ }
3632
+ if (/\bAgent\(/.test(quoteAware)) {
3633
+ throw new Error(
3634
+ `${runtime} workflow install: projection left literal Agent( call syntax — refusing to install ` +
3635
+ '(fail-closed post-projection guard, #2284)',
3636
+ );
3637
+ }
3638
+ }
3639
+
3640
+ if (!supportsPerCallModel) {
3641
+ for (const span of findDispatchCallSpans(content, toolName)) {
3642
+ const rawSpanText = content.slice(span.start, span.end);
3643
+ if (/\bmodel\s*[=:]/.test(rawSpanText)) {
3644
+ throw new Error(
3645
+ `${runtime} workflow install: projection left a leaked model= argument inside a ${toolName}(...) call ` +
3646
+ '— refusing to install (fail-closed post-projection guard, #2284)',
3647
+ );
3648
+ }
3649
+ }
3650
+ }
3651
+ }
3652
+
3653
+ /**
3654
+ * Project host-neutral `Agent(...)` named-subagent dispatch prose onto a
3655
+ * target runtime's real dispatch primitive. See the file-header comment above
3656
+ * for the governing rule: every behavioral branch reads `dispatch` (the
3657
+ * runtime's sourced `hostIntegration.dispatch` facts) — none is a hardcoded
3658
+ * assumption about a specific runtime. Handles all three real corpus call
3659
+ * forms (multi-line one-key-per-line, single-line object-literal, single-line
3660
+ * compact) via string-aware call-span detection rather than three independent
3661
+ * line-anchored regexes, and closes with a post-projection guard that fails
3662
+ * loud on any form it did not anticipate (#2284).
3663
+ *
3664
+ * @param {string} content
3665
+ * @param {{namedDispatch?: boolean, nested?: boolean, maxDepth?: number, background?: boolean, backgroundDispatch?: boolean, subagentToolkit?: string}} dispatch
3666
+ * @param {{toolName: string, namedRoleParam: string, promptContentParam: string, structuralRoleParam: string, leafRoleValue: string, backgroundParam: string, supportsPerCallModel: boolean, availableRoles: Set<string>|null, runtime: string}} toolConfig
3667
+ */
3668
+ function projectNamedDispatchToStructuralDelegate(content, dispatch, toolConfig) {
3669
+ const d = dispatch || {};
3670
+ const namedDispatch = d.namedDispatch === true;
3671
+ const backgroundCapable = d.background === true;
3672
+ const { toolName, promptContentParam } = toolConfig;
3673
+
3674
+ let converted = content;
3675
+
3676
+ // 1. The "Agent tool IS available" contract assertion (currently unique to
3677
+ // plan-phase.md, matched generically in case of future reuse elsewhere).
3678
+ const assertionRe = /The Agent tool IS available in a top-level ([^\n]+?) session\.\s+Always spawn\s+([\s\S]*?)\s+as separate Agent\(\) calls\./;
3679
+ converted = converted.replace(assertionRe, (_m, sessionName, roster) => {
3680
+ const rosterFlat = roster.replace(/\s+/g, ' ').trim();
3681
+ if (namedDispatch) {
3682
+ return `The \`${toolName}\` tool IS available in a top-level ${sessionName} session. Always dispatch ${rosterFlat} as separate \`${toolName}()\` calls.`;
3683
+ }
3684
+ return (
3685
+ `${sessionName} has no \`Agent\` tool. It exposes \`${toolName}\`, which dispatches by structural role — ` +
3686
+ 'it has no concept of a named subagent identity. GSD projects each named gsd-* role onto this primitive ' +
3687
+ `itself: resolve the role's prompt file from the active install, load its contents, and embed them in the ` +
3688
+ `\`${toolName}\` payload via \`${promptContentParam}\` as the dispatched task's operating instructions. ` +
3689
+ 'FAIL CLOSED — surface an explicit error and stop — if a referenced role prompt cannot be resolved; never ' +
3690
+ `execute the role inline as a substitute. In a top-level ${sessionName} session, always dispatch ` +
3691
+ `${rosterFlat} as separate \`${toolName}\` calls.`
3692
+ );
3693
+ });
3694
+
3695
+ // 1b. Dispatch-depth-availability prose immediately adjacent to a renamed
3696
+ // `Agent()` mention in the SAME sentence (plan-review-convergence.md
3697
+ // ~lines 108, 347, 355) — a bare "Agent" left un-renamed right next to
3698
+ // the projection's own `Agent()`→`${toolName}()` rename produced
3699
+ // self-contradictory installed text (e.g. "...delegate_task(...)...
3700
+ // with Agent available..."). Narrowly scoped to the EXACT known
3701
+ // phrases the projection itself creates the inconsistency beside —
3702
+ // never a broad bare-word `Agent` rename, which would corrupt
3703
+ // legitimate `Agent`-adjacent prose elsewhere in the corpus (role
3704
+ // names, "Agent Brief", agent-file references).
3705
+ converted = converted.replace(
3706
+ /\borchestrator runs at depth 0 with Agent available\b/g,
3707
+ `orchestrator runs at depth 0 with ${toolName} available`,
3708
+ );
3709
+ converted = converted.replace(
3710
+ /\(bug #936: depth-1 Agent has no Agent tool\)/g,
3711
+ `(bug #936: depth-1 ${toolName} has no nested ${toolName})`,
3712
+ );
3713
+
3714
+ // 2. Per-call model-selection prose ("Model resolution:" paragraph,
3715
+ // execute-phase.md) + inline backtick-quoted model-mention prose
3716
+ // examples (not live call sites) — only rewritten when the target
3717
+ // primitive has no per-call model parameter at all.
3718
+ if (!toolConfig.supportsPerCallModel) {
3719
+ 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"`\)\./;
3720
+ converted = converted.replace(
3721
+ modelResolutionRe,
3722
+ `**Model resolution:** \`${toolName}\` has no per-call model-selection parameter — every dispatched role ` +
3723
+ `always inherits the host session's active model. Never pass \`model=\` to \`${toolName}\`; drop the ` +
3724
+ '`executor_model` value entirely for this runtime.',
3725
+ );
3726
+ converted = converted.replace(/`model="[^"`\n]*"`,?\s*(?:and\s+)?/g, '');
3727
+ }
3728
+
3729
+ // 3. Background-dispatch PROSE mentions outside any real call span (e.g.
3730
+ // execute-phase.md:595,600 — `run_in_background: true` inline
3731
+ // documentation, not a call argument) — #2284 Finding 3. Only rewritten
3732
+ // when the target is documented to support background dispatch (a
3733
+ // prose mention of an unsupported capability would be equally
3734
+ // misleading as a real leaked argument).
3735
+ if (backgroundCapable) {
3736
+ converted = converted.replace(
3737
+ /\brun_in_background(\s*[=:]\s*(?:true|false))/g,
3738
+ `${toolConfig.backgroundParam}$1`,
3739
+ );
3740
+ }
3741
+
3742
+ // 4. Call-span-based normalization — the core of the fix. Every
3743
+ // `Agent(...)`/`Agent({...})` occurrence (all three corpus forms) is
3744
+ // located via string-aware balanced paren/brace matching, then
3745
+ // normalized as a unit; spans are rebuilt right-to-left so earlier
3746
+ // offsets stay valid while later ones are rewritten.
3747
+ const spans = findDispatchCallSpans(converted, 'Agent');
3748
+ for (let i = spans.length - 1; i >= 0; i--) {
3749
+ const { start, end, hasBraceWrapper } = spans[i];
3750
+ const rebuilt = _normalizeDispatchCallSpan(converted.slice(start, end), hasBraceWrapper, d, toolConfig);
3751
+ converted = converted.slice(0, start) + rebuilt + converted.slice(end);
3752
+ }
3753
+
3754
+ // 5. "Agent tool" capability mentions (conditions gating parallel vs.
3755
+ // sequential dispatch, e.g. map-codebase.md) → the real target primitive
3756
+ // name, which resolves these conditions accurately since it IS a real,
3757
+ // always-available dispatch primitive for this target.
3758
+ converted = converted.replace(/\bAgent tool\b/g, toolName);
3759
+
3760
+ // 5b. Catch-all: a `subagent_type` mention that is NOT part of any real
3761
+ // `Agent(...)` call span (e.g. map-codebase.md's inline documentation
3762
+ // prose ``Use Agent tool with `subagent_type="X"`, ...`` — disconnected
3763
+ // example syntax, not a live call). Renamed for the same accuracy the
3764
+ // real calls get; a literal quoted role value is still fail-closed
3765
+ // validated even though there is no call structure to inject
3766
+ // role-prompt/fail-closed guidance INTO.
3767
+ if (!namedDispatch) {
3768
+ converted = converted.replace(
3769
+ /\bsubagent_type(\s*[=:]\s*"[^"]*")/g,
3770
+ (_m, rest) => {
3771
+ const literalRole = _literalRoleValue(rest.replace(/^\s*[=:]\s*/, ''));
3772
+ if (literalRole !== null) {
3773
+ _assertRoleResolvable(literalRole, toolConfig.availableRoles, toolConfig.runtime, 'subagent_type (prose mention)');
3774
+ }
3775
+ return `${toolConfig.namedRoleParam}${rest}`;
3776
+ },
3777
+ );
3778
+ converted = converted.replace(/\bsubagent_type(\s*[=:])/g, `${toolConfig.namedRoleParam}$1`);
3779
+ }
3780
+
3781
+ // 5c. Safety net (#2284 requirement 2): the PRIMARY mechanism for
3782
+ // eliminating literal `Agent(` syntax is complete span detection (step
3783
+ // 4) — this unconditional final rename exists only so that even a call
3784
+ // span detection somehow misses at least loses its `Agent(` head
3785
+ // rather than shipping the literal Claude-shaped tool name verbatim.
3786
+ // A call caught only by this safety net is still INCOMPLETELY
3787
+ // normalized (no role/model handling) and gets caught by the
3788
+ // independent post-projection guard below via its OTHER invariants
3789
+ // (residual subagent_type / leaked model=), which this safety net does
3790
+ // not touch — the install still fails closed for a missed span.
3791
+ converted = converted.replace(/\bAgent\(/g, `${toolName}(`);
3792
+
3793
+ // 6. Post-projection guard (#2284 requirement 3): fail loud, never ship
3794
+ // silently, on any residual/leaked form the projection above did not
3795
+ // anticipate. `namedDispatch` gates the completeness checks — a
3796
+ // residual subagent_type is INTENTIONAL, not a defect, when the target
3797
+ // resolves named agents itself (see `_assertProjectionComplete`).
3798
+ _assertProjectionComplete(converted, toolConfig, namedDispatch);
3799
+
3800
+ return converted;
3801
+ }
3802
+
3803
+ const HERMES_DISPATCH_TOOL_CONFIG = Object.freeze({
3804
+ toolName: 'delegate_task',
3805
+ namedRoleParam: 'gsd_role',
3806
+ promptContentParam: 'gsd_role_prompt',
3807
+ structuralRoleParam: 'role',
3808
+ leafRoleValue: 'leaf',
3809
+ backgroundParam: 'background',
3810
+ supportsPerCallModel: false,
3811
+ });
3812
+
3813
+ /**
3814
+ * Hermes `.md` content converter (#2284): brand-swap (unchanged behavior,
3815
+ * descriptor-driven per `hostBehaviors.brandingRewrites`) followed by the
3816
+ * generic named-dispatch → `delegate_task` projection above, driven by
3817
+ * `capabilities/hermes/capability.json`'s `hostIntegration.dispatch` (read
3818
+ * via `_hostIntegrationDispatch`, values UNCHANGED by this fix — they are
3819
+ * already documentation-sourced and correct).
3820
+ */
3821
+ function convertClaudeToHermesMarkdown(content, ctx) {
3822
+ const runtime = (ctx && ctx.runtime) || 'hermes';
3823
+ const b = _hostBehaviors(runtime).brandingRewrites;
3824
+ let converted = content;
3825
+ if (b) {
3826
+ converted = converted.replace(/CLAUDE\.md/g, b['CLAUDE.md']);
3827
+ // #2284(b): skips <runtime_compatibility> comparison-table content (protected region).
3828
+ converted = applyClaudeCodeBrandSwap(converted, b['Claude Code']);
3829
+ converted = converted.replace(/\.claude\//g, b['.claude/']);
3830
+ }
3831
+ const dispatch = _hostIntegrationDispatch(runtime);
3832
+ const toolConfig = Object.assign({}, HERMES_DISPATCH_TOOL_CONFIG, {
3833
+ availableRoles: _resolveAvailableGsdRoles(),
3834
+ runtime,
3835
+ });
3836
+ return projectNamedDispatchToStructuralDelegate(converted, dispatch, toolConfig);
3837
+ }
3838
+
3839
+ // ── End Hermes converters ────────────────────────────────────────────────────
3840
+
2891
3841
  function convertSlashCommandsToCodexSkillMentions(content) {
2892
3842
  // Colon-style /gsd: never appears as a filesystem path segment, so no boundary guard is needed (unlike the hyphen-style below).
2893
3843
  let converted = content.replace(/\/gsd:([a-z0-9-]+)/gi, (_, commandName) => {
@@ -3089,6 +4039,36 @@ purpose: ${toSingleLine(description)}
3089
4039
  return `${cleanFrontmatter}\n\n${roleHeader}\n${body}`;
3090
4040
  }
3091
4041
 
4042
+ /**
4043
+ * #2310 — True if `model` is an Anthropic-flavored value that must never appear as a
4044
+ * Codex agent `.toml` `model`. Two forms: (a) a bare Claude Agent-tool tier alias
4045
+ * (opus/sonnet/haiku/fable — the canonical CLAUDE_AGENT_ALIASES, imported from
4046
+ * src/model-resolver.cts so it can't diverge); (b) any Claude model id in any provider
4047
+ * namespacing — `claude-*`, `anthropic/claude-*`, `us.anthropic.claude-*` (the forms the
4048
+ * catalog assigns to opencode/hermes/kilo, reachable on a Codex .toml via the runtime-
4049
+ * resolver path). No OpenAI/Codex model id contains "claude", so a case-insensitive
4050
+ * substring test is a safe, exhaustive guard for (b). Codex/ChatGPT rejects all of these.
4051
+ */
4052
+ function _isAnthropicFlavoredModel(model) {
4053
+ return typeof model === 'string' && (CLAUDE_AGENT_ALIASES.has(model) || model.toLowerCase().includes('claude'));
4054
+ }
4055
+
4056
+ // #2310 — dedupe stderr warnings so repeated agent emits don't spam (mirrors the
4057
+ // #2041/#1133 model-resolver warn-dedupe). Value is length-capped so an oversized
4058
+ // or secret-shaped override cannot leak in full to logs.
4059
+ const _codexModelOverrideDroppedWarned = new Set();
4060
+ function _warnCodexModelOverrideDropped(agentName, value) {
4061
+ const key = `${agentName}::${value}`;
4062
+ if (_codexModelOverrideDroppedWarned.has(key)) return;
4063
+ _codexModelOverrideDroppedWarned.add(key);
4064
+ const safe = String(value).length > 64 ? `${String(value).slice(0, 64)}…` : String(value);
4065
+ process.stderr.write(
4066
+ `gsd: warning — Codex agent "${agentName}" model "${safe}" is not a valid Codex model ` +
4067
+ `(Anthropic alias/id); dropping it so Codex uses a valid default. ` +
4068
+ `Set runtime:"codex" or pin a gpt-* model to route it.\n`,
4069
+ );
4070
+ }
4071
+
3092
4072
  /**
3093
4073
  * Generate a per-agent .toml config file for Codex.
3094
4074
  * Sets required agent metadata, sandbox_mode, and developer_instructions
@@ -3122,21 +4102,43 @@ function generateCodexAgentToml(agentName, agentContent, modelOverrides = null,
3122
4102
  // model_overrides is respected on Codex (which uses static TOML, not inline
3123
4103
  // Task() model parameters). See #2256.
3124
4104
  // 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) {
4105
+ // #2310 — a Codex .toml `model` MUST be a real Codex/OpenAI model id. Codex is a
4106
+ // passive/session-only model host (ADR-1239): GSD cannot reliably route per-agent
4107
+ // tiers, and a bare GSD/Claude tier alias (opus/sonnet/haiku/fable) or a claude-*
4108
+ // id 400s on a ChatGPT-account Codex ("The 'sonnet' model is not supported when
4109
+ // using Codex with a ChatGPT account"). So: embed ONLY an explicit real-Codex
4110
+ // model pin from model_overrides; omit anything Anthropic-flavored so the agent
4111
+ // inherits the always-available session model. (Removing the runtime-resolver
4112
+ // per-tier embedding below is the ADR-2310 passive-posture epic.)
4113
+ const rawModelOverride = modelOverrides?.[resolvedName] || modelOverrides?.[agentName];
4114
+ let pinnedModel = null;
4115
+ if (rawModelOverride) {
4116
+ if (typeof rawModelOverride === 'string' && rawModelOverride && !_isAnthropicFlavoredModel(rawModelOverride)) {
4117
+ pinnedModel = rawModelOverride; // explicit real-Codex model pin → embed verbatim (#2256)
4118
+ } else {
4119
+ _warnCodexModelOverrideDropped(resolvedName, rawModelOverride); // alias/claude-* → omit
4120
+ }
4121
+ }
4122
+ if (!pinnedModel && runtimeResolver) {
3131
4123
  // #2517 — runtime-aware tier resolution. Embeds Codex-native model + reasoning_effort
3132
4124
  // from RUNTIME_PROFILE_MAP / model_profile_overrides for the configured tier.
4125
+ // (Superseded on the default path by the ADR-2310 passive-posture epic.)
3133
4126
  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
- }
4127
+ if (entry?.model) pinnedModel = entry.model;
4128
+ }
4129
+ // #2310 — final safety gate: never emit an Anthropic-flavored model into a Codex
4130
+ // .toml, even from the runtime-resolver path (e.g. a defaults.json runtime that
4131
+ // does not match the codex install target).
4132
+ if (pinnedModel && _isAnthropicFlavoredModel(pinnedModel)) {
4133
+ _warnCodexModelOverrideDropped(resolvedName, pinnedModel);
4134
+ pinnedModel = null;
4135
+ }
4136
+ let hasPinnedModel = false;
4137
+ if (pinnedModel) {
4138
+ lines.push(`model = ${JSON.stringify(pinnedModel)}`);
4139
+ hasPinnedModel = true;
4140
+ // model is resolved here; reasoning_effort from catalog tier is REPLACED by the
4141
+ // unified effort resolver below (#443). Do NOT emit entry.reasoning_effort here.
3140
4142
  }
3141
4143
 
3142
4144
  // #443 — Unified effort for Codex .toml. Uses the same config-driven precedence chain
@@ -3364,14 +4366,26 @@ function _resolveMovedSkillsOldDir(runtime, targetDir, scope) {
3364
4366
 
3365
4367
  /**
3366
4368
  * 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';
4369
+ *
4370
+ * #2406 — standalone per-agent TOMLs (written by installCodexConfig to
4371
+ * `$CODEX_HOME/agents/<name>.toml`) are auto-discovered by Codex and are the
4372
+ * SOLE canonical registration source for each role. This block therefore no
4373
+ * longer emits `[agents.<name>]` role tables that point `config_file` back at
4374
+ * those same standalone TOMLs — that was a second, redundant declaration of
4375
+ * the same role in one config layer, and Codex logged "Ignoring malformed
4376
+ * agent role definition: duplicate agent role name" once per agent as a
4377
+ * result. Only the bare `[agents]` dispatch-tuning scalar table is emitted
4378
+ * here; role name/description/model/reasoning-effort/sandbox settings remain
4379
+ * fully discoverable through the standalone TOML alone.
4380
+ * @param {Array<{name: string, description: string}>} _agents unused — kept
4381
+ * in the signature for call-site compatibility (installCodexConfig and
4382
+ * existing tests still pass it positionally); per-agent role tables are no
4383
+ * longer generated from it.
4384
+ * @param {string} [_targetDir] unused — the standalone-TOML `config_file`
4385
+ * path it used to resolve is no longer emitted here; kept for the same
4386
+ * call-site-compatibility reason as `_agents`.
4387
+ */
4388
+ function generateCodexConfigBlock(_agents, _targetDir) {
3375
4389
  const lines = [
3376
4390
  GSD_CODEX_MARKER,
3377
4391
  '',
@@ -3380,24 +4394,12 @@ function generateCodexConfigBlock(agents, targetDir) {
3380
4394
  // ADR-1239 upgrade 2 / #2088 — explicit dispatch tuning. Pin `max_depth` on the
3381
4395
  // `[agents]` (AgentsToml) table rather than relying on codex-cli's implicit
3382
4396
  // 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.
4397
+ // `[agents]` scalar table is validated by validateCodexConfigSchema, which
4398
+ // permits a known-scalar-only `[agents]`.
3387
4399
  lines.push('[agents]');
3388
4400
  lines.push(`max_depth = ${GSD_CODEX_AGENTS_MAX_DEPTH}`);
3389
4401
  lines.push('');
3390
4402
 
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
4403
  return lines.join('\n');
3402
4404
  }
3403
4405
 
@@ -5922,12 +6924,14 @@ function installCodexConfig(targetDir, agentsSrc, sandboxTier = 'codex-agent-san
5922
6924
  // Symlink-escape guard (parity with _copyStaged / copyWithPathReplacement): the
5923
6925
  // lexical gate above does not resolve symlinks, so a pre-existing config.toml or
5924
6926
  // agents/ symlink could redirect writes outside targetDir. Reject those.
6927
+ // #2393: honor GSD_ALLOW_SYMLINKED_DEST for intentional user-owned symlink layouts.
6928
+ const symlinkOptIn = isSymlinkedDestOptIn();
5925
6929
  if (
5926
- hasExistingSymlinkBetween(resolvedTargetRoot, configPath) ||
5927
- hasExistingSymlinkBetween(resolvedTargetRoot, path.resolve(agentsTomlDir))
6930
+ hasExistingSymlinkBetween(resolvedTargetRoot, configPath, { allowOptInFollow: symlinkOptIn }) ||
6931
+ hasExistingSymlinkBetween(resolvedTargetRoot, path.resolve(agentsTomlDir), { allowOptInFollow: symlinkOptIn })
5928
6932
  ) {
5929
6933
  throw new Error(
5930
- `installCodexConfig: a Codex config path under "${targetDir}" contains a symlink escaping the install root — refusing to write`,
6934
+ `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
6935
  );
5932
6936
  }
5933
6937
  fs.mkdirSync(agentsTomlDir, { recursive: true });
@@ -5978,9 +6982,9 @@ function installCodexConfig(targetDir, agentsSrc, sandboxTier = 'codex-agent-san
5978
6982
  // `name` containing path separators must not escape agents/ (which would let
5979
6983
  // it clobber config.toml or write elsewhere under the configHome).
5980
6984
  const agentTomlPath = assertDestWithinConfigHome(agentsTomlDir, `${name}.toml`);
5981
- if (hasExistingSymlinkBetween(resolvedTargetRoot, agentTomlPath)) {
6985
+ if (hasExistingSymlinkBetween(resolvedTargetRoot, agentTomlPath, { allowOptInFollow: symlinkOptIn })) {
5982
6986
  throw new Error(
5983
- `installCodexConfig: agent toml path "${agentTomlPath}" contains a symlink escaping the install root — refusing to write`,
6987
+ `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
6988
  );
5985
6989
  }
5986
6990
  fs.writeFileSync(agentTomlPath, tomlContent);
@@ -6582,7 +7586,8 @@ const RUNTIME_CONTENT_DISPATCH = {
6582
7586
  const b = _hostBehaviors(ctx.runtime).brandingRewrites;
6583
7587
  if (b) {
6584
7588
  content = content.replace(/CLAUDE\.md/g, b['CLAUDE.md']);
6585
- content = content.replace(/\bClaude Code\b/g, b['Claude Code']);
7589
+ // #2284(b): skips <runtime_compatibility> comparison-table content (protected region).
7590
+ content = applyClaudeCodeBrandSwap(content, b['Claude Code']);
6586
7591
  content = content.replace(/\.claude\//g, b['.claude/']);
6587
7592
  }
6588
7593
  return content;
@@ -6599,16 +7604,13 @@ const RUNTIME_CONTENT_DISPATCH = {
6599
7604
  },
6600
7605
  },
6601
7606
  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
- },
7607
+ // #2284: brand-swap alone left the false "Agent tool IS available"
7608
+ // assertion + literal `Agent(...)` call syntax installed verbatim — see
7609
+ // convertClaudeToHermesMarkdown / projectNamedDispatchToStructuralDelegate
7610
+ // above (the Hermes converters section) for the full named-dispatch →
7611
+ // `delegate_task` projection, driven by capabilities/hermes/capability.json's
7612
+ // hostIntegration.dispatch facts.
7613
+ md: (content, ctx) => convertClaudeToHermesMarkdown(content, ctx),
6612
7614
  js: (content, ctx) => {
6613
7615
  const b = _hostBehaviors(ctx.runtime).brandingRewrites;
6614
7616
  if (b) {
@@ -6645,9 +7647,10 @@ function copyWithPathReplacement(srcDir, destDir, pathPrefix, runtime, isCommand
6645
7647
  }
6646
7648
  const resolvedConfinementRoot = path.resolve(confinementRoot);
6647
7649
  const resolvedDestDir = assertDestWithinConfigHome(confinementRoot, destDir);
6648
- if (hasExistingSymlinkBetween(resolvedConfinementRoot, resolvedDestDir)) {
7650
+ // #2393: honor GSD_ALLOW_SYMLINKED_DEST for intentional user-owned symlink layouts.
7651
+ if (hasExistingSymlinkBetween(resolvedConfinementRoot, resolvedDestDir, { allowOptInFollow: isSymlinkedDestOptIn() })) {
6649
7652
  throw new Error(
6650
- `copyWithPathReplacement: destDir "${destDir}" contains a symlink escaping the install root "${confinementRoot}" — refusing to write`,
7653
+ `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
7654
  );
6652
7655
  }
6653
7656
  // Use the validated absolute path for all writes below so the gate validates
@@ -7591,8 +8594,11 @@ function uninstall(isGlobal, runtime = DEFAULT_RUNTIME) {
7591
8594
  let permissionsModified = false;
7592
8595
  if (Array.isArray(settings.permissions.allow)) {
7593
8596
  const before = settings.permissions.allow.length;
8597
+ // #2278 — filter against the union of the current allow-rule forms
8598
+ // AND the retired legacy forms, so uninstall still cleans up
8599
+ // pre-fix installs that still carry the stale `Write(...)` entries.
7594
8600
  settings.permissions.allow = settings.permissions.allow.filter(
7595
- (e) => !GSD_CLAUDE_ALLOW_PERMISSIONS.includes(e)
8601
+ (e) => !GSD_CLAUDE_ALLOW_PERMISSIONS.includes(e) && !GSD_CLAUDE_LEGACY_ALLOW_PERMISSIONS.includes(e)
7596
8602
  );
7597
8603
  if (settings.permissions.allow.length !== before) {
7598
8604
  permissionsModified = true;
@@ -8303,7 +9309,7 @@ function resolveInstallRelativePath(baseDir, relPath) {
8303
9309
  if (fullPath !== root && !fullPath.startsWith(root + path.sep)) {
8304
9310
  return null;
8305
9311
  }
8306
- if (hasExistingSymlinkBetween(root, fullPath)) {
9312
+ if (hasExistingSymlinkBetween(root, fullPath, { allowOptInFollow: isSymlinkedDestOptIn() })) {
8307
9313
  return null;
8308
9314
  }
8309
9315
  return { relPath: normalized, fullPath };
@@ -8377,9 +9383,14 @@ function writeManifest(configDir, runtime = DEFAULT_RUNTIME, options = {}) {
8377
9383
  }
8378
9384
  }
8379
9385
  if (_hostBehaviors(runtime).flatCommandDir && fs.existsSync(opencodeCommandDir)) {
9386
+ // #2329: derive the manifest key prefix from the SAME descriptor value used
9387
+ // to compute opencodeCommandDir above, instead of a separately-hardcoded
9388
+ // literal — a divergence here would silently break the manifest even after
9389
+ // the destSubpath descriptor is corrected (Generative Fix Divergence guard).
9390
+ const flatCommandDirPrefix = _hostBehaviors(runtime).flatCommandDir || 'command';
8380
9391
  for (const file of fs.readdirSync(opencodeCommandDir)) {
8381
9392
  if (file.startsWith('gsd-') && file.endsWith('.md')) {
8382
- manifest.files['command/' + file] = fileHash(path.join(opencodeCommandDir, file));
9393
+ manifest.files[flatCommandDirPrefix + '/' + file] = fileHash(path.join(opencodeCommandDir, file));
8383
9394
  }
8384
9395
  }
8385
9396
  }
@@ -8974,14 +9985,24 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
8974
9985
  const _effectiveInstallMode = _isCoreProfileAlias ? 'minimal' : 'full';
8975
9986
  // Load the manifest and compute resolved profile for named profiles.
8976
9987
  // 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.
9988
+ // deps) to produce a resolvedProfile with the core skill set. For core/
9989
+ // standard profiles, resolveProfile's `registry` arg IS consulted (via
9990
+ // _capabilitySkillsForMode) so tier:core/tier:standard capability skills are
9991
+ // unioned in when registered. #2322 correction: for the DEFAULT `full`
9992
+ // profile, resolveProfile short-circuits to the `{skills:'*'}` sentinel
9993
+ // BEFORE ever reading `registry` (there is nothing to union — '*' already
9994
+ // means "everything"), so the registry consultation that matters for `full`
9995
+ // happens LATER, at staging time (stageSkillsForRuntimeAsSkills's '*'
9996
+ // fill-in, resolveRuntimeArtifactLayout's `capabilityRegistry` param below) —
9997
+ // not here. `_installedCapabilityRegistry` (not the frozen `_capabilityRegistry`)
9998
+ // is passed so an INSTALLED third-party capability (not just a first-party
9999
+ // one) is honored on every profile, `full` included (#2322 blocker 2).
8979
10000
  const _commandsDir = path.join(src, 'commands', 'gsd');
8980
10001
  const _skillsManifest = _isCoreProfileAlias ? new Map() : loadSkillsManifest(_commandsDir);
8981
10002
  const _resolvedProfile = resolveProfile({
8982
10003
  modes: [_activeProfileName],
8983
10004
  manifest: _skillsManifest,
8984
- registry: _capabilityRegistry,
10005
+ registry: _installedCapabilityRegistry,
8985
10006
  });
8986
10007
  // Unified staging function: all profiles use stageSkillsForProfile with the
8987
10008
  // registry-aware _resolvedProfile (ADR-857 phase 4c cutover).
@@ -9341,7 +10362,10 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
9341
10362
  resolveAttribution: getCommitAttribution,
9342
10363
  });
9343
10364
  } else {
9344
- installRuntimeArtifacts(runtime, targetDir, scope, _resolvedProfile, getCommitAttribution);
10365
+ // #2322: fallback path (adapter unavailable) — thread the composed
10366
+ // registry too, so this path stages third-party capability skills
10367
+ // identically to the primary adapter path above.
10368
+ installRuntimeArtifacts(runtime, targetDir, scope, _resolvedProfile, getCommitAttribution, _installedCapabilityRegistry);
9345
10369
  }
9346
10370
 
9347
10371
  // #1326 — Codex only: remove stale agents/openai.yaml sidecars from managed
@@ -9500,7 +10524,8 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
9500
10524
  } else if (_hostBehaviors(runtime).pluginOnlyInstall) {
9501
10525
  // pi (ADR-1239 / #2102 Stage 1): plugin-only install — pi's /gsd command is
9502
10526
  // registered programmatically by the native extension (pi/gsd.cjs →
9503
- // extensions/gsd.cjs, staged separately below) and dispatches in-process
10527
+ // extensions/gsd.js, staged separately below; the dest suffix must be
10528
+ // .ts/.js or pi's auto-discovery skips it silently — #2470) and dispatches in-process
9504
10529
  // through the embedded gsd-core command-routing hub. pi has no host-read
9505
10530
  // markdown surface (unlike Claude/OpenCode/etc., which scan commands/ or
9506
10531
  // command/ directories), so writing flat gsd-<cmd>.md files here would be
@@ -9910,6 +10935,22 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
9910
10935
  failures.push('VERSION');
9911
10936
  }
9912
10937
 
10938
+ // #2297: write a per-install runtime marker co-located with VERSION at
10939
+ // <install>/gsd-core/.gsd-runtime. It gives resolveModelInternal a reliable
10940
+ // "which runtime owns THIS install" signal in a no-project session (config.runtime
10941
+ // is null and GSD_RUNTIME is not exported), so the shared ~/.gsd/defaults.json
10942
+ // resolve_model_ids:"omit" policy (written below for non-alias runtimes only)
10943
+ // applies ONLY when a non-alias runtime is actually resolving — a Claude session
10944
+ // reads its own marker and keeps its tier aliases instead of inheriting another
10945
+ // runtime's install-order-dependent "omit". See src/model-resolver.cts.
10946
+ const runtimeMarkerDest = path.join(targetDir, 'gsd-core', '.gsd-runtime');
10947
+ fs.writeFileSync(runtimeMarkerDest, `${runtime}\n`);
10948
+ if (verifyFileInstalled(runtimeMarkerDest, '.gsd-runtime')) {
10949
+ console.log(` ${green}✓${reset} Wrote runtime marker (.gsd-runtime: ${runtime})`);
10950
+ } else {
10951
+ failures.push('.gsd-runtime');
10952
+ }
10953
+
9913
10954
  // Reusable: copy hooks/dist/ + hooks/lib/ into destRootDir, writing the
9914
10955
  // CommonJS package.json marker alongside them. Used below for the generic
9915
10956
  // configDir install path (guarded by hostBehaviors.skipSharedHooksInstall),
@@ -10023,13 +11064,15 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
10023
11064
  }
10024
11065
 
10025
11066
  // 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
11067
+ // Codex/Copilot/Cursor/Windsurf/Trae/Cline do not use the shared
10027
11068
  // hooks/lib/ helpers (Cursor uses standalone .js hook scripts registered
10028
11069
  // 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
11070
+ // hostBehaviors.skipSharedHooksInstall, #2089; Cline likewise #2090;
11071
+ // Trae likewise #2094; Codex uses hooks.json directly;
11072
+ // the others skip hooks entirely); ZCode also skips hooks entirely
11073
+ // (hooksSurface:'none' with no plugin surface — #1821). Kilo is NOT
11074
+ // excluded since #2305: its native plugin adapter (#2093) spawns the
11075
+ // staged hooks/*.js scripts, same as OpenCode. None of the
10033
11076
  // excluded runtimes must receive the hooks/lib/ helpers — otherwise the
10034
11077
  // Codex comment downstream ("we deliberately do *not* copy hooks/lib/ for
10035
11078
  // Codex") is contradicted in practice. (Gating lives at the call sites
@@ -10045,18 +11088,21 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
10045
11088
  return hooksOk;
10046
11089
  }
10047
11090
 
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.
11091
+ // #1821: ZCode declares hooksSurface:'none' AND has no plugin surface,
11092
+ // so the staged hook scripts are dead weight for it — excluded here.
10050
11093
  // OpenCode also declares hooksSurface:'none' but is deliberately NOT excluded:
10051
11094
  // its native plugin adapter (#1914, installed above under plugins/gsd-core.js)
10052
11095
  // spawns the staged hooks/*.js scripts via OpenCode's event bus and needs both
10053
- // them and the CommonJS package.json marker written below.
11096
+ // them and the CommonJS package.json marker written below. Kilo is the same
11097
+ // shape since #2093 (a nativePlugin spawning the staged hooks), so it must
11098
+ // NOT skip either — declaring skipSharedHooksInstall:true alongside a
11099
+ // nativePlugin left every guard the plugin spawns a silent no-op (#2305).
10054
11100
  // #2089: Cursor's exclusion is now descriptor-driven via
10055
11101
  // hostBehaviors.skipSharedHooksInstall (was hardcoded !isCursor).
10056
11102
  // #2090: Cline's exclusion is likewise descriptor-driven (cline declares
10057
11103
  // 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.
11104
+ // #2093/#2305: Kilo's former exclusion (descriptor-driven via
11105
+ // skipSharedHooksInstall:true) was removed in #2305 — see above.
10060
11106
  // #2094: Trae's exclusion is likewise descriptor-driven (trae declares
10061
11107
  // skipSharedHooksInstall:true) — the redundant `&& !isTrae` was removed.
10062
11108
  // #2101: ZCode's exclusion is likewise descriptor-driven (zcode declares
@@ -11221,6 +12267,19 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS
11221
12267
  fs.writeFileSync(defaultsPath, JSON.stringify(defaults, null, 2) + '\n');
11222
12268
  console.log(` ${green}✓${reset} Set resolve_model_ids: "omit" in ~/.gsd/defaults.json`);
11223
12269
  }
12270
+
12271
+ // #2395: also persist `runtime: <runtime>` for non-Claude runtimes, so
12272
+ // resolveRuntime() (precedence: GSD_RUNTIME env > config.runtime > 'claude')
12273
+ // resolves to the install's actual runtime identity out of the box — without
12274
+ // this, agent_runtime and every runtime-branded slash hint falls through to
12275
+ // the hard-coded 'claude' default. Mirrors the resolve_model_ids write above:
12276
+ // honor an explicit pre-existing value (any string), only default-populating
12277
+ // when absent. Claude is the resolveRuntime() fallback, so it needs no write.
12278
+ if (defaults.runtime === undefined || defaults.runtime === null || defaults.runtime === '') {
12279
+ defaults.runtime = runtime;
12280
+ fs.writeFileSync(defaultsPath, JSON.stringify(defaults, null, 2) + '\n');
12281
+ console.log(` ${green}✓${reset} Set runtime: "${runtime}" in ~/.gsd/defaults.json`);
12282
+ }
11224
12283
  } catch (e) {
11225
12284
  console.log(` ${yellow}⚠${reset} Could not write ~/.gsd/defaults.json: ${e.message}`);
11226
12285
  }
@@ -12157,6 +13216,7 @@ module.exports = {
12157
13216
  // #768 — Claude Code permissions pre-population
12158
13217
  mergeClaudePermissions,
12159
13218
  GSD_CLAUDE_ALLOW_PERMISSIONS,
13219
+ GSD_CLAUDE_LEGACY_ALLOW_PERMISSIONS,
12160
13220
  GSD_CLAUDE_DENY_PERMISSIONS,
12161
13221
  GSD_CODEX_MARKER,
12162
13222
  CODEX_AGENT_SANDBOX,
@@ -12206,6 +13266,18 @@ module.exports = {
12206
13266
  convertClaudeToCliineMarkdown,
12207
13267
  convertClaudeCommandToClineSkill,
12208
13268
  convertClaudeAgentToClineAgent,
13269
+ // #2284(b) — cross-cutting branding protected-region helper
13270
+ applyClaudeCodeBrandSwap,
13271
+ // #2284 — Hermes named-dispatch → delegate_task projection
13272
+ convertClaudeToHermesMarkdown,
13273
+ projectNamedDispatchToStructuralDelegate,
13274
+ _hostIntegrationDispatch,
13275
+ _resolveAvailableGsdRoles,
13276
+ HERMES_DISPATCH_TOOL_CONFIG,
13277
+ maskStringLiterals,
13278
+ findDispatchCallSpans,
13279
+ _assertProjectionComplete,
13280
+ _normalizeDispatchCallSpan,
12209
13281
  buildClineRulesBody,
12210
13282
  buildClineAgentsMdBody,
12211
13283
  buildClinePreToolUseHook,