@opengsd/gsd-core 1.7.0 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (261) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.opencode/plugins/gsd-core.js +45 -1
  4. package/README.md +2 -0
  5. package/agents/gsd-code-fixer.md +1 -1
  6. package/agents/gsd-codebase-mapper.md +1 -1
  7. package/agents/gsd-debug-session-manager.md +78 -4
  8. package/agents/gsd-debugger.md +87 -29
  9. package/agents/gsd-executor.md +49 -9
  10. package/agents/gsd-intel-updater.md +3 -3
  11. package/agents/gsd-phase-researcher.md +4 -2
  12. package/agents/gsd-plan-checker.md +20 -0
  13. package/agents/gsd-planner.md +44 -59
  14. package/agents/gsd-project-researcher.md +2 -2
  15. package/agents/gsd-ui-auditor.md +0 -40
  16. package/agents/gsd-verifier.md +2 -2
  17. package/bin/install.js +1338 -135
  18. package/commands/gsd/ai-integration-phase.md +1 -1
  19. package/commands/gsd/mempalace-capture.md +9 -5
  20. package/commands/gsd/new-milestone.md +1 -1
  21. package/commands/gsd/plan-phase.md +5 -3
  22. package/commands/gsd/plan-review-convergence.md +7 -2
  23. package/gsd-core/bin/gsd-tools.cjs +2690 -2472
  24. package/gsd-core/bin/lib/adapter-imperative.cjs +8 -1
  25. package/gsd-core/bin/lib/agent-command-router.cjs +20 -5
  26. package/gsd-core/bin/lib/api-coverage.cjs +360 -53
  27. package/gsd-core/bin/lib/audit.cjs +8 -8
  28. package/gsd-core/bin/lib/broken-windows.cjs +716 -0
  29. package/gsd-core/bin/lib/capability-command-router.cjs +733 -0
  30. package/gsd-core/bin/lib/capability-consent.cjs +40 -1
  31. package/gsd-core/bin/lib/capability-lifecycle.cjs +58 -0
  32. package/gsd-core/bin/lib/capability-loader.cjs +23 -1
  33. package/gsd-core/bin/lib/capability-registry.cjs +1450 -160
  34. package/gsd-core/bin/lib/capability-trust.cjs +468 -33
  35. package/gsd-core/bin/lib/capability-validator.cjs +882 -6
  36. package/gsd-core/bin/lib/capability-writer.cjs +6 -1
  37. package/gsd-core/bin/lib/check-command-router.cjs +140 -27
  38. package/gsd-core/bin/lib/cjs-command-router-adapter.cjs +15 -0
  39. package/gsd-core/bin/lib/claude-orchestration-command-router.cjs +209 -31
  40. package/gsd-core/bin/lib/claude-orchestration.cjs +203 -25
  41. package/gsd-core/bin/lib/command-aliases.cjs +14 -0
  42. package/gsd-core/bin/lib/commands.cjs +326 -21
  43. package/gsd-core/bin/lib/config-loader.cjs +214 -30
  44. package/gsd-core/bin/lib/config.cjs +158 -22
  45. package/gsd-core/bin/lib/core-utils.cjs +6 -1
  46. package/gsd-core/bin/lib/decisions.cjs +32 -8
  47. package/gsd-core/bin/lib/docs.cjs +6 -0
  48. package/gsd-core/bin/lib/estimate-cli.cjs +336 -0
  49. package/gsd-core/bin/lib/external-descriptor-trust.cjs +14 -2
  50. package/gsd-core/bin/lib/frontmatter.cjs +125 -15
  51. package/gsd-core/bin/lib/gap-checker.cjs +17 -2
  52. package/gsd-core/bin/lib/host-integration.cjs +215 -8
  53. package/gsd-core/bin/lib/init.cjs +155 -66
  54. package/gsd-core/bin/lib/install-engine.cjs +299 -23
  55. package/gsd-core/bin/lib/install-profiles.cjs +239 -1
  56. package/gsd-core/bin/lib/installer-migrations/005-opencode-baseline-commands-dir.cjs +146 -0
  57. package/gsd-core/bin/lib/installer-migrations/006-pi-extension-cjs-to-js.cjs +91 -0
  58. package/gsd-core/bin/lib/installer-migrations.cjs +44 -5
  59. package/gsd-core/bin/lib/markdown-sectionizer.cjs +107 -0
  60. package/gsd-core/bin/lib/milestone.cjs +248 -14
  61. package/gsd-core/bin/lib/model-catalog.cjs +69 -4
  62. package/gsd-core/bin/lib/model-resolver.cjs +189 -7
  63. package/gsd-core/bin/lib/observability/logger.cjs +7 -2
  64. package/gsd-core/bin/lib/onboard-projection.cjs +11 -8
  65. package/gsd-core/bin/lib/phase-command-router.cjs +10 -1
  66. package/gsd-core/bin/lib/phase-estimation.cjs +398 -0
  67. package/gsd-core/bin/lib/phase-id.cjs +304 -9
  68. package/gsd-core/bin/lib/phase.cjs +258 -17
  69. package/gsd-core/bin/lib/plan-drift-guard.cjs +1 -1
  70. package/gsd-core/bin/lib/plan-scan.cjs +70 -2
  71. package/gsd-core/bin/lib/planning-workspace.cjs +9 -2
  72. package/gsd-core/bin/lib/profile-output.cjs +34 -8
  73. package/gsd-core/bin/lib/review-lane-descriptor.cjs +927 -0
  74. package/gsd-core/bin/lib/review-lane-invocation.cjs +348 -0
  75. package/gsd-core/bin/lib/review-lane-runner.cjs +594 -0
  76. package/gsd-core/bin/lib/review-reviewer-selection.cjs +114 -32
  77. package/gsd-core/bin/lib/roadmap-parser.cjs +61 -10
  78. package/gsd-core/bin/lib/roadmap.cjs +23 -7
  79. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +38 -5
  80. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +23 -9
  81. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +156 -0
  82. package/gsd-core/bin/lib/runtime-name-policy.cjs +15 -2
  83. package/gsd-core/bin/lib/smart-entry.cjs +70 -5
  84. package/gsd-core/bin/lib/state-document.cjs +171 -24
  85. package/gsd-core/bin/lib/state-transition.cjs +50 -11
  86. package/gsd-core/bin/lib/state.cjs +206 -32
  87. package/gsd-core/bin/lib/surface.cjs +51 -9
  88. package/gsd-core/bin/lib/uat-predicate.cjs +6 -4
  89. package/gsd-core/bin/lib/uat.cjs +428 -11
  90. package/gsd-core/bin/lib/ui-consideration-probe.cjs +2 -2
  91. package/gsd-core/bin/lib/unusable-input.cjs +216 -0
  92. package/gsd-core/bin/lib/validate.cjs +44 -8
  93. package/gsd-core/bin/lib/verification.cjs +163 -31
  94. package/gsd-core/bin/lib/verify.cjs +348 -42
  95. package/gsd-core/bin/lib/worktree-safety.cjs +360 -15
  96. package/gsd-core/bin/shared/config-defaults.manifest.json +1 -0
  97. package/gsd-core/bin/shared/config-schema.manifest.json +4 -15
  98. package/gsd-core/bin/shared/model-catalog.json +5 -0
  99. package/gsd-core/bin/shared/runtime-aliases.manifest.json +5 -0
  100. package/gsd-core/references/api-coverage.md +37 -7
  101. package/gsd-core/references/checkpoints.md +1 -1
  102. package/gsd-core/references/common-bug-patterns.md +13 -0
  103. package/gsd-core/references/context-budget.md +40 -0
  104. package/gsd-core/references/debugger-bug-taxonomy.md +111 -0
  105. package/gsd-core/references/debugger-fix-acceptance.md +157 -0
  106. package/gsd-core/references/debugger-philosophy.md +1 -0
  107. package/gsd-core/references/debugger-prevention.md +98 -0
  108. package/gsd-core/references/debugger-rca-branching.md +98 -0
  109. package/gsd-core/references/debugger-repro-hardening.md +130 -0
  110. package/gsd-core/references/debugger-sbfl.md +110 -0
  111. package/gsd-core/references/debugger-semantic-recall.md +81 -0
  112. package/gsd-core/references/execute-phase-quota-recovery.md +55 -0
  113. package/gsd-core/references/execute-phase-requirement-revert.md +8 -0
  114. package/gsd-core/references/execute-phase-response-language.md +7 -0
  115. package/gsd-core/references/gate-prompts.md +6 -3
  116. package/gsd-core/references/model-profile-resolution.md +64 -13
  117. package/gsd-core/references/offer-next.md +88 -0
  118. package/gsd-core/references/planner-antipatterns.md +6 -0
  119. package/gsd-core/references/planner-mvp-mode.md +12 -13
  120. package/gsd-core/references/planner-preconditions.md +156 -0
  121. package/gsd-core/references/planner-reversibility.md +132 -0
  122. package/gsd-core/references/planning-config.md +2 -1
  123. package/gsd-core/references/reviewer-instances.md +28 -19
  124. package/gsd-core/references/runtime-aware-dispatch.md +42 -0
  125. package/gsd-core/references/skeleton-template.md +1 -1
  126. package/gsd-core/references/thinking-models-planning.md +3 -1
  127. package/gsd-core/references/ui-consideration-probe.md +2 -2
  128. package/gsd-core/references/worktree-branch-check.md +4 -4
  129. package/gsd-core/templates/DEBUG.md +5 -3
  130. package/gsd-core/templates/summary-minimal.md +4 -0
  131. package/gsd-core/templates/summary-standard.md +4 -0
  132. package/gsd-core/templates/summary.md +7 -0
  133. package/gsd-core/workflows/add-phase.md +2 -0
  134. package/gsd-core/workflows/add-tests.md +3 -1
  135. package/gsd-core/workflows/add-todo.md +32 -1
  136. package/gsd-core/workflows/ai-integration-phase.md +8 -6
  137. package/gsd-core/workflows/audit-fix.md +6 -2
  138. package/gsd-core/workflows/audit-milestone.md +8 -0
  139. package/gsd-core/workflows/autonomous.md +19 -15
  140. package/gsd-core/workflows/check-todos.md +5 -3
  141. package/gsd-core/workflows/cleanup.md +7 -1
  142. package/gsd-core/workflows/code-review-fix.md +14 -6
  143. package/gsd-core/workflows/code-review.md +93 -24
  144. package/gsd-core/workflows/complete-milestone.md +3 -0
  145. package/gsd-core/workflows/debug.md +35 -7
  146. package/gsd-core/workflows/diagnose-issues.md +5 -1
  147. package/gsd-core/workflows/discovery-phase.md +7 -0
  148. package/gsd-core/workflows/discuss-phase/modes/advisor.md +2 -4
  149. package/gsd-core/workflows/discuss-phase/modes/auto.md +0 -6
  150. package/gsd-core/workflows/discuss-phase/templates/context.md +16 -2
  151. package/gsd-core/workflows/discuss-phase-assumptions.md +18 -9
  152. package/gsd-core/workflows/discuss-phase.md +2 -2
  153. package/gsd-core/workflows/do.md +7 -1
  154. package/gsd-core/workflows/docs-update.md +9 -0
  155. package/gsd-core/workflows/eval-review.md +4 -1
  156. package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +4 -0
  157. package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +160 -0
  158. package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +4 -4
  159. package/gsd-core/workflows/execute-phase/steps/regression-gate.md +2 -2
  160. package/gsd-core/workflows/execute-phase.md +110 -149
  161. package/gsd-core/workflows/execute-plan.md +20 -8
  162. package/gsd-core/workflows/explore.md +4 -0
  163. package/gsd-core/workflows/extract-learnings.md +21 -0
  164. package/gsd-core/workflows/graduation.md +3 -0
  165. package/gsd-core/workflows/health.md +7 -1
  166. package/gsd-core/workflows/help/modes/full.md +9 -5
  167. package/gsd-core/workflows/import.md +11 -2
  168. package/gsd-core/workflows/inbox.md +7 -0
  169. package/gsd-core/workflows/ingest-docs.md +19 -10
  170. package/gsd-core/workflows/manager.md +3 -1
  171. package/gsd-core/workflows/map-codebase.md +17 -10
  172. package/gsd-core/workflows/mvp-phase.md +3 -0
  173. package/gsd-core/workflows/new-milestone.md +79 -23
  174. package/gsd-core/workflows/new-project.md +28 -19
  175. package/gsd-core/workflows/new-workspace.md +3 -1
  176. package/gsd-core/workflows/next.md +5 -2
  177. package/gsd-core/workflows/onboard.md +3 -0
  178. package/gsd-core/workflows/plan-phase.md +56 -51
  179. package/gsd-core/workflows/plan-review-convergence.md +61 -12
  180. package/gsd-core/workflows/plant-seed.md +3 -0
  181. package/gsd-core/workflows/profile-user.md +7 -1
  182. package/gsd-core/workflows/progress.md +31 -3
  183. package/gsd-core/workflows/quick.md +33 -10
  184. package/gsd-core/workflows/remove-workspace.md +3 -0
  185. package/gsd-core/workflows/review.md +172 -585
  186. package/gsd-core/workflows/scan.md +10 -2
  187. package/gsd-core/workflows/secure-phase.md +13 -2
  188. package/gsd-core/workflows/settings-integrations.md +3 -0
  189. package/gsd-core/workflows/settings.md +3 -0
  190. package/gsd-core/workflows/ship.md +88 -11
  191. package/gsd-core/workflows/sketch.md +3 -0
  192. package/gsd-core/workflows/smart-entry.md +4 -1
  193. package/gsd-core/workflows/spike.md +7 -1
  194. package/gsd-core/workflows/ui-phase.md +11 -2
  195. package/gsd-core/workflows/ui-review.md +11 -1
  196. package/gsd-core/workflows/undo.md +7 -0
  197. package/gsd-core/workflows/update.md +106 -5
  198. package/gsd-core/workflows/validate-phase.md +13 -2
  199. package/gsd-core/workflows/verify-phase.md +2 -2
  200. package/gsd-core/workflows/verify-work.md +15 -4
  201. package/hooks/dist/gsd-context-monitor.js +27 -9
  202. package/hooks/dist/gsd-cursor-session-start.js +6 -2
  203. package/hooks/dist/gsd-cursor-stop.js +6 -2
  204. package/hooks/dist/gsd-cursor-subagent-start.js +6 -2
  205. package/hooks/dist/gsd-graphify-update.sh +9 -0
  206. package/hooks/dist/gsd-phase-boundary.sh +14 -2
  207. package/hooks/dist/gsd-prompt-guard.js +101 -2
  208. package/hooks/dist/gsd-read-guard.js +100 -2
  209. package/hooks/dist/gsd-read-injection-scanner.js +109 -2
  210. package/hooks/dist/gsd-statusline.js +97 -9
  211. package/hooks/dist/gsd-workflow-guard.js +110 -6
  212. package/hooks/dist/gsd-worktree-path-guard.js +132 -8
  213. package/hooks/dist/lib/cursor-workspace.js +74 -0
  214. package/hooks/gsd-context-monitor.js +27 -9
  215. package/hooks/gsd-cursor-session-start.js +6 -2
  216. package/hooks/gsd-cursor-stop.js +6 -2
  217. package/hooks/gsd-cursor-subagent-start.js +6 -2
  218. package/hooks/gsd-graphify-update.sh +9 -0
  219. package/hooks/gsd-phase-boundary.sh +14 -2
  220. package/hooks/gsd-prompt-guard.js +101 -2
  221. package/hooks/gsd-read-guard.js +100 -2
  222. package/hooks/gsd-read-injection-scanner.js +109 -2
  223. package/hooks/gsd-statusline.js +97 -9
  224. package/hooks/gsd-workflow-guard.js +110 -6
  225. package/hooks/gsd-worktree-path-guard.js +132 -8
  226. package/hooks/lib/cursor-workspace.js +74 -0
  227. package/package.json +10 -8
  228. package/pi/gsd.cjs +34 -3
  229. package/scripts/changeset/lint.cjs +1 -0
  230. package/scripts/changeset/parse.cjs +26 -0
  231. package/scripts/check-coverage-gate.cjs +51 -0
  232. package/scripts/check-glossary-refs.cjs +244 -0
  233. package/scripts/ci-rebase-check.cjs +48 -4
  234. package/scripts/ci-test-scope.cjs +67 -17
  235. package/scripts/gen-adr-index.cjs +528 -0
  236. package/scripts/gen-capability-matrix.cjs +26 -2
  237. package/scripts/gen-capability-registry.cjs +132 -34
  238. package/scripts/gen-emitted-baseline.cjs +145 -0
  239. package/scripts/gen-test-timings.cjs +201 -0
  240. package/scripts/lint-compiled-artifact-sync.cjs +146 -0
  241. package/scripts/lint-emitted-drift-ack.cjs +149 -0
  242. package/scripts/lint-fix-has-regression-test.cjs +131 -0
  243. package/scripts/lint-portable-timeout.cjs +140 -0
  244. package/scripts/lint-resolution-provenance.cjs +9 -0
  245. package/scripts/lint-test-file-count.allowlist.json +1 -0
  246. package/scripts/mutation-matrix.cjs +4 -0
  247. package/scripts/prompt-injection-scan.sh +6 -0
  248. package/scripts/registry-schema.cjs +57 -8
  249. package/scripts/release-notes/conventional-title.cjs +19 -1
  250. package/scripts/release-notes/format-github-release-notes.cjs +7 -3
  251. package/scripts/release-tarball-smoke.cjs +18 -11
  252. package/scripts/run-tests.cjs +420 -58
  253. package/scripts/workflow-size.cjs +16 -8
  254. package/skills/gsd-ai-integration-phase/SKILL.md +1 -1
  255. package/skills/gsd-mempalace-capture/SKILL.md +9 -5
  256. package/skills/gsd-new-milestone/SKILL.md +1 -1
  257. package/skills/gsd-plan-phase/SKILL.md +5 -3
  258. package/skills/gsd-plan-review-convergence/SKILL.md +7 -2
  259. package/vscode/package.json +1 -1
  260. package/scripts/gen-golden-install-parity-zcode.cjs +0 -77
  261. package/scripts/update-size-baseline.cjs +0 -68
