@opengsd/gsd-core 1.12.0 → 1.13.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 (286) 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 +12 -0
  4. package/agents/gsd-executor.md +63 -35
  5. package/agents/gsd-plan-checker.md +76 -57
  6. package/agents/gsd-planner.md +14 -0
  7. package/agents/gsd-ui-checker.md +19 -3
  8. package/agents/gsd-ui-researcher.md +29 -0
  9. package/agents/gsd-verifier.md +23 -1
  10. package/bin/install.js +239 -67
  11. package/commands/gsd/execute-phase.md +1 -1
  12. package/commands/gsd/ns-workflow.md +2 -1
  13. package/commands/gsd/phase.md +1 -1
  14. package/commands/gsd/quick-batch.md +105 -0
  15. package/commands/gsd/surface.md +18 -8
  16. package/gsd-core/bin/gsd-tools.cjs +195 -50
  17. package/gsd-core/bin/lib/capability-activation.cjs +27 -0
  18. package/gsd-core/bin/lib/capability-registry.cjs +514 -114
  19. package/gsd-core/bin/lib/capability-state.cjs +7 -1
  20. package/gsd-core/bin/lib/capability-validator.cjs +120 -4
  21. package/gsd-core/bin/lib/capability-writer.cjs +14 -4
  22. package/gsd-core/bin/lib/check-command-router.cjs +85 -2
  23. package/gsd-core/bin/lib/claude-orchestration.cjs +10 -25
  24. package/gsd-core/bin/lib/clusters.cjs +1 -0
  25. package/gsd-core/bin/lib/command-aliases.cjs +16 -0
  26. package/gsd-core/bin/lib/commands.cjs +337 -13
  27. package/gsd-core/bin/lib/config-loader.cjs +3 -0
  28. package/gsd-core/bin/lib/core-utils.cjs +34 -7
  29. package/gsd-core/bin/lib/decisions.cjs +213 -1
  30. package/gsd-core/bin/lib/edge-probe.cjs +14 -1
  31. package/gsd-core/bin/lib/file-overlap-partitioner.cjs +74 -0
  32. package/gsd-core/bin/lib/frontmatter.cjs +137 -23
  33. package/gsd-core/bin/lib/gap-checker.cjs +22 -13
  34. package/gsd-core/bin/lib/git-base-branch.cjs +10 -2
  35. package/gsd-core/bin/lib/health-diagnostic-rules/phase-structure.cjs +8 -2
  36. package/gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.cjs +54 -11
  37. package/gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs +75 -22
  38. package/gsd-core/bin/lib/host-integration.cjs +57 -5
  39. package/gsd-core/bin/lib/init-command-router.cjs +14 -0
  40. package/gsd-core/bin/lib/init.cjs +132 -15
  41. package/gsd-core/bin/lib/install-engine.cjs +184 -12
  42. package/gsd-core/bin/lib/install-model-override-resolver.cjs +45 -0
  43. package/gsd-core/bin/lib/install-profiles.cjs +22 -14
  44. package/gsd-core/bin/lib/installer-migration-report.cjs +1 -0
  45. package/gsd-core/bin/lib/io.cjs +35 -0
  46. package/gsd-core/bin/lib/loop-resolver.cjs +14 -8
  47. package/gsd-core/bin/lib/markdown-table.cjs +123 -0
  48. package/gsd-core/bin/lib/milestone.cjs +22 -2
  49. package/gsd-core/bin/lib/phase-command-router.cjs +13 -6
  50. package/gsd-core/bin/lib/phase-id.cjs +251 -9
  51. package/gsd-core/bin/lib/phase.cjs +774 -35
  52. package/gsd-core/bin/lib/plan-document.cjs +10 -0
  53. package/gsd-core/bin/lib/planning-snapshot.cjs +147 -20
  54. package/gsd-core/bin/lib/planning-workspace.cjs +103 -28
  55. package/gsd-core/bin/lib/quick-batch-command-router.cjs +285 -0
  56. package/gsd-core/bin/lib/quick-batch-dispatch.cjs +250 -0
  57. package/gsd-core/bin/lib/quick-batch.cjs +840 -0
  58. package/gsd-core/bin/lib/review-lane-descriptor.cjs +53 -5
  59. package/gsd-core/bin/lib/review-lane-invocation.cjs +73 -1
  60. package/gsd-core/bin/lib/review-lane-runner.cjs +136 -10
  61. package/gsd-core/bin/lib/roadmap-parser.cjs +499 -26
  62. package/gsd-core/bin/lib/roadmap.cjs +187 -58
  63. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +233 -33
  64. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +16 -17
  65. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +286 -108
  66. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +215 -43
  67. package/gsd-core/bin/lib/shell-command-projection.cjs +4 -0
  68. package/gsd-core/bin/lib/smart-entry.cjs +7 -9
  69. package/gsd-core/bin/lib/state-document.cjs +30 -5
  70. package/gsd-core/bin/lib/state-md-schema.cjs +23 -13
  71. package/gsd-core/bin/lib/state-transition.cjs +333 -44
  72. package/gsd-core/bin/lib/state.cjs +684 -125
  73. package/gsd-core/bin/lib/surface.cjs +23 -8
  74. package/gsd-core/bin/lib/tdd-red-evidence.cjs +133 -0
  75. package/gsd-core/bin/lib/uat.cjs +1419 -515
  76. package/gsd-core/bin/lib/update-context.cjs +6 -2
  77. package/gsd-core/bin/lib/validate.cjs +230 -12
  78. package/gsd-core/bin/lib/verification-command-router.cjs +2 -1
  79. package/gsd-core/bin/lib/verification.cjs +273 -12
  80. package/gsd-core/bin/lib/verify-command-router.cjs +1 -0
  81. package/gsd-core/bin/lib/verify.cjs +346 -16
  82. package/gsd-core/bin/lib/workstream-inventory.cjs +20 -2
  83. package/gsd-core/bin/lib/worktree-safety.cjs +8 -0
  84. package/gsd-core/bin/shared/config-schema.manifest.json +8 -0
  85. package/gsd-core/bin/verify-reapply-patches.cjs +70 -3
  86. package/gsd-core/references/agent-contracts.md +3 -3
  87. package/gsd-core/references/edge-probe.md +17 -13
  88. package/gsd-core/references/execute-mvp-tdd.md +18 -16
  89. package/gsd-core/references/execute-phase-response-language.md +6 -0
  90. package/gsd-core/references/executor-examples.md +42 -0
  91. package/gsd-core/references/few-shot-examples/plan-checker.md +15 -15
  92. package/gsd-core/references/mvp-concepts.md +2 -2
  93. package/gsd-core/references/plan-checker-examples.md +41 -0
  94. package/gsd-core/references/planner-antipatterns.md +25 -0
  95. package/gsd-core/references/planner-chunked.md +5 -1
  96. package/gsd-core/references/planner-coupling.md +42 -0
  97. package/gsd-core/references/planner-quick-batch.md +71 -0
  98. package/gsd-core/references/planner-reviews.md +47 -0
  99. package/gsd-core/references/planner-revision.md +75 -2
  100. package/gsd-core/references/planning-config.md +2 -1
  101. package/gsd-core/references/response-language-directive.md +9 -0
  102. package/gsd-core/references/revision-loop.md +118 -11
  103. package/gsd-core/references/tdd.md +14 -9
  104. package/gsd-core/references/verifier-evidence-gate.md +160 -0
  105. package/gsd-core/templates/phase-prompt.md +4 -0
  106. package/gsd-core/templates/verification-report.md +5 -0
  107. package/gsd-core/workflows/add-backlog.md +2 -0
  108. package/gsd-core/workflows/add-phase.md +2 -0
  109. package/gsd-core/workflows/add-tests.md +1 -1
  110. package/gsd-core/workflows/add-todo.md +1 -1
  111. package/gsd-core/workflows/ai-integration-phase.md +1 -1
  112. package/gsd-core/workflows/analyze-dependencies.md +2 -0
  113. package/gsd-core/workflows/audit-fix.md +2 -0
  114. package/gsd-core/workflows/audit-milestone.md +2 -0
  115. package/gsd-core/workflows/audit-uat.md +2 -0
  116. package/gsd-core/workflows/autonomous.md +2 -0
  117. package/gsd-core/workflows/check-todos.md +1 -1
  118. package/gsd-core/workflows/cleanup.md +1 -1
  119. package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +15 -13
  120. package/gsd-core/workflows/code-review-fix.md +2 -0
  121. package/gsd-core/workflows/code-review.md +73 -31
  122. package/gsd-core/workflows/complete-milestone.md +13 -4
  123. package/gsd-core/workflows/debug.md +1 -1
  124. package/gsd-core/workflows/diagnose-issues.md +5 -1
  125. package/gsd-core/workflows/discuss-phase/modes/advisor.md +2 -0
  126. package/gsd-core/workflows/discuss-phase/modes/all.md +2 -0
  127. package/gsd-core/workflows/discuss-phase/modes/analyze.md +2 -0
  128. package/gsd-core/workflows/discuss-phase/modes/auto.md +2 -0
  129. package/gsd-core/workflows/discuss-phase/modes/batch.md +2 -0
  130. package/gsd-core/workflows/discuss-phase/modes/chain.md +2 -0
  131. package/gsd-core/workflows/discuss-phase/modes/default.md +2 -0
  132. package/gsd-core/workflows/discuss-phase/modes/power.md +2 -0
  133. package/gsd-core/workflows/discuss-phase/modes/text.md +2 -0
  134. package/gsd-core/workflows/discuss-phase/templates/context.md +2 -0
  135. package/gsd-core/workflows/discuss-phase/templates/discussion-log.md +2 -0
  136. package/gsd-core/workflows/discuss-phase-assumptions.md +1 -1
  137. package/gsd-core/workflows/discuss-phase-power.md +2 -0
  138. package/gsd-core/workflows/discuss-phase.md +1 -1
  139. package/gsd-core/workflows/do.md +43 -13
  140. package/gsd-core/workflows/docs-update.md +1 -1
  141. package/gsd-core/workflows/edit-phase.md +2 -0
  142. package/gsd-core/workflows/eval-review.md +1 -1
  143. package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +2 -0
  144. package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +17 -1
  145. package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +8 -2
  146. package/gsd-core/workflows/execute-phase/steps/regression-gate-run.md +2 -0
  147. package/gsd-core/workflows/execute-phase/steps/tdd-applicability-resolution.md +25 -0
  148. package/gsd-core/workflows/execute-phase/steps/worktree-recovery-policy.md +2 -0
  149. package/gsd-core/workflows/execute-phase.md +32 -14
  150. package/gsd-core/workflows/execute-plan.md +8 -8
  151. package/gsd-core/workflows/explore.md +2 -0
  152. package/gsd-core/workflows/extract-learnings.md +2 -0
  153. package/gsd-core/workflows/fast.md +6 -0
  154. package/gsd-core/workflows/forensics.md +2 -0
  155. package/gsd-core/workflows/graduation.md +1 -1
  156. package/gsd-core/workflows/health.md +1 -1
  157. package/gsd-core/workflows/help/modes/brief.md +2 -0
  158. package/gsd-core/workflows/help/modes/default.md +2 -0
  159. package/gsd-core/workflows/help/modes/full.md +12 -0
  160. package/gsd-core/workflows/help/modes/topic.md +2 -0
  161. package/gsd-core/workflows/help.md +2 -0
  162. package/gsd-core/workflows/import.md +3 -3
  163. package/gsd-core/workflows/inbox.md +1 -1
  164. package/gsd-core/workflows/ingest-docs.md +1 -1
  165. package/gsd-core/workflows/insert-phase.md +2 -0
  166. package/gsd-core/workflows/list-phase-assumptions.md +2 -0
  167. package/gsd-core/workflows/list-seeds.md +2 -0
  168. package/gsd-core/workflows/list-workspaces.md +2 -0
  169. package/gsd-core/workflows/manager.md +3 -3
  170. package/gsd-core/workflows/map-codebase.md +2 -0
  171. package/gsd-core/workflows/milestone-summary.md +2 -0
  172. package/gsd-core/workflows/mvp-phase.md +1 -1
  173. package/gsd-core/workflows/new-milestone.md +1 -1
  174. package/gsd-core/workflows/new-project.md +5 -3
  175. package/gsd-core/workflows/new-workspace.md +1 -1
  176. package/gsd-core/workflows/next.md +2 -0
  177. package/gsd-core/workflows/node-repair.md +2 -0
  178. package/gsd-core/workflows/note.md +2 -0
  179. package/gsd-core/workflows/onboard.md +1 -1
  180. package/gsd-core/workflows/pause-work.md +19 -4
  181. package/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md +100 -18
  182. package/gsd-core/workflows/plan-phase/steps/prd-express-path.md +2 -0
  183. package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +9 -0
  184. package/gsd-core/workflows/plan-phase.md +130 -12
  185. package/gsd-core/workflows/plan-review-convergence.md +102 -10
  186. package/gsd-core/workflows/plant-seed.md +1 -1
  187. package/gsd-core/workflows/pr-branch.md +11 -3
  188. package/gsd-core/workflows/profile-user.md +1 -1
  189. package/gsd-core/workflows/progress/steps/forensic-audit.md +1 -1
  190. package/gsd-core/workflows/progress.md +25 -3
  191. package/gsd-core/workflows/quick/steps/plan-checker-loop.md +37 -2
  192. package/gsd-core/workflows/quick/steps/research-phase.md +3 -3
  193. package/gsd-core/workflows/quick-batch/steps/batch-init.md +55 -0
  194. package/gsd-core/workflows/quick-batch/steps/completion.md +65 -0
  195. package/gsd-core/workflows/quick-batch/steps/merge-wave.md +100 -0
  196. package/gsd-core/workflows/quick-batch/steps/plan-checker-loop.md +147 -0
  197. package/gsd-core/workflows/quick-batch/steps/planner-wave.md +158 -0
  198. package/gsd-core/workflows/quick-batch/steps/research-phase.md +95 -0
  199. package/gsd-core/workflows/quick-batch/steps/resume-mode.md +49 -0
  200. package/gsd-core/workflows/quick-batch/steps/verification-wave.md +73 -0
  201. package/gsd-core/workflows/quick-batch/steps/worktree-dispatch.md +169 -0
  202. package/gsd-core/workflows/quick-batch.md +203 -0
  203. package/gsd-core/workflows/quick.md +13 -3
  204. package/gsd-core/workflows/reapply-patches.md +2 -0
  205. package/gsd-core/workflows/remove-phase.md +2 -0
  206. package/gsd-core/workflows/remove-workspace.md +1 -1
  207. package/gsd-core/workflows/resume-project.md +6 -2
  208. package/gsd-core/workflows/review.md +215 -10
  209. package/gsd-core/workflows/scan.md +2 -0
  210. package/gsd-core/workflows/section-manifest.json +12 -0
  211. package/gsd-core/workflows/secure-phase.md +1 -1
  212. package/gsd-core/workflows/session-report.md +2 -0
  213. package/gsd-core/workflows/settings-advanced.md +2 -0
  214. package/gsd-core/workflows/settings-integrations.md +9 -8
  215. package/gsd-core/workflows/settings.md +1 -1
  216. package/gsd-core/workflows/ship.md +10 -10
  217. package/gsd-core/workflows/sketch-wrap-up.md +2 -0
  218. package/gsd-core/workflows/sketch.md +1 -1
  219. package/gsd-core/workflows/smart-entry.md +1 -1
  220. package/gsd-core/workflows/spec-phase.md +24 -19
  221. package/gsd-core/workflows/spike-wrap-up.md +2 -0
  222. package/gsd-core/workflows/spike.md +1 -1
  223. package/gsd-core/workflows/stats.md +2 -0
  224. package/gsd-core/workflows/sync-skills.md +12 -4
  225. package/gsd-core/workflows/thread.md +2 -0
  226. package/gsd-core/workflows/transition.md +2 -0
  227. package/gsd-core/workflows/ui-phase.md +26 -5
  228. package/gsd-core/workflows/ui-review.md +1 -1
  229. package/gsd-core/workflows/ultraplan-phase.md +2 -0
  230. package/gsd-core/workflows/undo.md +1 -1
  231. package/gsd-core/workflows/update.md +41 -38
  232. package/gsd-core/workflows/validate-phase.md +1 -1
  233. package/gsd-core/workflows/verify-work.md +49 -3
  234. package/hooks/dist/gsd-check-update-worker.js +19 -2
  235. package/hooks/dist/gsd-context-monitor.js +283 -12
  236. package/hooks/dist/gsd-node-runner.sh +1 -0
  237. package/hooks/dist/gsd-prompt-guard.js +30 -5
  238. package/hooks/dist/gsd-read-guard.js +2 -0
  239. package/hooks/dist/gsd-read-injection-scanner.js +5 -5
  240. package/hooks/dist/gsd-secret-read-guard.js +1079 -0
  241. package/hooks/dist/gsd-statusline.js +7 -3
  242. package/hooks/dist/gsd-validate-commit.sh +444 -7
  243. package/hooks/dist/gsd-workflow-guard.js +2 -1
  244. package/hooks/dist/lib/git-cmd.js +210 -1
  245. package/hooks/dist/lib/injection-patterns.js +36 -6
  246. package/hooks/dist/managed-hooks-registry.cjs +1 -0
  247. package/hooks/gsd-check-update-worker.js +19 -2
  248. package/hooks/gsd-context-monitor.js +283 -12
  249. package/hooks/gsd-node-runner.sh +1 -0
  250. package/hooks/gsd-prompt-guard.js +30 -5
  251. package/hooks/gsd-read-guard.js +2 -0
  252. package/hooks/gsd-read-injection-scanner.js +5 -5
  253. package/hooks/gsd-secret-read-guard.js +1079 -0
  254. package/hooks/gsd-statusline.js +7 -3
  255. package/hooks/gsd-validate-commit.sh +444 -7
  256. package/hooks/gsd-workflow-guard.js +2 -1
  257. package/hooks/hooks.json +6 -0
  258. package/hooks/lib/git-cmd.js +210 -1
  259. package/hooks/lib/injection-patterns.js +36 -6
  260. package/hooks/managed-hooks-registry.cjs +1 -0
  261. package/package.json +5 -5
  262. package/scripts/build-hooks.js +11 -4
  263. package/scripts/ci-test-scope.cjs +7 -0
  264. package/scripts/docs-guard-registry.cjs +10 -0
  265. package/scripts/gen-loop-host-contract.cjs +67 -15
  266. package/scripts/lib/shellcheck-fetch.cjs +247 -0
  267. package/scripts/lint-allow-test-rule-refs.allowlist.json +0 -6
  268. package/scripts/lint-allow-test-rule-refs.effective-ceiling.json +1 -1
  269. package/scripts/lint-allow-test-rule-refs.unverified-ceiling.json +1 -1
  270. package/scripts/lint-docs-guard-registration.exempt-baseline.cjs +5 -0
  271. package/scripts/lint-phase-enumeration-drift.cjs +24 -6
  272. package/scripts/lint-phase-id-drift.cjs +133 -8
  273. package/scripts/lint-portable-grep.cjs +176 -0
  274. package/scripts/lint-response-language-coverage.cjs +524 -0
  275. package/scripts/lint-test-file-count.allowlist.json +3 -1
  276. package/scripts/lint-workflow-shellcheck-baseline.json +1027 -0
  277. package/scripts/lint-workflow-shellcheck.cjs +614 -0
  278. package/scripts/npm-audit-baseline.cjs +376 -0
  279. package/scripts/prompt-injection-scan.sh +8 -0
  280. package/scripts/require-issue-link-policy.cjs +16 -1
  281. package/skills/gsd-execute-phase/SKILL.md +1 -1
  282. package/skills/gsd-ns-workflow/SKILL.md +1 -0
  283. package/skills/gsd-phase/SKILL.md +1 -1
  284. package/skills/gsd-quick-batch/SKILL.md +105 -0
  285. package/skills/gsd-surface/SKILL.md +18 -8
  286. package/vscode/package.json +1 -1
