@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
@@ -31,6 +31,7 @@ const runtime_slash_cjs_1 = require("./runtime-slash.cjs");
31
31
  const schema_detect_cjs_1 = require("./schema-detect.cjs");
32
32
  const artifacts_cjs_1 = require("./artifacts.cjs");
33
33
  const markdown_sectionizer_cjs_1 = require("./markdown-sectionizer.cjs");
34
+ const model_catalog_cjs_1 = require("./model-catalog.cjs");
34
35
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- agent-install-check.cjs is an export= CommonJS module
35
36
  const agentInstallCheck = require("./agent-install-check.cjs");
36
37
  const { checkAgentsInstalled } = agentInstallCheck;
@@ -42,7 +43,7 @@ const configLoaderMod = require("./config-loader.cjs");
42
43
  const { loadConfig, CONFIG_DEFAULTS } = configLoaderMod;
43
44
  // eslint-disable-next-line @typescript-eslint/no-require-imports
44
45
  const phaseIdMod = require("./phase-id.cjs");
45
- const { normalizePhaseName, phaseTokenMatches, escapeRegex, getMilestoneFromPhaseId, OPTIONAL_PHASE_TAG_SOURCE, PHASE_NUMBER_TOKEN_SOURCE } = phaseIdMod;
46
+ const { normalizePhaseName, phaseTokenMatches, escapeRegex, getMilestoneFromPhaseId, OPTIONAL_PHASE_TAG_SOURCE, PHASE_NUMBER_TOKEN_SOURCE, extractPhaseToken, comparePhaseNum } = phaseIdMod;
46
47
  // eslint-disable-next-line @typescript-eslint/no-require-imports
47
48
  const phaseLocatorMod = require("./phase-locator.cjs");
48
49
  const { findPhaseInternal } = phaseLocatorMod;
@@ -52,6 +53,9 @@ const { getMilestoneInfo, stripShippedMilestones, extractCurrentMilestone } = ro
52
53
  // eslint-disable-next-line @typescript-eslint/no-require-imports
53
54
  const worktreeSafetyMod = require("./worktree-safety.cjs");
54
55
  const { inspectWorktreeHealth } = worktreeSafetyMod;
56
+ // eslint-disable-next-line @typescript-eslint/no-require-imports -- commands.cjs is an export= CommonJS module
57
+ const commandsMod = require("./commands.cjs");
58
+ const { determinePhaseStatus } = commandsMod;
55
59
  const { planningDir, planningRoot } = planningWorkspace;
56
60
  const { extractFrontmatter, parseMustHavesBlock } = frontmatterMod;
57
61
  const { writeStateMd } = stateMod;
@@ -59,14 +63,35 @@ const { MODEL_PROFILES } = modelProfilesMod;
59
63
  // Unused but imported for structural parity
60
64
  void stripShippedMilestones;
61
65
  void schema_detect_cjs_1.detectSchemaFiles;