@@ -66,8 +66,8 @@ function classifyElement(text) {
66
66
  */
67
67
  exports.UI_TAXONOMY = [
68
68
  { id: 'empty', name: 'Empty / no data', elements: ['form', 'list-collection', 'media'], consideration: 'What is shown when there is no data — zero items, an unfilled form, or absent media?' },
69
- { id: 'loading', name: 'Loading / in-flight', elements: ['form', 'list-collection', 'media', 'nav'], consideration: 'What is shown while data or content is still loading (skeleton, spinner, progressive reveal)?' },
70
- { id: 'error', name: 'Error / failure', elements: ['form', 'list-collection', 'media', 'nav'], consideration: 'What is shown when the load or submit fails (message, retry affordance, partial fallback)?' },
69
+ { id: 'loading', name: 'Loading / in-flight', elements: ['form', 'list-collection', 'media', 'nav', 'interactive-control'], consideration: 'What is shown while data or content is still loading (skeleton, spinner, progressive reveal)?' },
70
+ { id: 'error', name: 'Error / failure', elements: ['form', 'list-collection', 'media', 'nav', 'interactive-control'], consideration: 'What is shown when the load or submit fails (message, retry affordance, partial fallback)?' },
71
71
  { id: 'populated', name: 'Populated / happy path', elements: ['list-collection', 'media'], consideration: 'What does the normal populated (happy-path) state look like at a typical volume of content?' },
72
72
  { id: 'partial', name: 'Partial / incomplete', elements: ['form', 'list-collection'], consideration: 'What is shown for partial or incomplete data — some fields or rows present, others missing?' },
73
73
  { id: 'overflow', name: 'Overflow / truncation', elements: ['list-collection', 'nav', 'static-content'], consideration: 'What happens when content exceeds its container — scroll, clip, wrap, or truncate?' },
@@ -0,0 +1,216 @@
1
+ "use strict";
2
+ /**
3
+ * Unusable Input Diagnostic — the out-of-band half of ADR-1411's
4
+ * "corrupt is not absent" amendment (epic #1879).
5
+ *
6
+ * ADR-1411 splits the amendment's mechanism in two. Where a read already returns a
7
+ * provenance envelope, the cause is named *in-band* — that is `ConfigResolution.reason`
8
+ * (#1880, shipped). Where a read returns a bare sentinel or a plausible default it cannot
9
+ * extend, the return value is preserved exactly and the cause is surfaced *out-of-band*,
10
+ * as a deduplicated diagnostic on stderr. This module owns that second mechanism.
11
+ *
12
+ * It exists as a shared seam rather than a pattern copied per site because four call sites
13
+ * across four modules need identical behaviour (#1882 frontmatter, #1881 roadmap-parser,
14
+ * #1883 planning-workspace/verify, #1884 planning lock). Four hand-rolled copies of one
15
+ * behaviour is `DEFECT.GENERATIVE-FIX` by construction; one seam with a frozen reason set
16
+ * is the documented cure.
17
+ *
18
+ * Two contracts this module must not break:
19
+ *
20
+ * - **Unconditional.** ADR-1411 diverges deliberately from ADR-227's never-implemented
21
+ * `GSD_DEBUG` opt-in: "an opt-in nobody sets is indistinguishable from the silence
22
+ * #1879 is about". There is no config gate here, by design.
23
+ * - **Never throws.** Callers are leaf readers that promised a total function. A failed
24
+ * stderr write (closed stream, EPIPE) must not turn a silent degradation into a crash.
25
+ */
26
+ var __importDefault = (this && this.__importDefault) || function (mod) {
27
+ return (mod && mod.__esModule) ? mod : { "default": mod };
28
+ };
29
+ const node_crypto_1 = __importDefault(require("node:crypto"));
30
+ // ─── Reason vocabulary ────────────────────────────────────────────────────────
31
+ /**
32
+ * Frozen so tests assert a typed surface instead of diagnostic prose
33
+ * (CONTRIBUTING.md — Prohibited: Raw Text Matching on Test Outputs).
34
+ *
35
+ * Adding a reason is three coordinated changes, matching the repo's `REASON`-enum
36
+ * convention: the entry here, the emitting call site, and the test that locks
37
+ * `Object.keys(UNUSABLE_REASON).sort()`. Each epic-#1879 phase adds only its own —
38
+ * pre-declaring the later phases' reasons would be speculative generality and would
39
+ * leave values no call site emits.
40
+ */
41
+ const UNUSABLE_REASON = Object.freeze({
42
+ /**
43
+ * A file opened a `---` frontmatter fence at byte 0, carried at least one parseable
44
+ * key, and never closed the fence — a truncated or half-written file, NOT a file that
45
+ * legitimately has no frontmatter. (#1882)
46
+ */
47
+ FRONTMATTER_UNTERMINATED: 'frontmatter_unterminated',
48
+ /**
49
+ * A ROADMAP.md exists but could not be read (EACCES/EIO/…). Distinct from a project that
50
+ * simply has no ROADMAP yet: absence returns the same sentinel, silently. (#1881)
51
+ */
52
+ ROADMAP_UNREADABLE: 'roadmap_unreadable',
53
+ });
54
+ /** One human-readable clause per reason. Prose lives here, never in a test assertion. */
55
+ const REASON_PROSE = Object.freeze({
56
+ [UNUSABLE_REASON.FRONTMATTER_UNTERMINATED]: 'frontmatter opens with "---" but never closes; metadata was NOT applied',
57
+ [UNUSABLE_REASON.ROADMAP_UNREADABLE]: 'ROADMAP.md exists but could not be read; phase and milestone lookups fell back to defaults',
58
+ });
59
+ // ─── Dedup state ──────────────────────────────────────────────────────────────
60
+ /**
61
+ * Process-lifetime dedup set. Mirrors `config-loader.cjs`'s `_warnedUnknownConfigKeys`
62
+ * guard, which ADR-1411 names as the precedent to reuse.
63
+ */
64
+ const _warnedUnusableInputs = new Set();
65
+ /**
66
+ * Count of diagnostics actually WRITTEN, which is not the same as the size of the dedup set:
67
+ * one emission records every key the input could later be identified by, so set size counts
68
+ * identities while this counts events. Tests assert on this because the behavioural claim is
69
+ * "how many diagnostics did the operator see", not "how many keys are interned".
70
+ */
71
+ let _unusableInputEmissions = 0;
72
+ /**
73
+ * ASCII control characters (including NUL) are stripped from any path before it is used
74
+ * as a key component or written to a terminal. Two reasons, both real:
75
+ *
76
+ * - the key separator is NUL, so a `sourcePath` containing NUL could otherwise forge a
77
+ * collision with a different (path, reason) pair and suppress a genuine second failure;
78
+ * - a path carrying ANSI escapes would be replayed verbatim into the operator's terminal.
79
+ */
80
+ const CONTROL_CHARS = /[\u0000-\u001F\u007F]/g;
81
+ /**
82
+ * Strip control characters. Deliberately does NOT normalize path separators.
83
+ *
84
+ * An earlier revision folded backslashes to `/` unconditionally, reasoning that `C:\a\b.md`
85
+ * and `C:/a/b.md` are one file and should not report twice. That is true on Windows, and
86
+ * false — destructively — everywhere else: `\` is a legal filename character on Linux and
87
+ * macOS, so `/repo/weird\name/PLAN.md` and `/repo/weird/name/PLAN.md` are two genuinely
88
+ * different files that collapsed to one key, and the second one's diagnostic was silently
89
+ * swallowed. ADR-1411 forbids exactly that ("keying too coarsely suppresses a genuine second
90
+ * failure in a different file"), and this repo targets Linux/macOS/Windows alike.
91
+ *
92
+ * The trade is now explicit and one-directional: two spellings of one Windows path may
93
+ * report twice (mild noise), but two distinct files can never silence each other (lost
94
+ * signal). Dropping a real diagnostic is the strictly worse failure.
95
+ */
96
+ function sanitizeSource(source) {
97
+ return source.replace(CONTROL_CHARS, '');
98
+ }
99
+ /**
100
+ * Identify the offending input. A path is preferred because it is what an operator can act
101
+ * on. When the caller has only an in-memory string, fall back to a short content digest so
102
+ * that *different* bad inputs still produce *different* keys.
103
+ *
104
+ * The leading `p`/`d` tag is what keeps the two namespaces disjoint. Without it a caller
105
+ * whose file is literally named `<unnamed:8efa5269728e7271>` would key identically to a
106
+ * path-less caller whose content happens to hash to that digest — no brute force required,
107
+ * since the digest of any predictable content (a shared template, known boilerplate) can
108
+ * simply be computed and used as a filename to pre-seed suppression. Because control
109
+ * characters — including NUL — are stripped from `source`, a caller-supplied path can never
110
+ * contain the separator and so can never forge a key in the other namespace either.
111
+ *
112
+ * The digest is computed only on the emission path, which is rare, so it never costs
113
+ * anything on a healthy read.
114
+ */
115
+ function sourceKey(source, content) {
116
+ if (typeof source === 'string' && source.trim() !== '') {
117
+ return `p\u0000${sanitizeSource(source)}`;
118
+ }
119
+ const digest = node_crypto_1.default.createHash('sha256').update(content ?? '').digest('hex').slice(0, 16);
120
+ return `d\u0000${digest}`;
121
+ }
122
+ /** Human-facing name for the offending input, derived from the same key. */
123
+ function displaySource(key) {
124
+ return key.startsWith('p\u0000') ? key.slice(2) : `<unnamed:${key.slice(2)}>`;
125
+ }
126
+ /**
127
+ * Emit a deduplicated diagnostic naming an input that exists but cannot be used.
128
+ *
129
+ * The key is `<normalized source>\0<reason>`. ADR-1411 requires the resolved path AND the
130
+ * distinguishing cause — keying on the path alone would let a second, different fault on
131
+ * the same file go unreported; keying on the message prose would couple the guard to
132
+ * wording.
133
+ *
134
+ * @returns `true` when this call actually wrote a diagnostic, `false` when it was
135
+ * deduplicated. Returning the decision is what lets tests assert emission *counts* on a
136
+ * typed surface rather than scraping stderr.
137
+ */
138
+ function warnUnusableInput({ reason, source, content }) {
139
+ // Defensive: an unknown reason must not emit a diagnostic with `undefined` in it.
140
+ const prose = Object.prototype.hasOwnProperty.call(REASON_PROSE, reason)
141
+ ? REASON_PROSE[reason]
142
+ : null;
143
+ if (prose === null)
144
+ return false;
145
+ // The guarantee, stated precisely, because it is not symmetric:
146
+ //
147
+ // * a file reported BY NAME is reported at most once, and
148
+ // * an anonymous re-parse of content already reported by name stays silent, and
149
+ // * two DIFFERENT files always both report, even when their truncated bytes are identical.
150
+ //
151
+ // The asymmetry is the anonymous-FIRST ordering (a path-less parse, then a named parse of the
152
+ // same content), which emits twice. That is a deliberate limit, not an oversight. A path-less
153
+ // caller cannot identify its file, so suppressing the later named report would also suppress a
154
+ // genuine second failure in a DIFFERENT file whenever two files share byte-identical truncated
155
+ // content — the over-coarse keying ADR-1411 explicitly forbids. Between a duplicate line and a
156
+ // swallowed diagnostic the ADR ranks the swallow worse, so the duplicate is accepted; and the
157
+ // second line is the more useful of the two, because it carries the filename.
158
+ //
159
+ // Mechanically: check ONLY the key matching what this caller actually knows, but record every
160
+ // key the input could later be identified by.
161
+ const identity = sourceKey(source, content);
162
+ const keys = [`${identity}\u0000${reason}`];
163
+ if (typeof content === 'string' && identity.startsWith('p\u0000')) {
164
+ keys.push(`${sourceKey(undefined, content)}\u0000${reason}`);
165
+ }
166
+ if (_warnedUnusableInputs.has(keys[0]))
167
+ return false;
168
+ for (const k of keys)
169
+ _warnedUnusableInputs.add(k);
170
+ try {
171
+ process.stderr.write(`gsd: warning — ${displaySource(identity)}: ${prose}. (#1879)\n`);
172
+ // Counted only after a write that actually completed. Incrementing before the try counted
173
+ // attempts, so on a broken stderr the counter claimed a diagnostic had reached the operator
174
+ // when nothing had — a seam documented as "written" reporting something else.
175
+ _unusableInputEmissions += 1;
176
+ }
177
+ catch {
178
+ /* a closed or broken stderr must never escalate a degraded read into a crash */
179
+ }
180
+ return true;
181
+ }
182
+ // ─── Test seams ───────────────────────────────────────────────────────────────
183
+ /**
184
+ * Clear the dedup state between cases.
185
+ *
186
+ * This exists because the set is process-global: without it, the second test to use a key
187
+ * silently observes the first test's suppression. #2674 is the cautionary precedent — a
188
+ * reset helper that cleared two of three sets was a silent no-op for the very suite that
189
+ * existed to test it, and the cases only passed because each happened to pick a key no
190
+ * other case reused.
191
+ */
192
+ function _resetUnusableInputWarningsForTests() {
193
+ _warnedUnusableInputs.clear();
194
+ _unusableInputEmissions = 0;
195
+ }
196
+ /** Number of diagnostics written — the typed surface tests assert on instead of stderr prose. */
197
+ function _unusableInputEmissionCountForTests() {
198
+ return _unusableInputEmissions;
199
+ }
200
+ /** Size of the dedup set (identities interned, not events). Retained for key-shape assertions. */
201
+ function _unusableInputWarningCountForTests() {
202
+ return _warnedUnusableInputs.size;
203
+ }
204
+ /** Test seam: the sanitized form of a source, so control-char handling is asserted on a
205
+ * returned value instead of by scraping what reached stderr. */
206
+ function _sanitizeSourceForTests(source) {
207
+ return sanitizeSource(source);
208
+ }
209
+ module.exports = {
210
+ UNUSABLE_REASON,
211
+ _sanitizeSourceForTests,
212
+ warnUnusableInput,
213
+ _resetUnusableInputWarningsForTests,
214
+ _unusableInputWarningCountForTests,
215
+ _unusableInputEmissionCountForTests,
216
+ };
@@ -37,30 +37,35 @@ exports.canonicalPlanStem = canonicalPlanStem;
37
37
  exports.phaseVariants = phaseVariants;
38
38
  exports.buildRoadmapPhaseVariants = buildRoadmapPhaseVariants;
39
39
  exports.buildNotStartedPhaseVariants = buildNotStartedPhaseVariants;
40
+ exports.textEncodingError = textEncodingError;
40
41
  // eslint-disable-next-line @typescript-eslint/no-require-imports
41
42
  const phaseIdMod = require("./phase-id.cjs");
42
- const { OPTIONAL_PROJECT_CODE_PREFIX_SOURCE, PHASE_NUMBER_TOKEN_SOURCE } = phaseIdMod;
43
+ const { OPTIONAL_PROJECT_CODE_PREFIX_SOURCE, PHASE_NUMBER_TOKEN_SOURCE, PHASE_CONTINUATION_SEGMENT_SOURCE, } = phaseIdMod;
43
44
  // ── Issue #26: regex constants (W005, W006-archived) ────────────────────────
44
45
  // Matches legacy numeric dirs (01-setup), milestone-prefixed dirs (02-01-setup),
45
46
  // deep dirs (02-04-01-deep), and project-code-prefixed variants (GSD-02-01-setup).
46
47
  exports.phaseDirNameRe = new RegExp(`^${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}\\d{2,}(?:-\\d+)*(?:\\.\\d+)*-[\\w-]+$`, 'i');
47
48
  // Extracts the full phase token from a directory name, including milestone-prefixed
48
49
  // multi-segment tokens like "02-01" from "02-01-setup" or "GSD-02-01-setup".
49
- // #2043: a *continuation* sub-phase segment must be zero-padded (≥2 digits), so a
50
+ // #2043: a *continuation* sub-phase segment must be zero-padded, so a
50
51
  // single-digit slug word after a phase number (e.g. "46-6-rs-…", slug "6 Rs …") is
51
- // NOT absorbed — it captures "46", not "46-6". The first component stays "\d+"
52
+ // NOT absorbed — it captures "46", not "46-6". #2232: the continuation width is
53
+ // exactly 2 (PHASE_CONTINUATION_SEGMENT_SOURCE), so a ≥3-digit slug word (a year:
54
+ // "14-2026-photos-…") is not absorbed either — it captures "14", not "14-2026".
55
+ // The first component stays "\d+"
52
56
  // (with the "[A-Z]?" suffix) so single-digit letter-suffixed phase ids ("1A") and
53
57
  // milestone-prefixed single-digit sub-phases ("M1-2" → prefix "M1-" stripped, then
54
58
  // "2") still match. The trailing boundary "(?:-|$)" (was "(?:-[a-z]|$)") lets a slug
55
59
  // that starts with a digit terminate the token.
56
- exports.PHASE_TOKEN_FROM_DIR_RE = new RegExp(`^${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}(\\d+(?:-\\d{2,})*[A-Z]?(?:\\.\\d+)*)(?:-|$)`, 'i');
60
+ exports.PHASE_TOKEN_FROM_DIR_RE = new RegExp(`^${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}(\\d+(?:-${PHASE_CONTINUATION_SEGMENT_SOURCE})*[A-Z]?(?:\\.\\d+)*)(?:-|$)`, 'i');
57
61
  exports.MILESTONE_ARCHIVE_DIR_RE = /^v\d+.*-phases$/i;
58
62
  // ── Issue #26: I001 canonicalization ────────────────────────────────────────
59
63
  function canonicalPlanStem(stem) {
60
- // #2043: the plan component (after the phase number) must be zero-padded
61
- // (≥2 digits), so a digit-leading slug word (e.g. "46-6-rs-…") is not mistaken
62
- // for a "46-6" phase/plan pair.
63
- const m = stem.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE}-\\d{2,})`, 'i'));
64
+ // #2043: the plan component (after the phase number) must be zero-padded,
65
+ // so a digit-leading slug word (e.g. "46-6-rs-…") is not mistaken
66
+ // for a "46-6" phase/plan pair. #2232: exactly 2 digits, so a year-leading
67
+ // slug ("14-2026-photos-…") is not mistaken for a "14-2026" pair either.
68
+ const m = stem.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE}-${PHASE_CONTINUATION_SEGMENT_SOURCE})`, 'i'));
64
69
  return m ? m[1] : stem;
