@opengsd/gsd-core 1.7.0-rc.6 → 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 (195) 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 +31 -3
  8. package/agents/gsd-planner.md +29 -36
  9. package/agents/gsd-security-auditor.md +13 -15
  10. package/agents/gsd-verifier.md +2 -2
  11. package/bin/install.js +1157 -84
  12. package/commands/gsd/ai-integration-phase.md +1 -1
  13. package/commands/gsd/mempalace-capture.md +31 -1
  14. package/commands/gsd/new-milestone.md +1 -1
  15. package/commands/gsd/plan-phase.md +5 -3
  16. package/commands/gsd/plan-review-convergence.md +3 -2
  17. package/commands/gsd/surface.md +6 -6
  18. package/gsd-core/bin/gsd-tools.cjs +1866 -2434
  19. package/gsd-core/bin/lib/adapter-imperative.cjs +8 -1
  20. package/gsd-core/bin/lib/agent-command-router.cjs +20 -5
  21. package/gsd-core/bin/lib/api-coverage.cjs +341 -49
  22. package/gsd-core/bin/lib/audit.cjs +7 -6
  23. package/gsd-core/bin/lib/broken-windows.cjs +716 -0
  24. package/gsd-core/bin/lib/capability-command-router.cjs +733 -0
  25. package/gsd-core/bin/lib/capability-registry.cjs +157 -88
  26. package/gsd-core/bin/lib/capability-writer.cjs +6 -1
  27. package/gsd-core/bin/lib/check-command-router.cjs +129 -26
  28. package/gsd-core/bin/lib/claude-orchestration-command-router.cjs +115 -27
  29. package/gsd-core/bin/lib/claude-orchestration.cjs +84 -9
  30. package/gsd-core/bin/lib/clock.cjs +19 -0
  31. package/gsd-core/bin/lib/command-aliases.cjs +14 -0
  32. package/gsd-core/bin/lib/commands.cjs +129 -13
  33. package/gsd-core/bin/lib/config-loader.cjs +20 -4
  34. package/gsd-core/bin/lib/config.cjs +81 -18
  35. package/gsd-core/bin/lib/core-utils.cjs +14 -3
  36. package/gsd-core/bin/lib/decisions.cjs +32 -8
  37. package/gsd-core/bin/lib/docs.cjs +6 -0
  38. package/gsd-core/bin/lib/drift.cjs +4 -4
  39. package/gsd-core/bin/lib/external-descriptor-trust.cjs +14 -2
  40. package/gsd-core/bin/lib/frontmatter.cjs +22 -0
  41. package/gsd-core/bin/lib/gap-checker.cjs +17 -2
  42. package/gsd-core/bin/lib/gsd2-import.cjs +2 -1
  43. package/gsd-core/bin/lib/init.cjs +138 -60
  44. package/gsd-core/bin/lib/install-engine.cjs +301 -25
  45. package/gsd-core/bin/lib/install-profiles.cjs +239 -1
  46. package/gsd-core/bin/lib/installer-migration-authoring.cjs +2 -1
  47. package/gsd-core/bin/lib/installer-migrations/005-opencode-baseline-commands-dir.cjs +146 -0
  48. package/gsd-core/bin/lib/installer-migrations/006-pi-extension-cjs-to-js.cjs +91 -0
  49. package/gsd-core/bin/lib/installer-migrations.cjs +45 -6
  50. package/gsd-core/bin/lib/markdown-sectionizer.cjs +449 -0
  51. package/gsd-core/bin/lib/markdown-table.cjs +698 -0
  52. package/gsd-core/bin/lib/milestone.cjs +463 -43
  53. package/gsd-core/bin/lib/model-catalog.cjs +19 -4
  54. package/gsd-core/bin/lib/model-resolver.cjs +189 -7
  55. package/gsd-core/bin/lib/onboard-projection.cjs +11 -8
  56. package/gsd-core/bin/lib/phase-command-router.cjs +50 -2
  57. package/gsd-core/bin/lib/phase-id.cjs +26 -4
  58. package/gsd-core/bin/lib/phase-lifecycle.cjs +62 -36
  59. package/gsd-core/bin/lib/phase-locator.cjs +23 -2
  60. package/gsd-core/bin/lib/phase.cjs +636 -72
  61. package/gsd-core/bin/lib/plan-scan.cjs +73 -2
  62. package/gsd-core/bin/lib/roadmap-parser.cjs +225 -17
  63. package/gsd-core/bin/lib/roadmap.cjs +113 -52
  64. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +14 -7
  65. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +3 -2
  66. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +24 -9
  67. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +41 -17
  68. package/gsd-core/bin/lib/schema-detect.cjs +2 -1
  69. package/gsd-core/bin/lib/security.cjs +1 -1
  70. package/gsd-core/bin/lib/shell-command-projection.cjs +61 -25
  71. package/gsd-core/bin/lib/smart-entry.cjs +73 -7
  72. package/gsd-core/bin/lib/state-document.cjs +7 -4
  73. package/gsd-core/bin/lib/state-transition.cjs +122 -46
  74. package/gsd-core/bin/lib/state.cjs +456 -137
  75. package/gsd-core/bin/lib/surface.cjs +53 -11
  76. package/gsd-core/bin/lib/template.cjs +2 -1
  77. package/gsd-core/bin/lib/uat.cjs +474 -13
  78. package/gsd-core/bin/lib/ui-safety-gate.cjs +23 -1
  79. package/gsd-core/bin/lib/validate.cjs +12 -8
  80. package/gsd-core/bin/lib/verification.cjs +112 -17
  81. package/gsd-core/bin/lib/verify.cjs +224 -25
  82. package/gsd-core/bin/lib/workstream.cjs +3 -2
  83. package/gsd-core/bin/lib/worktree-safety.cjs +1 -1
  84. package/gsd-core/bin/lib/write-set.cjs +38 -0
  85. package/gsd-core/bin/shared/config-schema.manifest.json +5 -2
  86. package/gsd-core/references/api-coverage.md +37 -7
  87. package/gsd-core/references/checkpoints.md +13 -1
  88. package/gsd-core/references/common-bug-patterns.md +13 -0
  89. package/gsd-core/references/debugger-bug-taxonomy.md +111 -0
  90. package/gsd-core/references/debugger-fix-acceptance.md +157 -0
  91. package/gsd-core/references/debugger-philosophy.md +1 -0
  92. package/gsd-core/references/debugger-prevention.md +98 -0
  93. package/gsd-core/references/debugger-rca-branching.md +98 -0
  94. package/gsd-core/references/debugger-repro-hardening.md +130 -0
  95. package/gsd-core/references/debugger-sbfl.md +110 -0
  96. package/gsd-core/references/debugger-semantic-recall.md +81 -0
  97. package/gsd-core/references/execute-phase-quota-recovery.md +55 -0
  98. package/gsd-core/references/execute-phase-requirement-revert.md +8 -0
  99. package/gsd-core/references/execute-phase-response-language.md +7 -0
  100. package/gsd-core/references/planner-antipatterns.md +6 -0
  101. package/gsd-core/references/planner-mvp-mode.md +12 -13
  102. package/gsd-core/references/planner-preconditions.md +156 -0
  103. package/gsd-core/references/planner-reversibility.md +132 -0
  104. package/gsd-core/references/reviewer-instances.md +9 -7
  105. package/gsd-core/references/skeleton-template.md +1 -1
  106. package/gsd-core/references/thinking-models-planning.md +3 -1
  107. package/gsd-core/templates/DEBUG.md +5 -3
  108. package/gsd-core/workflows/add-phase.md +2 -0
  109. package/gsd-core/workflows/add-tests.md +4 -2
  110. package/gsd-core/workflows/add-todo.md +32 -1
  111. package/gsd-core/workflows/ai-integration-phase.md +4 -2
  112. package/gsd-core/workflows/audit-fix.md +2 -2
  113. package/gsd-core/workflows/check-todos.md +3 -1
  114. package/gsd-core/workflows/cleanup.md +7 -1
  115. package/gsd-core/workflows/code-review.md +17 -5
  116. package/gsd-core/workflows/complete-milestone.md +3 -0
  117. package/gsd-core/workflows/debug.md +27 -5
  118. package/gsd-core/workflows/diagnose-issues.md +1 -1
  119. package/gsd-core/workflows/discovery-phase.md +7 -0
  120. package/gsd-core/workflows/discuss-phase/templates/context.md +16 -2
  121. package/gsd-core/workflows/discuss-phase-assumptions.md +3 -0
  122. package/gsd-core/workflows/do.md +7 -1
  123. package/gsd-core/workflows/docs-update.md +1 -0
  124. package/gsd-core/workflows/eval-review.md +3 -0
  125. package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +4 -4
  126. package/gsd-core/workflows/execute-phase/steps/regression-gate.md +2 -2
  127. package/gsd-core/workflows/execute-phase.md +30 -37
  128. package/gsd-core/workflows/execute-plan.md +15 -4
  129. package/gsd-core/workflows/fast.md +8 -22
  130. package/gsd-core/workflows/graduation.md +3 -0
  131. package/gsd-core/workflows/health.md +7 -1
  132. package/gsd-core/workflows/help/modes/full.md +6 -2
  133. package/gsd-core/workflows/import.md +8 -2
  134. package/gsd-core/workflows/inbox.md +7 -0
  135. package/gsd-core/workflows/ingest-docs.md +15 -10
  136. package/gsd-core/workflows/manager.md +3 -1
  137. package/gsd-core/workflows/map-codebase.md +4 -4
  138. package/gsd-core/workflows/mvp-phase.md +3 -0
  139. package/gsd-core/workflows/new-milestone.md +69 -21
  140. package/gsd-core/workflows/new-project.md +17 -15
  141. package/gsd-core/workflows/new-workspace.md +3 -1
  142. package/gsd-core/workflows/onboard.md +3 -0
  143. package/gsd-core/workflows/plan-phase.md +14 -5
  144. package/gsd-core/workflows/plan-review-convergence.md +48 -3
  145. package/gsd-core/workflows/plant-seed.md +3 -0
  146. package/gsd-core/workflows/profile-user.md +7 -1
  147. package/gsd-core/workflows/progress.md +33 -5
  148. package/gsd-core/workflows/quick.md +21 -7
  149. package/gsd-core/workflows/remove-workspace.md +3 -0
  150. package/gsd-core/workflows/review.md +123 -68
  151. package/gsd-core/workflows/scan.md +1 -1
  152. package/gsd-core/workflows/secure-phase.md +4 -1
  153. package/gsd-core/workflows/settings-integrations.md +3 -0
  154. package/gsd-core/workflows/settings.md +3 -0
  155. package/gsd-core/workflows/ship.md +58 -5
  156. package/gsd-core/workflows/sketch.md +3 -0
  157. package/gsd-core/workflows/smart-entry.md +3 -0
  158. package/gsd-core/workflows/spec-phase.md +1 -1
  159. package/gsd-core/workflows/spike.md +7 -1
  160. package/gsd-core/workflows/transition.md +1 -1
  161. package/gsd-core/workflows/ui-phase.md +3 -1
  162. package/gsd-core/workflows/ui-review.md +3 -0
  163. package/gsd-core/workflows/undo.md +7 -0
  164. package/gsd-core/workflows/update.md +2 -0
  165. package/gsd-core/workflows/validate-phase.md +3 -0
  166. package/gsd-core/workflows/verify-phase.md +2 -2
  167. package/gsd-core/workflows/verify-work.md +7 -3
  168. package/hooks/dist/gsd-context-monitor.js +27 -9
  169. package/hooks/dist/gsd-statusline.js +252 -17
  170. package/hooks/gsd-context-monitor.js +27 -9
  171. package/hooks/gsd-statusline.js +252 -17
  172. package/package.json +8 -4
  173. package/pi/gsd.cjs +8 -2
  174. package/scripts/changeset/lint.cjs +1 -0
  175. package/scripts/changeset/parse.cjs +26 -0
  176. package/scripts/check-glossary-refs.cjs +220 -0
  177. package/scripts/ci-rebase-check.cjs +48 -4
  178. package/scripts/ci-test-scope.cjs +39 -1
  179. package/scripts/gen-adr-index.cjs +526 -0
  180. package/scripts/gen-golden-install-parity-zcode.cjs +35 -45
  181. package/scripts/gen-install-tree-fixtures.cjs +75 -0
  182. package/scripts/gen-test-timings.cjs +201 -0
  183. package/scripts/lint-allow-test-rule-refs.allowlist.json +0 -1
  184. package/scripts/lint-portable-timeout.cjs +140 -0
  185. package/scripts/lint-table-schema-drift.cjs +157 -0
  186. package/scripts/lint-test-file-count.allowlist.json +1 -0
  187. package/scripts/release-tarball-smoke.cjs +18 -11
  188. package/scripts/run-tests.cjs +420 -58
  189. package/skills/gsd-ai-integration-phase/SKILL.md +1 -1
  190. package/skills/gsd-mempalace-capture/SKILL.md +31 -1
  191. package/skills/gsd-new-milestone/SKILL.md +1 -1
  192. package/skills/gsd-plan-phase/SKILL.md +5 -3
  193. package/skills/gsd-plan-review-convergence/SKILL.md +3 -2
  194. package/skills/gsd-surface/SKILL.md +6 -6
  195. package/vscode/package.json +1 -1
