@opengsd/gsd-core 1.14.0 → 1.15.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 (283) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.ja-JP.md +3 -3
  4. package/README.ko-KR.md +3 -3
  5. package/README.pt-BR.md +3 -3
  6. package/README.zh-CN.md +3 -3
  7. package/agents/gsd-code-fixer.compact.md +7 -6
  8. package/agents/gsd-code-fixer.md +9 -8
  9. package/agents/gsd-debug-session-manager.compact.md +17 -2
  10. package/agents/gsd-debug-session-manager.md +17 -2
  11. package/agents/gsd-debugger.md +2 -2
  12. package/agents/gsd-eval-auditor.compact.md +1 -1
  13. package/agents/gsd-eval-auditor.md +1 -1
  14. package/agents/gsd-executor.md +13 -8
  15. package/agents/gsd-intel-updater.compact.md +1 -1
  16. package/agents/gsd-intel-updater.md +1 -1
  17. package/agents/gsd-phase-researcher.md +19 -11
  18. package/agents/gsd-plan-checker.md +8 -7
  19. package/agents/gsd-planner.md +12 -8
  20. package/agents/gsd-project-researcher.compact.md +1 -1
  21. package/agents/gsd-project-researcher.md +1 -1
  22. package/agents/gsd-research-synthesizer.compact.md +1 -1
  23. package/agents/gsd-research-synthesizer.md +1 -1
  24. package/agents/gsd-ui-auditor.md +155 -17
  25. package/agents/gsd-ui-researcher.compact.md +1 -1
  26. package/agents/gsd-ui-researcher.md +1 -1
  27. package/agents/gsd-verifier.md +10 -9
  28. package/bin/install.js +642 -95
  29. package/commands/gsd/autonomous.md +2 -2
  30. package/commands/gsd/capture.md +1 -1
  31. package/commands/gsd/mempalace-capture.md +7 -3
  32. package/commands/gsd/plan-review-convergence.md +6 -6
  33. package/commands/gsd/progress.md +1 -1
  34. package/commands/gsd/quick-batch.md +1 -1
  35. package/commands/gsd/review.md +2 -3
  36. package/gsd-core/bin/gsd-tools.cjs +335 -22
  37. package/gsd-core/bin/lib/adr-parser.cjs +3 -1
  38. package/gsd-core/bin/lib/audit.cjs +81 -13
  39. package/gsd-core/bin/lib/capability-registry.cjs +82 -187
  40. package/gsd-core/bin/lib/capability-validator.cjs +0 -1
  41. package/gsd-core/bin/lib/check-command-router.cjs +101 -14
  42. package/gsd-core/bin/lib/codex-agent-toml.cjs +21 -25
  43. package/gsd-core/bin/lib/commands.cjs +175 -42
  44. package/gsd-core/bin/lib/config-loader.cjs +65 -4
  45. package/gsd-core/bin/lib/config.cjs +33 -7
  46. package/gsd-core/bin/lib/decisions.cjs +30 -14
  47. package/gsd-core/bin/lib/frontmatter.cjs +13 -0
  48. package/gsd-core/bin/lib/graphify.cjs +10 -2
  49. package/gsd-core/bin/lib/host-runtime-detection.cjs +9 -0
  50. package/gsd-core/bin/lib/init.cjs +207 -41
  51. package/gsd-core/bin/lib/install-engine.cjs +13 -0
  52. package/gsd-core/bin/lib/installer-migrations.cjs +8 -1
  53. package/gsd-core/bin/lib/milestone.cjs +18 -5
  54. package/gsd-core/bin/lib/model-resolver.cjs +159 -50
  55. package/gsd-core/bin/lib/phase-command-router.cjs +9 -1
  56. package/gsd-core/bin/lib/phase-id-card.cjs +32 -0
  57. package/gsd-core/bin/lib/phase-id-display.cjs +78 -0
  58. package/gsd-core/bin/lib/phase-id.cjs +109 -7
  59. package/gsd-core/bin/lib/phase-locator.cjs +29 -10
  60. package/gsd-core/bin/lib/phase.cjs +227 -26
  61. package/gsd-core/bin/lib/plan-document.cjs +49 -1
  62. package/gsd-core/bin/lib/planning-document.cjs +459 -0
  63. package/gsd-core/bin/lib/planning-inspect.cjs +18 -1
  64. package/gsd-core/bin/lib/planning-workspace.cjs +8 -3
  65. package/gsd-core/bin/lib/pr-branch-patterns.cjs +57 -0
  66. package/gsd-core/bin/lib/probe-core.cjs +7 -1
  67. package/gsd-core/bin/lib/project-root.cjs +41 -2
  68. package/gsd-core/bin/lib/review-lane-descriptor.cjs +10 -30
  69. package/gsd-core/bin/lib/review-reviewer-selection.cjs +2 -2
  70. package/gsd-core/bin/lib/roadmap-command-router.cjs +12 -4
  71. package/gsd-core/bin/lib/roadmap-parser.cjs +163 -3
  72. package/gsd-core/bin/lib/roadmap-upgrade.cjs +1539 -13
  73. package/gsd-core/bin/lib/roadmap.cjs +251 -31
  74. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +283 -31
  75. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +3 -1
  76. package/gsd-core/bin/lib/runtime-homes.cjs +4 -0
  77. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +215 -33
  78. package/gsd-core/bin/lib/runtime-name-policy.cjs +111 -1
  79. package/gsd-core/bin/lib/shell-command-projection.cjs +10 -6
  80. package/gsd-core/bin/lib/state-transition.cjs +39 -2
  81. package/gsd-core/bin/lib/state.cjs +42 -0
  82. package/gsd-core/bin/lib/surface.cjs +17 -1
  83. package/gsd-core/bin/lib/tdd-red-evidence.cjs +78 -5
  84. package/gsd-core/bin/lib/uat-predicate.cjs +47 -4
  85. package/gsd-core/bin/lib/uat.cjs +8 -0
  86. package/gsd-core/bin/lib/ui-consideration-probe.cjs +15 -2
  87. package/gsd-core/bin/lib/ui-frontend-evidence.cjs +100 -9
  88. package/gsd-core/bin/lib/undo-commit-selection.cjs +131 -0
  89. package/gsd-core/bin/lib/verification.cjs +268 -15
  90. package/gsd-core/bin/lib/verify-command-grounding.cjs +46 -2
  91. package/gsd-core/bin/lib/verify.cjs +132 -25
  92. package/gsd-core/bin/lib/worktree-base-ref.cjs +482 -73
  93. package/gsd-core/bin/lib/worktree-safety.cjs +784 -51
  94. package/gsd-core/bin/shared/config-defaults.manifest.json +3 -0
  95. package/gsd-core/bin/shared/config-schema.manifest.json +1 -0
  96. package/gsd-core/references/checkpoints.md +5 -3
  97. package/gsd-core/references/edge-probe-fixtures/01-round-half-even/expected-coverage.json +28 -3
  98. package/gsd-core/references/edge-probe-fixtures/02-merge-intervals/expected-coverage.json +37 -4
  99. package/gsd-core/references/edge-probe-fixtures/03-truncate-graphemes/expected-coverage.json +28 -3
  100. package/gsd-core/references/edge-probe-fixtures/04-money-rounding/expected-coverage.json +28 -3
  101. package/gsd-core/references/edge-probe-fixtures/05-list-dedupe/expected-coverage.json +37 -4
  102. package/gsd-core/references/edge-probe-fixtures/06-resolved-mixed/expected-coverage.json +37 -4
  103. package/gsd-core/references/edge-probe.md +195 -21
  104. package/gsd-core/references/execute-phase-between-wave-reset.md +7 -6
  105. package/gsd-core/references/execute-phase-wave-guard.md +22 -11
  106. package/gsd-core/references/gsd-run-resolver.md +1 -1
  107. package/gsd-core/references/model-profiles.md +1 -1
  108. package/gsd-core/references/phase-argument-parsing.md +9 -7
  109. package/gsd-core/references/phase-id-convention.md +28 -0
  110. package/gsd-core/references/planner-gap-closure.md +2 -0
  111. package/gsd-core/references/planner-load-graph-context.md +24 -13
  112. package/gsd-core/references/planner-verify-command-grounding.md +14 -0
  113. package/gsd-core/references/planning-config.md +11 -2
  114. package/gsd-core/references/tdd.md +27 -4
  115. package/gsd-core/references/ui-consideration-probe.md +10 -5
  116. package/gsd-core/references/verify-command-path-resolvability.md +10 -2
  117. package/gsd-core/references/worktree-path-safety.md +321 -0
  118. package/gsd-core/templates/verification-report.md +1 -1
  119. package/gsd-core/workflows/_runtime-launcher.snippet.sh +1 -1
  120. package/gsd-core/workflows/add-backlog.md +1 -1
  121. package/gsd-core/workflows/add-phase.md +1 -1
  122. package/gsd-core/workflows/add-tests.md +2 -2
  123. package/gsd-core/workflows/add-todo.md +3 -3
  124. package/gsd-core/workflows/ai-integration-phase.md +11 -3
  125. package/gsd-core/workflows/audit-fix.md +1 -1
  126. package/gsd-core/workflows/audit-milestone.md +1 -1
  127. package/gsd-core/workflows/audit-uat.md +1 -1
  128. package/gsd-core/workflows/autonomous/steps/converge-fail-fast.md +9 -18
  129. package/gsd-core/workflows/autonomous.md +16 -6
  130. package/gsd-core/workflows/check-todos.md +2 -2
  131. package/gsd-core/workflows/cleanup.md +2 -2
  132. package/gsd-core/workflows/code-review/steps/dispatch-fix.md +4 -3
  133. package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +1 -1
  134. package/gsd-core/workflows/code-review-fix.md +108 -22
  135. package/gsd-core/workflows/code-review.md +63 -46
  136. package/gsd-core/workflows/complete-milestone/detail/elaboration.md +1 -1
  137. package/gsd-core/workflows/complete-milestone.md +2 -2
  138. package/gsd-core/workflows/debug.md +3 -3
  139. package/gsd-core/workflows/diagnose-issues.md +1 -1
  140. package/gsd-core/workflows/discuss-phase/modes/advisor.md +1 -1
  141. package/gsd-core/workflows/discuss-phase/modes/chain.md +1 -1
  142. package/gsd-core/workflows/discuss-phase-assumptions.md +1 -1
  143. package/gsd-core/workflows/discuss-phase.md +1 -1
  144. package/gsd-core/workflows/do.md +2 -2
  145. package/gsd-core/workflows/docs-update.md +3 -3
  146. package/gsd-core/workflows/edit-phase.md +1 -1
  147. package/gsd-core/workflows/eval-review.md +10 -3
  148. package/gsd-core/workflows/execute-phase/detail/elaboration.md +2 -2
  149. package/gsd-core/workflows/execute-phase/steps/code-review-disposition.md +1017 -0
  150. package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +1 -1
  151. package/gsd-core/workflows/execute-phase/steps/completion-reconciliation.md +3 -3
  152. package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +37 -3
  153. package/gsd-core/workflows/execute-phase/steps/gap-closure-artifacts.md +1 -1
  154. package/gsd-core/workflows/execute-phase/steps/partial-wave.md +1 -1
  155. package/gsd-core/workflows/execute-phase/steps/per-plan-executor-routing.md +1 -1
  156. package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +1 -1
  157. package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +46 -9
  158. package/gsd-core/workflows/execute-phase/steps/protected-branch.md +1 -1
  159. package/gsd-core/workflows/execute-phase/steps/ready-wave-gate.md +37 -0
  160. package/gsd-core/workflows/execute-phase/steps/regression-gate-run.md +1 -1
  161. package/gsd-core/workflows/execute-phase/steps/stale-reverification.md +24 -0
  162. package/gsd-core/workflows/execute-phase/steps/tdd-applicability-resolution.md +1 -1
  163. package/gsd-core/workflows/execute-phase/steps/threat-id-gate.md +28 -0
  164. package/gsd-core/workflows/execute-phase/steps/wave-post-gate-hooks.md +1 -1
  165. package/gsd-core/workflows/execute-phase/steps/worktree-base-check.md +25 -0
  166. package/gsd-core/workflows/execute-phase.md +36 -26
  167. package/gsd-core/workflows/execute-plan.md +5 -4
  168. package/gsd-core/workflows/explore.md +3 -3
  169. package/gsd-core/workflows/extract-learnings.md +2 -1
  170. package/gsd-core/workflows/fast.md +1 -1
  171. package/gsd-core/workflows/forensics.md +1 -1
  172. package/gsd-core/workflows/graduation.md +1 -1
  173. package/gsd-core/workflows/health.md +2 -2
  174. package/gsd-core/workflows/help/modes/full.compact.md +3 -3
  175. package/gsd-core/workflows/help/modes/full.md +5 -5
  176. package/gsd-core/workflows/help/modes/topic.md +15 -5
  177. package/gsd-core/workflows/import.md +2 -2
  178. package/gsd-core/workflows/inbox.md +2 -2
  179. package/gsd-core/workflows/ingest-docs.md +3 -3
  180. package/gsd-core/workflows/insert-phase.md +1 -1
  181. package/gsd-core/workflows/list-seeds.md +1 -1
  182. package/gsd-core/workflows/list-workspaces.md +1 -1
  183. package/gsd-core/workflows/manager.md +2 -2
  184. package/gsd-core/workflows/map-codebase.md +2 -2
  185. package/gsd-core/workflows/milestone-summary.md +1 -1
  186. package/gsd-core/workflows/mvp-phase.md +1 -1
  187. package/gsd-core/workflows/new-milestone.md +2 -2
  188. package/gsd-core/workflows/new-project/steps/auto-mode-config.md +3 -3
  189. package/gsd-core/workflows/new-project/steps/codebase-map-offer.md +1 -1
  190. package/gsd-core/workflows/new-project.md +7 -7
  191. package/gsd-core/workflows/new-workspace.md +2 -2
  192. package/gsd-core/workflows/next.md +1 -1
  193. package/gsd-core/workflows/note.md +1 -1
  194. package/gsd-core/workflows/onboard.md +1 -1
  195. package/gsd-core/workflows/pause-work.md +1 -1
  196. package/gsd-core/workflows/plan-phase/detail/elaboration.md +1 -1
  197. package/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md +17 -5
  198. package/gsd-core/workflows/plan-phase/steps/prd-express-path.md +1 -1
  199. package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +23 -5
  200. package/gsd-core/workflows/plan-phase.md +24 -7
  201. package/gsd-core/workflows/plan-review-convergence.md +21 -5
  202. package/gsd-core/workflows/plant-seed.md +62 -20
  203. package/gsd-core/workflows/pr-branch.md +113 -13
  204. package/gsd-core/workflows/profile-user.md +2 -2
  205. package/gsd-core/workflows/progress.md +1 -1
  206. package/gsd-core/workflows/quick/steps/plan-checker-loop.md +25 -0
  207. package/gsd-core/workflows/quick/steps/quick-verification.md +1 -1
  208. package/gsd-core/workflows/quick/steps/worktree-pre-dispatch-commit.md +1 -1
  209. package/gsd-core/workflows/quick-batch/steps/batch-init.md +1 -1
  210. package/gsd-core/workflows/quick-batch/steps/completion.md +1 -1
  211. package/gsd-core/workflows/quick-batch/steps/merge-wave.md +1 -1
  212. package/gsd-core/workflows/quick-batch/steps/planner-wave.md +1 -1
  213. package/gsd-core/workflows/quick-batch/steps/research-phase.md +1 -1
  214. package/gsd-core/workflows/quick-batch/steps/resume-mode.md +1 -1
  215. package/gsd-core/workflows/quick-batch/steps/verification-wave.md +1 -1
  216. package/gsd-core/workflows/quick-batch/steps/worktree-dispatch.md +1 -1
  217. package/gsd-core/workflows/quick-batch.md +1 -1
  218. package/gsd-core/workflows/quick.md +21 -9
  219. package/gsd-core/workflows/reapply-patches.md +9 -3
  220. package/gsd-core/workflows/remove-phase.md +1 -1
  221. package/gsd-core/workflows/remove-workspace.md +2 -2
  222. package/gsd-core/workflows/resume-project.md +1 -1
  223. package/gsd-core/workflows/review.md +31 -16
  224. package/gsd-core/workflows/scan.md +1 -1
  225. package/gsd-core/workflows/secure-phase.md +3 -2
  226. package/gsd-core/workflows/settings-advanced.md +30 -10
  227. package/gsd-core/workflows/settings-integrations.md +2 -3
  228. package/gsd-core/workflows/settings.md +4 -4
  229. package/gsd-core/workflows/ship.md +3 -2
  230. package/gsd-core/workflows/sketch-wrap-up.md +1 -1
  231. package/gsd-core/workflows/sketch.md +1 -1
  232. package/gsd-core/workflows/smart-entry.md +2 -2
  233. package/gsd-core/workflows/spec-phase.md +15 -5
  234. package/gsd-core/workflows/spike-wrap-up.md +1 -1
  235. package/gsd-core/workflows/spike.md +1 -1
  236. package/gsd-core/workflows/stats.md +1 -1
  237. package/gsd-core/workflows/sync-skills.md +5 -5
  238. package/gsd-core/workflows/thread.md +1 -1
  239. package/gsd-core/workflows/transition.md +1 -1
  240. package/gsd-core/workflows/ui-phase.md +44 -8
  241. package/gsd-core/workflows/ui-review.md +18 -4
  242. package/gsd-core/workflows/ultraplan-phase.md +1 -1
  243. package/gsd-core/workflows/undo.md +339 -20
  244. package/gsd-core/workflows/update.md +7 -7
  245. package/gsd-core/workflows/validate-phase.md +3 -2
  246. package/gsd-core/workflows/verify-work/steps/automated-ui-verification.md +1 -1
  247. package/gsd-core/workflows/verify-work/steps/mvp-uat-framing.md +1 -1
  248. package/gsd-core/workflows/verify-work.md +81 -16
  249. package/hooks/dist/gsd-agent-isolation-guard.js +24 -0
  250. package/hooks/dist/gsd-secret-read-guard.js +27 -1
  251. package/hooks/dist/gsd-statusline.js +70 -13
  252. package/hooks/dist/gsd-validate-commit.sh +63 -4
  253. package/hooks/gsd-agent-isolation-guard.js +24 -0
  254. package/hooks/gsd-secret-read-guard.js +27 -1
  255. package/hooks/gsd-statusline.js +70 -13
  256. package/hooks/gsd-validate-commit.sh +63 -4
  257. package/package.json +3 -2
  258. package/scripts/build-hooks.js +15 -6
  259. package/scripts/check-contract-drift.cjs +127 -11
  260. package/scripts/command-contract-helpers.cjs +3 -0
  261. package/scripts/docs-guard-registry.cjs +28 -0
  262. package/scripts/gen-loop-host-contract.cjs +69 -0
  263. package/scripts/lib/macos-conformance-tier.generated.cjs +14 -0
  264. package/scripts/lib/ndjson-reporter.cjs +3 -2
  265. package/scripts/lib/platform-conformance-tier.generated.cjs +11 -0
  266. package/scripts/lint-docs-guard-registration.exempt-baseline.cjs +28 -1
  267. package/scripts/lint-phase-arg-assignment.cjs +257 -0
  268. package/scripts/lint-phase-id-drift.cjs +290 -5
  269. package/scripts/lint-pr-branch-pattern-drift.cjs +148 -0
  270. package/scripts/lint-retired-runtime-name.cjs +619 -0
  271. package/scripts/lint-state-write-path-drift.cjs +93 -0
  272. package/scripts/lint-test-file-count.allowlist.json +28 -9
  273. package/scripts/lint-workflow-shellcheck-baseline.json +15 -0
  274. package/scripts/prompt-injection-scan.sh +4 -0
  275. package/scripts/release-tarball-smoke.cjs +194 -1
  276. package/skills/gsd-autonomous/SKILL.md +2 -2
  277. package/skills/gsd-capture/SKILL.md +1 -1
  278. package/skills/gsd-mempalace-capture/SKILL.md +7 -3
  279. package/skills/gsd-plan-review-convergence/SKILL.md +5 -5
  280. package/skills/gsd-progress/SKILL.md +1 -1
  281. package/skills/gsd-quick-batch/SKILL.md +1 -1
  282. package/skills/gsd-review/SKILL.md +2 -3
  283. package/vscode/package.json +1 -1