@@ -16,10 +16,10 @@ const pattern_cjs_1 = require("./pattern.cjs");
16
16
  const text_lines_cjs_1 = require("./text-lines.cjs");
17
17
  // eslint-disable-next-line @typescript-eslint/no-require-imports
18
18
  const ioMod = require("./io.cjs");
19
- const { output, error, formatDiagnosticToken } = ioMod;
19
+ const { output, error, formatDiagnosticToken, declineNoOp } = ioMod;
20
20
  // eslint-disable-next-line @typescript-eslint/no-require-imports
21
21
  const phaseIdMod = require("./phase-id.cjs");
22
- const { normalizePhaseName, phaseMarkdownRegexSource, matchPhaseDirs, stripProjectCodePrefix, OPTIONAL_PHASE_TAG_SOURCE, roadmapPhaseLookupSources, isSentinelPhaseId, scopeToPhase } = phaseIdMod;
22
+ const { normalizePhaseName, phaseMarkdownRegexSource, matchPhaseDirs, stripProjectCodePrefix, OPTIONAL_PHASE_TAG_SOURCE, roadmapPhaseLookupSources, phaseHeadingPrefixSrcFor, PHASE_HEADING_BASELINE, isSentinelPhaseId, scopeToPhase, bracketQualifiedKey, foldBracketId } = phaseIdMod;
23
23
  // eslint-disable-next-line @typescript-eslint/no-require-imports