62
- function cmdVerifySummary(cwd, summaryPath, checkFileCount, raw) {
63
- if (!summaryPath) {
64
- error('summary-path required');
65
- }
66
+ /**
67
+ * Pure core of `verify-summary` (#2572).
68
+ *
69
+ * Same artifact↔git checks the CLI verb has always run, lifted out of the
70
+ * `output()` wrapper so other verbs can consume the structured
71
+ * `{ passed, checks, errors }` contract directly instead of shelling out and
72
+ * re-parsing JSON. `cmdVerifySummary` is now a thin adapter over this.
73
+ *
74
+ * Never throws and never writes to stdout: a missing SUMMARY, a non-repo, or an
75
+ * unresolvable commit all come back as structured `false`/`missing` values.
76
+ *
77
+ * Caveat for callers surfacing `commits_exist`: the hash pattern is a loose
78
+ * `\b[0-9a-f]{7,40}\b`, so any hex-shaped token in the prose counts as a
79
+ * candidate. That is cheap as an advisory signal and unacceptable as a gate.
80
+ *
81
+ * @param checkFileCount How many extracted candidates to probe. Defaults to 2 —
82
+ * the value the CLI verb has always used. Pass `Infinity` to probe every
83
+ * candidate (see `cmdPhaseComplete`, which reports on all of them).
84
+ * @param opts.checkCommits When `false`, the `git cat-file` probes are skipped
85
+ * entirely and `commits_exist` comes back `false` meaning *not checked*.
86
+ * Callers that do not surface `commits_exist` should pass `false` so this
87
+ * stays a pure-filesystem check with no subprocess cost.
88
+ */
89
+ function verifySummaryCore(cwd, summaryPath, checkFileCount, opts) {
66
90
  const fullPath = node_path_1.default.join(cwd, summaryPath);
67
91
  const checkCount = checkFileCount || 2;
92
+ const checkCommits = opts?.checkCommits !== false;
68
93
  if (!node_fs_1.default.existsSync(fullPath)) {
69
- const result = {
94
+ return {
70
95
  passed: false,
71
96
  checks: {
72
97
  summary_exists: false,
@@ -76,21 +101,75 @@ function cmdVerifySummary(cwd, summaryPath, checkFileCount, raw) {
76
101
  },
77
102
  errors: ['SUMMARY.md not found'],
78
103
  };
79
- output(result, raw, 'failed');
80
- return;
81
104
  }
82
105
  const content = node_fs_1.default.readFileSync(fullPath, 'utf-8');
83
106
  const errors = [];
107
+ const projectRoot = node_path_1.default.resolve(cwd);
108
+ /**
109
+ * Is `candidate` plausibly a repo-relative file this check should probe?
110
+ *
111
+ * Deliberately narrowing. This is an ADVISORY, so the two error directions are
112
+ * not symmetric: a false positive tells a user their healthy project is
113
+ * missing a file that was never claimed, while a false negative just means one
114
+ * reference goes unprobed. Every rejection below is a noise class confirmed on
115
+ * #2685; when in doubt, skip rather than warn.
116
+ */
117
+ const isProbableProjectFile = (candidate) => {
118
+ // Only repo-relative paths — a bare filename is too ambiguous to locate.
119
+ if (!candidate.includes('/'))
120
+ return false;
121
+ // URLs, protocol-relative links, and any other scheme.
122
+ if (candidate.startsWith('http') || candidate.startsWith('//'))
123
+ return false;
124
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(candidate))
125
+ return false;
126
+ // Globs name a set, not a file: `src/**/*.cts` is never "missing".
127
+ if (/[*?]/.test(candidate))
128
+ return false;
129
+ // Bare hostnames (`docs.example.com/guide.html`). A repo-relative path's
130
+ // first segment is a directory name, which in practice contains a dot only
131
+ // when it is a dotfile directory (`.github/`, `.changeset/`, `.planning/`)
132
+ // — i.e. the dot is at index 0. A dot anywhere later marks a hostname.
133
+ const firstSegment = candidate.split('/')[0] || '';
134
+ if (firstSegment.indexOf('.') > 0)
135
+ return false;
136
+ // Containment guard: a `../`-bearing reference must not turn this advisory
137
+ // into a filesystem existence probe outside the project.
138
+ const resolved = node_path_1.default.resolve(projectRoot, candidate);
139
+ if (resolved !== projectRoot && !resolved.startsWith(projectRoot + node_path_1.default.sep))
140
+ return false;
141
+ return true;
142
+ };
143
+ // Pattern 2 excludes `[` and `]` from its path class (#2685 Blocker 1). All
144
+ // three SUMMARY templates prescribe a YAML flow sequence for `key-files`:
145
+ //
146
+ // key-files:
147
+ // created: [src/auth/login.ts, src/auth/session.ts]
148
+ //
149
+ // and the label matches `(?:Created|Modified|…):` case-insensitively. Without
150
+ // the bracket exclusion the class captures the literal `[` as part of the
151
+ // first path, yielding `[src/auth/login.ts` — a candidate that can never exist
152
+ // on disk. That fired on healthy projects built from GSD's own shipped
153
+ // template. The exclusion also stops a markdown list in the body from
154
+ // reintroducing the same artifact.
155
+ //
156
+ // Stripping frontmatter first was the other remedy offered on #2685. It is a
157
+ // verified no-op on top of this exclusion — measured identical extraction
158
+ // across all three shipped templates — because the exclusion already makes a
159
+ // flow-sequence line contribute nothing. Consequence worth naming: the
160
+ // `key-files` block, the most authoritative statement of what a phase created,
161
+ // is still not read. Recovering it needs a real frontmatter parse, which is
162
+ // deliberately left as a follow-up rather than smuggled in here.
84
163
  const mentionedFiles = new Set();
85
164
  const patterns = [
86
165
  /`([^`]+\.[a-zA-Z]+)`/g,
87
- /(?:Created|Modified|Added|Updated|Edited):\s*`?([^\s`]+\.[a-zA-Z]+)`?/gi,
166
+ /(?:Created|Modified|Added|Updated|Edited):\s*`?([^\s`[\]]+\.[a-zA-Z]+)`?/gi,
88
167
  ];
89
168
  for (const pattern of patterns) {
90
169
  let m;
91
170
  while ((m = pattern.exec(content)) !== null) {
92
171
  const filePath = m[1];
93
- if (filePath && !filePath.startsWith('http') && filePath.includes('/')) {
172
+ if (filePath && isProbableProjectFile(filePath)) {
94
173
  mentionedFiles.add(filePath);
95
174
  }
96
175
  }
@@ -98,12 +177,12 @@ function cmdVerifySummary(cwd, summaryPath, checkFileCount, raw) {
98
177
  const filesToCheck = Array.from(mentionedFiles).slice(0, checkCount);
99
178
  const missing = [];
100
179
  for (const file of filesToCheck) {
101
- if (!node_fs_1.default.existsSync(node_path_1.default.join(cwd, file))) {
180
+ if (!node_fs_1.default.existsSync(node_path_1.default.resolve(projectRoot, file))) {
102
181
  missing.push(file);
103
182
  }
104
183
  }
105
184
  const commitHashPattern = /\b[0-9a-f]{7,40}\b/g;
106
- const hashes = content.match(commitHashPattern) || [];
185
+ const hashes = checkCommits ? content.match(commitHashPattern) || [] : [];
107
186
  let commitsExist = false;
108
187
  if (hashes.length > 0) {
109
188
  for (const hash of hashes.slice(0, 3)) {
@@ -140,8 +219,15 @@ function cmdVerifySummary(cwd, summaryPath, checkFileCount, raw) {
140
219
  self_check: selfCheck,
141
220
  };
142
221
  const passed = missing.length === 0 && selfCheck !== 'failed';
143
- const result = { passed, checks, errors };
144
- output(result, raw, passed ? 'passed' : 'failed');
222
+ return { passed, checks, errors };
223
+ }
224
+ /** CLI adapter over verifySummaryCore — arg guard + output shaping only. */
225
+ function cmdVerifySummary(cwd, summaryPath, checkFileCount, raw) {
226
+ if (!summaryPath) {
227
+ error('summary-path required');
228
+ }
229
+ const result = verifySummaryCore(cwd, summaryPath, checkFileCount);
230
+ output(result, raw, result.passed ? 'passed' : 'failed');
145
231
  }
146
232
  /**
147
233
  * Issue #429 — negative-grep comment-text echo gate.
@@ -516,17 +602,153 @@ function scanFileWideNegativeGateConflict(content) {
516
602
  // This detector is warn-only: it never sets valid=false.
517
603
  return { warnings, valid: true };
518
604
  }
605
+ /**
606
+ * Single pass over `<task ...>…</task>` blocks. The body pattern is
607
+ * ReDoS-safe stop-at-next-open (mirrors `taggedBlockPattern` in
608
+ * markdown-sectionizer.cts): bounded attributes (`[^>]{0,1000}`) and a body
609
+ * boundary that terminates at the NEXT `<task[\s>]` opening, so a document
610
+ * full of unclosed `<task>` openings scans linearly. Captures both the
611
+ * attribute string (group 1, so the `type=` selector is not lost the way it is
612
+ * with `extractTaggedBlocks`) and the body (group 2).
613
+ */
614
+ const PLAN_TASK_BLOCK_RE = /<task(\s[^>]{0,1000})?>((?:(?!<task[\s>])[\s\S])*?)<\/task>/g;
615
+ /**
616
+ * Extract one `PlanTaskInfo` per `<task …>…</task>` block in `content`.
617
+ *
618
+ * Why a dedicated regex instead of `extractTaggedBlocks('task', true)`:
619
+ * `extractTaggedBlocks` discards the opening tag, so the task's `type=`
620
+ * attribute (which selects the validation branch) is lost. This helper
621
+ * captures both the attribute string and the body in one pass, then reuses
622
+ * `extractTaggedBlocks` on the body for sub-element extraction.
623
+ */
624
+ function extractPlanTaskInfos(content) {
625
+ const infos = [];
626
+ if (typeof content !== 'string' || content.length === 0)
627
+ return infos;
628
+ PLAN_TASK_BLOCK_RE.lastIndex = 0;
629
+ let match;
630
+ while ((match = PLAN_TASK_BLOCK_RE.exec(content)) !== null) {
631
+ const attrs = match[1] ?? '';
632
+ const body = match[2] ?? '';
633
+ const typeMatch = attrs.match(/\btype\s*=\s*["']?([\w:-]+)/i);
634
+ const type = typeMatch ? typeMatch[1].toLowerCase() : '';
635
+ const nameArr = (0, markdown_sectionizer_cjs_1.extractTaggedBlocks)(body, 'name');
636
+ const hasName = nameArr.length > 0;
637
+ const name = hasName ? nameArr[0].trim() : '';
638
+ infos.push({
639
+ name,
640
+ type,
641
+ hasName,
642
+ hasFiles: /<files>/.test(body),
643
+ hasAction: /<action>/.test(body),
644
+ hasVerify: /<verify>/.test(body),
645
+ hasDone: /<done>/.test(body),
646
+ hasWhatBuilt: /<what-built>/.test(body),
647
+ hasHowToVerify: /<how-to-verify>/.test(body),
648
+ hasDecision: /<decision>/.test(body),
649
+ hasOptions: /<options>/.test(body),
650
+ hasInstructions: /<instructions>/.test(body),
651
+ hasVerification: /<verification>/.test(body),
652
+ hasResumeSignal: /<resume-signal>/.test(body),
653
+ });
654
+ // Guard against zero-length matches looping forever.
655
+ if (match.index === PLAN_TASK_BLOCK_RE.lastIndex) {
656
+ PLAN_TASK_BLOCK_RE.lastIndex++;
657
+ }
658
+ }
659
+ return infos;
660
+ }
661
+ function isCheckpointType(type) {
662
+ return type.startsWith('checkpoint:');
663
+ }
664
+ /**
665
+ * Validate one plan task's structure against its type-specific canonical field
666
+ * set (per `gsd-core/references/checkpoints.md`):
667
+ * - `checkpoint:human-verify` requires `<what-built>` / `<how-to-verify>` /
668
+ * `<resume-signal>` (the "checkpoint triple").
669
+ * - `checkpoint:decision` requires `<decision>` / `<options>` /
670
+ * `<resume-signal>`.
671
+ * - `checkpoint:human-action` requires `<action>` / `<instructions>` /
672
+ * `<verification>` / `<resume-signal>`.
673
+ * - Unknown `checkpoint:*` subtypes require only the universal
674
+ * `<resume-signal>` (forward-compat — newer checkpoint types registered
675
+ * in the reference don't need a verifier change to pass structure
676
+ * validation).
677
+ * - All other types (`auto`, `tracer`, `manual`, bare `<task>`, …) keep the
678
+ * historical `<action>` / `<verify>` / `<done>` / `<files>` requirements.
679
+ */
680
+ function validatePlanTaskStructure(task) {
681
+ const errors = [];
682
+ const warnings = [];
683
+ const taskName = task.hasName ? task.name : 'unnamed';
684
+ if (!task.hasName) {
685
+ errors.push('Task missing <name> element');
686
+ }
687
+ if (isCheckpointType(task.type)) {
688
+ if (!task.hasResumeSignal) {
689
+ errors.push(`Task '${taskName}' missing <resume-signal>`);
690
+ }
691
+ switch (task.type) {
692
+ case 'checkpoint:human-verify':
693
+ if (!task.hasWhatBuilt)
694
+ errors.push(`Task '${taskName}' missing <what-built>`);
695
+ if (!task.hasHowToVerify)
696
+ errors.push(`Task '${taskName}' missing <how-to-verify>`);
697
+ break;
698
+ case 'checkpoint:decision':
699
+ if (!task.hasDecision)
700
+ errors.push(`Task '${taskName}' missing <decision>`);
701
+ if (!task.hasOptions)
702
+ errors.push(`Task '${taskName}' missing <options>`);
703
+ break;
704
+ case 'checkpoint:human-action':
705
+ if (!task.hasAction)
706
+ errors.push(`Task '${taskName}' missing <action>`);
707
+ if (!task.hasInstructions)
708
+ errors.push(`Task '${taskName}' missing <instructions>`);
709
+ if (!task.hasVerification)
710
+ errors.push(`Task '${taskName}' missing <verification>`);
711
+ break;
712
+ default:
713
+ // Unknown checkpoint:* subtype: <resume-signal> is the only universal
714
+ // requirement (forward-compat).
715
+ break;
716
+ }
717
+ }
718
+ else {
719
+ if (!task.hasAction)
720
+ errors.push(`Task '${taskName}' missing <action>`);
721
+ if (!task.hasVerify)
722
+ warnings.push(`Task '${taskName}' missing <verify>`);
723
+ if (!task.hasDone)
724
+ warnings.push(`Task '${taskName}' missing <done>`);
725
+ if (!task.hasFiles)
726
+ warnings.push(`Task '${taskName}' missing <files>`);
727
+ }
728
+ return { errors, warnings };
729
+ }
519
730
  function cmdVerifyPlanStructure(cwd, filePath, raw) {
520
731
  if (!filePath) {
521
732
  error('file path required');
522
733
  }
734
+ if (filePath.includes('\0')) {
735
+ error('file path contains null bytes');
736
+ }
523
737
  const fullPath = node_path_1.default.isAbsolute(filePath) ? filePath : node_path_1.default.join(cwd, filePath);
524
738
  const content = (0, shell_command_projection_cjs_1.platformReadSync)(fullPath);
525
739
  if (!content) {
526
740
  output({ error: 'File not found', path: filePath }, raw);
527
741
  return;
528
742
  }
529
- const fm = extractFrontmatter(content);
743
+ // #2701: fail loud on NUL/binary corruption before structure checks. A
744
+ // structurally intact-but-NUL-corrupted plan otherwise passes as valid and is
745
+ // silently skipped by recursive/binary-skipping searchers downstream.
746
+ const encErr = (0, validate_cjs_2.textEncodingError)(content, filePath);
747
+ if (encErr) {
748
+ output({ valid: false, errors: [encErr] }, raw);
749
+ return;
750
+ }
751
+ const fm = extractFrontmatter(content, fullPath);
530
752
  const errors = [];
531
753
  const warnings = [];
532
754
  const required = ['phase', 'plan', 'type', 'wave', 'depends_on', 'files_modified', 'autonomous', 'must_haves'];
@@ -534,25 +756,20 @@ function cmdVerifyPlanStructure(cwd, filePath, raw) {
534
756
  if (fm[field] === undefined)
535
757
  errors.push(`Missing required frontmatter field: ${field}`);
536
758
  }
759
+ const extractedTasks = extractPlanTaskInfos(content);
537
760
  const tasks = [];
538
- for (const taskContent of (0, markdown_sectionizer_cjs_1.extractTaggedBlocks)(content, 'task', true)) {
539
- const nameArr = (0, markdown_sectionizer_cjs_1.extractTaggedBlocks)(taskContent, 'name');
540
- const taskName = nameArr.length ? nameArr[0].trim() : 'unnamed';
541
- const hasFiles = /<files>/.test(taskContent);
542
- const hasAction = /<action>/.test(taskContent);
543
- const hasVerify = /<verify>/.test(taskContent);
544
- const hasDone = /<done>/.test(taskContent);
545
- if (nameArr.length === 0)
546
- errors.push('Task missing <name> element');
547
- if (!hasAction)
548
- errors.push(`Task '${taskName}' missing <action>`);
549
- if (!hasVerify)
550
- warnings.push(`Task '${taskName}' missing <verify>`);
551
- if (!hasDone)
552
- warnings.push(`Task '${taskName}' missing <done>`);
553
- if (!hasFiles)
554
- warnings.push(`Task '${taskName}' missing <files>`);
555
- tasks.push({ name: taskName, hasFiles, hasAction, hasVerify, hasDone });
761
+ for (const task of extractedTasks) {
762
+ const verdict = validatePlanTaskStructure(task);
763
+ errors.push(...verdict.errors);
764
+ warnings.push(...verdict.warnings);
765
+ tasks.push({
766
+ name: task.hasName ? task.name : 'unnamed',
767
+ type: task.type,
768
+ hasFiles: task.hasFiles,
769
+ hasAction: task.hasAction,
770
+ hasVerify: task.hasVerify,
771
+ hasDone: task.hasDone,
772
+ });
556
773
  }
557
774
  if (tasks.length === 0)
558
775
  warnings.push('No <task> elements found');
@@ -567,6 +784,24 @@ function cmdVerifyPlanStructure(cwd, filePath, raw) {
567
784
  if (hasCheckpoints && fm['autonomous'] !== 'false' && String(fm['autonomous']) !== 'false') {
568
785
  errors.push('Has checkpoint tasks but autonomous is not false');
569
786
  }
787
+ // #1951: a decision rated one-way is supposed to be confirmed before it is
788
+ // walked through. Warn (never error — <reversibility> stays additive) when a
789
+ // one-way rating has no checkpoint:decision anywhere ahead of it in the plan,
790
+ // which is the planner emitting the rating but skipping the gate.
791
+ const decisionCheckpointOffsets = [];
792
+ for (const m of content.matchAll(/<task\s+type=["']?checkpoint:decision/g)) {
793
+ if (m.index !== undefined)
794
+ decisionCheckpointOffsets.push(m.index);
795
+ }
796
+ for (const m of content.matchAll(/<reversibility\s[^>]*rating=["']?one-way/g)) {
797
+ const at = m.index;
798
+ if (at === undefined)
799
+ continue;
800
+ if (!decisionCheckpointOffsets.some((offset) => offset < at)) {
801
+ warnings.push('Task rated <reversibility rating="one-way"> has no preceding checkpoint:decision — '
802
+ + 'a one-way door must be confirmed before the agent walks through it');
803
+ }
804
+ }
570
805
  const echoScan = scanNegativeGrepCommentEcho(content);
571
806
  errors.push(...echoScan.errors);
572
807
  warnings.push(...echoScan.warnings);
@@ -767,7 +1002,7 @@ function collectPromisedFilesAtOrAfterWave(phaseDir, minWave) {
767
1002
  const planContent = (0, shell_command_projection_cjs_1.platformReadSync)(planFullPath);
768
1003
  if (!planContent)
769
1004
  continue;
770
- const fm = extractFrontmatter(planContent);
1005
+ const fm = extractFrontmatter(planContent, planFullPath);
771
1006
  const waveRaw = fm['wave'];
772
1007
  const wave = typeof waveRaw === 'string' ? parseInt(waveRaw, 10) : (typeof waveRaw === 'number' ? waveRaw : NaN);
773
1008
  if (isNaN(wave) || wave < minWave)
@@ -802,7 +1037,7 @@ function cmdVerifyKeyLinks(cwd, planFilePath, raw) {
802
1037
  }
803
1038
  // Derive the current plan's wave number and phase directory for wave-aware
804
1039
  // missing-file handling (fix #1202).
805
- const currentFm = extractFrontmatter(content);
1040
+ const currentFm = extractFrontmatter(content, fullPath);
806
1041
  const currentWaveRaw = currentFm['wave'];
807
1042
  const currentWave = typeof currentWaveRaw === 'string'
808
1043
  ? parseInt(currentWaveRaw, 10)
@@ -899,8 +1134,16 @@ function listMilestoneArchiveDirs(planBase) {
899
1134
  .map((e) => node_path_1.default.join(milestonesDir, e.name))
900
1135
  .sort((a, b) => node_path_1.default.basename(a).localeCompare(node_path_1.default.basename(b), undefined, { numeric: true }));
901
1136
  }
902
- catch {
903
- return [];
1137
+ catch (err) {
1138
+ // #1883: distinguish genuine absence from a permission/I-O failure. ENOENT
1139
+ // (no milestones/ dir yet) keeps the long-standing [] contract that
1140
+ // collectPhaseRoots / forEachArchivedPhaseToken depend on for "no archives";
1141
+ // every other error (EACCES, EIO, …) must propagate — otherwise an unreadable
1142
+ // milestones/ dir is silently reported as "no archives" and active-milestone
1143
+ // resolution / archived-phase filtering misbehaves.
1144
+ if (err.code === 'ENOENT')
1145
+ return [];
1146
+ throw err;
904
1147
  }
905
1148
  }
906
1149
  function forEachArchivedPhaseToken(planBase, onPhase) {
@@ -1073,8 +1316,9 @@ function cmdValidateConsistency(cwd, raw) {
1073
1316
  }
1074
1317
  }
1075
1318
  for (const plan of plans) {
1076
- const content = node_fs_1.default.readFileSync(node_path_1.default.join(phasePath, plan), 'utf-8');
1077
- const fmData = extractFrontmatter(content);
1319
+ const planFilePath = node_path_1.default.join(phasePath, plan);
1320
+ const content = node_fs_1.default.readFileSync(planFilePath, 'utf-8');
1321
+ const fmData = extractFrontmatter(content, planFilePath);
1078
1322
  if (!fmData['wave']) {
1079
1323
  warnings.push(`${phaseLabel}/${plan}: missing 'wave' in frontmatter`);
1080
1324
  }
@@ -1207,9 +1451,22 @@ function cmdValidateHealth(cwd, options, raw) {
1207
1451
  try {
1208
1452
  const rawCfg = node_fs_1.default.readFileSync(configPath, 'utf-8');
1209
1453
  const parsed = JSON.parse(rawCfg);
1210
- const validProfiles = ['quality', 'balanced', 'budget', 'inherit'];
1211
- if (parsed['model_profile'] && !validProfiles.includes(parsed['model_profile'])) {
1212
- addIssue('warning', 'W004', `config.json: invalid model_profile "${parsed['model_profile']}"`, `Valid values: ${validProfiles.join(', ')}`);
1454
+ if (parsed['model_profile'] && !model_catalog_cjs_1.VALID_PROFILES.includes(parsed['model_profile'])) {
1455
+ addIssue('warning', 'W004', `config.json: invalid model_profile "${parsed['model_profile']}"`, `Valid values: ${model_catalog_cjs_1.VALID_PROFILES.join(', ')}`);
1456
+ }
1457
+ const configModels = parsed['models'];
1458
+ if (configModels && typeof configModels === 'object' && !Array.isArray(configModels)) {
1459
+ for (const [phaseType, tierValue] of Object.entries(configModels)) {
1460
+ if (!model_catalog_cjs_1.VALID_PHASE_TYPES.has(phaseType)) {
1461
+ addIssue('warning', 'W022', `config.json: models has an unknown phase type "${phaseType}" which will be ignored`, `Valid phase types: ${[...model_catalog_cjs_1.VALID_PHASE_TYPES].join(', ')}`);
1462
+ }
1463
+ else if (typeof tierValue !== 'string' || !model_catalog_cjs_1.VALID_TIERS.has(tierValue)) {
1464
+ addIssue('warning', 'W022', `config.json: models.${phaseType} has an invalid tier value ${JSON.stringify(tierValue)} which will be ignored`, `Valid tiers: ${[...model_catalog_cjs_1.VALID_TIERS].join(', ')}`);
1465
+ }
1466
+ }
1467
+ }
1468
+ else if (configModels !== undefined && configModels !== null) {
1469
+ addIssue('warning', 'W022', `config.json: models is set to ${JSON.stringify(configModels)}, but must be an object mapping phase types to tiers — this value will be ignored`, `Set models to an object like {"planning": "sonnet"}, or remove the key to use profile defaults`);
1213
1470
  }
1214
1471
  }
1215
1472
  catch (err) {
@@ -1260,6 +1517,49 @@ function cmdValidateHealth(cwd, options, raw) {
1260
1517
  addIssue('warning', 'W005', `Phase directory "${e.name}" doesn't follow NN-name format`, 'Rename to match pattern (e.g., 01-setup)');
1261
1518
  }
1262
1519
  }