@@ -26,6 +26,7 @@ const { readGsdCommandNames, transformContentToHyphen } = commandRoster;
26
26
  const runtimeNamePolicy = require("./runtime-name-policy.cjs");
27
27
  const { getDirName } = runtimeNamePolicy;
28
28
  const capabilityRegistry = require("./capability-registry.cjs");
29
+ const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
29
30
  // #1383: resolve GSD's version WITHOUT a top-level
30
31
  // `require('../../../package.json')`. That require ran at module load on every
31
32
  // gsd-tools invocation (this module sits in the gsd-tools loader chain) and
@@ -867,7 +868,13 @@ function convertClaudeCommandToCursorSkill(content, skillName) {
867
868
  description = toSingleLine(description);
868
869
  const shortDescription = description.length > 180 ? `${description.slice(0, 177)}...` : description;
869
870
  const adapter = getCursorSkillAdapterHeader(skillName);
870
- return `---\nname: ${yamlIdentifier(skillName)}\ndescription: ${yamlQuote(shortDescription)}\n---\n\n${adapter}\n\n${body.trimStart()}`;
871
+ // #2341: mark user-invocable:false so the skill is NOT shown in Cursor's '/'
872
+ // menu (it defaults to true). Cursor also writes a commands/ surface (#785),
873
+ // and surfacing both duplicated every /gsd-* entry. This mirrors the #789
874
+ // CodeBuddy de-dup: the commands/ surface is the sole '/' entry point; skills
875
+ // stay model-invocable background knowledge. (user-invocable:false hides from
876
+ // '/' while keeping model invocation — distinct from disable-model-invocation.)
877
+ return `---\nname: ${yamlIdentifier(skillName)}\ndescription: ${yamlQuote(shortDescription)}\nuser-invocable: false\n---\n\n${adapter}\n\n${body.trimStart()}`;
871
878
  }