24
24
  const phaseLocatorMod = require("./phase-locator.cjs");
25
25
  const { findPhaseInternal, listMilestonePhaseDirs, listAllPhaseDirs } = phaseLocatorMod;
@@ -35,7 +35,7 @@ const phase_lifecycle_cjs_1 = require("./phase-lifecycle.cjs");
35
35
  const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
36
36
  // eslint-disable-next-line @typescript-eslint/no-require-imports
37
37
  const planningWorkspace = require("./planning-workspace.cjs");
38
- const { planningPaths, withPlanningLock, findContextMdIn } = planningWorkspace;
38
+ const { planningPaths, withPlanningLock, findContextMdIn, resolvePhaseIdConvention } = planningWorkspace;
39
39
  // #3641: milestone-scope's convention resolution reads the project config
40
40
  // (no cycle — config-loader does not import this module).
41
41
  // eslint-disable-next-line @typescript-eslint/no-require-imports
@@ -93,22 +93,20 @@ function countPhasePlansAndSummaries(phaseDir) {
93
93
  const { planCount, summaryCount } = scanPhasePlans(phaseDir);
94
94
  // hasContext and hasResearch are not plan-scan concerns — read the directory
95
95
  // once and share the listing for all non-plan metadata that cmdRoadmapAnalyze needs.
96
- let phaseFiles = [];
97
- // #3885 (ADR-3473 §8.5): distinguish "genuinely absent" (ENOENT) from
98
- // "could not read" (EACCES/EIO/...) — the collapse of both to an empty
99
- // listing is exactly the defect class this item closes. Mirrors
100
- // core-utils.cts's getPhaseFileStats / phase-locator.cts's
101
- // listMilestonePhaseDirs SCOPE.UNREADABLE discriminator.
102
- let contextReadError = null;
103
- try {
104
- phaseFiles = node_fs_1.default.readdirSync(phaseDir);
105
- }
106
- catch (err) {
107
- const code = err?.code;
108
- if (code !== 'ENOENT') {
109
- contextReadError = `Could not read phase directory ${formatDiagnosticToken(phaseDir)}: ${formatDiagnosticToken(err?.message ?? String(err))}`;
110
- }
111
- }
96
+ //
97
+ // #4014 (epic #3473 B4): the listing + unreadable-vs-empty discrimination
98
+ // is now owned by findContextMdIn's directory-string form, retiring this
99
+ // function's own readdirSync try/catch (mirrors core-utils.cts's
100
+ // getPhaseFileStats / phase-locator.cts's listMilestonePhaseDirs
101
+ // SCOPE.UNREADABLE discriminator).
102
+ const { files: phaseFiles, scope } = findContextMdIn(phaseDir);
103
+ // #3885 (ADR-3473 §8.5): `contextReadError` stays additive for the shipped
104
+ // `AnalyzePhase.context_read_error` JSON field — derived from `scope`
105
+ // rather than from its own caught error, since findContextMdIn's
106
+ // directory-string form reports SCOPE, not the raw errno message.
107
+ const contextReadError = scope === SCOPE.UNREADABLE
108
+ ? `Could not read phase directory ${formatDiagnosticToken(phaseDir)}`
109
+ : null;
112
110
  // #3511: scope the raw listing to this phase dir before the
113
111
  // phase-numbered-artifact predicates (hasContext/hasResearch) — planCount/
114
112
  // summaryCount above stay on scanPhasePlans's own unscoped listing since a
@@ -121,6 +119,7 @@ function countPhasePlansAndSummaries(phaseDir) {
121
119
  hasContext: findContextMdIn(scopedFiles) !== null,
122
120
  hasResearch: scopedFiles.some(f => f.endsWith('-RESEARCH.md') || f === 'RESEARCH.md'),
123
121
  contextReadError,
122
+ scope,
124
123
  };
125
124
  }