65
70
  }
66
71
  // ── Issue #6: phase variant helpers (W006/W007) ──────────────────────────────
@@ -133,3 +138,34 @@ function buildNotStartedPhaseVariants(roadmapContent) {
133
138
  }
134
139
  return notStartedPhases;
135
140
  }
141
+ /**
142
+ * Detect binary corruption (embedded NUL bytes) in a text artifact's bytes.
143
+ *
144
+ * #2701: the plan/summary/verification/state validators must FAIL LOUD on a
145
+ * NUL-corrupted file instead of reporting `valid: true`. A NUL byte is the
146
+ * unambiguous signal — UTF-8 text never contains 0x00 — and a file carrying one
147
+ * is binary-classified by `file(1)`, then silently OMITTED from recursive /
148
+ * binary-skipping search results (`rg -l`, `grep -rI`, exit 0), so the corruption
149
+ * reads downstream as "file absent" rather than "file corrupt." The error message
150
+ * names that consequence so the next investigator is not misdirected.
151
+ *
152
+ * This is a pure, opt-in check called explicitly by each validator at its own
153
+ * entry point. It is deliberately NOT placed inside the shared `platformReadSync`
154
+ * read primitive (which dozens of best-effort, tolerant reads flow through and
155
+ * which must not start hard-failing on encoding). It does NOT strip, sanitize, or
156
+ * repair the NUL bytes — corruption is a signal of an upstream authoring-tool bug
157
+ * and must stay visible.
158
+ *
159
+ * @param buf the file bytes (Buffer or string; a string is searched char-wise)
160
+ * @param relPath a path/label for the diagnostic message
161
+ * @returns an error string when NUL is found, or `null` when the bytes are clean text
162
+ */
163
+ function textEncodingError(buf, relPath) {
164
+ const nul = typeof buf === 'string' ? buf.indexOf('\0') : buf.indexOf(0x00);
165
+ if (nul === -1)
166
+ return null;
167
+ return (`${relPath}: file contains NUL bytes (first at offset ${nul}). ` +
168
+ 'Artifact files must be UTF-8 text. A NUL-corrupted file is binary-classified ' +
169
+ 'and silently skipped by recursive / binary-skipping search tools (rg, grep -I), ' +
170
+ 'so downstream verification reports its contents as missing rather than corrupt.');
171
+ }
@@ -15,6 +15,17 @@
15
15
  * inside a fenced code block) is ignored — this is the exact failure mode that