872
879
  /**
873
880
  * Convert a Claude Code command to a Cursor 1.6 slash command (#785).
@@ -2143,8 +2150,8 @@ function computePathPrefix({ isGlobal, isOpencode, isWindowsHost: _isWindowsHost
2143
2150
  // Without this, path.join on Windows produces a backslash prefix that
2144
2151
  // leaks into markdown content and breaks cross-platform substring checks.
2145
2152
  // See DEFECT.WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT in CONTEXT.md.
2146
- const posixTarget = String(resolvedTarget).replace(/\\/g, '/');
2147
- const posixHome = homeDir ? String(homeDir).replace(/\\/g, '/') : homeDir;
2153
+ const posixTarget = (0, shell_command_projection_cjs_1.posixNormalize)(String(resolvedTarget));
2154
+ const posixHome = homeDir ? (0, shell_command_projection_cjs_1.posixNormalize)(String(homeDir)) : homeDir;
2148
2155
  if (isGlobal && posixTarget.startsWith(posixHome) && !isOpencode) {
2149
2156
  return '$HOME' + posixTarget.slice(posixHome.length) + '/';
2150
2157
  }
@@ -2461,8 +2468,8 @@ function rewriteStagedSkillBodies(stagedDir, opts) {
2461
2468
  const { runtime, configDir, scope = 'global', homedir = () => node_os_1.default.homedir(), platform = process.platform, resolveAttribution, } = opts;
2462
2469
  if (!node_fs_1.default.existsSync(stagedDir))
2463
2470
  return;
2464
- const resolvedTarget = node_path_1.default.resolve(configDir).replace(/\\/g, '/');
2465
- const homeDir = homedir().replace(/\\/g, '/');
2471
+ const resolvedTarget = (0, shell_command_projection_cjs_1.posixNormalize)(node_path_1.default.resolve(configDir));
2472
+ const homeDir = (0, shell_command_projection_cjs_1.posixNormalize)(homedir());
2466
2473
  const isGlobal = scope === 'global';
2467
2474
  const isOpencode = false; // #2087: opencode installs via the combined-family engine path, never through the generic rewrite
2468
2475
  const isWindowsHost = platform === 'win32';
@@ -2490,8 +2497,8 @@ function rewriteStagedCommandBodies(stagedDir, opts) {
2490
2497
  const { runtime, configDir, scope = 'global', homedir = () => node_os_1.default.homedir(), platform = process.platform, resolveAttribution, } = opts;
2491
2498
  if (!node_fs_1.default.existsSync(stagedDir))
2492
2499
  return stagedDir;
2493
- const resolvedTarget = node_path_1.default.resolve(configDir).replace(/\\/g, '/');
2494
- const homeDir = homedir().replace(/\\/g, '/');
2500
+ const resolvedTarget = (0, shell_command_projection_cjs_1.posixNormalize)(node_path_1.default.resolve(configDir));
2501
+ const homeDir = (0, shell_command_projection_cjs_1.posixNormalize)(homedir());
2495
2502
  const isGlobal = scope === 'global';
2496
2503
  const isOpencode = false; // #2087: opencode installs via the combined-family engine path, never through the generic rewrite
2497
2504
  const isWindowsHost = platform === 'win32';
@@ -67,9 +67,10 @@ function createRuntimeArtifactInstallPlan(args) {
67
67
  // to kind.stage() for agents kind entries with a converter (convertedAgentsKind).
68
68
  // NO _stampNonClaudeRuntimeDefaults — agents are NOT stamped in the inline loop.
69
69
  const os = _require('node:os');
70
+ const { posixNormalize } = _require('./shell-command-projection.cjs');
70
71
  const homedirFn = homedir ?? (() => os.homedir());
71
- const resolvedTarget = path.resolve(layout.configDir).replace(/\\/g, '/');
72
- const homeDir = homedirFn().replace(/\\/g, '/');
72
+ const resolvedTarget = posixNormalize(path.resolve(layout.configDir));
73
+ const homeDir = posixNormalize(homedirFn());
73
74
  const isGlobal = scope === 'global';
74
75
  const isOpencode = layout.runtime === 'opencode';
75
76
  const isWindowsHost = (platform ?? process.platform) === 'win32';
@@ -23,6 +23,7 @@ const { stageSkillsForProfile, stageAgentsForProfile, stageAgentsForRuntimeWithC
23
23
  // eslint-disable-next-line @typescript-eslint/no-require-imports
24
24
  const runtimeArtifactConversion = require("./runtime-artifact-conversion.cjs");
25
25
  const conversionExports = runtimeArtifactConversion;
26
+ const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
26
27
  // In .cts (CommonJS output) files, `require` is available as a global.
27
28
  const _require = require;
28
29
  // ---------------------------------------------------------------------------
@@ -192,7 +193,7 @@ function kimiAgentsKind(destSubpath, prefix, configDir) {
192
193
  continue;
193
194
  const agentPath = node_path_1.default.join(stagedAgents, entry.name);
194
195
  subagents.push({
195
- path: node_path_1.default.join('agents', entry.name).replace(/\\/g, '/'),
196
+ path: (0, shell_command_projection_cjs_1.posixNormalize)(node_path_1.default.join('agents', entry.name)),
196
197
  content: node_fs_1.default.readFileSync(agentPath, 'utf8'),
197
198
  });
198
199
  }
@@ -226,8 +227,12 @@ function kimiAgentsKind(destSubpath, prefix, configDir) {
226
227
  * arg so scope-aware converters (antigravity, copilot) can choose
227
228
  * between global home paths and workspace-relative paths without
228
229
  * colliding with the `runtime` string at position 3.
230
+ * @param capabilityRegistry #2322: optional capability registry — captured in the
231
+ * stage() closure so third-party capability skills are bound to
232
+ * their declaring capId at staging time. Absent -> stage() stages
233
+ * nothing third-party (fail closed).
229
234
  */