126
125
  // `phaseMarkdownRegexSource` lives in phase-id.cjs (#3537) and is imported above.
@@ -131,22 +130,24 @@ function countPhasePlansAndSummaries(phaseDir) {
131
130
  * exact production pattern instead of hand-duplicating it.
132
131
  * #1729: OPTIONAL_PHASE_TAG_SOURCE after the number tolerates a pre-colon ( ) tag.
133
132
  */
134
- function buildPhaseHeadingRegex(escapedPhase) {
135
- return new RegExp(`^(?:\\[[^\\]]{1,200}\\]\\s*)?Phase\\s+${escapedPhase}${OPTIONAL_PHASE_TAG_SOURCE}:\\s*(.+)$`, 'i');
133
+ function buildPhaseHeadingRegex(escapedPhase, convention) {
134
+ return new RegExp(`^${phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.ANY_BRACKET, convention)}${escapedPhase}${OPTIONAL_PHASE_TAG_SOURCE}:\\s*(.+)$`, 'i');
136
135
  }
137
136
  /**
138
137
  * Search for a phase header (and its section) within the given content string.
139
138
  * Returns a result object if found (either a full match or a malformed_roadmap
140
139
  * checklist-only match), or null if the phase is not present at all.
141
140
  */
142
- function searchPhaseInContent(content, escapedPhase, phaseNum) {
143
- const headingPattern = buildPhaseHeadingRegex(escapedPhase);
141
+ function searchPhaseInContent(content, escapedPhase, phaseNum, convention) {
142
+ const headingPattern = buildPhaseHeadingRegex(escapedPhase, convention);
144
143
  const headings = (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(content);
145
144
  const headingIndex = headings.findIndex((heading) => headingPattern.test(heading.text));
146
145
  const headerMatch = headingIndex === -1 ? null : headings[headingIndex].text.match(headingPattern);
147
146
  if (!headerMatch) {
148
147
  // Fallback: check if phase exists in summary list but missing detail section
149
- const checklistPattern = new RegExp(`-\\s*\\[[ x]\\]\\s*\\*\\*Phase\\s+${escapedPhase}${OPTIONAL_PHASE_TAG_SOURCE}:\\s*([^*]+)\\*\\*`, 'i');
148
+ // A BARE `Phase\s+` at base — takes the label-only baseline, so a bracket
149
+ // repo gains the bracket-ID form and nothing else.
150
+ const checklistPattern = new RegExp(`-\\s*\\[[ x]\\]\\s*\\*\\*${phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.LABEL_ONLY, convention)}${escapedPhase}${OPTIONAL_PHASE_TAG_SOURCE}:\\s*([^*]+)\\*\\*`, 'i');
150
151
  const checklistMatch = content.match(checklistPattern);
151
152
  if (checklistMatch) {
152
153
  return {
@@ -232,11 +233,12 @@ function getRoadmapPhaseWithFallback(cwd, phaseNum) {
232
233
  // #2121/#2114: iterate the shared lookup-source list (exact → numeric →
233
234
  // prefix-tolerant) so this resolver matches getRoadmapPhaseInternal and a
234
235
  // bare-number query resolves a drifted project-code-prefixed heading.
236
+ const convention = resolvePhaseIdConvention(cwd);
235
237
  for (const source of roadmapPhaseLookupSources(phaseNum)) {
236
- const milestoneResult = searchPhaseInContent(milestoneContent, source, phaseNum);
238
+ const milestoneResult = searchPhaseInContent(milestoneContent, source, phaseNum, convention);
237
239
  if (milestoneResult && !milestoneResult.error)
238
240
  return milestoneResult.section ?? null;
239
- const fullResult = searchPhaseInContent(fullContent, source, phaseNum);
241
+ const fullResult = searchPhaseInContent(fullContent, source, phaseNum, convention);
240
242
  if (fullResult && !fullResult.error)
241
243
  return fullResult.section ?? null;
242
244
  }
@@ -258,6 +260,7 @@ function cmdRoadmapGetPhase(cwd, phaseNum, raw) {
258
260
  const rawContent = node_fs_1.default.readFileSync(roadmapPath, 'utf-8');
259
261
  const milestoneContent = extractCurrentMilestone(rawContent, cwd);
260
262
  const fullContent = stripShippedMilestones(rawContent);
263
+ const convention = resolvePhaseIdConvention(cwd);
261
264
  // #2121/#2114: iterate the shared lookup-source list (exact → numeric →
262
265
  // prefix-tolerant) so all three roadmap resolvers share one contract and a
263
266
  // bare-number query resolves a drifted `### Phase AB-29:` heading. This
@@ -268,12 +271,12 @@ function cmdRoadmapGetPhase(cwd, phaseNum, raw) {
268
271
  // heading — so a milestone checklist never blocks a full-roadmap header.
269
272
  let malformed = null;
270
273
  for (const source of roadmapPhaseLookupSources(phaseNum)) {
271
- const milestoneResult = searchPhaseInContent(milestoneContent, source, phaseNum);
274
+ const milestoneResult = searchPhaseInContent(milestoneContent, source, phaseNum, convention);
272
275
  if (milestoneResult && !milestoneResult.error) {
273
276
  output(milestoneResult, raw, milestoneResult.section);
274
277
  return;
275
278
  }
276
- const fullResult = searchPhaseInContent(fullContent, source, phaseNum);
279
+ const fullResult = searchPhaseInContent(fullContent, source, phaseNum, convention);
277
280
  if (fullResult && !fullResult.error) {
278
281
  output(fullResult, raw, fullResult.section);
279
282
  return;
@@ -302,6 +305,30 @@ function cmdRoadmapGetPhase(cwd, phaseNum, raw) {
302
305
  error('Failed to read ROADMAP.md: ' + e.message);
303
306
  }
304
307
  }
308
+ // #612 composes the convention-qualified sentinel reading with upstream's
309
+ // canonical legacy sentinel owner. A reserved bracket milestone OR a reserved
310
+ // phase token excludes the occurrence.
311
+ const isSentinelPhase = (num, bracketId) => {
312
+ if (bracketId && isSentinelPhaseId(`${bracketId}-${num}`, 'bracket'))
313
+ return true;
314
+ return isSentinelPhaseId(num);
315
+ };
316
+ // #2761 M1: missing-detail identity is milestone-qualified under bracket.
317
+ // Prefer the canonical qualified-key owner, which case-folds accepted ids, so
318
+ // `[gsd.02] 01` and `[GSD.02] 01` are one occurrence. It is intentionally not
319
+ // padding-tolerant: the milestone grammar has one canonical spelling (pad2
320
+ // below 100, no leading zero above), so `[GSD.2]` is malformed rather than an
321
+ // alternate spelling of `[GSD.02]`. Hyphenated tokens and other shapes the
322
+ // qualified-key owner refuses retain a folded composite, keeping distinct
323
+ // bracket/token pairs from collapsing onto one missing-detail verdict.
324
+ const occurrenceKey = (num, bracketId) => {
325
+ if (!bracketId)
326
+ return num;
327
+ const qualified = num.includes('-')
328
+ ? null
329
+ : bracketQualifiedKey(`${bracketId}-${num}`, 'bracket');
330
+ return qualified ?? `${foldBracketId(bracketId)}|${num}`;
331
+ };
305
332
  /**
306
333
  * #3165: scan `content` for phase-detail headings (`##/###/#### Phase N: Name`)
307
334
  * and enrich each with its on-disk plan/summary/completion status and ROADMAP
@@ -311,29 +338,41 @@ function cmdRoadmapGetPhase(cwd, phaseNum, raw) {
311
338
  * `cmdRoadmapAnalyze`'s former inline loop so the fallback re-runs the EXACT
312
339
  * same enrichment, not a second derivation.
313
340
  */
314
- function collectAnalyzePhases(content, phasesDir, phaseDirNames) {
341
+ function collectAnalyzePhases(content, phasesDir, phaseDirNames, convention) {
315
342
  // Extract all phase headings: ## Phase N: Name or ### Phase N: Name
316
343
  // #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
344
+ // #612: CAPTURING intro under the bracket convention — group 1 is the
345
+ // `[CODE.MM]` bracket id (undefined otherwise), group 2 the token, group 3 the
346
+ // name. The bracket id is what the sentinel filter needs: READING-B puts the
347
+ // sentinel milestone in the bracket, not in the token.
317
348
  // phase-id-owner: uses the [.-] (dot-or-dash) separator variant, not the canonical dot-only token; a swap to PHASE_NUMBER_TOKEN_SOURCE would drop hyphenated phase-id matches.
318
349
  // #3036: widen the id capture to accept non-numeric-leading ids (e.g. B7, P0.3-2)
319
350
  // that get-phase/execute-phase already resolve. An optional leading letter prefix
320
351
  // ([A-Za-z]?) covers letter-prefixed ids without breaking numeric-leading ones.
321
352
  // phase-id-owner: uses the [.-] (dot-or-dash) separator variant, not the canonical dot-only token; a swap to PHASE_NUMBER_TOKEN_SOURCE would drop hyphenated phase-id matches.
322
- const phasePattern = /#{2,4}\s*(?:\[[^\]]{1,200}\]\s*)?Phase\s+([A-Za-z]?\d+[A-Z]?(?:[.-]\d+)*)(?:\s*\([^)\n]{0,200}\))?\s*:\s*([^\n]+)/gi;
353
+ const phasePattern = new RegExp(`#{2,4}\\s*${phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.ANY_BRACKET, convention, true)}([A-Za-z]?\\d+[A-Z]?(?:[.-]\\d+)*)(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:\\s*([^\\n]+)`, 'gi');
354
+ // The capturing intro inserts the bracket id at group 1 only under the
355
+ // bracket convention; the token and name shift by the same offset.
356
+ const G = convention === 'bracket' ? 1 : 0;
323
357
  const phases = [];
324
358
  let match;
359
+ // The caller needs the exact occurrence identities from the same scan that
360
+ // built `phases`; returning them together also keeps fallback rescans atomic.
361
+ const detailKeys = new Set();
325
362
  while ((match = phasePattern.exec(content)) !== null) {
326
- const phaseNum = match[1];
327
- if (isSentinelPhaseId(phaseNum))
363
+ const bracketId = G ? match[1] : undefined;
364
+ const phaseNum = match[1 + G];
365
+ if (isSentinelPhase(phaseNum, bracketId))
328
366
  continue;
329
- const phaseName = match[2].replace(/\(INSERTED\)/i, '').trim();
367
+ detailKeys.add(occurrenceKey(phaseNum, bracketId));
368
+ const phaseName = match[2 + G].replace(/\(INSERTED\)/i, '').trim();
330
369
  // Extract goal from the section
331
370
  const sectionStart = match.index;
332
371
  const restOfContent = content.slice(sectionStart);
333
372
  // #3691: `\d` → `\d[\d.]*` so decimal phase headings (e.g. `### Phase 02.3:`) are
334
373
  // recognised as section boundaries. #3036: `[A-Za-z]?\d` so non-numeric-leading ids
335
374
  // (e.g. B7) are also recognised.
336
- const nextHeader = restOfContent.match(/\n#{2,4}\s+(?:\[[^\]]{1,200}\]\s*)?Phase\s+[A-Za-z]?\d[\d.-]*/i);
375
+ const nextHeader = restOfContent.match(new RegExp(`\\n#{2,4}\\s+${phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.ANY_BRACKET, convention)}[A-Za-z]?\\d[\\d.-]*`, 'i'));
337
376
  const sectionEnd = nextHeader ? sectionStart + nextHeader.index : content.length;
338
377
  const section = content.slice(sectionStart, sectionEnd);
339
378
  const goalMatch = section.match(/\*\*Goal(?::\*\*|\*\*:)\s*([^\n]+)/i);
@@ -353,13 +392,33 @@ function collectAnalyzePhases(content, phasesDir, phaseDirNames) {
353
392
  // hit a non-ENOENT error — no directory at all is `disk_status:
354
393
  // 'no_directory'`, a real (if uninteresting) answer, not a read error.
355
394
  let contextReadError = null;
395
+ // #4014 (epic #3473 B4): additive sibling — SCOPE.COMPLETE by default
396
+ // (no directory at all is a genuine, not-unreadable answer), overwritten
397
+ // below only when dirMatch resolves.
398
+ let contextScope = SCOPE.COMPLETE;
356
399
  // DEAD catch removed (#2245 audit): matchPhaseDirs(...) is a pure
357
400
  // array lookup on an already-resolved string array, and
358
401
  // countPhasePlansAndSummaries is itself fully defensive (its own
359
402
  // readdirSync is self-guarded, and it delegates to scanPhasePlans, which
360
403
  // never throws) — nothing in this block can throw, so the try/catch could
361
404
  // never be triggered.
362
- const dirMatch = matchPhaseDirs(phaseDirNames, normalized).matches[0];
405
+ // #612: the DIRECTORY read is selected by the same `convention` the four
406
+ // heading/checklist patterns above already thread. Left two-argument, this
407
+ // one call reported EVERY canonical `{CODE}.{MM}-{PP}-slug` directory as
408
+ // `disk_status: "no_directory"` with `plan_count`/`summary_count` 0 —
409
+ // `extractPhaseToken('GSD.02-01-one')` with no convention returns the whole
410
+ // dir name — while the same build resolved those same directories correctly
411
+ // in three other places on the same repo (W006/W007 through their shared
412
+ // directory matcher, `state json` via the milestone filter, and the W026
413
+ // milestone-complete read through the same convention-aware owner). It
414
+ // failed ONLY for the directory shape the convention exists to name: a
415
+ // mid-migration bracket repo carrying legacy `01-one` dirs resolved fine.
416
+ // That is verbatim the asymmetry the note above the W026 rule says this PR
417
+ // closed — the directory read widens with the heading read, or every bracket
418
+ // phase resolves to nothing.
419
+ // Upstream centralized this choice in `matchPhaseDirs`; thread the same
420
+ // convention into that owner rather than reviving the primitive `.find()`.
421
+ const dirMatch = matchPhaseDirs(phaseDirNames, normalized, convention).matches[0];
363
422
  if (dirMatch) {
364
423
  const counts = countPhasePlansAndSummaries(node_path_1.default.join(phasesDir, dirMatch));
365
424
  planCount = counts.planCount;
@@ -367,6 +426,7 @@ function collectAnalyzePhases(content, phasesDir, phaseDirNames) {
367
426
  hasContext = counts.hasContext;
368
427
  hasResearch = counts.hasResearch;
369
428
  contextReadError = counts.contextReadError;
429
+ contextScope = counts.scope;
370
430
  // ADR-3180 §7.4 (issue #3186, disk-strict, #3168 fix): route "is this
371
431
  // phase complete" through the canonical owner (`isPhaseComplete`),
372
432
  // which calls readVerificationStatus UNCONDITIONALLY — plan count is
@@ -401,7 +461,7 @@ function collectAnalyzePhases(content, phasesDir, phaseDirNames) {
401
461
  // checkbox — no passing `*-VERIFICATION.md`, plans outstanding — now
402
462
  // reports incomplete; this is the deliberate Tier-2 break (ADR-3180 §7.4
403
463
  // Decision 3).
404
- const checkboxPattern = new RegExp(`-\\s*\\[(x| )\\]\\s*.*Phase\\s+${phaseMarkdownRegexSource(phaseNum)}${OPTIONAL_PHASE_TAG_SOURCE}[:\\s]`, 'i');
464
+ const checkboxPattern = new RegExp(`-\\s*\\[(x| )\\]\\s*.*${phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.LABEL_ONLY, convention)}${phaseMarkdownRegexSource(phaseNum)}${OPTIONAL_PHASE_TAG_SOURCE}[:\\s]`, 'i');
405
465
  const checkboxMatch = content.match(checkboxPattern);
406
466
  const roadmapComplete = checkboxMatch ? checkboxMatch[1] === 'x' : false;
407
467
  phases.push({
@@ -417,6 +477,7 @@ function collectAnalyzePhases(content, phasesDir, phaseDirNames) {
417
477
  disk_status: diskStatus,
418
478
  roadmap_complete: roadmapComplete,
419
479
  context_read_error: contextReadError,
480
+ context_scope: contextScope,
420
481
  });
421
482
  }
422
483
  // #3577: markdown-table row declarations join the enumeration — same
@@ -426,6 +487,9 @@ function collectAnalyzePhases(content, phasesDir, phaseDirNames) {
426
487
  const stripPadA = (s) => s.replace(/^0+(?=.)/, '');
427
488
  const seen = new Set(phases.map((ph) => stripPadA(ph.number)));
428
489
  for (const tr of collectTablePhaseRows(content)) {
490
+ // #3577 table declarations were part of the pre-existing detail set.
491
+ // Preserve that behavior while heading occurrences gain bracket identity.
492
+ detailKeys.add(occurrenceKey(tr.id));
429
493
  if (seen.has(stripPadA(tr.id)))
430
494
  continue;
431
495
  const dirMatchA = matchPhaseDirs(phaseDirNames, normalizePhaseName(tr.id)).matches[0];
@@ -434,6 +498,9 @@ function collectAnalyzePhases(content, phasesDir, phaseDirNames) {
434
498
  let tHasContext = false;
435
499
  let tHasResearch = false;
436
500
  let tContextReadError = null;
501
+ // #4014 (epic #3473 B4): additive sibling, same default rule as the
502
+ // heading-declared branch above.
503
+ let tContextScope = SCOPE.COMPLETE;
437
504
  if (dirMatchA) {
438
505
  const counts = countPhasePlansAndSummaries(node_path_1.default.join(phasesDir, dirMatchA));
439
506
  tPlanCount = counts.planCount;
@@ -445,6 +512,7 @@ function collectAnalyzePhases(content, phasesDir, phaseDirNames) {
445
512
  // existsSync (which cannot itself distinguish EACCES from absent), but
446
513
  // an unreadable phase directory is still surfaced via the sibling call.
447
514
  tContextReadError = counts.contextReadError;
515
+ tContextScope = counts.scope;
448
516
  }
449
517
  phases.push({
450
518
  number: tr.id,
@@ -459,9 +527,10 @@ function collectAnalyzePhases(content, phasesDir, phaseDirNames) {
459
527
  disk_status: dirMatchA ? 'ok' : 'no_directory',
460
528
  roadmap_complete: false,
461
529
  context_read_error: tContextReadError,
530
+ context_scope: tContextScope,
462
531
  });
463
532
  }
464
- return phases;
533
+ return { phases, detailKeys };
465
534
  }
466
535
  function cmdRoadmapAnalyze(cwd, raw) {
467
536
  const roadmapPath = planningPaths(cwd).roadmap;
@@ -475,6 +544,10 @@ function cmdRoadmapAnalyze(cwd, raw) {
475
544
  // indistinguishable from a genuinely empty milestone.
476
545
  const { value: content, scope } = extractCurrentMilestoneScoped(rawContent, cwd);
477
546
  const phasesDir = planningPaths(cwd).phases;
547
+ // #612: resolve once per command and thread the same reading through both
548
+ // the scoped scan and any fallback scan.
549
+ const convention = resolvePhaseIdConvention(cwd);
550
+ const G = convention === 'bracket' ? 1 : 0;
478
551
  // Build phase directory lookup once (O(1) readdir instead of O(N) per phase)
479
552
  // #3185 exemption reason (ADR-3180 Decision 4a): this is a heading->directory
480
553
  // LOOKUP INDEX, not a milestone enumeration. It must see the PHYSICAL set so
@@ -490,7 +563,9 @@ function cmdRoadmapAnalyze(cwd, raw) {
490
563
  // Scan the scoped milestone window for phase-detail headings and enrich each
491
564
  // with its on-disk status. Extracted into `collectAnalyzePhases` (#3165) so
492
565
  // the SAME enrichment re-runs on the fallback below — not a second copy.
493
- let phases = collectAnalyzePhases(content, phasesDir, _phaseDirNames);
566
+ let collected = collectAnalyzePhases(content, phasesDir, _phaseDirNames, convention);
567
+ let phases = collected.phases;
568
+ let detailKeys = collected.detailKeys;
494
569
  // `effectiveContent` is what the downstream checklist scan (missing_details)
495
570
  // iterates. Defaults to the scoped window; switched to the fallback document
496
571
  // when the recovery path below fires, so a phase found via fallback is not
@@ -512,9 +587,11 @@ function cmdRoadmapAnalyze(cwd, raw) {
512
587
  // populated, flagged result.
513
588
  if (phases.length === 0 && scope !== SCOPE.COMPLETE && _phaseDirNames.length > 0) {
514
589
  const fallbackContent = stripShippedMilestones(rawContent);
515
- const fallbackPhases = collectAnalyzePhases(fallbackContent, phasesDir, _phaseDirNames);
516
- if (fallbackPhases.length > 0) {
517
- phases = fallbackPhases;
590
+ const fallbackCollection = collectAnalyzePhases(fallbackContent, phasesDir, _phaseDirNames, convention);
591
+ if (fallbackCollection.phases.length > 0) {
592
+ collected = fallbackCollection;
593
+ phases = collected.phases;
594
+ detailKeys = collected.detailKeys;
518
595
  effectiveContent = fallbackContent;
519
596
  }
520
597
  }
@@ -537,17 +614,41 @@ function cmdRoadmapAnalyze(cwd, raw) {
537
614
  // The char class must allow `-` (not just `.`) so dash-separated milestone-prefixed
538
615
  // IDs (e.g. `1-01`) match the detail-heading scanner above; otherwise they truncate
539
616
  // at the dash (`1-01` -> `1`) and every such phase reports a phantom missing detail.
617
+ // #612: CAPTURING label-only intro — the bracket id rides along so the
618
+ // sentinel filter below is not blind to `- [ ] **[GSD.999] 01: Icebox**`.
540
619
  // phase-id-owner: uses the [.-] (dot-or-dash) separator variant, not the canonical dot-only token; a swap to PHASE_NUMBER_TOKEN_SOURCE would drop hyphenated phase-id matches.
541
620
  // #3036: widen to accept non-numeric-leading ids (same widening as the detail-heading pattern above).
542
621
  // phase-id-owner: uses the [.-] (dot-or-dash) separator variant, not the canonical dot-only token; a swap to PHASE_NUMBER_TOKEN_SOURCE would drop hyphenated phase-id matches.
543
- const checklistPattern = /-\s*\[[ x]\]\s*\*\*Phase\s+([A-Za-z]?\d+[A-Z]?(?:[.-]\d+)*)/gi;
544
- const checklistPhases = new Set();
622
+ const checklistPattern = new RegExp(`-\\s*\\[[ x]\\]\\s*\\*\\*${phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.LABEL_ONLY, convention, true)}([A-Za-z]?\\d+[A-Z]?(?:[.-]\\d+)*)`, 'gi');
623
+ // #2761 M1: an OCCURRENCE list keyed by `occurrenceKey`, not a token->bracket
624
+ // map. The map was first-wins on the bare token, so of two checklist entries
625
+ // sharing a token across brackets the FIRST one's bracket id classified BOTH:
626
+ // `- [ ] **[GSD.999] 01: Icebox**` written above `- [ ] **[GSD.02] 01: …**`
627
+ // made the real phase inherit the icebox's sentinel verdict and vanish from
628
+ // `missing_phase_details`; written below it, the same document reported it.
629
+ // Dedupe still happens — it is now per PHASE rather than per token, which is
630
+ // what makes the classification order-independent.
631
+ const checklistOccurrences = [];
632
+ const seenChecklistKeys = new Set();
545
633
  let checklistMatch;
546
634
  while ((checklistMatch = checklistPattern.exec(effectiveContent)) !== null) {
547
- checklistPhases.add(checklistMatch[1]);
635
+ const token = checklistMatch[1 + G];
636
+ const bracketId = G ? checklistMatch[1] : undefined;
637
+ const key = occurrenceKey(token, bracketId);
638
+ if (seenChecklistKeys.has(key))
639
+ continue;
640
+ seenChecklistKeys.add(key);
641
+ checklistOccurrences.push({ token, bracketId });
548
642
  }
549
- const detailPhases = new Set(phases.map(p => p.number));
550
- const missingDetails = [...checklistPhases].filter(p => !detailPhases.has(p) && !isSentinelPhaseId(p));
643
+ // The EMITTED value stays the bare token, unchanged: `phases[].number` is a
644
+ // token under every convention, and `missing_phase_details` is read against
645
+ // it. Only the classification moved to the qualified key — so two different
646
+ // brackets' `01` both missing report `01` once, rather than one of them
647
+ // silently covering for the other.
648
+ const missingDetails = [...new Set(checklistOccurrences
649
+ .filter(o => !detailKeys.has(occurrenceKey(o.token, o.bracketId))
650
+ && !isSentinelPhase(o.token, o.bracketId))
651
+ .map(o => o.token))];
551
652
  // #3217 (ADR-3180 §7.6 rules 3-4): `progress_percent` used to accumulate
552
653
  // `totalPlans`/`totalSummaries` above — a heading-matched enumeration
553
654
  // (`phasePattern` over the milestone-windowed `content`) paired against
@@ -672,7 +773,7 @@ function cmdRoadmapMilestoneScope(cwd, raw) {
672
773
  }
673
774
  const { value: window, scope } = extractCurrentMilestoneScoped(rawContent, cwd, undefined, phaseIdConvention);
674
775
  // Document order (Set insertion order) — deterministic for a given document.
675
- const phases = [...scanMilestonePhaseIds(window)];
776
+ const phases = [...scanMilestonePhaseIds(window, phaseIdConvention)];
676
777
  output({ scope, phases, phase_count: phases.length }, raw, undefined);
677
778
  }
678
779
  // ─── cmdRoadmapUpdatePlanProgress ─────────────────────────────────────────────
@@ -724,7 +825,7 @@ function cmdRoadmapUpdatePlanProgress(cwd, phaseNum, raw) {
724
825
  // inflate summary_count and silently flip the phase to Complete.
725
826
  const summaryCount = countMatchedSummaries(phaseInfo.plans, phaseInfo.summaries);
726
827
  if (planCount === 0) {
727
- output({ updated: false, reason: 'No plans found', plan_count: 0, summary_count: 0 }, raw, 'no plans');
828
+ declineNoOp(raw, 'updated', 'No plans found', 'roadmap update-plan-progress skipped — no plans found for this phase. ROADMAP.md was left unchanged.', { plan_count: 0, summary_count: 0 });
728
829
  return;
729
830
  }
730
831
  // Verification gate (#2022): do NOT check the phase checkbox or stamp a
@@ -766,12 +867,19 @@ function cmdRoadmapUpdatePlanProgress(cwd, phaseNum, raw) {
766
867
  const status = isComplete ? 'Complete' : summaryCount > 0 ? 'In Progress' : 'Planned';
767
868
  const today = clock_cjs_1.realClock.localToday();
768
869
  if (!node_fs_1.default.existsSync(roadmapPath)) {
769
- output({ updated: false, reason: 'ROADMAP.md not found', plan_count: planCount, summary_count: summaryCount }, raw, 'no roadmap');
870
+ declineNoOp(raw, 'updated', 'ROADMAP.md not found', 'roadmap update-plan-progress skipped — ROADMAP.md not found.', { plan_count: planCount, summary_count: summaryCount });
770
871
  return;
771
872
  }
772
873
  // Wrap entire read-modify-write in lock to prevent concurrent corruption
874
+ let updated = false;
773
875
  withPlanningLock(cwd, () => {
774
- let roadmapContent = node_fs_1.default.readFileSync(roadmapPath, 'utf-8');
876
+ // #3957 (B9.4): captured BEFORE any transform runs, so the write/report
877
+ // decision below reflects whether the transforms actually changed
878
+ // anything — not just that they ran. Every transform below still runs
879
+ // unconditionally exactly as before; only the final write-and-report
880
+ // step becomes conditional on `roadmapContent !== originalContent`.
881
+ const originalContent = node_fs_1.default.readFileSync(roadmapPath, 'utf-8');
882
+ let roadmapContent = originalContent;
775
883
  const phasePattern = phaseMarkdownRegexSource(phaseNum);
776
884
  // Progress table row: update Plans Complete/Status/Completed columns BY
777
885
  // COLUMN NAME (handles 4- or 5-column RoadmapProgress tables regardless of
@@ -968,17 +1076,30 @@ function cmdRoadmapUpdatePlanProgress(cwd, phaseNum, raw) {
968
1076
  }
969
1077
  }
970
1078
  }
971
- (0, shell_command_projection_cjs_1.platformWriteSync)(roadmapPath, roadmapContent);
1079
+ // #3957 (B9.4): write and report an update only when the transforms
1080
+ // above actually produced different bytes — mirroring the sibling
1081
+ // `cmdRoadmapAnnotateDependencies`'s existing `nextContent !== content`
1082
+ // gate. Previously this wrote and reported `updated: true`
1083
+ // unconditionally, even on an idempotent re-run that changed nothing.
1084
+ if (roadmapContent !== originalContent) {
1085
+ (0, shell_command_projection_cjs_1.platformWriteSync)(roadmapPath, roadmapContent);
1086
+ updated = true;
1087
+ }
972
1088
  });
973
- output({
974
- updated: true,
1089
+ const computed = {
975
1090
  phase: phaseNum,
976
1091
  plan_count: planCount,
977
1092
  summary_count: summaryCount,
978
1093
  status,
979
1094
  complete: isComplete,
980
1095
  verification_stale_check_indeterminate: verificationStaleCheckIndeterminate,
981
- }, raw, `${summaryCount}/${planCount} ${status}`);
1096
+ };
1097
+ if (updated) {
1098
+ output({ updated: true, ...computed }, raw, `${summaryCount}/${planCount} ${status}`);
1099
+ }
1100
+ else {
1101
+ declineNoOp(raw, 'updated', "no changes were needed — ROADMAP.md already reflects this phase's plan/summary counts and status", "roadmap update-plan-progress skipped — no changes were needed; ROADMAP.md already reflects this phase's plan/summary counts and status.", computed);
1102
+ }
982
1103
  }
983
1104
  // ─── cmdRoadmapAnnotateDependencies ───────────────────────────────────────────
984
1105
  /**
@@ -1000,12 +1121,20 @@ function cmdRoadmapAnnotateDependencies(cwd, phaseNum, raw) {
1000
1121
  }
1001
1122
  const roadmapPath = planningPaths(cwd).roadmap;
1002
1123
  if (!node_fs_1.default.existsSync(roadmapPath)) {
1003
- output({ updated: false, reason: 'ROADMAP.md not found' }, raw, 'no roadmap');
1124
+ declineNoOp(raw, 'updated', 'ROADMAP.md not found', 'roadmap annotate-dependencies skipped — ROADMAP.md not found.');
1004
1125
  return;
1005
1126
  }
1006
1127
  const phaseInfo = findPhaseInternal(cwd, phaseNum);
1007
- if (!phaseInfo || phaseInfo.plans.length === 0) {
1008
- output({ updated: false, reason: 'no plans found for phase', phase: phaseNum }, raw, 'no plans');
1128
+ // #3957 (B9.1): distinguish "phase does not resolve at all" from "phase
1129
+ // resolves but has zero plans" — previously both collapsed into the same
1130
+ // 'no plans found for phase' reason, which is simply false for the first
1131
+ // case (there IS no such phase to have plans).
1132
+ if (!phaseInfo) {
1133
+ declineNoOp(raw, 'updated', `phase ${phaseNum} not found`, `roadmap annotate-dependencies skipped — phase ${formatDiagnosticToken(String(phaseNum))} not found.`, { phase: phaseNum });
1134
+ return;
1135
+ }
1136
+ if (phaseInfo.plans.length === 0) {
1137
+ declineNoOp(raw, 'updated', `phase ${phaseNum} has no plans`, `roadmap annotate-dependencies skipped — phase ${formatDiagnosticToken(String(phaseNum))} has no plans.`, { phase: phaseNum });
1009
1138
  return;
1010
1139
  }
1011
1140
  // Read each PLAN.md and extract wave + must_haves.truths
@@ -1023,7 +1152,7 @@ function cmdRoadmapAnnotateDependencies(cwd, phaseNum, raw) {
1023
1152
  catch { /* skip unreadable plans */ }
1024
1153
  }
1025
1154
  if (planData.length === 0) {
1026
- output({ updated: false, reason: 'could not read plan frontmatter' }, raw, 'no frontmatter');
1155
+ declineNoOp(raw, 'updated', 'could not read plan frontmatter', 'roadmap annotate-dependencies skipped — could not read plan frontmatter for any plan in this phase.');
1027
1156
  return;
1028
1157
  }
1029
1158
  // Group plans by wave (sorted)