16
16
  * issue #586 / PR #650 identified. The shared extractFrontmatter parser anchors
17
17
  * its regex at byte 0 of the document, which provides this guarantee.
18
+ *
19
+ * #2348 staleness signal: whether a *-VERIFICATION.md is stale (a summary newer
20
+ * than it) is decided from git commit time when a file is committed AND clean,
21
+ * and from filesystem mtime otherwise. mtimes are assigned at checkout time and
22
+ * are not preserved by `git clone` / `cp -R`, and any unrelated `touch` /
23
+ * reformat / editor-save re-stales a valid report — so a committed phase could
24
+ * read `passed` on one machine and `stale` on a fresh clone purely from checkout
25
+ * order. Git commit time is content-tied and clone-stable; mtime is retained
26
+ * only for uncommitted or working-tree-dirty files, where it is the true
27
+ * last-changed signal. Both are real wall-clock change times, so the comparison
28
+ * is sound even when one file uses each.
18
29
  */
19
30
  var __importDefault = (this && this.__importDefault) || function (mod) {
20
31
  return (mod && mod.__esModule) ? mod : { "default": mod };
@@ -29,6 +40,8 @@ const phaseId = require("./phase-id.cjs");
29
40
  const frontmatterMod = require("./frontmatter.cjs");
30
41
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- plan-scan.cjs is an export= CommonJS module
31
42
  const scanPhasePlans = require("./plan-scan.cjs");
43
+ const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
44
+ const runtime_slash_cjs_1 = require("./runtime-slash.cjs");
32
45
  const { output, error } = io;
33
46
  const { extractPhaseToken } = phaseId;
34
47
  const { extractFrontmatter } = frontmatterMod;
@@ -49,6 +62,12 @@ const VERIFIER_STATUSES = ['passed', 'gaps_found', 'human_needed'];
49
62
  *
50
63
  * For 'gaps_found', next_command is built at call time in readVerificationStatus
51
64
  * by substituting the phase number — it is NOT stored as a function in the table.
65
+ *
66
+ * #2617: `next_command` here holds a BARE command name (`execute-phase`), never a
67
+ * prefixed one. Every return path projects it through `formatGsdSlash` with the
68
+ * caller's runtime, so Codex sees `$gsd-execute-phase` and slash-hyphen runtimes
69
+ * see `/gsd-execute-phase`. Storing a prefixed literal is what leaked the
70
+ * hard-coded (and deprecated) `/gsd:` colon form to every runtime.
52
71
  */
53
72
  const VERIFICATION_ROUTING_TABLE = {
54
73
  passed: {
@@ -65,7 +84,12 @@ const VERIFICATION_ROUTING_TABLE = {
65
84
  human_needed: {
66
85
  status: 'human_needed',
67
86
  next_action: "Human verification required. Complete the manual tests in the phase's *-UAT.md, then re-run the verify step until status is passed.",
68
- next_command: '',
87
+ // #2617: was '' — next_action told the user to "re-run the verify step" but
88
+ // named no command, while init.cts's parallel projector emitted
89
+ // `verify-work <N>` for this same state. The two surfaces disagreed on
90
+ // whether a next command existed at all; init's answer was the useful one,
91
+ // and init now delegates here rather than re-deriving it.
92
+ next_command: 'verify-work',
69
93
  },
70
94
  stale: {
71
95
  status: 'stale',
@@ -77,30 +101,117 @@ const VERIFICATION_ROUTING_TABLE = {
77
101
  missing: {
78
102
  status: 'missing',
79
103
  next_action: 'No verification report found — the verify step never completed. Re-run execute-phase.',
80
- next_command: '/gsd:execute-phase',
104
+ next_command: 'execute-phase',
81
105
  },
82
106
  // INTERNAL SENTINEL: constructed when the file has a status value not in
83
107
  // VERIFIER_STATUSES. Never emitted by the verifier.
84
108
  unknown: {
85
109
  status: 'unknown',
86
110
  next_action: '', // filled in dynamically with the raw value
87
- next_command: '/gsd:execute-phase',
111
+ next_command: 'execute-phase',
88
112
  },
89
113
  };
114
+ /**
115
+ * Project a BARE command name (plus optional argument tail) into the surface the
116
+ * given runtime actually installs (#2617).
117
+ *
118
+ * `formatGsdSlash` owns the per-runtime shape (`$gsd-<cmd>` for shell-var
119
+ * runtimes like Codex, `/gsd-<cmd>` otherwise) and is idempotent, so passing an
120
+ * already-prefixed string is safe. An empty command stays empty — "no next
121
+ * command" must not become a bare prefix.
122
+ */
123
+ function projectNextCommand(bare, runtime, tail = '') {
124
+ if (!bare)
125
+ return '';
126
+ return `${(0, runtime_slash_cjs_1.formatGsdSlash)(bare, runtime)}${tail}`;
127
+ }
128
+ /** Normalize separators to posix (git emits `/`; callers may pass `\` on Windows). */
129
+ function toPosix(p) {
130
+ return p.replace(/\\/g, '/');
131
+ }
132
+ /**
133
+ * Match a git-emitted (repo-root-relative) path back to the caller's
134
+ * phaseDir-relative request by exact match or `/`-bounded suffix — precise
135
+ * enough that a root file and a nested `plans/` file can never collide (a plain
136
+ * basename match could). Returns the original caller-form file string, or null.
137
+ */
138
+ function matchRequestedFile(gitPath, requested, requestedPosix) {
139
+ const g = toPosix(gitPath);
140
+ for (let i = 0; i < requested.length; i++) {
141
+ const want = requestedPosix[i];
142
+ if (g === want || g.endsWith('/' + want))
143
+ return requested[i];
144
+ }
145
+ return null;
146
+ }
147
+ /**
148
+ * Parse `git log --format=%ct --name-only` output into file → most-recent commit
149
+ * time (ms). Output is reverse-chronological, so a file's FIRST appearance
150
+ * top-down is its latest commit. `%ct` headers are pure digits; path lines
151
+ * contain a `.` (the `.md` extension) — so the two are unambiguous.
152
+ */
153
+ function parseCommitTimes(stdout, requested, requestedPosix) {
154
+ const out = new Map();
155
+ let currentCt = null;
156
+ for (const line of stdout.split('\n')) {
157
+ if (line.length === 0)
158
+ continue;
159
+ if (/^\d+$/.test(line)) {
160
+ currentCt = Number.parseInt(line, 10);
161
+ continue;
162
+ }
163
+ if (currentCt === null)
164
+ continue;
165
+ const rel = matchRequestedFile(line, requested, requestedPosix);
166
+ if (rel !== null && !out.has(rel))
167
+ out.set(rel, currentCt * 1000);
168
+ }
169
+ return out;
170
+ }
171
+ function defaultPhaseCleanCommitTimesMs(phaseDir, files, execGitFn = shell_command_projection_cjs_1.execGit) {
172
+ if (files.length === 0)
173
+ return new Map();
174
+ const requestedPosix = files.map(toPosix);
175
+ const logRes = execGitFn(['log', '--first-parent', '--format=%ct', '--name-only', '--', ...files], {
176
+ cwd: phaseDir,
177
+ });
178
+ if (logRes.error || logRes.exitCode !== 0 || logRes.stdout.length === 0)
179
+ return new Map();
180
+ const commitTimes = parseCommitTimes(logRes.stdout, files, requestedPosix);
181
+ if (commitTimes.size === 0)
182
+ return commitTimes;
183
+ // Drop dirty files (working tree ≠ HEAD) so their mtime is used instead. If the
184
+ // dirty-check itself is INCONCLUSIVE (git diff errored / non-zero — as opposed
185
+ // to "ran and reported no dirty files"), we cannot prove any file is clean, so
186
+ // fail SAFE: discard the commit times and let every file fall back to mtime,
187
+ // the same direction as a git-log failure. Trusting possibly-stale commit times
188
+ // here would silently mask a real edit (false "not stale"). (#2348)
189
+ const diffRes = execGitFn(['diff', '--name-only', 'HEAD', '--', ...files], { cwd: phaseDir });
190
+ if (diffRes.error || diffRes.exitCode !== 0)
191
+ return new Map();
192
+ for (const line of diffRes.stdout.split('\n')) {
193
+ if (line.length === 0)
194
+ continue;
195
+ const rel = matchRequestedFile(line, files, requestedPosix);
196
+ if (rel !== null)
197
+ commitTimes.delete(rel);
198
+ }
199
+ return commitTimes;
200
+ }
90
201
  /**
91
202
  * Build a 'missing' result from the routing table.
92
203
  * Used for two early-return paths: no *-VERIFICATION.md file found, and
93
204
  * file present but no parseable frontmatter status.
94
205
  */
95
- function missingResult() {
206
+ function missingResult(runtime, phaseArg) {
96
207
  const route = VERIFICATION_ROUTING_TABLE['missing'];
97
208
  return {
98
209
  status: route.status,
99
210
  next_action: route.next_action,
100
- next_command: route.next_command,
211
+ next_command: projectNextCommand(route.next_command, runtime, phaseArg),
101
212
  };
102
213
  }
103
- function findStaleVerificationSummary(phaseDir, fsImpl = node_fs_1.default) {
214
+ function findStaleVerificationSummary(phaseDir, fsImpl = node_fs_1.default, phaseCleanCommitTimesMs = defaultPhaseCleanCommitTimesMs) {
104
215
  // FS errors (TOCTOU: a SUMMARY listed by scanPhasePlans then removed before statSync;
105
216
  // unreadable dir; broken symlink; file->dir swap) must degrade to "not stale" rather
106
217
  // than throw uncaught into callers that are NOT under the planning lock
@@ -112,23 +223,31 @@ function findStaleVerificationSummary(phaseDir, fsImpl = node_fs_1.default) {
112
223
  const verificationFile = phaseFiles.filter((f) => f.endsWith('-VERIFICATION.md')).sort()[0];
113
224
  if (!verificationFile)
114
225
  return null;
115
- const verificationMtimeMs = fsImpl.statSync(node_path_1.default.join(phaseDir, verificationFile)).mtimeMs;
116
- let newestStaleSummary = null;
117
- const summaryFiles = scanPhasePlans(phaseDir).summaryFiles;
118
- for (const summaryFile of summaryFiles.sort()) {
119
- const summaryMtimeMs = fsImpl.statSync(node_path_1.default.join(phaseDir, summaryFile)).mtimeMs;
120
- if (summaryMtimeMs <= verificationMtimeMs)
121
- continue;
122
- if (!newestStaleSummary || summaryMtimeMs > newestStaleSummary.mtimeMs) {
123
- newestStaleSummary = { summaryFile, mtimeMs: summaryMtimeMs };
226
+ const summaryFiles = scanPhasePlans(phaseDir).summaryFiles
227
+ .slice()
228
+ .sort();
229
+ // No summary can be newer than the verification → never stale. Return before
230
+ // touching git so a phase with no summaries costs zero subprocesses. (#2348)
231
+ if (summaryFiles.length === 0)
232
+ return null;
233
+ // Each file's effective "last changed" time = its commit time when committed
234
+ // AND clean (content-tied and clone-stable), else its filesystem mtime (the
235
+ // uncommitted working-tree edit). Both are real wall-clock change times, so
236
+ // comparing a clean file's commit time against a dirty file's mtime is sound.
237
+ // One resolver call = two git subprocesses for the whole phase. (#2348)
238
+ const cleanCommitMs = phaseCleanCommitTimesMs(phaseDir, [verificationFile, ...summaryFiles]);
239
+ const effectiveTimeMs = (file) => cleanCommitMs.has(file)
240
+ ? cleanCommitMs.get(file)
241
+ : fsImpl.statSync(node_path_1.default.join(phaseDir, file)).mtimeMs;
242
+ const verificationTimeMs = effectiveTimeMs(verificationFile);
243
+ for (const summaryFile of summaryFiles) {
244
+ // The caller only needs whether the phase is stale, not which summary —
245
+ // the first stale summary (in sorted order) is enough. Short-circuit.
246
+ if (effectiveTimeMs(summaryFile) > verificationTimeMs) {
247
+ return { verificationFile, summaryFile };
124
248
  }
125
249
  }
126
- if (!newestStaleSummary)
127
- return null;
128
- return {
129
- verificationFile,
130
- summaryFile: newestStaleSummary.summaryFile,
131
- };
250
+ return null;
132
251
  }
133
252
  catch {
134
253
  return null;
@@ -148,13 +267,25 @@ function findStaleVerificationSummary(phaseDir, fsImpl = node_fs_1.default) {
148
267
  *
149
268
  * @param phaseDir - Absolute path to the phase directory.
150
269
  * @param opts - Options. `opts.fs` allows test injection (defaults to node:fs).
270
+ * `opts.runtime` selects the command surface `next_command` is
271
+ * projected into (#2617).
151
272
  */
152
273
  function readVerificationStatus(phaseDir, opts = {}) {
153
274
  const fsImpl = opts.fs ?? node_fs_1.default;
275
+ const phaseCleanCommitTimesMs = opts.phaseCleanCommitTimesMs ?? defaultPhaseCleanCommitTimesMs;
276
+ const runtime = opts.runtime ?? 'claude';
154
277
  // Phase token for the gaps_found command
155
278
  const baseName = node_path_1.default.basename(phaseDir);
156
279
  const phaseToken = extractPhaseToken(baseName);
157
- const phaseNumber = phaseToken.length > 0 ? phaseToken : baseName;
280
+ const derivedPhaseNumber = phaseToken.length > 0 ? phaseToken : baseName;
281
+ // #2617: the phase number becomes a COMMAND ARGUMENT, so it is appended only
282
+ // when it is unambiguously one. extractPhaseToken also returns project-code
283
+ // forms (`PROJ-07`), which are indistinguishable by shape from an ordinary
284
+ // directory name — `gsd-651-parent` yields `gsd-651` — and emitting
285
+ // `execute-phase gsd-651` is worse than emitting no argument at all. Callers
286
+ // that already know the number (init) pass it explicitly and always get it.
287
+ const phaseArgSource = opts.phaseNumber ?? (/^\d+(\.\d+)*$/.test(derivedPhaseNumber) ? derivedPhaseNumber : '');
288
+ const phaseArg = phaseArgSource ? ` ${phaseArgSource}` : '';
158
289
  // 1. Find *-VERIFICATION.md
159
290
  let verificationFile = null;
160
291
  try {
@@ -167,7 +298,7 @@ function readVerificationStatus(phaseDir, opts = {}) {
167
298
  verificationFile = null;
168
299
  }
169
300
  if (!verificationFile) {
170
- return missingResult();
301
+ return missingResult(runtime, phaseArg);
171
302
  }
172
303
  // 2. Read and parse frontmatter using the shared parser.
173
304
  // extractFrontmatter anchors at byte 0, so body `status:` lines are ignored.
@@ -175,7 +306,7 @@ function readVerificationStatus(phaseDir, opts = {}) {
175
306
  let rawStatus = null;
176
307
  try {
177
308
  const content = fsImpl.readFileSync(filePath, 'utf-8');
178
- const fm = extractFrontmatter(content);
309
+ const fm = extractFrontmatter(content, filePath);
179
310
  const statusVal = fm['status'];
180
311
  // status is always a scalar string in a well-formed VERIFICATION.md frontmatter;
181
312
  // only accept string values — arrays and objects are not valid status values.
@@ -188,7 +319,7 @@ function readVerificationStatus(phaseDir, opts = {}) {
188
319
  rawStatus = null;
189
320
  }
190
321
  if (!rawStatus) {
191
- return missingResult();
322
+ return missingResult(runtime, phaseArg);
192
323
  }
193
324
  // gaps_found takes priority over stale — gap closure is the correct next
194
325
  // step regardless of whether summaries are newer than the verification file.
@@ -197,16 +328,16 @@ function readVerificationStatus(phaseDir, opts = {}) {
197
328
  return {
198
329
  status: entry.status,
199
330
  next_action: entry.next_action,
200
- next_command: `/gsd:plan-phase ${phaseNumber} --gaps`,
331
+ next_command: projectNextCommand('plan-phase', runtime, `${phaseArg} --gaps`),
201
332
  };
202
333
  }
203
- const staleVerification = findStaleVerificationSummary(phaseDir, fsImpl);
334
+ const staleVerification = findStaleVerificationSummary(phaseDir, fsImpl, phaseCleanCommitTimesMs);
204
335
  if (staleVerification) {
205
336
  const entry = VERIFICATION_ROUTING_TABLE['stale'];
206
337
  return {
207
338
  status: entry.status,
208
339
  next_action: entry.next_action,
209
- next_command: `/gsd:verify-work ${phaseNumber}`,
340
+ next_command: projectNextCommand('verify-work', runtime, phaseArg),
210
341
  };
211
342
  }
212
343
  // 3. Route — exclude internal sentinels from raw-file lookup (they are
@@ -220,7 +351,7 @@ function readVerificationStatus(phaseDir, opts = {}) {
220
351
  return {
221
352
  status: entry.status,
222
353
  next_action: entry.next_action,
223
- next_command: entry.next_command,
354
+ next_command: projectNextCommand(entry.next_command, runtime, phaseArg),
224
355
  };
225
356
  }
226
357
  // Unknown value
@@ -228,7 +359,7 @@ function readVerificationStatus(phaseDir, opts = {}) {
228
359
  return {
229
360
  status: unknownRoute.status,
230
361
  next_action: `Unexpected verification status '${rawStatus}'. Re-run execute-phase verification.`,
231
- next_command: unknownRoute.next_command,
362
+ next_command: projectNextCommand(unknownRoute.next_command, runtime, phaseArg),
232
363
  };
233
364
  }
234
365
  /**
@@ -245,12 +376,13 @@ function cmdVerificationStatus(cwd, phaseDirArg, raw) {
245
376
  return;
246
377
  }
247
378
  const phaseDir = node_path_1.default.resolve(cwd, phaseDirArg);
248
- const result = readVerificationStatus(phaseDir);
379
+ const result = readVerificationStatus(phaseDir, { runtime: (0, runtime_slash_cjs_1.resolveRuntime)(cwd) });
249
380
  output(result, raw);
250
381
  }
251
382
  module.exports = {
252
383
  VERIFIER_STATUSES,
253
384
  VERIFICATION_ROUTING_TABLE,
385
+ defaultPhaseCleanCommitTimesMs,
254
386
  findStaleVerificationSummary,
255
387
  readVerificationStatus,
256
388
  cmdVerificationStatus,