230
- function skillsKind(destSubpath, prefix, converterName, runtime, configDir, nested = false, scope = 'global') {
235
+ function skillsKind(destSubpath, prefix, converterName, runtime, configDir, nested = false, scope = 'global', capabilityRegistry) {
231
236
  return {
232
237
  kind: 'skills',
233
238
  destSubpath,
@@ -246,7 +251,7 @@ function skillsKind(destSubpath, prefix, converterName, runtime, configDir, nest
246
251
  : [];
247
252
  const isGlobal = scope === 'global';
248
253
  const wrappedConverter = (content, skillName) => realConverter(content, skillName, runtime, cmdNames, isGlobal);
249
- return stageSkillsForRuntimeAsSkills(findInstallSourceRoot(configDir), resolved, wrappedConverter, prefix, nested);
254
+ return stageSkillsForRuntimeAsSkills(findInstallSourceRoot(configDir), resolved, wrappedConverter, prefix, nested, capabilityRegistry);
250
255
  },
251
256
  };
252
257
  }
@@ -284,7 +289,7 @@ function getRegistry() {
284
289
  * Map a single ArtifactKindDescriptor entry to an ArtifactKind using the
285
290
  * matching builder function. Mirrors the hand-built calls in the old switch.
286
291
  */
287
- function dispatchKindEntry(entry, runtime, configDir, scope) {
292
+ function dispatchKindEntry(entry, runtime, configDir, scope, capabilityRegistry) {
288
293
  const { kind, destSubpath, prefix, nesting, converter } = entry;
289
294
  const nested = nesting === 'nested';
290
295
  let result;
@@ -303,7 +308,7 @@ function dispatchKindEntry(entry, runtime, configDir, scope) {
303
308
  if (converter == null) {
304
309
  throw new TypeError(`resolveRuntimeArtifactLayout: skills entry for '${runtime}' has converter=null (converter is required for skills)`);
305
310
  }
306
- result = skillsKind(destSubpath, prefix, converter, runtime, configDir, nested, scope);
311
+ result = skillsKind(destSubpath, prefix, converter, runtime, configDir, nested, scope, capabilityRegistry);
307
312
  break;
308
313
  case 'kimi-agents':
309
314
  result = kimiAgentsKind(destSubpath, prefix, configDir);
@@ -321,11 +326,21 @@ function dispatchKindEntry(entry, runtime, configDir, scope) {
321
326
  *
322
327
  * ADR-857 phase 5d: driven by the capability-registry artifactLayout descriptor
323
328
  * instead of a hardcoded switch statement.
329
+ *
330
+ * @param capabilityRegistry #2322: optional — when the caller has a composed
331
+ * capability registry in scope (e.g. capability-writer.cts's `capability set`
332
+ * path, or a fresh install's registry-aware profile resolution), pass it here
333
+ * so the skills kind's stage() closure can materialize installed third-party
334
+ * capability skills bound to their declaring capId. Both call paths (surface
335
+ * apply AND the installer) must pass their registry here — resolveProfile's
336
+ * own `'*'` (full profile) short-circuit never carries a registry, so if it
337
+ * is not threaded in at layout-build time a `full`-profile install stages no
338
+ * third-party capability skills regardless of registration (#2322 blocker 2).
324
339
  */
325
- function resolveRuntimeArtifactLayout(runtime, configDir, scope = 'global') {
326
- return resolveRuntimeArtifactLayoutFromRegistry(getRegistry(), runtime, configDir, scope);
340
+ function resolveRuntimeArtifactLayout(runtime, configDir, scope = 'global', capabilityRegistry) {
341
+ return resolveRuntimeArtifactLayoutFromRegistry(getRegistry(), runtime, configDir, scope, capabilityRegistry);
327
342
  }
328
- function resolveRuntimeArtifactLayoutFromRegistry(registry, runtime, configDir, scope = 'global') {
343
+ function resolveRuntimeArtifactLayoutFromRegistry(registry, runtime, configDir, scope = 'global', capabilityRegistry) {
329
344
  if (typeof configDir !== 'string' || configDir === '') {
330
345
  throw new TypeError('configDir must be a non-empty string');
331
346
  }
@@ -337,7 +352,7 @@ function resolveRuntimeArtifactLayoutFromRegistry(registry, runtime, configDir,
337
352
  throw new TypeError(`Unknown runtime: '${runtime}' — add to runtime-artifact-layout.cjs table`);
338
353
  }
339
354
  const entries = desc[scope] ?? [];
340
- const kinds = entries.map((entry) => dispatchKindEntry(entry, runtime, configDir, scope));
355
+ const kinds = entries.map((entry) => dispatchKindEntry(entry, runtime, configDir, scope, capabilityRegistry));
341
356
  return { runtime, configDir, scope, kinds };
342
357
  }
343
358
  module.exports = { resolveRuntimeArtifactLayout, resolveRuntimeArtifactLayoutFromRegistry, findInstallSourceRoot };
@@ -267,7 +267,7 @@ function normalizeNodePath(execPath, opts) {
267
267
  return execPath;
268
268
  const env = (opts && opts.env) || process.env;
269
269
  const existsSync = (opts && opts.existsSync) || node_fs_1.default.existsSync;
270
- const normalizedForMatch = execPath.replace(/\\/g, '/');
270
+ const normalizedForMatch = shellCmdProjection.posixNormalize(execPath);
271
271
  if (/\/fnm_multishells\/[0-9]+_[0-9]+\/node(\.exe)?$/i.test(normalizedForMatch)) {
272
272
  const candidates = [];
273
273
  if (env.FNM_DIR) {
@@ -283,11 +283,16 @@ function normalizeNodePath(execPath, opts) {
283
283
  }
284
284
  return execPath;
285
285
  }
286
- if (/^\/usr\/local\/Cellar\/node(@\d+)?\/[^/]+\/bin\/node(\.exe)?$/.test(execPath)) {
287
- return '/usr/local/bin/node';
288
- }
289
- if (/^\/opt\/homebrew\/Cellar\/node(@\d+)?\/[^/]+\/bin\/node(\.exe)?$/.test(execPath)) {
290
- return '/opt/homebrew/bin/node';
286
+ // Homebrew (macOS Intel /usr/local, Apple Silicon /opt/homebrew, Linuxbrew
287
+ // /home/linuxbrew/.linuxbrew, and any custom HOMEBREW_PREFIX) pins node at
288
+ // <prefix>/Cellar/node(<@ver>)?/<ver>/bin/node, then deletes prior versions on
289
+ // `brew upgrade node`. Rewrite to the stable <prefix>/bin/node symlink, which
290
+ // survives the upgrade. Derive <prefix> from the path itself (more reliable
291
+ // than HOMEBREW_PREFIX env — the path IS the install location) so every layout
292
+ // is covered by one branch instead of one per known prefix (#2185).
293
+ const homebrewMatch = normalizedForMatch.match(/^(.+)\/Cellar\/node(@\d+)?\/[^/]+\/bin\/node(\.exe)?$/i);
294
+ if (homebrewMatch) {
295
+ return `${homebrewMatch[1]}/bin/node${homebrewMatch[3] || ''}`;
291
296
  }
292
297
  // mise pins a concrete node version at <data>/installs/node/<ver>/bin/node
293
298
  // (Windows: <data>/installs/node/<ver>/node.exe). Node realpaths
@@ -304,6 +309,22 @@ function normalizeNodePath(execPath, opts) {
304
309
  if (existsSync(shim))
305
310
  return shim;
306
311
  }
312
+ // volta pins a concrete node image at <VOLTA_HOME>/tools/image/node/<ver>/bin/node
313
+ // (Windows: <VOLTA_HOME>/tools/image/node/<ver>/node.exe — volta's own layout
314
+ // puts node.exe at the image root, no bin/). `volta uninstall node@<ver>` prunes
315
+ // that image, so a baked hook command 404s — the same ephemeral-path failure
316
+ // #977 fixed for fnm and #1619 for mise. The stable alias is the shim
317
+ // <VOLTA_HOME>/bin/node, a symlink to volta-shim that always resolves to the
318
+ // active pin. Derive <VOLTA_HOME> from execPath rather than the env so a custom
319
+ // VOLTA_HOME and the Windows %LOCALAPPDATA%\Volta default both work (#2185's
320
+ // reasoning), and only rewrite when the shim exists — otherwise fall back to
321
+ // the raw execPath unchanged.
322
+ const voltaMatch = normalizedForMatch.match(/^(.*)\/tools\/image\/node\/[^/]+\/(?:bin\/)?node(\.exe)?$/);
323
+ if (voltaMatch) {
324
+ const shim = `${voltaMatch[1]}/bin/node${voltaMatch[2] || ''}`;
325
+ if (existsSync(shim))
326
+ return shim;
327
+ }
307
328
  return execPath;
308
329
  }
309
330
  function resolveNodeRunner(opts) {
@@ -311,7 +332,7 @@ function resolveNodeRunner(opts) {
311
332
  if (!execPath)
312
333
  return null;
313
334
  const stablePath = normalizeNodePath(execPath, opts);
314
- return JSON.stringify(stablePath.replace(/\\/g, '/'));
335
+ return JSON.stringify(shellCmdProjection.posixNormalize(stablePath));
315
336
  }
316
337
  function resolveBashRunner(opts) {
317
338
  const platform = (opts && opts.platform) || process.platform;
@@ -332,7 +353,7 @@ function resolveBashRunner(opts) {
332
353
  }
333
354
  for (const candidate of candidates) {
334
355
  if (candidate && exists(candidate)) {
335
- return JSON.stringify(candidate.replace(/\\/g, '/'));
356
+ return JSON.stringify(shellCmdProjection.posixNormalize(candidate));
336
357
  }
337
358
  }
338
359
  return null;
@@ -372,7 +393,7 @@ function rewriteLegacyManagedNodeHookCommands(settings, absoluteRunner, opts) {
372
393
  }
373
394
  else {
374
395
  _runnerToken = m[1];
375
- const runnerPath = (m[2] || m[3] || m[4] || '').replace(/\\/g, '/');
396
+ const runnerPath = shellCmdProjection.posixNormalize(m[2] || m[3] || m[4] || '');
376
397
  const stableRunner = normalizeNodePath(runnerPath);
377
398
  if (stableRunner === runnerPath && platform !== 'win32')
378
399
  continue;
@@ -562,10 +583,10 @@ function buildCodexHookWindowsShimIR(scriptAbsPath, absoluteRunnerToken) {
562
583
  catch {
563
584
  interpreter = absoluteRunnerToken;
564
585
  }
565
- const targetAbs = scriptAbsPath.replace(/\\/g, '/');
586
+ const targetAbs = shellCmdProjection.posixNormalize(scriptAbsPath);
566
587
  const scriptQuoted = JSON.stringify(targetAbs);
567
588
  const cmdPath = scriptAbsPath.replace(/\.js$/, '.cmd');
568
- const hookCommand = JSON.stringify(cmdPath.replace(/\\/g, '/'));
589
+ const hookCommand = JSON.stringify(shellCmdProjection.posixNormalize(cmdPath));
569
590
  const runnerQuoted = JSON.stringify(interpreter);
570
591
  return {
571
592
  invocation: { interpreter, target: scriptAbsPath },
@@ -584,7 +605,7 @@ function ensureCodexHooksJsonSessionStart(targetDir, opts = {}) {
584
605
  const hooksJsonPath = node_path_1.default.join(targetDir, 'hooks.json');
585
606
  if (!absoluteRunner)
586
607
  return { changed: false, wrote: false, path: hooksJsonPath };
587
- const scriptPath = node_path_1.default.resolve(targetDir, 'hooks', 'gsd-check-update.js').replace(/\\/g, '/');
608
+ const scriptPath = shellCmdProjection.posixNormalize(node_path_1.default.resolve(targetDir, 'hooks', 'gsd-check-update.js'));
588
609
  const cmdShimPath = scriptPath.replace(/\.js$/, '.cmd');
589
610
  let managedCommand;
590
611
  if (platform === 'win32') {
@@ -614,7 +635,7 @@ function ensureCodexHooksJsonSessionStart(targetDir, opts = {}) {
614
635
  if (!managedCommand)
615
636
  return { changed: false, wrote: false, path: hooksJsonPath };
616
637
  const commandWindows = platform === 'win32'
617
- ? JSON.stringify(cmdShimPath.replace(/\\/g, '/'))
638
+ ? JSON.stringify(shellCmdProjection.posixNormalize(cmdShimPath))
618
639
  : undefined;
619
640
  return reconcileCodexHooksJsonSessionStart(targetDir, { managedCommand, commandWindows });
620
641
  }
@@ -624,7 +645,7 @@ function ensureCodexHooksJsonEvent(targetDir, eventName, opts = {}) {
624
645
  const hooksJsonPath = node_path_1.default.join(targetDir, 'hooks.json');
625
646
  if (!absoluteRunner)
626
647
  return { changed: false, wrote: false, path: hooksJsonPath };
627
- const scriptPath = node_path_1.default.resolve(targetDir, 'hooks', 'gsd-context-monitor.js').replace(/\\/g, '/');
648
+ const scriptPath = shellCmdProjection.posixNormalize(node_path_1.default.resolve(targetDir, 'hooks', 'gsd-context-monitor.js'));
628
649
  let managedCommand;
629
650
  if (platform === 'win32') {
630
651
  const shimIR = buildCodexHookWindowsShimIR(scriptPath, absoluteRunner);
@@ -667,6 +688,7 @@ function buildHookCommand(configDir, hookName, opts) {
667
688
  opts = {};
668
689
  const platform = opts.platform || process.platform;
669
690
  const runtime = opts.runtime || 'generic';
691
+ const hookShell = opts.hookShell;
670
692
  const isShellHook = hookName.endsWith('.sh');
671
693
  if (shellHookOmitsBashRunner({ platform, runtime, isShellHook })) {
672
694
  if (opts.portableHooks) {
@@ -676,7 +698,7 @@ function buildHookCommand(configDir, hookName, opts) {
676
698
  });
677
699
  return JSON.stringify(`${portableBaseDir}/hooks/${hookName}`);
678
700
  }
679
- return JSON.stringify(configDir.replace(/\\/g, '/') + '/hooks/' + hookName);
701
+ return JSON.stringify(shellCmdProjection.posixNormalize(configDir) + '/hooks/' + hookName);
680
702
  }
681
703
  const nodeRunner = resolveNodeRunner();
682
704
  const runner = isShellHook ? resolveBashRunner(opts) : nodeRunner;
@@ -692,14 +714,16 @@ function buildHookCommand(configDir, hookName, opts) {
692
714
  scriptPath: `${portableBaseDir}/hooks/${hookName}`,
693
715
  runtime: opts.runtime || 'generic',
694
716
  platform,
717
+ hookShell,
695
718
  });
696
719
  }
697
- const hooksPath = configDir.replace(/\\/g, '/') + '/hooks/' + hookName;
720
+ const hooksPath = shellCmdProjection.posixNormalize(configDir) + '/hooks/' + hookName;
698
721
  return projectManagedHookCommand({
699
722
  absoluteRunner: runner,
700
723
  scriptPath: hooksPath,
701
724
  runtime,
702
725
  platform,
726
+ hookShell,
703
727
  });
704
728
  }
705
729
  // ---------------------------------------------------------------------------
@@ -844,7 +868,7 @@ function writeClineArtifacts(targetDir, isGlobalInstall) {
844
868
  function buildCursorHookEntry(scriptPath) {
845
869
  return {
846
870
  type: 'command',
847
- command: scriptPath.replace(/\\/g, '/'),
871
+ command: shellCmdProjection.posixNormalize(scriptPath),
848
872
  [GSD_CURSOR_HOOK_MARKER]: true,
849
873
  };
850
874
  }
@@ -13,6 +13,7 @@ exports.ORM_INFO = exports.SCHEMA_PATTERNS = void 0;
13
13
  exports.detectSchemaFiles = detectSchemaFiles;
14
14
  exports.detectSchemaOrm = detectSchemaOrm;
15
15
  exports.checkSchemaDrift = checkSchemaDrift;
16
+ const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
16
17
  exports.SCHEMA_PATTERNS = [
17
18
  { pattern: /^src\/collections\/.*\.ts$/, orm: 'payload' },
18
19
  { pattern: /^src\/globals\/.*\.ts$/, orm: 'payload' },
@@ -61,7 +62,7 @@ function detectSchemaFiles(files) {
61
62
  const matches = [];
62
63
  const orms = new Set();
63
64
  for (const rawFile of files) {
64
- const file = rawFile.replace(/\\/g, '/');
65
+ const file = (0, shell_command_projection_cjs_1.posixNormalize)(rawFile);
65
66
  for (const { pattern, orm } of exports.SCHEMA_PATTERNS) {
66
67
  if (pattern.test(file)) {
67
68
  matches.push(rawFile);
@@ -341,7 +341,7 @@ function sanitizeForDisplay(text) {
341
341
  let sanitized = sanitizeForPrompt(text);
342
342
  const protocolLeakPatterns = [
343
343
  /^\s*(?:assistant|user|system)\s+to=[^:\s]+:[^\n]+$/i,
344
- /^\s*<\|(?:assistant|user|system)[^|]*\|>\s*$/i,
344
+ /^\s*<\|(?:assistant|user|system)[^|]*\|>\s*$/i, // allow-adhoc-markdown: not a GFM table-cell scan — matches `<|role|>` protocol-leak marker tokens (prompt-injection sanitization), a false-positive on the table-regex pipe+cell-class fingerprint
345
345
  ];
346
346
  sanitized = sanitized
347
347
  .split('\n')
@@ -15,6 +15,9 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
15
15
  return (mod && mod.__esModule) ? mod : { "default": mod };
16
16
  };
17
17
  Object.defineProperty(exports, "__esModule", { value: true });
18
+ exports.toPosixPath = toPosixPath;
19
+ exports.toNativePath = toNativePath;
20
+ exports.posixNormalize = posixNormalize;
18
21
  exports.hookCommandNeedsPowerShellCallOperator = hookCommandNeedsPowerShellCallOperator;
19
22
  exports.formatHookCommandForRuntime = formatHookCommandForRuntime;
20
23
  exports.shellHookOmitsBashRunner = shellHookOmitsBashRunner;
@@ -52,26 +55,58 @@ const node_fs_1 = __importDefault(require("node:fs"));
52
55
  // can intercept calls from this seam — destructured imports capture references
53
56
  // at load time and become un-mockable.
54
57
  const node_child_process_1 = __importDefault(require("node:child_process"));
58
+ /**
59
+ * Convert a filesystem path to POSIX form (forward slashes) by translating the
60
+ * platform-native separator. Single seam for native→POSIX conversion.
61
+ *
62
+ * Prefer this over `p.replace(/\\/g, '/')`: the regex form hardcodes both
63
+ * separators and corrupts POSIX paths containing a literal backslash (a legal
64
+ * filename character). Splitting on `path.sep` only ever touches real
65
+ * separators — a no-op on POSIX, `\`→`/` on Windows.
66
+ */
67
+ function toPosixPath(p) {
68
+ return p.split(node_path_1.default.sep).join(node_path_1.default.posix.sep);
69
+ }
70
+ /**
71
+ * Convert a filesystem path to the platform-native separator form. No-op on
72
+ * POSIX; `/`→`\` on Windows. Prefer this over a
73
+ * `process.platform === 'win32' ? p.replace(/\//g, '\\') : p` ternary.
74
+ */
75
+ function toNativePath(p) {
76
+ return p.split(node_path_1.default.posix.sep).join(node_path_1.default.sep);
77
+ }
78
+ /**
79
+ * Normalize ALL backslashes to forward slashes, unconditionally and independent
80
+ * of the running OS. Use this when emitting a path into a POSIX/bash target
81
+ * (which may differ from the running platform — e.g. generating a Windows config
82
+ * on a Linux runner) or when parsing input whose separators are unpredictable.
83
+ *
84
+ * Contrast `toPosixPath`, which is running-OS-relative (splits on `path.sep`) and
85
+ * is for *this machine's* filesystem paths. Do NOT use `toPosixPath` for
86
+ * target-platform projection — on a Linux runner it would not convert a
87
+ * Windows-target path's backslashes.
88
+ */
89
+ function posixNormalize(p) {
90
+ return p.replace(/\\/g, '/');
91
+ }
55
92
  /**
56
93
  * Return true when a managed hook command must be prefixed with PowerShell's
57
94
  * call operator so a quoted executable token is invokable by the target
58
95
  * runtime/shell combination.
59
96
  *
60
- * Current evidence-backed policy:
61
- * - Claude Code on Windows does NOT need it: its hook commands execute under
62
- * bash/Git Bash and `& ` breaks there (#3413).
63
- * - #1928: Gemini CLI — the ONLY runtime with a verified need for the `& `
64
- * prefix on Windows — was removed (Google sunset it 2026-06-18). No currently
65
- * supported runtime has a verified need, so this seam is now inert. It is
66
- * retained (not deleted) so a future runtime with a verified need is a
67
- * one-line re-enable, per the conservative policy below. Note: Antigravity —
68
- * the Gemini-backend successor — never matched the old `runtime === 'gemini'`
69
- * check, so its behavior (no prefix) is unchanged.
97
+ * The `&`/no-`&` decision is keyed on the **effective hook-execution shell**
98
+ * (`opts.hookShell`), not on runtime alone — a single runtime (Claude Code)
99
+ * can host either Git Bash or PowerShell on Windows, and no single static
100
+ * command string is valid in both (#2236):
101
+ * - Git Bash: `"node.exe" "hook.js"` works; `& "node.exe" …` → syntax error.
102
+ * - PowerShell: `& "node.exe" "hook.js"` works; bare `"node.exe" …` →
103
+ * `Unexpected token`.
70
104
  *
71
- * Keep the policy conservative until another runtime has a verified need.
105
+ * Default is `false` (Git Bash form) for backward compatibility. Set
106
+ * `opts.hookShell = 'powershell'` to emit the PowerShell call-operator form.
72
107
  */
73
- function hookCommandNeedsPowerShellCallOperator(_opts = {}) {
74
- return false;
108
+ function hookCommandNeedsPowerShellCallOperator(opts = {}) {
109
+ return opts.hookShell === 'powershell';
75
110
  }
76
111
  /**
77
112
  * Project a fully-assembled hook command string for the target runtime.
@@ -122,7 +157,7 @@ function formatManagedHookScriptToken(scriptPath, opts = {}) {
122
157
  const platform = opts.platform || process.platform;
123
158
  if (platform !== 'win32')
124
159
  return null;
125
- return JSON.stringify(scriptPath.replace(/\\/g, '/'));
160
+ return JSON.stringify(posixNormalize(scriptPath));
126
161
  }
127
162
  function projectLocalHookPrefix({ runtime: _runtime = 'claude', dirName, hookPathStyle }) {
128
163
  if (!dirName)
@@ -140,29 +175,30 @@ function projectLocalHookPrefix({ runtime: _runtime = 'claude', dirName, hookPat
140
175
  : `"$CLAUDE_PROJECT_DIR"/${dirName}`;
141
176
  }
142
177
  function projectPortableHookBaseDir({ configDir, homeDir }) {
143
- const normalizedConfigDir = String(configDir || '').replace(/\\/g, '/');
144
- const normalizedHome = String(homeDir || '').replace(/\\/g, '/');
178
+ const normalizedConfigDir = posixNormalize(String(configDir || ''));
179
+ const normalizedHome = posixNormalize(String(homeDir || ''));
145
180
  if (!normalizedConfigDir || !normalizedHome)
146
181
  return normalizedConfigDir;
147
182
  return normalizedConfigDir.startsWith(normalizedHome)
148
183
  ? '$HOME' + normalizedConfigDir.slice(normalizedHome.length)
149
184
  : normalizedConfigDir;
150
185
  }
151
- function projectShellCommandText({ runnerToken, argTokens = [], runtime = 'generic', platform = process.platform, }) {
186
+ function projectShellCommandText({ runnerToken, argTokens = [], runtime = 'generic', platform = process.platform, hookShell, }) {
152
187
  if (!runnerToken)
153
188
  return null;
154
189
  const parts = [runnerToken, ...argTokens.filter(Boolean)];
155
- return formatHookCommandForRuntime(parts.join(' '), { platform, runtime });
190
+ return formatHookCommandForRuntime(parts.join(' '), { platform, runtime, hookShell });
156
191
  }
157
- function projectManagedHookCommand({ absoluteRunner, scriptPath, runtime = 'generic', platform = process.platform }) {
192
+ function projectManagedHookCommand({ absoluteRunner, scriptPath, runtime = 'generic', platform = process.platform, hookShell }) {
158
193
  if (!absoluteRunner || !scriptPath)
159
194
  return null;
160
- const normalizedScriptPath = platform === 'win32' ? scriptPath.replace(/\\/g, '/') : scriptPath;
195
+ const normalizedScriptPath = platform === 'win32' ? posixNormalize(scriptPath) : scriptPath;
161
196
  return projectShellCommandText({
162
197
  runnerToken: absoluteRunner,
163
198
  argTokens: [JSON.stringify(normalizedScriptPath)],
164
199
  runtime,
165
200
  platform,
201
+ hookShell,
166
202
  });
167
203
  }
168
204
  const MANAGED_HOOK_BASENAMES_BY_SURFACE = {
@@ -255,14 +291,14 @@ function isManagedHookCommand(commandText, opts = {}) {
255
291
  for (const arg of opts.args) {
256
292
  if (typeof arg !== 'string')
257
293
  continue;
258
- const argBasename = arg.replace(/\\/g, '/').split('/').pop() || '';
294
+ const argBasename = posixNormalize(arg).split('/').pop() || '';
259
295
  if (isManagedHookBasename(argBasename, { surface }))
260
296
  return true;
261
297
  }
262
298
  }
263
- const normalizedCommand = commandText.replace(/\\/g, '/');
299
+ const normalizedCommand = posixNormalize(commandText);
264
300
  if (typeof opts.configDir === 'string' && opts.configDir.length > 0) {
265
- const normalizedHooksDir = `${node_path_1.default.join(opts.configDir, 'hooks').replace(/\\/g, '/')}/`;
301
+ const normalizedHooksDir = `${posixNormalize(node_path_1.default.join(opts.configDir, 'hooks'))}/`;
266
302
  if (!normalizedCommand.includes(normalizedHooksDir))
267
303
  return false;
268
304
  }
@@ -292,7 +328,7 @@ const ANCHORED_HOOK_SCRIPT_TOKEN = /^"\$[A-Za-z_][A-Za-z0-9_]*"\//;
292
328
  function projectLegacySettingsHookCommand({ absoluteRunner, scriptPath, scriptToken, runtime = 'generic', platform = process.platform, }) {
293
329
  if (!absoluteRunner || !scriptPath)
294
330
  return null;
295
- const normalizedScriptPath = platform === 'win32' ? scriptPath.replace(/\\/g, '/') : scriptPath;
331
+ const normalizedScriptPath = platform === 'win32' ? posixNormalize(scriptPath) : scriptPath;
296
332
  // #1693: a script path already carrying a `"$CLAUDE_PROJECT_DIR"`-anchored
297
333
  // quoted prefix (local installs) is already a valid shell token — only the
298
334
  // variable is quoted, the rest is bare. JSON.stringify-ing it on Windows
@@ -350,7 +386,7 @@ function projectPathActionProjection({ mode = 'repair', targetDir, platform = pr
350
386
  let shellActions;
351
387
  if (isWin32) {
352
388
  const psTargetDir = escapePowerShellSingleQuoted(targetDir);
353
- const bashTargetDir = escapeSingleQuotedShellLiteral(String(targetDir).replace(/\\/g, '/'));
389
+ const bashTargetDir = escapeSingleQuotedShellLiteral(posixNormalize(String(targetDir)));
354
390
  shellActions = [
355
391
  {
356
392
  label: 'PowerShell',
@@ -42,6 +42,7 @@ exports.runSmartEntry = runSmartEntry;
42
42
  const node_fs_1 = __importDefault(require("node:fs"));
43
43
  const node_path_1 = __importDefault(require("node:path"));
44
44
  const node_child_process_1 = require("node:child_process");
45
+ const markdown_sectionizer_cjs_1 = require("./markdown-sectionizer.cjs");
45
46
  // eslint-disable-next-line @typescript-eslint/no-require-imports
46
47
  const ioMod = require("./io.cjs");
47
48
  const { output } = ioMod;
@@ -51,6 +52,9 @@ const { planningPaths } = planningWorkspace;
51
52
  // eslint-disable-next-line @typescript-eslint/no-require-imports
52
53
  const frontmatter = require("./frontmatter.cjs");
53
54
  const { extractFrontmatter } = frontmatter;
55
+ // eslint-disable-next-line @typescript-eslint/no-require-imports -- phase-lifecycle.cjs is an export= CommonJS module
56
+ const phaseLifecycle = require("./phase-lifecycle.cjs");
57
+ const { deriveProgressFromRoadmap } = phaseLifecycle;
54
58
  // eslint-disable-next-line @typescript-eslint/no-require-imports
55
59
  const stateDocument = require("./state-document.cjs");
56
60
  const { stateExtractField } = stateDocument;
@@ -244,6 +248,8 @@ function detectSignals(cwd, now = Date.now) {
244
248
  has_git: git.has_git,
245
249
  verify_failed: false,
246
250
  stale_activity: false,
251
+ roadmap_total_phases: null,
252
+ roadmap_completed_phases: null,
247
253
  };
248
254
  if (!hasPlanning)
249
255
  return empty;
@@ -274,9 +280,9 @@ function detectSignals(cwd, now = Date.now) {
274
280
  fmScalar(fm, body, 'progress', 'Progress');
275
281
  // Blockers list: `- <text>` items under a `## Blockers` heading.
276
282
  const blockers = [];
277
- const blockersMatch = body.match(/##\s*Blockers\s*\n([\s\S]*?)(?=\n##|$)/i); // allow-adhoc-markdown: read-only blockers section-collect in smart-entry.cts; mirrors state.cts (#1372), pending collectSection migration
278
- if (blockersMatch) {
279
- const items = blockersMatch[1].match(/^-\s+(.+)$/gm) || [];
283
+ const blockersSection = (0, markdown_sectionizer_cjs_1.collectSection)(body, (h) => h.level === 2 && h.text.trim().toLowerCase() === 'blockers', { levelBounded: true });
284
+ if (blockersSection) {
285
+ const items = blockersSection.body.match(/^-\s+(.+)$/gm) || [];
280
286
  for (const item of items)
281
287
  blockers.push(item.replace(/^-\s+/, '').trim());
282
288
  }
@@ -290,6 +296,24 @@ function detectSignals(cwd, now = Date.now) {
290
296
  // STATUS: marker on the current phase's summary/verify artifact.
291
297
  const verifyFailed = /\bverify-fail(ed)?|verification-fail|uat-fail\b/i.test(statusRaw || '') ||
292
298
  detectVerifyFailed(cwd, currentPhaseRaw);
299
+ // #2427: derive global phase counts from ROADMAP.md's Progress table. These
300
+ // are preferred over STATE.md's cached milestone-scoped `total_phases` (which
301
+ // goes stale when phases are appended after a milestone switch) for the
302
+ // completion check. Null when ROADMAP.md is absent or has no parseable
303
+ // Progress table — isComplete falls back to the legacy comparison in that case.
304
+ let roadmapTotalPhases = null;
305
+ let roadmapCompletedPhases = null;
306
+ if (hasRoadmap) {
307
+ try {
308
+ const roadmapContent = node_fs_1.default.readFileSync(paths.roadmap, 'utf8');
309
+ const derived = deriveProgressFromRoadmap(roadmapContent);
310
+ roadmapTotalPhases = derived.totalPhases;
311
+ roadmapCompletedPhases = derived.completedPhases;
312
+ }
313
+ catch {
314
+ /* ROADMAP.md unreadable — leave null; isComplete falls back to legacy. */
315
+ }
316
+ }
293
317
  return {
294
318
  current_phase: parseIntOrNull(currentPhaseRaw),
295
319
  total_phases: parseIntOrNull(totalPhasesRaw),
@@ -304,14 +328,56 @@ function detectSignals(cwd, now = Date.now) {
304
328
  has_git: git.has_git,
305
329
  verify_failed: verifyFailed,
306
330
  stale_activity: staleActivity,
331
+ roadmap_total_phases: roadmapTotalPhases,
332
+ roadmap_completed_phases: roadmapCompletedPhases,
307
333
  };
308
334
  }
309
335
  // ─── Situation classification ─────────────────────────────────────────────────
310
- /** True when the workflow has fully completed all phases. */
336
+ /**
337
+ * True when the workflow has fully completed all phases.
338
+ *
339
+ * #2427: completion is grounded in ROADMAP.md's Progress table (global,
340
+ * authoritative, never stale) when available, with a legacy fallback to
341
+ * STATE.md's cached `total_phases` when the roadmap has no parseable Progress
342
+ * table (e.g. a fresh project or a non-standard roadmap layout). The status
343
+ * regex was tightened to require milestone-level completion language
344
+ * (`milestone complete` / `all phases complete` / `complete(d)`) and no longer
345
+ * matches per-phase messages like "Phase X shipped — PR #N" that falsely
346
+ * satisfied the pre-fix alternation (`\bcomplete(d)?|done|shipped\b`).
347
+ */
311
348
  function isComplete(s) {
312
- if (s.total_phases === null || s.current_phase === null)
313
- return false;
314
- return s.current_phase >= s.total_phases && /\bcomplete(d)?|done|shipped\b/i.test(s.status);
349
+ // Prefer ROADMAP-derived counts (global, authoritative) over STATE.md's
350
+ // cached milestone-scoped total_phases (stale-prone). Fall back to legacy
351
+ // when the roadmap has no Progress table.
352
+ if (s.roadmap_total_phases !== null && s.roadmap_completed_phases !== null) {
353
+ if (s.roadmap_total_phases === 0)
354
+ return false;
355
+ if (s.roadmap_completed_phases < s.roadmap_total_phases)
356
+ return false;
357
+ }
358
+ else {
359
+ // Legacy path: STATE.md comparison. Still subject to the two-scale bug,
360
+ // but only fires when ROADMAP.md is absent or has no Progress table.
361
+ if (s.total_phases === null || s.current_phase === null)
362
+ return false;
363
+ if (s.current_phase < s.total_phases)
364
+ return false;
365
+ }
366
+ // Status regex: require milestone-level completion language. The pre-fix
367
+ // regex matched any "shipped" / "done" substring (per-phase language).
368
+ // Tightened to match:
369
+ // - "milestone complete" (ADR-2207 terminal status — usually written as
370
+ // "<version> milestone complete", e.g. "v1.0 milestone complete"; the
371
+ // substring match handles both forms)
372
+ // - "all phases complete" (ADR-2207 intermediate terminal)
373
+ // - "complete" / "completed" (legacy short form — STATE.md milestone
374
+ // status is a single value, not a per-phase log)
375
+ // Intentionally does NOT match "done" alone even though normalizeStateStatus
376
+ // (state-document.cts) treats "done" as "completed" — in the milestone
377
+ // status field, "done" is per-phase noise (e.g. "Phase X done"), not a
378
+ // milestone-completion signal. Mirrors workstream-inventory-builder.cts's
379
+ // terminal pattern \bmilestone\s+complete\b.
380
+ return /\b(milestone\s+complete|all\s+phases\s+complete|complete(d)?)\b/i.test(s.status);
315
381
  }
316
382
  /** Idle-stranded: clean tree, committed work not shipped, optionally stale. */
317
383
  function isIdleStranded(s) {