@@ -1947,7 +1947,6 @@ const KNOWN_HOST_BEHAVIORS = new Set([
1947
1947
  'skillPriorityFrontmatter',
1948
1948
  'skillsGlobalOnboarding',
1949
1949
  'skillsManifestPrefix',
1950
- 'skipCodexSkillsManifest',
1951
1950
  'skipHomePrefixSubstitution',
1952
1951
  'skipSettingsUi',
1953
1952
  'skipSharedHooksInstall',
@@ -281,6 +281,7 @@ function loadDecisionExtraction(contextPath) {
281
281
  return {
282
282
  trackable: extraction.decisions.filter((d) => d.trackable),
283
283
  outcome: extraction.outcome,
284
+ unreadableIds: extraction.unreadableIds ?? [],
284
285
  };
285
286
  }
286
287
  /**
@@ -329,21 +330,48 @@ function cmdDecisionCoveragePlan(projectDir, args, raw) {
329
330
  output({ passed: true, skipped: true, reason: 'CONTEXT.md missing', total: 0, covered: 0, uncovered: [], message: 'No CONTEXT.md - nothing to check.' }, raw, undefined);
330
331
  return;
331
332
  }
332
- const { trackable: decisions, outcome } = loadDecisionExtraction(contextPath);
333
+ // #4794: a NON-FILE path (a directory — the adjacent same-looking positional
334
+ // swapped, the issue's repro 2) is a caller error like #2770's empty argument:
335
+ // fs.existsSync is true, the read yields nothing, and the gate used to
336
+ // certify passed:true on a phase full of decisions. Fail closed, naming it.
337
+ // The stat is wrapped: a path that vanishes between existsSync and statSync
338
+ // (or any stat failure) must answer the SAME fail-closed JSON, never a throw.
339
+ let contextIsFile = false;
340
+ let contextKind = 'non-file entry';
341
+ try {
342
+ const st = node_fs_1.default.statSync(contextPath);
343
+ contextIsFile = st.isFile();
344
+ if (st.isDirectory())
345
+ contextKind = 'directory';
346
+ }
347
+ catch {
348
+ contextIsFile = false;
349
+ contextKind = 'unreadable path';
350
+ }
351
+ if (!contextIsFile) {
352
+ output({ passed: false, skipped: false, reason: 'context path is not a file', total: null, covered: null, message: `Decision coverage gate: the context path "${contextArg}" is not a readable file (${contextKind}). Swap the adjacent positionals or pass --context <path-to-CONTEXT.md>.` }, raw, undefined);
353
+ return;
354
+ }
355
+ const { trackable: decisions, outcome, unreadableIds } = loadDecisionExtraction(contextPath);
333
356
  // #1365 fail-loud gate: any could-not-parse outcome must NOT silently pass —
334
357
  // even when some decisions were extracted (e.g. D-01 valid but D-02 malformed).
335
358
  // A parse-miss on ANY bullet means the gate cannot certify full coverage.
336
359
  // Fire independent of decisions.length so a partial-parse still blocks.
337
360
  if (outcome === 'could-not-parse') {
361
+ // #4794: nothing was measured — the answer must not carry the fields of a
362
+ // gate that did. total/covered are null (a type change is the point:
363
+ // 0 reads as data, null does not), `uncovered` is OMITTED (the list was
364
+ // never built), and the ids that failed to parse are carried so a caller
365
+ // capturing stdout knows which decision to fix.
338
366
  const partialParse = decisions.length > 0;
339
367
  output({
340
368
  passed: false,
341
369
  skipped: false,
342
370
  reason: 'could-not-parse',
343
- total: decisions.length,
344
- covered: 0,
345
- uncovered: [],
346
- message: partialParse
371
+ total: null,
372
+ covered: null,
373
+ unreadable: unreadableIds,
374
+ message: (partialParse
347
375
  ? 'Decision coverage gate: decisions could not be fully parsed — one or more ' +
348
376
  '`- **D-NN ...**` bullets appear malformed (missing `:` or ` — ` separator, or a phase ' +
349
377
  'prefix that is not a digit run, e.g. `D4x-01`). Fix the bullet format so all decisions ' +
@@ -353,7 +381,8 @@ function cmdDecisionCoveragePlan(projectDir, args, raw) {
353
381
  'or D- tokens) but no decision bullets could be extracted. Check the formatting of the decisions ' +
354
382
  'block and ensure bullets follow the `- **D-NN:** text`, `- **D4-NN:** text` (phase-prefixed), ' +
355
383
  'or `- **D-NN — title** body` form. An ID grammar the parser does not support (e.g. `DEC-01`) ' +
356
- 'also lands here.',
384
+ 'also lands here.')
385
+ + (unreadableIds.length > 0 ? ' Unreadable ids: ' + unreadableIds.join(', ') + '.' : ''),
357
386
  }, raw, undefined);
358
387
  return;
359
388
  }
@@ -546,8 +575,10 @@ function findUiSpecInDir(phaseDir) {
546
575
  * matches the token `dashboard` exactly like the real compound `micro-frontend`
547
576
  * (the boundary rule of #3718 is intentional and untouched). The gate therefore
548
577
  * blocks only when the token match is corroborated by static frontend evidence
549
- * in the repo tree (hasStaticFrontendEvidence: package.json UI-framework dep or
550
- * a component-framework file). This mirrors the sibling post-wave gate
578
+ * in the repo tree (hasStaticFrontendEvidence: package.json UI-framework dep, a
579
+ * component-framework file, or native UI evidence — a `.xaml` file or a
580
+ * `.swift`/`.kt`/`.dart` file carrying its ecosystem's UI import marker,
581
+ * #4658). This mirrors the sibling post-wave gate
551
582
  * computeUiSafetyGate, which requires `hasUiFiles` (git diff) before blocking.
552
583
  * matchedToken/matchedLine surface what tripped the sniffer so an operator can
553
584
  * judge the flag in one second instead of reaching for --skip-ui.
@@ -969,8 +1000,22 @@ function resolvePhaseDirOrEmpty(projectDir, phase) {
969
1000
  * phase's `-PLAN.md` files against the filesystem WITHOUT executing anything —
970
1001
  * see verify-command-grounding.cjs for the recognizer contract.
971
1002
  *
972
- * Args: check verify-command-paths <phase>
1003
+ * Args: check verify-command-paths <phase> | check verify-command-paths --dir <plan-dir>
973
1004
  * Invocable as: gsd_run check verify-command-paths <phase>
1005
+ * gsd_run check verify-command-paths --dir <plan-dir>
1006
+ *
1007
+ * `--dir` (#4767) names a directory holding `-PLAN.md` files directly, for
1008
+ * plans that live outside `.planning/phases/` — quick mode's
1009
+ * `.planning/quick/<id>/` is the motivating caller, which until #4767 never ran
1010
+ * this probe at all. The directory is resolved against the project root AND
1011
+ * CONTAINED WITHIN IT — an absolute or climbing `--dir` that lands outside the
1012
+ * root is `unresolvable`, never read — then probed exactly as a phase directory
1013
+ * is; `projectRoot` stays the project root in both forms. `--dir <value>` is the
1014
+ * only accepted spelling: `--dir=<value>` yields no `dir` flag and falls through to
1015
+ * the no-argument arm, as does an empty value. Both are `partitionPredicateArgs`
1016
+ * behaviour, inherited and unchanged. (How that parser resolves a REPEATED `--dir`
1017
+ * is deliberately not characterised here — a malformed later occurrence does not
1018
+ * displace an earlier valid one, so the obvious "last one wins" gloss is wrong.)
974
1019
  *
975
1020
  * When the phase cannot be resolved to a directory, this emits a non-throwing
976
1021
  * degraded JSON payload (status/commands/counts all zeroed, `readError`
@@ -979,18 +1024,60 @@ function resolvePhaseDirOrEmpty(projectDir, phase) {
979
1024
  * look", which a non-zero exit / thrown error would collapse.
980
1025
  */
981
1026
  function cmdVerifyCommandPaths(projectDir, args, raw) {
982
- // args[0] = 'check', args[1] = 'verify-command-paths', args[2] = phase
983
- const phase = args[2] || '';
984
- if (!phase) {
1027
+ // args[0] = 'check', args[1] = 'verify-command-paths', then either a phase
1028
+ // positional or `--dir <plan-dir>` (#4767).
1029
+ const { flags, positionals } = partitionPredicateArgs(args.slice(2));
1030
+ const dirFlag = typeof flags['dir'] === 'string' ? flags['dir'] : '';
1031
+ // First non-flag positional: `--raw` (valueless) lands in positionals too, and its position
1032
+ // relative to the phase argument is the caller's choice.
1033
+ const phase = positionals.find(p => !p.startsWith('--')) ?? '';
1034
+ if (!phase && !dirFlag) {
985
1035
  output({
986
1036
  status: 'unresolvable',
987
1037
  commands: [],
988
1038
  counts: { blocker: 0, warning: 0, total: 0 },
989
- readError: 'verify-command-paths requires a phase argument: check verify-command-paths <phase>',
1039
+ readError: 'verify-command-paths requires a phase argument or --dir: check verify-command-paths <phase> | --dir <plan-dir>',
990
1040
  }, raw, undefined);
991
1041
  return;
992
1042
  }
993
- const phaseDir = resolvePhaseDirOrEmpty(projectDir, phase);
1043
+ // `--dir` is CALLER-SUPPLIED, so it is contained before it reaches the
1044
+ // `readdirSync`/`readFileSync` calls in probePhaseVerifyCommands (#4785 review).
1045
+ // Same predicate and policy as `resolvePath` above, and for the reason ADR-4650
1046
+ // gives at the other read site: the reads below FOLLOW SYMLINKS, so containment
1047
+ // must be decided on the resolved target, not a lexical prefix — a link inside
1048
+ // the root pointing outside it passes `tryWithinRootLexical` and is then read.
1049
+ // Read the value the predicate RETURNED; never re-derive the path. An escape
1050
+ // degrades to the same non-throwing payload the unresolvable-phase arm emits,
1051
+ // because a consumer must be able to tell "could not look" from "nothing to
1052
+ // report" (and `error()` would collapse them).
1053
+ //
1054
+ // RESIDUAL, stated rather than left to be rediscovered: this is check-then-use, so
1055
+ // a symlink planted at the resolved path BETWEEN this call and the reads inside
1056
+ // probePhaseVerifyCommands would be followed. A link already in place when the
1057
+ // command runs IS refused — the predicate resolves it and returns null (driven) —
1058
+ // so the window is the in-process gap, not the ordinary case. It is a property of
1059
+ // every `tryWithinRoot` call site in this repo, including `resolvePath` above and
1060
+ // the artifact scan below, not of this arm; closing it needs O_NOFOLLOW/dirfd
1061
+ // semantics inside the ADR-4650 predicate, which is a wider change than the bug
1062
+ // this fixes.
1063
+ let phaseDir;
1064
+ if (dirFlag) {
1065
+ const candidate = node_path_1.default.isAbsolute(dirFlag) ? dirFlag : node_path_1.default.join(projectDir, dirFlag);
1066
+ const contained = (0, security_cjs_1.tryWithinRoot)(candidate, projectDir, security_cjs_1.PathAcceptance.AbsoluteInsideRoot);
1067
+ if (contained === null) {
1068
+ output({
1069
+ status: 'unresolvable',
1070
+ commands: [],
1071
+ counts: { blocker: 0, warning: 0, total: 0 },
1072
+ readError: `--dir resolves outside the project root: ${dirFlag}`,
1073
+ }, raw, undefined);
1074
+ return;
1075
+ }
1076
+ phaseDir = contained;
1077
+ }
1078
+ else {
1079
+ phaseDir = resolvePhaseDirOrEmpty(projectDir, phase);
1080
+ }
994
1081
  if (!phaseDir) {
995
1082
  output({
996
1083
  status: 'unresolvable',
@@ -382,9 +382,9 @@ function stripReasoningEffort(doc) {
382
382
  /**
383
383
  * The 17 roles measured as widening under derivation (declare Write/Edit,
384
384
  * never in the pre-#3897 `CODEX_AGENT_SANDBOX` map, so the old
385
- * `|| 'read-only'` fallback silently under-granted them). Pinned to
386
- * `read-only` pending the open question of whether Codex enforces
387
- * `sandbox_mode` or treats it as advisory (HALT.md). This list is CLOSED and
385
+ * `|| 'read-only'` fallback silently under-granted them) — pinned to
386
+ * `read-only` from 2026-09-08 (HALT.md) until #4770 lifted the hold on
387
+ * 2026-09-21. This list is CLOSED and
388
388
  * SHRINK-ONLY: a new writing role never lands here (S6, T26); it is validated
389
389
  * against the live tool contract every time it is consulted
390
390
  * ({@link _deriveCodexSandboxModeFromTools}) and against the real
@@ -401,29 +401,25 @@ function stripReasoningEffort(doc) {
401
401
  * parses the list correctly, so this role genuinely derives
402
402
  * `workspace-write` from its tool contract — HALT.md's original 16-role
403
403
  * count measured against the pre-fix (single-line) readers and undercounted
404
- * this role. It is held here for the same reason as the other 16: pending
405
- * Codex's `sandbox_mode` enforcement decision, not because the derivation is
406
- * wrong.
404
+ * this role.
405
+ *
406
+ * **LIFTED 2026-09-21 (#4770, maintainer decision: documented enforcement
407
+ * suffices).** The map is empty: the rung-3 hold's recorded reopen condition
408
+ * — official OpenAI documentation establishing Codex `sandbox_mode` as an
409
+ * enforced technical boundary that custom subagent TOML files honor — is
410
+ * satisfied, so every role now derives `sandbox_mode` purely from its own
411
+ * `tools:` frontmatter (`workspace-write` iff Write/Edit is declared). The
412
+ * shrink-to-zero invariant (ADR-3473 §8.3) is satisfied by reaching zero;
413
+ * the map is kept as an empty frozen structure so a future re-hold has a
414
+ * shape to land in, and {@link validateCodexSandboxHolds} keeps failing if
415
+ * the list ever grows a role that no longer exists in `agents/`. The
416
+ * #3897 security-review F1/F3 fail-closed pins are unchanged and
417
+ * map-independent: `suspicious` identities (non-ASCII after
418
+ * normalization) still pin `read-only`, and post-lift the sandbox derives
419
+ * from an artifact's own CONTENT, so the F1 identity-confusion attack no
420
+ * longer has a hold to ride.
407
421
  */
408
- exports.CODEX_SANDBOX_HOLDS = Object.freeze({
409
- 'gsd-ai-researcher': 'declares Write/Edit; pending Codex sandbox_mode enforcement decision',
410
- 'gsd-code-fixer': 'declares Write/Edit; pending Codex sandbox_mode enforcement decision',
411
- 'gsd-code-reviewer': 'declares Write/Edit; pending Codex sandbox_mode enforcement decision',
412
- 'gsd-debug-session-manager': 'declares Write/Edit; pending Codex sandbox_mode enforcement decision',
413
- 'gsd-doc-classifier': 'declares Write/Edit; pending Codex sandbox_mode enforcement decision',
414
- 'gsd-doc-synthesizer': 'declares Write/Edit; pending Codex sandbox_mode enforcement decision',
415
- 'gsd-doc-verifier': 'declares Write/Edit; pending Codex sandbox_mode enforcement decision',
416
- 'gsd-doc-writer': 'declares Write/Edit; pending Codex sandbox_mode enforcement decision',
417
- 'gsd-dom-verifier': 'declares Write/Edit; pending Codex sandbox_mode enforcement decision',
418
- 'gsd-domain-researcher': 'declares Write/Edit; pending Codex sandbox_mode enforcement decision',
419
- 'gsd-eval-auditor': 'declares Write/Edit; pending Codex sandbox_mode enforcement decision',
420
- 'gsd-eval-planner': 'declares Write/Edit; pending Codex sandbox_mode enforcement decision',
421
- 'gsd-intel-updater': 'declares Write/Edit; pending Codex sandbox_mode enforcement decision',
422
- 'gsd-pattern-mapper': 'declares Write/Edit; pending Codex sandbox_mode enforcement decision',
423
- 'gsd-ui-auditor': 'declares Write/Edit; pending Codex sandbox_mode enforcement decision',
424
- 'gsd-ui-researcher': 'declares Write/Edit; pending Codex sandbox_mode enforcement decision',
425
- 'gsd-nyquist-auditor': 'declares Write/Edit (YAML list-form tools:, surfaced by the list-form parse fix); pending Codex sandbox_mode enforcement decision',
426
- });
422
+ exports.CODEX_SANDBOX_HOLDS = Object.freeze({});
427
423
  // True iff a `tools:` frontmatter value declares Write or Edit as a whole
428
424
  // token (never a substring match, so a hypothetical "Edith"-named tool could
429
425
  // never collide). Single predicate owner for both the emitter
@@ -30,7 +30,10 @@ const coreUtilsMod = require("./core-utils.cjs");
30
30
  const { toPosixPath, generateSlugInternal, extractOneLinerFromBody } = coreUtilsMod;
31
31
  // eslint-disable-next-line @typescript-eslint/no-require-imports
32
32
  const phaseIdMod = require("./phase-id.cjs");
33
- const { normalizePhaseName, comparePhaseNum, extractPhaseToken, PHASE_NUMBER_TOKEN_SOURCE, isSentinelPhaseId, renderPhaseBranchName } = phaseIdMod;
33
+ const { normalizePhaseName, comparePhaseNum, extractPhaseToken, PHASE_NUMBER_TOKEN_SOURCE, isSentinelPhaseId, renderPhaseBranchName, parsePhaseId, renderPhaseId, phaseHeadingPrefixSrcFor, PHASE_HEADING_BASELINE, } = phaseIdMod;
34
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
35
+ const phaseIdDisplayMod = require("./phase-id-display.cjs");
36
+ const { renderBracketMilestoneDisplay } = phaseIdDisplayMod;
34
37
  // eslint-disable-next-line @typescript-eslint/no-require-imports
35
38
  const phaseLocatorMod = require("./phase-locator.cjs");
36
39
  const { getArchivedPhaseDirs, findPhaseInternal, listMilestonePhaseDirs } = phaseLocatorMod;
@@ -56,7 +59,7 @@ const codex_agent_toml_cjs_1 = require("./codex-agent-toml.cjs");
56
59
  const hostIntegrationMod = require("./host-integration.cjs");
57
60
  // eslint-disable-next-line @typescript-eslint/no-require-imports
58
61
  const planningWorkspace = require("./planning-workspace.cjs");
59
- const { planningDir, planningPaths, todosDir } = planningWorkspace;
62
+ const { planningDir, planningPaths, todosDir, resolvePhaseIdConvention } = planningWorkspace;
60
63
  // eslint-disable-next-line @typescript-eslint/no-require-imports
61
64
  const frontmatter = require("./frontmatter.cjs");
62
65
  const { extractFrontmatter, agentScalarNeedsDoubleQuoting, escapeDoubleQuotedScalar } = frontmatter;
@@ -72,6 +75,32 @@ const { scanPhasePlans } = planScanMod;
72
75
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- verification.cjs is an export= CommonJS module
73
76
  const verificationMod = require("./verification.cjs");
74
77
  const { resolveVerificationFile } = verificationMod;
78
+ /**
79
+ * Project one canonical bracket directory onto its display identity and slug.
80
+ *
81
+ * The identity is deliberately obtained only through parsePhaseId/renderPhaseId;
82
+ * this helper owns no second bracket grammar. Callers invoke it only after the
83
+ * project's resolved convention is exactly `bracket`.
84
+ */
85
+ function bracketPhaseDirProjection(dir) {
86
+ const id = parsePhaseId(dir);
87
+ const number = id.subphase ? `${id.phase}.${id.subphase}` : id.phase;
88
+ const identityPrefix = `${id.project}.${id.milestone}-${number}`;
89
+ const slug = dir.slice(identityPrefix.length).replace(/^-/, '');
90
+ return {
91
+ number,
92
+ display_id: renderPhaseId(id),
93
+ name: slug ? slug.replace(/-/g, ' ') : '',
94
+ };
95
+ }
96
+ function recoverBracketPhaseName(dir, phaseToken) {
97
+ const tokenBoundary = `-${phaseToken}`;
98
+ const tokenOffset = dir.indexOf(tokenBoundary);
99
+ if (tokenOffset === -1)
100
+ return '';
101
+ const afterToken = dir.slice(tokenOffset + tokenBoundary.length).replace(/^-/, '');
102
+ return afterToken ? afterToken.replace(/-/g, ' ') : '';
103
+ }
75
104
  // ─── Phase Status ─────────────────────────────────────────────────────────────
76
105
  /**
77
106
  * Phase-status precedence ladder — furthest-along wins (#2408).
@@ -237,26 +266,43 @@ function cmdListTodos(cwd, area, raw) {
237
266
  * displayed field is passed through sanitizeForDisplay and each file path is
238
267
  * validated with requireSafePath before reading. Read-only — never mutates.
239
268
  */
269
+ /**
270
+ * Seed id grammars. `SEED-YYMMDD-xxx` (date + 3 base36 chars, the shape
271
+ * `.planning/quick/` uses) is what plant-seed has minted since #4378 removed
272
+ * the shared `wc -l` counter; `SEED-NNN` is the legacy counter form, which
273
+ * keeps parsing forever — existing seeds must never lose their identity.
274
+ *
275
+ * Known (theoretical, documented-not-fixed per #4378 review): a frontmatter-less
276
+ * legacy file whose counter is exactly 6 digits and whose slug opens with
277
+ * exactly 3 base36 chars parses as new-format. Requires a counter >= 100000 AND
278
+ * a missing frontmatter id; with frontmatter the legacy id always wins.
279
+ */
280
+ const CANONICAL_SEED_ID_RE = /^SEED-(?:\d{6}-[a-z0-9]{3}|\d+)$/i;
281
+ const SEED_ID_PREFIX_RE = /^(SEED-(?:\d{6}-[a-z0-9]{3}|\d+))/i;
282
+ const SEED_SLUG_RE = /^SEED-(?:\d{6}-[a-z0-9]{3}|\d+)-(.+)$/i;
240
283
  /**
241
284
  * Derive the canonical `{ seed_id, slug }` from a seed filename stem and the
242
285
  * frontmatter `id:` value. Pure (no I/O) so it can be property-tested directly.
243
286
  *
244
- * seed_id: frontmatter `id:` when it matches `SEED-NNN`, else the numeric prefix
245
- * of the filename (`SEED-NNN-…`), else the whole stem. slug: the descriptive
246
- * remainder after `SEED-NNN-`, else the stem with a leading `SEED-` stripped.
247
- * `rawFmId` is `unknown` because frontmatter values are not guaranteed strings.
287
+ * seed_id: frontmatter `id:` when it matches a seed id grammar (`SEED-YYMMDD-xxx`
288
+ * or legacy `SEED-NNN`), else the id prefix of the filename (`SEED-…-<slug>`),
289
+ * else the whole stem. The prefix fallback must keep the FULL new-format id —
290
+ * truncating at the date gives every same-day seed the same id (#4378).
291
+ * slug: the descriptive remainder after the id, else the stem with a leading
292
+ * `SEED-` stripped. `rawFmId` is `unknown` because frontmatter values are not
293
+ * guaranteed strings.
248
294
  */
249
295
  function deriveSeedIdentity(stem, rawFmId) {
250
296
  const fmId = typeof rawFmId === 'string' ? rawFmId.trim() : '';
251
297
  let seedId;
252
- if (/^SEED-\d+$/i.test(fmId)) {
298
+ if (CANONICAL_SEED_ID_RE.test(fmId)) {
253
299
  seedId = fmId;
254
300
  }
255
301
  else {
256
- const numMatch = stem.match(/^(SEED-\d+)/i);
257
- seedId = numMatch ? numMatch[1] : stem;
302
+ const prefixMatch = stem.match(SEED_ID_PREFIX_RE);
303
+ seedId = prefixMatch ? prefixMatch[1] : stem;
258
304
  }
259
- const slugMatch = stem.match(/^SEED-\d+-(.+)$/i);
305
+ const slugMatch = stem.match(SEED_SLUG_RE);
260
306
  const slug = slugMatch ? slugMatch[1] : stem.replace(/^SEED-/i, '');
261
307
  return { seed_id: seedId, slug };
262
308
  }
@@ -303,9 +349,11 @@ function cmdListSeeds(cwd, statusFilter, raw) {
303
349
  // sanitizeForDisplay is for output, not comparison.
304
350
  if (wantStatus && status !== wantStatus)
305
351
  continue;
306
- // Canonical seed id is `SEED-NNN` (frontmatter `id:`, e.g. SEED-001). Fall
307
- // back to the numeric prefix of the filename, then to the whole stem. The
308
- // descriptive remainder of the filename (`SEED-NNN-<slug>.md`) is the slug.
352
+ // Canonical seed ids are `SEED-YYMMDD-xxx` (frontmatter `id:`, what
353
+ // plant-seed has minted since #4378) or legacy `SEED-NNN`; deriveSeedIdentity
354
+ // owns that grammar. Fall back to the id prefix of the filename, then to the
355
+ // whole stem. The descriptive remainder of the filename (`SEED-…-<slug>.md`)
356
+ // is the slug.
309
357
  const stem = node_path_1.default.basename(entry.name, '.md');
310
358
  const { seed_id: seedId, slug } = deriveSeedIdentity(stem, fm.id);
311
359
  let title = (0, security_cjs_1.sanitizeForDisplay)(fmStr(fm.title).slice(0, 100));
@@ -503,13 +551,10 @@ function cmdResolveExecution(cwd, agentType, raw, opts) {
503
551
  opts = opts || {};
504
552
  const config = loadConfig(cwd);
505
553
  const profile = config['model_profile'] || 'balanced';
506
- // #2068: resolve the model per-attempt so dynamic_routing escalates the MODEL
507
- // (heavy tier) alongside effort. Gated on an explicit --attempt exactly like the
508
- // effort resolution below, so the two fields stay symmetric: with no --attempt
509
- // the model comes from the classic profile path (unchanged for everyone,
510
- // including dynamic_routing-enabled users who don't pass --attempt), and only an
511
- // explicit attempt routes through the tier ladder. resolveModelForTier itself
512
- // still falls back to resolveModelInternal when dynamic_routing is off.
554
+ // #2068: resolve the model per-attempt so dynamic_routing ESCALATES the MODEL
555
+ // (heavy tier) alongside effort. The FIRST-spawn tier now comes from
556
+ // resolveModelInternal's own dynamic_routing step (#4505), so the absent-attempt
557
+ // branch below reaches it too — this gate is only about escalation.
513
558
  let model = (opts.attempt !== undefined && opts.attempt !== null)
514
559
  ? resolveModelForTier(cwd, agentType, opts.attempt)
515
560
  : resolveModelInternal(cwd, agentType);
@@ -3006,6 +3051,11 @@ async function cmdWebsearch(query, options, raw) {
3006
3051
  function cmdProgressRender(cwd, format, raw) {
3007
3052
  const phasesDir = planningPaths(cwd).phases;
3008
3053
  const milestone = getMilestoneInfo(cwd).value;
3054
+ const phaseIdConvention = resolvePhaseIdConvention(cwd);
3055
+ const milestoneDisplay = phaseIdConvention === 'bracket'
3056
+ ? renderBracketMilestoneDisplay(milestone?.version, loadConfig(cwd).project_code)
3057
+ : null;
3058
+ const milestoneVersion = milestoneDisplay ?? milestone?.version ?? null;
3009
3059
  const phases = [];
3010
3060
  let totalPlans = 0;
3011
3061
  let totalSummaries = 0;
@@ -3016,12 +3066,36 @@ function cmdProgressRender(cwd, format, raw) {
3016
3066
  // comparePhaseNum. This command previously read the phases directory
3017
3067
  // directly with neither, which is why `query progress` listed 999.*
3018
3068
  // backlog directories as current-milestone phases (#3167).
3019
- const { value: dirs, scope } = listMilestonePhaseDirs(phasesDir, { cwd });
3069
+ const { value: dirs, scope } = listMilestonePhaseDirs(phasesDir, {
3070
+ cwd,
3071
+ phaseIdConvention,
3072
+ });
3020
3073
  phaseScope = scope;
3021
3074
  for (const dir of dirs) {
3022
- const dm = dir.match(/^(\d+(?:\.\d+)*)-?(.*)/);
3023
- const phaseNum = dm ? dm[1] : dir;
3024
- const phaseName = dm && dm[2] ? dm[2].replace(/-/g, ' ') : '';
3075
+ let phaseNum;
3076
+ let phaseName;
3077
+ let displayId;
3078
+ if (phaseIdConvention === 'bracket') {
3079
+ try {
3080
+ const projection = bracketPhaseDirProjection(dir);
3081
+ phaseNum = projection.number;
3082
+ phaseName = projection.name;
3083
+ displayId = projection.display_id;
3084
+ }
3085
+ catch {
3086
+ // A malformed/tolerated directory must not suppress every later row.
3087
+ // It cannot receive a canonical display_id because parsePhaseId
3088
+ // rejected it, but the convention-aware token keeps it observable.
3089
+ const phaseToken = extractPhaseToken(dir, phaseIdConvention);
3090
+ phaseNum = phaseToken || dir;
3091
+ phaseName = recoverBracketPhaseName(dir, phaseToken);
3092
+ }
3093
+ }
3094
+ else {
3095
+ const dm = dir.match(/^(\d+(?:\.\d+)*)-?(.*)/);
3096
+ phaseNum = dm ? dm[1] : dir;
3097
+ phaseName = dm && dm[2] ? dm[2].replace(/-/g, ' ') : '';
3098
+ }
3025
3099
  // #3183: canonical plan/summary counts (root+nested, superseded-excluded,
3026
3100
  // canonical pairing) from the single owner.
3027
3101
  const phaseScan = scanPhasePlans(node_path_1.default.join(phasesDir, dir));
@@ -3030,7 +3104,14 @@ function cmdProgressRender(cwd, format, raw) {
3030
3104
  totalPlans += plans;
3031
3105
  totalSummaries += summaries;
3032
3106
  const status = determinePhaseStatus(plans, summaries, node_path_1.default.join(phasesDir, dir), 'Pending');
3033
- phases.push({ number: phaseNum, name: phaseName, plans, summaries, status });
3107
+ phases.push({
3108
+ number: phaseNum,
3109
+ ...(displayId ? { display_id: displayId } : {}),
3110
+ name: phaseName,
3111
+ plans,
3112
+ summaries,
3113
+ status,
3114
+ });
3034
3115
  }
3035
3116
  }
3036
3117
  catch { /* intentionally empty */ }
@@ -3048,12 +3129,12 @@ function cmdProgressRender(cwd, format, raw) {
3048
3129
  // Render markdown table
3049
3130
  const bar = (0, phase_lifecycle_cjs_1.renderProgressBar)(percent, 10);
3050
3131
  const percentSuffix = percent === null ? '' : ` (${percent}%)`;
3051
- let out = `# ${milestone?.version ?? ''} ${milestone?.name ?? ''}\n\n`;
3132
+ let out = `# ${milestoneVersion ?? ''} ${milestone?.name ?? ''}\n\n`;
3052
3133
  out += `**Progress:** [${bar}] ${totalSummaries}/${totalPlans} plans${percentSuffix}\n\n`;
3053
3134
  out += `| Phase | Name | Plans | Status |\n`;
3054
3135
  out += `|-------|------|-------|--------|\n`;
3055
3136
  for (const p of phases) {
3056
- out += `| ${p.number} | ${p.name} | ${p.summaries}/${p.plans} | ${p.status} |\n`;
3137
+ out += `| ${p.display_id ?? p.number} | ${p.name} | ${p.summaries}/${p.plans} | ${p.status} |\n`;
3057
3138
  }
3058
3139
  output({ rendered: out }, raw, out);
3059
3140
  }
@@ -3066,7 +3147,7 @@ function cmdProgressRender(cwd, format, raw) {
3066
3147
  else {
3067
3148
  // JSON format
3068
3149
  output({
3069
- milestone_version: milestone?.version ?? null,
3150
+ milestone_version: milestoneVersion,
3070
3151
  milestone_name: milestone?.name ?? null,
3071
3152
  phases,
3072
3153
  total_plans: totalPlans,
@@ -3361,6 +3442,11 @@ function cmdStats(cwd, format, raw) {
3361
3442
  const reqPath = planningPaths(cwd).requirements;
3362
3443
  const statePath = planningPaths(cwd).state;
3363
3444
  const milestone = getMilestoneInfo(cwd).value;
3445
+ const phaseIdConvention = resolvePhaseIdConvention(cwd);
3446
+ const milestoneDisplay = phaseIdConvention === 'bracket'
3447
+ ? renderBracketMilestoneDisplay(milestone?.version, loadConfig(cwd).project_code)
3448
+ : null;
3449
+ const milestoneVersion = milestoneDisplay ?? milestone?.version ?? null;
3364
3450
  // Phase & plan stats (reuse progress pattern)
3365
3451
  const phasesByNumber = new Map();
3366
3452
  let totalPlans = 0;
@@ -3380,20 +3466,43 @@ function cmdStats(cwd, format, raw) {
3380
3466
  // prose mentioning `### Phase N:` inside an inline code span produced a phantom
3381
3467
  // Not-Started row and made phases_total disagree with roadmap analyze.
3382
3468
  // 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.
3383
- const headingPattern = /#{2,4}\s*(?:\[[^\]]{1,200}\]\s*)?Phase\s+([A-Za-z]?\d+[A-Z]?(?:[.-]\d+)*)(?:\s*\([^)\n]{0,200}\))?\s*:\s*([^\n]+)/gi;
3469
+ const capturesBracketId = phaseIdConvention === 'bracket';
3470
+ const headingPrefix = phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.ANY_BRACKET, phaseIdConvention, capturesBracketId);
3471
+ // phase-id-owner: this preserves cmdStats's shipped dot-or-dash heading
3472
+ // token variant; PHASE_NUMBER_TOKEN_SOURCE is dot-only and would drop M-NN.
3473
+ const headingPattern = new RegExp(
3474
+ // phase-id-owner: cmdStats's shipped [.-] token variant; the canonical token is dot-only.
3475
+ `#{2,4}\\s*${headingPrefix}([A-Za-z]?\\d+[A-Z]?(?:[.-]\\d+)*)(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:\\s*([^\\n]+)`, 'gi');
3384
3476
  let match;
3385
3477
  while ((match = headingPattern.exec(roadmapContent)) !== null) {
3478
+ const bracketId = capturesBracketId ? match[1] : undefined;
3479
+ const phaseToken = capturesBracketId ? match[2] : match[1];
3480
+ const phaseName = capturesBracketId ? match[3] : match[2];
3386
3481
  // #3185: the heading seed carried no sentinel filter, so a
3387
3482
  // `### Phase 999.1:` backlog heading produced a stats row even with no
3388
3483
  // directory on disk. Uses the canonical predicate (phase-id.cts), not a
3389
3484
  // local literal — the rule had five copies and three regex variants
3390
3485
  // before this phase, disagreeing about Phase 0.
3391
- if (isSentinelPhaseId(match[1]))
3486
+ const sentinelId = bracketId ? `${bracketId}-${phaseToken}` : phaseToken;
3487
+ if (capturesBracketId
3488
+ ? isSentinelPhaseId(sentinelId, phaseIdConvention)
3489
+ : isSentinelPhaseId(sentinelId))
3392
3490
  continue;
3393
- const key = normalizePhaseName(match[1]);
3491
+ const key = normalizePhaseName(phaseToken);
3492
+ let displayId;
3493
+ if (bracketId) {
3494
+ try {
3495
+ displayId = renderPhaseId(parsePhaseId(`${bracketId}-${phaseToken}`));
3496
+ }
3497
+ catch {
3498
+ // Read tolerance can admit a non-canonical heading spelling; keep
3499
+ // the stats row but do not invent a canonical identity for it.
3500
+ }
3501
+ }
3394
3502
  phasesByNumber.set(key, {
3395
3503
  number: key,
3396
- name: match[2].replace(/\(INSERTED\)/i, '').trim(),
3504
+ ...(displayId ? { display_id: displayId } : {}),
3505
+ name: phaseName.replace(/\(INSERTED\)/i, '').trim(),
3397
3506
  plans: 0,
3398
3507
  summaries: 0,
3399
3508
  status: 'Not Started',
@@ -3407,15 +3516,36 @@ function cmdStats(cwd, format, raw) {
3407
3516
  // sentinel filter — and getMilestonePhaseFilter degrades to a pass-all
3408
3517
  // predicate when its heading set is empty, at which point every directory
3409
3518
  // on disk passed, backlog included (#3167).
3410
- const { value: dirs, scope } = listMilestonePhaseDirs(phasesDir, { cwd });
3519
+ const { value: dirs, scope } = listMilestonePhaseDirs(phasesDir, {
3520
+ cwd,
3521
+ phaseIdConvention,
3522
+ });
3411
3523
  phaseScope = scope;
3412
3524
  for (const dir of dirs) {
3413
- // Use extractPhaseToken to correctly parse M-NN-style and code-prefixed dir names.
3414
- const phaseToken = extractPhaseToken(dir);
3415
- const phaseNum = phaseToken || dir;
3416
- // phaseName is everything after the token (strip leading '-')
3417
- const afterToken = dir.slice(phaseToken ? phaseToken.length : 0).replace(/^-/, '');
3418
- const phaseName = afterToken ? afterToken.replace(/-/g, ' ') : '';
3525
+ let phaseNum;
3526
+ let phaseName;
3527
+ let displayId;
3528
+ if (phaseIdConvention === 'bracket') {
3529
+ try {
3530
+ const projection = bracketPhaseDirProjection(dir);
3531
+ phaseNum = projection.number;
3532
+ phaseName = projection.name;
3533
+ displayId = projection.display_id;
3534
+ }
3535
+ catch {
3536
+ const phaseToken = extractPhaseToken(dir, phaseIdConvention);
3537
+ phaseNum = phaseToken || dir;
3538
+ phaseName = recoverBracketPhaseName(dir, phaseToken);
3539
+ }
3540
+ }
3541
+ else {
3542
+ // Use extractPhaseToken to correctly parse M-NN-style and code-prefixed dir names.
3543
+ const phaseToken = extractPhaseToken(dir);
3544
+ phaseNum = phaseToken || dir;
3545
+ // phaseName is everything after the token (strip leading '-')
3546
+ const afterToken = dir.slice(phaseToken ? phaseToken.length : 0).replace(/^-/, '');
3547
+ phaseName = afterToken ? afterToken.replace(/-/g, ' ') : '';
3548
+ }
3419
3549
  // #3183: canonical plan/summary counts (root+nested, superseded-excluded,
3420
3550
  // canonical pairing) from the single owner.
3421
3551
  const phaseScan = scanPhasePlans(node_path_1.default.join(phasesDir, dir));
@@ -3428,6 +3558,9 @@ function cmdStats(cwd, format, raw) {
3428
3558
  const existing = phasesByNumber.get(normalizedNum);
3429
3559
  phasesByNumber.set(normalizedNum, {
3430
3560
  number: normalizedNum,
3561
+ ...(existing?.display_id || displayId
3562
+ ? { display_id: existing?.display_id || displayId }
3563
+ : {}),
3431
3564
  name: existing?.name || phaseName,
3432
3565
  plans: (existing?.plans || 0) + plans,
3433
3566
  summaries: (existing?.summaries || 0) + summaries,
@@ -3486,7 +3619,7 @@ function cmdStats(cwd, format, raw) {
3486
3619
  }
3487
3620
  }
3488
3621
  const result = {
3489
- milestone_version: milestone?.version ?? null,
3622
+ milestone_version: milestoneVersion,
3490
3623
  milestone_name: milestone?.name ?? null,
3491
3624
  phases,
3492
3625
  phases_completed: completedPhases,
@@ -3506,7 +3639,7 @@ function cmdStats(cwd, format, raw) {
3506
3639
  };
3507
3640
  if (format === 'table') {
3508
3641
  const bar = (0, phase_lifecycle_cjs_1.renderProgressBar)(percent, 10);
3509
- let out = `# ${milestone?.version ?? ''} ${milestone?.name ?? ''} — Statistics\n\n`;
3642
+ let out = `# ${milestoneVersion ?? ''} ${milestone?.name ?? ''} — Statistics\n\n`;
3510
3643
  const percentSuffix = percent === null ? '' : ` (${percent}%)`;
3511
3644
  out += `**Progress:** [${bar}] ${completedPhases}/${phases.length} phases${percentSuffix}\n`;
3512
3645
  if (totalPlans > 0 && planPercent !== null) {
@@ -3520,7 +3653,7 @@ function cmdStats(cwd, format, raw) {
3520
3653
  out += `| Phase | Name | Plans | Completed | Status |\n`;
3521
3654
  out += `|-------|------|-------|-----------|--------|\n`;
3522
3655
  for (const p of phases) {
3523
- out += `| ${p.number} | ${p.name} | ${p.plans} | ${p.summaries} | ${p.status} |\n`;
3656
+ out += `| ${p.display_id ?? p.number} | ${p.name} | ${p.plans} | ${p.summaries} | ${p.status} |\n`;
3524
3657
  }
3525
3658
  if (gitCommits > 0) {
3526
3659
  out += `\n**Git:** ${gitCommits} commits`;