1520
+ // W023 (#2408): detect two or more real on-disk phase directories that
1521
+ // normalize to the same phase key (e.g. `05-real/` + `05-real-stray/`).
1522
+ // The collision silently breaks /gsd-stats status accuracy (now folded by
1523
+ // precedence — see commands.cts foldPhaseStatus) and forces an operator
1524
+ // decision. Wording is neutral — never guesses which directory is "real".
1525
+ {
1526
+ const groups = new Map();
1527
+ for (const e of phaseDirEntries) {
1528
+ // extractPhaseToken never returns empty — for unparseable dir names it
1529
+ // falls back to the dir name itself. Two distinct unparseable names
1530
+ // therefore normalize to distinct keys and cannot false-positive here;
1531
+ // only dirs whose tokens collapse to the same key (e.g. `05-real` and
1532
+ // `05-real-stray` → token `05`) produce a collision group.
1533
+ const token = extractPhaseToken(e.name);
1534
+ const key = normalizePhaseName(token);
1535
+ const list = groups.get(key);
1536
+ if (list)
1537
+ list.push(e.name);
1538
+ else
1539
+ groups.set(key, [e.name]);
1540
+ }
1541
+ for (const [key, dirs] of groups) {
1542
+ if (dirs.length < 2)
1543
+ continue;
1544
+ // Compute each dir's status independently so the warning is informative.
1545
+ // Sort by phase id for stable output regardless of readdir order; tie-
1546
+ // break on the dir name itself so two dirs sharing the same phase token
1547
+ // (the collision case itself) still sort deterministically (V8's stable
1548
+ // sort would otherwise fall back to non-portable fs.readdirSync order).
1549
+ const described = dirs
1550
+ .slice()
1551
+ .sort((a, b) => comparePhaseNum(a, b) || String(a).localeCompare(String(b)))
1552
+ .map((d) => {
1553
+ const files = phaseDirFiles.get(d) || [];
1554
+ const plans = files.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md').length;
1555
+ const summaries = files.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md').length;
1556
+ const status = determinePhaseStatus(plans, summaries, node_path_1.default.join(phasesDir, d), 'Not Started');
1557
+ return `${d} (${status})`;
1558
+ })
1559
+ .join(', ');
1560
+ addIssue('warning', 'W023', `Phase directories collide on normalized key "${key}": ${described}`, 'Inspect each directory; rename or remove the duplicate so only one directory maps to this phase key');
1561
+ }
1562
+ }
1263
1563
  for (const e of phaseDirEntries) {
1264
1564
  const phaseFiles = phaseDirFiles.get(e.name) || [];
1265
1565
  const plans = phaseFiles.filter((f) => f.endsWith('-PLAN.md') || f === 'PLAN.md');
@@ -1949,6 +2249,7 @@ module.exports = {
1949
2249
  scanNegativeGrepCommentEcho,
1950
2250
  scanFileWideNegativeGateConflict,
1951
2251
  cmdVerifySummary,
2252
+ verifySummaryCore,
1952
2253
  cmdVerifyPlanStructure,
1953
2254
  cmdVerifyPhaseCompleteness,
1954
2255
  cmdVerifyReferences,
@@ -1960,4 +2261,9 @@ module.exports = {
1960
2261
  cmdValidateAgents,
1961
2262
  cmdVerifySchemaDrift,
1962
2263
  cmdVerifyCodebaseDrift,
2264
+ // Test seam (#1883): listMilestoneArchiveDirs is private and exercised through
2265
+ // the validate command, which runs in a subprocess — an fs monkeypatch in the
2266
+ // test process cannot reach it. Exposed under a leading underscore so the
2267
+ // permission-error path can be unit-tested directly (no chmod 0o000).
2268
+ _listMilestoneArchiveDirs: listMilestoneArchiveDirs,
1963
2269
  };