@opengsd/gsd-core 1.7.0 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (261) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.opencode/plugins/gsd-core.js +45 -1
  4. package/README.md +2 -0
  5. package/agents/gsd-code-fixer.md +1 -1
  6. package/agents/gsd-codebase-mapper.md +1 -1
  7. package/agents/gsd-debug-session-manager.md +78 -4
  8. package/agents/gsd-debugger.md +87 -29
  9. package/agents/gsd-executor.md +49 -9
  10. package/agents/gsd-intel-updater.md +3 -3
  11. package/agents/gsd-phase-researcher.md +4 -2
  12. package/agents/gsd-plan-checker.md +20 -0
  13. package/agents/gsd-planner.md +44 -59
  14. package/agents/gsd-project-researcher.md +2 -2
  15. package/agents/gsd-ui-auditor.md +0 -40
  16. package/agents/gsd-verifier.md +2 -2
  17. package/bin/install.js +1338 -135
  18. package/commands/gsd/ai-integration-phase.md +1 -1
  19. package/commands/gsd/mempalace-capture.md +9 -5
  20. package/commands/gsd/new-milestone.md +1 -1
  21. package/commands/gsd/plan-phase.md +5 -3
  22. package/commands/gsd/plan-review-convergence.md +7 -2
  23. package/gsd-core/bin/gsd-tools.cjs +2690 -2472
  24. package/gsd-core/bin/lib/adapter-imperative.cjs +8 -1
  25. package/gsd-core/bin/lib/agent-command-router.cjs +20 -5
  26. package/gsd-core/bin/lib/api-coverage.cjs +360 -53
  27. package/gsd-core/bin/lib/audit.cjs +8 -8
  28. package/gsd-core/bin/lib/broken-windows.cjs +716 -0
  29. package/gsd-core/bin/lib/capability-command-router.cjs +733 -0
  30. package/gsd-core/bin/lib/capability-consent.cjs +40 -1
  31. package/gsd-core/bin/lib/capability-lifecycle.cjs +58 -0
  32. package/gsd-core/bin/lib/capability-loader.cjs +23 -1
  33. package/gsd-core/bin/lib/capability-registry.cjs +1450 -160
  34. package/gsd-core/bin/lib/capability-trust.cjs +468 -33
  35. package/gsd-core/bin/lib/capability-validator.cjs +882 -6
  36. package/gsd-core/bin/lib/capability-writer.cjs +6 -1
  37. package/gsd-core/bin/lib/check-command-router.cjs +140 -27
  38. package/gsd-core/bin/lib/cjs-command-router-adapter.cjs +15 -0
  39. package/gsd-core/bin/lib/claude-orchestration-command-router.cjs +209 -31
  40. package/gsd-core/bin/lib/claude-orchestration.cjs +203 -25
  41. package/gsd-core/bin/lib/command-aliases.cjs +14 -0
  42. package/gsd-core/bin/lib/commands.cjs +326 -21
  43. package/gsd-core/bin/lib/config-loader.cjs +214 -30
  44. package/gsd-core/bin/lib/config.cjs +158 -22
  45. package/gsd-core/bin/lib/core-utils.cjs +6 -1
  46. package/gsd-core/bin/lib/decisions.cjs +32 -8
  47. package/gsd-core/bin/lib/docs.cjs +6 -0
  48. package/gsd-core/bin/lib/estimate-cli.cjs +336 -0
  49. package/gsd-core/bin/lib/external-descriptor-trust.cjs +14 -2
  50. package/gsd-core/bin/lib/frontmatter.cjs +125 -15
  51. package/gsd-core/bin/lib/gap-checker.cjs +17 -2
  52. package/gsd-core/bin/lib/host-integration.cjs +215 -8
  53. package/gsd-core/bin/lib/init.cjs +155 -66
  54. package/gsd-core/bin/lib/install-engine.cjs +299 -23
  55. package/gsd-core/bin/lib/install-profiles.cjs +239 -1
  56. package/gsd-core/bin/lib/installer-migrations/005-opencode-baseline-commands-dir.cjs +146 -0
  57. package/gsd-core/bin/lib/installer-migrations/006-pi-extension-cjs-to-js.cjs +91 -0
  58. package/gsd-core/bin/lib/installer-migrations.cjs +44 -5
  59. package/gsd-core/bin/lib/markdown-sectionizer.cjs +107 -0
  60. package/gsd-core/bin/lib/milestone.cjs +248 -14
  61. package/gsd-core/bin/lib/model-catalog.cjs +69 -4
  62. package/gsd-core/bin/lib/model-resolver.cjs +189 -7
  63. package/gsd-core/bin/lib/observability/logger.cjs +7 -2
  64. package/gsd-core/bin/lib/onboard-projection.cjs +11 -8
  65. package/gsd-core/bin/lib/phase-command-router.cjs +10 -1
  66. package/gsd-core/bin/lib/phase-estimation.cjs +398 -0
  67. package/gsd-core/bin/lib/phase-id.cjs +304 -9
  68. package/gsd-core/bin/lib/phase.cjs +258 -17
  69. package/gsd-core/bin/lib/plan-drift-guard.cjs +1 -1
  70. package/gsd-core/bin/lib/plan-scan.cjs +70 -2
  71. package/gsd-core/bin/lib/planning-workspace.cjs +9 -2
  72. package/gsd-core/bin/lib/profile-output.cjs +34 -8
  73. package/gsd-core/bin/lib/review-lane-descriptor.cjs +927 -0
  74. package/gsd-core/bin/lib/review-lane-invocation.cjs +348 -0
  75. package/gsd-core/bin/lib/review-lane-runner.cjs +594 -0
  76. package/gsd-core/bin/lib/review-reviewer-selection.cjs +114 -32
  77. package/gsd-core/bin/lib/roadmap-parser.cjs +61 -10
  78. package/gsd-core/bin/lib/roadmap.cjs +23 -7
  79. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +38 -5
  80. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +23 -9
  81. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +156 -0
  82. package/gsd-core/bin/lib/runtime-name-policy.cjs +15 -2
  83. package/gsd-core/bin/lib/smart-entry.cjs +70 -5
  84. package/gsd-core/bin/lib/state-document.cjs +171 -24
  85. package/gsd-core/bin/lib/state-transition.cjs +50 -11
  86. package/gsd-core/bin/lib/state.cjs +206 -32
  87. package/gsd-core/bin/lib/surface.cjs +51 -9
  88. package/gsd-core/bin/lib/uat-predicate.cjs +6 -4
  89. package/gsd-core/bin/lib/uat.cjs +428 -11
  90. package/gsd-core/bin/lib/ui-consideration-probe.cjs +2 -2
  91. package/gsd-core/bin/lib/unusable-input.cjs +216 -0
  92. package/gsd-core/bin/lib/validate.cjs +44 -8
  93. package/gsd-core/bin/lib/verification.cjs +163 -31
  94. package/gsd-core/bin/lib/verify.cjs +348 -42
  95. package/gsd-core/bin/lib/worktree-safety.cjs +360 -15
  96. package/gsd-core/bin/shared/config-defaults.manifest.json +1 -0
  97. package/gsd-core/bin/shared/config-schema.manifest.json +4 -15
  98. package/gsd-core/bin/shared/model-catalog.json +5 -0
  99. package/gsd-core/bin/shared/runtime-aliases.manifest.json +5 -0
  100. package/gsd-core/references/api-coverage.md +37 -7
  101. package/gsd-core/references/checkpoints.md +1 -1
  102. package/gsd-core/references/common-bug-patterns.md +13 -0
  103. package/gsd-core/references/context-budget.md +40 -0
  104. package/gsd-core/references/debugger-bug-taxonomy.md +111 -0
  105. package/gsd-core/references/debugger-fix-acceptance.md +157 -0
  106. package/gsd-core/references/debugger-philosophy.md +1 -0
  107. package/gsd-core/references/debugger-prevention.md +98 -0
  108. package/gsd-core/references/debugger-rca-branching.md +98 -0
  109. package/gsd-core/references/debugger-repro-hardening.md +130 -0
  110. package/gsd-core/references/debugger-sbfl.md +110 -0
  111. package/gsd-core/references/debugger-semantic-recall.md +81 -0
  112. package/gsd-core/references/execute-phase-quota-recovery.md +55 -0
  113. package/gsd-core/references/execute-phase-requirement-revert.md +8 -0
  114. package/gsd-core/references/execute-phase-response-language.md +7 -0
  115. package/gsd-core/references/gate-prompts.md +6 -3
  116. package/gsd-core/references/model-profile-resolution.md +64 -13
  117. package/gsd-core/references/offer-next.md +88 -0
  118. package/gsd-core/references/planner-antipatterns.md +6 -0
  119. package/gsd-core/references/planner-mvp-mode.md +12 -13
  120. package/gsd-core/references/planner-preconditions.md +156 -0
  121. package/gsd-core/references/planner-reversibility.md +132 -0
  122. package/gsd-core/references/planning-config.md +2 -1
  123. package/gsd-core/references/reviewer-instances.md +28 -19
  124. package/gsd-core/references/runtime-aware-dispatch.md +42 -0
  125. package/gsd-core/references/skeleton-template.md +1 -1
  126. package/gsd-core/references/thinking-models-planning.md +3 -1
  127. package/gsd-core/references/ui-consideration-probe.md +2 -2
  128. package/gsd-core/references/worktree-branch-check.md +4 -4
  129. package/gsd-core/templates/DEBUG.md +5 -3
  130. package/gsd-core/templates/summary-minimal.md +4 -0
  131. package/gsd-core/templates/summary-standard.md +4 -0
  132. package/gsd-core/templates/summary.md +7 -0
  133. package/gsd-core/workflows/add-phase.md +2 -0
  134. package/gsd-core/workflows/add-tests.md +3 -1
  135. package/gsd-core/workflows/add-todo.md +32 -1
  136. package/gsd-core/workflows/ai-integration-phase.md +8 -6
  137. package/gsd-core/workflows/audit-fix.md +6 -2
  138. package/gsd-core/workflows/audit-milestone.md +8 -0
  139. package/gsd-core/workflows/autonomous.md +19 -15
  140. package/gsd-core/workflows/check-todos.md +5 -3
  141. package/gsd-core/workflows/cleanup.md +7 -1
  142. package/gsd-core/workflows/code-review-fix.md +14 -6
  143. package/gsd-core/workflows/code-review.md +93 -24
  144. package/gsd-core/workflows/complete-milestone.md +3 -0
  145. package/gsd-core/workflows/debug.md +35 -7
  146. package/gsd-core/workflows/diagnose-issues.md +5 -1
  147. package/gsd-core/workflows/discovery-phase.md +7 -0
  148. package/gsd-core/workflows/discuss-phase/modes/advisor.md +2 -4
  149. package/gsd-core/workflows/discuss-phase/modes/auto.md +0 -6
  150. package/gsd-core/workflows/discuss-phase/templates/context.md +16 -2
  151. package/gsd-core/workflows/discuss-phase-assumptions.md +18 -9
  152. package/gsd-core/workflows/discuss-phase.md +2 -2
  153. package/gsd-core/workflows/do.md +7 -1
  154. package/gsd-core/workflows/docs-update.md +9 -0
  155. package/gsd-core/workflows/eval-review.md +4 -1
  156. package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +4 -0
  157. package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +160 -0
  158. package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +4 -4
  159. package/gsd-core/workflows/execute-phase/steps/regression-gate.md +2 -2
  160. package/gsd-core/workflows/execute-phase.md +110 -149
  161. package/gsd-core/workflows/execute-plan.md +20 -8
  162. package/gsd-core/workflows/explore.md +4 -0
  163. package/gsd-core/workflows/extract-learnings.md +21 -0
  164. package/gsd-core/workflows/graduation.md +3 -0
  165. package/gsd-core/workflows/health.md +7 -1
  166. package/gsd-core/workflows/help/modes/full.md +9 -5
  167. package/gsd-core/workflows/import.md +11 -2
  168. package/gsd-core/workflows/inbox.md +7 -0
  169. package/gsd-core/workflows/ingest-docs.md +19 -10
  170. package/gsd-core/workflows/manager.md +3 -1
  171. package/gsd-core/workflows/map-codebase.md +17 -10
  172. package/gsd-core/workflows/mvp-phase.md +3 -0
  173. package/gsd-core/workflows/new-milestone.md +79 -23
  174. package/gsd-core/workflows/new-project.md +28 -19
  175. package/gsd-core/workflows/new-workspace.md +3 -1
  176. package/gsd-core/workflows/next.md +5 -2
  177. package/gsd-core/workflows/onboard.md +3 -0
  178. package/gsd-core/workflows/plan-phase.md +56 -51
  179. package/gsd-core/workflows/plan-review-convergence.md +61 -12
  180. package/gsd-core/workflows/plant-seed.md +3 -0
  181. package/gsd-core/workflows/profile-user.md +7 -1
  182. package/gsd-core/workflows/progress.md +31 -3
  183. package/gsd-core/workflows/quick.md +33 -10
  184. package/gsd-core/workflows/remove-workspace.md +3 -0
  185. package/gsd-core/workflows/review.md +172 -585
  186. package/gsd-core/workflows/scan.md +10 -2
  187. package/gsd-core/workflows/secure-phase.md +13 -2
  188. package/gsd-core/workflows/settings-integrations.md +3 -0
  189. package/gsd-core/workflows/settings.md +3 -0
  190. package/gsd-core/workflows/ship.md +88 -11
  191. package/gsd-core/workflows/sketch.md +3 -0
  192. package/gsd-core/workflows/smart-entry.md +4 -1
  193. package/gsd-core/workflows/spike.md +7 -1
  194. package/gsd-core/workflows/ui-phase.md +11 -2
  195. package/gsd-core/workflows/ui-review.md +11 -1
  196. package/gsd-core/workflows/undo.md +7 -0
  197. package/gsd-core/workflows/update.md +106 -5
  198. package/gsd-core/workflows/validate-phase.md +13 -2
  199. package/gsd-core/workflows/verify-phase.md +2 -2
  200. package/gsd-core/workflows/verify-work.md +15 -4
  201. package/hooks/dist/gsd-context-monitor.js +27 -9
  202. package/hooks/dist/gsd-cursor-session-start.js +6 -2
  203. package/hooks/dist/gsd-cursor-stop.js +6 -2
  204. package/hooks/dist/gsd-cursor-subagent-start.js +6 -2
  205. package/hooks/dist/gsd-graphify-update.sh +9 -0
  206. package/hooks/dist/gsd-phase-boundary.sh +14 -2
  207. package/hooks/dist/gsd-prompt-guard.js +101 -2
  208. package/hooks/dist/gsd-read-guard.js +100 -2
  209. package/hooks/dist/gsd-read-injection-scanner.js +109 -2
  210. package/hooks/dist/gsd-statusline.js +97 -9
  211. package/hooks/dist/gsd-workflow-guard.js +110 -6
  212. package/hooks/dist/gsd-worktree-path-guard.js +132 -8
  213. package/hooks/dist/lib/cursor-workspace.js +74 -0
  214. package/hooks/gsd-context-monitor.js +27 -9
  215. package/hooks/gsd-cursor-session-start.js +6 -2
  216. package/hooks/gsd-cursor-stop.js +6 -2
  217. package/hooks/gsd-cursor-subagent-start.js +6 -2
  218. package/hooks/gsd-graphify-update.sh +9 -0
  219. package/hooks/gsd-phase-boundary.sh +14 -2
  220. package/hooks/gsd-prompt-guard.js +101 -2
  221. package/hooks/gsd-read-guard.js +100 -2
  222. package/hooks/gsd-read-injection-scanner.js +109 -2
  223. package/hooks/gsd-statusline.js +97 -9
  224. package/hooks/gsd-workflow-guard.js +110 -6
  225. package/hooks/gsd-worktree-path-guard.js +132 -8
  226. package/hooks/lib/cursor-workspace.js +74 -0
  227. package/package.json +10 -8
  228. package/pi/gsd.cjs +34 -3
  229. package/scripts/changeset/lint.cjs +1 -0
  230. package/scripts/changeset/parse.cjs +26 -0
  231. package/scripts/check-coverage-gate.cjs +51 -0
  232. package/scripts/check-glossary-refs.cjs +244 -0
  233. package/scripts/ci-rebase-check.cjs +48 -4
  234. package/scripts/ci-test-scope.cjs +67 -17
  235. package/scripts/gen-adr-index.cjs +528 -0
  236. package/scripts/gen-capability-matrix.cjs +26 -2
  237. package/scripts/gen-capability-registry.cjs +132 -34
  238. package/scripts/gen-emitted-baseline.cjs +145 -0
  239. package/scripts/gen-test-timings.cjs +201 -0
  240. package/scripts/lint-compiled-artifact-sync.cjs +146 -0
  241. package/scripts/lint-emitted-drift-ack.cjs +149 -0
  242. package/scripts/lint-fix-has-regression-test.cjs +131 -0
  243. package/scripts/lint-portable-timeout.cjs +140 -0
  244. package/scripts/lint-resolution-provenance.cjs +9 -0
  245. package/scripts/lint-test-file-count.allowlist.json +1 -0
  246. package/scripts/mutation-matrix.cjs +4 -0
  247. package/scripts/prompt-injection-scan.sh +6 -0
  248. package/scripts/registry-schema.cjs +57 -8
  249. package/scripts/release-notes/conventional-title.cjs +19 -1
  250. package/scripts/release-notes/format-github-release-notes.cjs +7 -3
  251. package/scripts/release-tarball-smoke.cjs +18 -11
  252. package/scripts/run-tests.cjs +420 -58
  253. package/scripts/workflow-size.cjs +16 -8
  254. package/skills/gsd-ai-integration-phase/SKILL.md +1 -1
  255. package/skills/gsd-mempalace-capture/SKILL.md +9 -5
  256. package/skills/gsd-new-milestone/SKILL.md +1 -1
  257. package/skills/gsd-plan-phase/SKILL.md +5 -3
  258. package/skills/gsd-plan-review-convergence/SKILL.md +7 -2
  259. package/vscode/package.json +1 -1
  260. package/scripts/gen-golden-install-parity-zcode.cjs +0 -77
  261. package/scripts/update-size-baseline.cjs +0 -68
@@ -48,6 +48,88 @@ const OPTIONAL_PHASE_TAG_SOURCE = '(?:\\s*\\([^)\\n]{0,200}\\))?';
48
48
  // (scripts/lint-phase-id-drift.cjs) fails CI if a literal re-derivation is
49
49
  // introduced outside this module without a `// phase-id-owner:` justification.
50
50
  const PHASE_NUMBER_TOKEN_SOURCE = '\\d+[A-Z]?(?:\\.\\d+)*';
51
+ // #2232: the canonical CONTINUATION-segment grammar — a dash-separated segment
52
+ // that extends a phase token (a zero-padded sub-phase or plan number, e.g. the
53
+ // "01" in "02-01-setup"). getPhaseDirFromPhaseId writes these zero-padded to
54
+ // exactly 2 digits, so the digit RUN of a genuine continuation is exactly 2:
55
+ // #2043's `\d{2,}` (2-or-more) over-collected a slug word that merely leads
56
+ // with ≥2 digits (a year: "14-2026-photos-…" yielded token "14-2026", so every
57
+ // phase-locating verb reported the phase as missing). The `(?!\d)` guard caps
58
+ // the run at 2 without anchoring what may follow, so call sites keep their own
59
+ // trailing grammar (letter suffixes, dotted sub-phases, segment boundaries).
60
+ // POLICY (locked by boundary tests): sub-phase/plan numbers ≥100 are out of the
61
+ // dir-token grammar — the LEADING phase number stays unbounded (`\d+`), only
62
+ // continuation segments are width-capped. Shared from here so the five #2043
63
+ // call sites cannot drift independently (see scripts/lint-phase-id-drift.cjs).
64
+ const PHASE_CONTINUATION_SEGMENT_SOURCE = '\\d{2}(?!\\d)';
65
+ const PHASE_CONTINUATION_SEGMENT_PREFIX_RE = new RegExp(`^${PHASE_CONTINUATION_SEGMENT_SOURCE}`);
66
+ function isPhaseContinuationSegment(seg) {
67
+ return PHASE_CONTINUATION_SEGMENT_PREFIX_RE.test(seg);
68
+ }
69
+ // #612 (PR-1): bracket-convention token/heading sources, kept next to the M-NN
70
+ // PHASE_NUMBER_TOKEN_SOURCE so this owner file stays the single origin of every
71
+ // phase-token grammar. `src/phase-id.cts` is exempt from the #2128 drift guard
72
+ // (scripts/lint-phase-id-drift.cjs) by construction, and that guard fails any
73
+ // literal re-derivation of the token grammar elsewhere — so the downstream
74
+ // bracket readers (PR-2: roadmap/validate/verify) must build their regexes by
75
+ // interpolating these exports, never by copying the literal.
76
+ //
77
+ // The canonical numeric WIDTH of a bracket identity field, mirroring pad2()'s
78
+ // output: exactly 2 digits, or 3+ with no leading zero. Owned here as a SOURCE
79
+ // so the read side (BRACKET_PHASE_TOKEN_SOURCE, below) and the emit-side
80
+ // validator (CANONICAL_NUMERIC_RE, which toDir enforces) are one rule rather
81
+ // than two literals that agree today and drift tomorrow.
82
+ const BRACKET_CANONICAL_NUMERIC_SOURCE = '(?:[1-9]\\d{2,}|\\d{2})';
83
+ // BRACKET_PHASE_TOKEN_SOURCE differs from PHASE_NUMBER_TOKEN_SOURCE by a
84
+ // dot-OR-dash sub-separator: a bracket dir/heading numeric run is `MM-PP[.SS]`
85
+ // (a hyphen joins milestone↔phase, a dot joins phase↔sub-phase), whereas M-NN
86
+ // sub-phases are dot-only.
87
+ //
88
+ // The run is POSITIONAL, not a free repetition — `MM-PP[.SS][-LL]` — and each
89
+ // position gets the width its DELIMITER can actually afford:
90
+ //
91
+ // MM leading unbounded — delimited by the `{CODE}.` prefix
92
+ // -PP dash-1 canonical — the grammar REQUIRES this dash, so it is a field
93
+ // separator, not a continuation heuristic
94
+ // .SS dot canonical — a slug carries no dot (toDir sanitizes them
95
+ // away), so this position cannot collide
96
+ // -LL dash-2 #2232 cap — the ONLY slug-adjacent position, and therefore
97
+ // the only one a slug word can collide with
98
+ //
99
+ // #2232 reconciliation: the slug-adjacent position interpolates the single-owner
100
+ // PHASE_CONTINUATION_SEGMENT_SOURCE, so the #2232 bug class cannot reopen on the
101
+ // bracket path — dir `PROJ.01-14-2026-photos-…` (a slug leading with a year)
102
+ // yields `01-14`, never `01-14-2026`.
103
+ //
104
+ // DELIBERATE DIVERGENCE from the M-NN dir-token path (pinned by the parity gate
105
+ // in tests/continuation-grammar-parity.test.cjs, which fails if these two rules
106
+ // drift for a reason nobody intended): the non-slug-adjacent positions stay
107
+ // WIDER than #2232's cap. Bracket admits 3+-digit milestone/phase/sub-phase
108
+ // (CANONICAL_NUMERIC_RE — `[GSD.100] 05` is a pinned regression), and unlike the
109
+ // M-NN continuations those positions are delimiter-disambiguated rather than
110
+ // heuristically recognized, so there is no year collision to defend against.
111
+ // Interpolating the cap verbatim at every position would only under-collect ids
112
+ // that toDir itself emits: `PROJ.02-105-slug` (3-digit phase) would read as
113
+ // `02`, and `[GSD.02] 05.100` (3-digit sub-phase) as `05`. Upstream draws this
114
+ // same line for the same reason — core-utils/phase cap the paired PLAN component
115
+ // while the leading phase component stays unbounded (phase numbers ≥100 are
116
+ // legitimate). The trade-off this accepts is #2232's policy verbatim: a PLAN
117
+ // ≥100 is out of the token grammar.
118
+ //
119
+ // Still deliberately MORE PERMISSIVE than parsePhaseId's strict grammar (it
120
+ // admits a letter-suffixed and unpadded leading token that the parser rejects):
121
+ // this is a READ-TOLERANCE source for the PR-2 readers, which must recognize a
122
+ // bracket-shaped token before deciding what to do with it — it is not the
123
+ // emit/identity grammar. parsePhaseId stays the arbiter of well-formedness.
124
+ const BRACKET_PHASE_TOKEN_SOURCE = `\\d+[A-Z]?` +
125
+ `(?:-${BRACKET_CANONICAL_NUMERIC_SOURCE}(?!\\d))?` +
126
+ `(?:\\.${BRACKET_CANONICAL_NUMERIC_SOURCE}(?!\\d))?` +
127
+ `(?:-${PHASE_CONTINUATION_SEGMENT_SOURCE})?`;
128
+ // A phase HEADING intro under bracket is either a `[...]` bracket (optionally
129
+ // followed by a `Phase ` label) or a bare `Phase ` label; a bare number is NOT
130
+ // a phase-heading intro. The `[^\]]{1,200}` bound mirrors the existing
131
+ // roadmap-parser heading regexes (ReDoS-safe: a header is one short line).
132
+ const PHASE_HEADING_PREFIX_SRC = '(?:\\[[^\\]]{1,200}\\]\\s*(?:Phase\\s+)?|Phase\\s+)';
51
133
  function stripProjectCodePrefix(value, caseInsensitive = true) {
52
134
  const input = String(value);
53
135
  const re = caseInsensitive ? PROJECT_CODE_PREFIX_STRIP_RE_I : PROJECT_CODE_PREFIX_STRIP_RE;
@@ -80,7 +162,23 @@ function normalizePhaseName(phase) {
80
162
  // Custom phase IDs (e.g. PROJ-42, AUTH-101): return as-is
81
163
  return str;
82
164
  }
83
- function getMilestoneFromPhaseId(phaseId) {
165
+ function getMilestoneFromPhaseId(phaseId, convention) {
166
+ // READING-B (#612): under the bracket convention the milestone comes from the
167
+ // `[PROJECT.MM]` / `{CODE}.{MM}-` prefix, never the phase-token leading
168
+ // integer (ADR-612 Decision 6). Gated on 'bracket' so the `null` and
169
+ // 'milestone-prefixed' (M-NN) paths keep the legacy leading-int rule
170
+ // (READING-A) below, byte-untouched. The optional parameter keeps this helper
171
+ // pure (no config read) and backward-compatible: every existing single-arg
172
+ // caller resolves to the unchanged READING-A body.
173
+ if (convention === 'bracket') {
174
+ const b = String(phaseId).match(/^([A-Z][A-Z0-9_]*)\.(\d+)/);
175
+ if (!b)
176
+ return null;
177
+ const mm = parseInt(b[2], 10);
178
+ if (SENTINEL_RANGES.includes(mm))
179
+ return null; // sentinel milestones have no real milestone
180
+ return `v${mm}.0`;
181
+ }
84
182
  const stripped = stripProjectCodePrefix(phaseId);
85
183
  const m = stripped.match(/^0*(\d+)-\d/);
86
184
  if (!m)
@@ -105,6 +203,145 @@ function getPhaseDirFromPhaseId(phaseId, phaseName, projectCode) {
105
203
  const base = parts.join('-');
106
204
  return projectCode ? `${projectCode}-${base}` : base;
107
205
  }
206
+ const pad2 = (n) => String(parseInt(n, 10)).padStart(2, '0');
207
+ function parsePhaseId(input) {
208
+ // No .trim(): the match anchors (`^`...`$`) then reject leading/trailing
209
+ // whitespace outright, folding that case into the same "not a bracket
210
+ // phase id" rejection below rather than needing its own check.
211
+ const str = String(input);
212
+ // Display form: [PROJECT.MM] PP[.SS][-LL]. The match itself stays
213
+ // permissive on purpose (it will happily match an unpadded number or a
214
+ // multi-space run) — canonicality is enforced UNIFORMLY below via the
215
+ // render round-trip (ADR-612 Decision 4) rather than by hand-tuning every
216
+ // numeric / whitespace sub-pattern, so a field added later inherits the
217
+ // check for free instead of needing its own regex micro-surgery.
218
+ const disp = str.match(/^\[([A-Z][A-Z0-9_]*)\.(\d+)\]\s+(\d+)(?:\.(\d+))?(?:-(\d+))?$/);
219
+ if (disp) {
220
+ const id = { project: disp[1], milestone: pad2(disp[2]), phase: pad2(disp[3]) };
221
+ if (disp[4] !== undefined)
222
+ id.subphase = pad2(disp[4]);
223
+ if (disp[5] !== undefined)
224
+ id.plan = pad2(disp[5]);
225
+ // Canonicality by construction: re-render the parsed id and require
226
+ // byte-equality with the input. This rejects unpadded ('[GSD.5] 5'),
227
+ // over-padded ('[GSD.005] 05'), and multi-space-separated ('[GSD.02] 05')
228
+ // variants uniformly, without special-casing any one of them — the emit
229
+ // path (renderPhaseId) is the single source of truth for "canonical".
230
+ if (renderPhaseId(id) !== str) {
231
+ throw new Error(`parsePhaseId: not canonical: ${JSON.stringify(input)}`);
232
+ }
233
+ return id;
234
+ }
235
+ // Dir / token form: {PROJECT}.{MM}-{PP}[.{SS}][-{plan|slug}]
236
+ const dir = str.match(/^([A-Z][A-Z0-9_]*)\.(\d+)-(\d+)(?:\.(\d+))?(?:-(.+))?$/);
237
+ if (dir) {
238
+ const id = { project: dir[1], milestone: pad2(dir[2]), phase: pad2(dir[3]) };
239
+ if (dir[4] !== undefined)
240
+ id.subphase = pad2(dir[4]);
241
+ // Trailing segment: a pure-integer tail is the plan; anything else is a
242
+ // slug (dropped from the tuple — it is not an identity dimension). The
243
+ // plan tail participates in the canonicality check below; the slug tail
244
+ // is read-tolerant pass-through (a slug is not an identity dimension) and
245
+ // is exempt from it.
246
+ const tail = dir[5];
247
+ const tailIsPlan = tail !== undefined && /^\d+$/.test(tail);
248
+ if (tailIsPlan)
249
+ id.plan = pad2(tail);
250
+ // Canonicality by construction, mirroring the display branch: rebuild the
251
+ // exact dir/token string this id would emit and require it match the
252
+ // input verbatim. Rejects unpadded milestone/phase ('GSD.2-5') and
253
+ // unpadded plan tails ('GSD.02-05-1') without special-casing either.
254
+ const sub = id.subphase ? `.${id.subphase}` : '';
255
+ const tailOut = tail === undefined ? '' : tailIsPlan ? `-${pad2(tail)}` : `-${tail}`;
256
+ const canonical = `${id.project}.${id.milestone}-${id.phase}${sub}${tailOut}`;
257
+ if (canonical !== str) {
258
+ throw new Error(`parsePhaseId: not canonical: ${JSON.stringify(input)}`);
259
+ }
260
+ return id;
261
+ }
262
+ // Ambiguous / bare tokens (e.g. `02-04`, `05`, `2-01`) match neither branch,
263
+ // as does a display/dir form carrying leading/trailing whitespace (the
264
+ // anchors never match it): reject rather than guess a tuple (ADR-612
265
+ // conservative default). The rejection lives ONLY in this new parser —
266
+ // normalizePhaseName and every other legacy reader keep accepting those
267
+ // tokens unchanged.
268
+ throw new Error(`parsePhaseId: not a bracket phase id: ${JSON.stringify(input)}`);
269
+ }
270
+ function renderPhaseId(id) {
271
+ const sub = id.subphase ? `.${id.subphase}` : '';
272
+ const plan = id.plan ? `-${id.plan}` : '';
273
+ return `[${id.project}.${id.milestone}] ${id.phase}${sub}${plan}`;
274
+ }
275
+ // PhaseId is a structural type: nothing forces a caller through parsePhaseId,
276
+ // so toDir cannot trust project/milestone/phase/subphase are already
277
+ // canonical — each is validated below against the exact shape parsePhaseId
278
+ // itself would ever produce, closing off a hand-built id as a path-traversal
279
+ // vector. PROJECT_ID_RE mirrors the parser's `[A-Z][A-Z0-9_]*` grammar;
280
+ // CANONICAL_NUMERIC_RE mirrors pad2()'s output shape — exactly 2 digits, or
281
+ // 3+ digits with no leading zero. It is BUILT from
282
+ // BRACKET_CANONICAL_NUMERIC_SOURCE rather than re-spelled as a literal, so this
283
+ // emit-side gate and the read-side token source cannot disagree about what
284
+ // "canonical width" means (the anchors here make the source's trailing `(?!\d)`
285
+ // guard, which the unanchored read side needs, redundant).
286
+ const PROJECT_ID_RE = /^[A-Z][A-Z0-9_]*$/;
287
+ const CANONICAL_NUMERIC_RE = new RegExp(`^${BRACKET_CANONICAL_NUMERIC_SOURCE}$`);
288
+ function toDir(id, slug) {
289
+ if (!PROJECT_ID_RE.test(id.project)) {
290
+ throw new Error(`toDir: invalid project: ${JSON.stringify(id.project)}`);
291
+ }
292
+ if (!CANONICAL_NUMERIC_RE.test(id.milestone)) {
293
+ throw new Error(`toDir: invalid milestone: ${JSON.stringify(id.milestone)}`);
294
+ }
295
+ if (!CANONICAL_NUMERIC_RE.test(id.phase)) {
296
+ throw new Error(`toDir: invalid phase: ${JSON.stringify(id.phase)}`);
297
+ }
298
+ if (id.subphase !== undefined && !CANONICAL_NUMERIC_RE.test(id.subphase)) {
299
+ throw new Error(`toDir: invalid subphase: ${JSON.stringify(id.subphase)}`);
300
+ }
301
+ // A non-string slug (e.g. an omitted second argument) must not be silently
302
+ // coerced by String(...) into the literal token 'undefined'/'null' on disk.
303
+ if (typeof slug !== 'string') {
304
+ throw new Error(`toDir: slug must be a string: ${JSON.stringify(slug)}`);
305
+ }
306
+ const sub = id.subphase ? `.${id.subphase}` : '';
307
+ // Slug guard: the slug becomes an on-disk path segment, so collapse it to a
308
+ // safe lowercase token — never a path separator or `..` traversal.
309
+ const safeSlug = slug.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
310
+ // A slug that sanitizes to nothing (e.g. '!!!') would otherwise emit a
311
+ // dangling trailing hyphen.
312
+ if (!safeSlug) {
313
+ throw new Error(`toDir: slug sanitizes to empty: ${JSON.stringify(slug)}`);
314
+ }
315
+ // An all-digit slug (e.g. '2026') is string-indistinguishable from the
316
+ // parsePhaseId dir branch's plan tail, so it would re-parse as a plan, not
317
+ // a slug — silently breaking the disk↔identity bijection on read-back.
318
+ if (/^\d+$/.test(safeSlug)) {
319
+ throw new Error(`toDir: slug must not be all-digit: ${JSON.stringify(slug)}`);
320
+ }
321
+ return `${id.project}.${id.milestone}-${id.phase}${sub}-${safeSlug}`;
322
+ }
323
+ // Milestone integers reserved as non-milestone sentinels (0.x backlog / 999.x
324
+ // icebox); a phase id in these ranges has no real milestone.
325
+ const SENTINEL_RANGES = Object.freeze([0, 999]);
326
+ function isSentinelPhaseId(phaseId, convention) {
327
+ const s = String(phaseId);
328
+ // Bracket milestone lives in the `{CODE}.{MM}` prefix. GATED on
329
+ // convention === 'bracket' for the same reason as extractPhaseToken below and
330
+ // getMilestoneFromPhaseId above: that prefix is string-indistinguishable from
331
+ // the legacy #1324 letter-prefixed-decimal family (`P0.0-foundation` is a real
332
+ // phase, NOT sentinel milestone 0) whenever the code ends in a digit. A
333
+ // convention-less caller uses the legacy/bare leading-int rule below, so no
334
+ // existing reader gains a false positive; the bracket reading is opt-in.
335
+ if (convention === 'bracket') {
336
+ const bracket = s.match(/^[A-Z][A-Z0-9_]*\.(\d+)/); // bracket: milestone in the prefix
337
+ if (bracket)
338
+ return SENTINEL_RANGES.includes(parseInt(bracket[1], 10));
339
+ }
340
+ const legacy = stripProjectCodePrefix(s).match(/^0*(\d+)/); // legacy/bare: leading int
341
+ if (!legacy)
342
+ return false;
343
+ return SENTINEL_RANGES.includes(parseInt(legacy[1], 10));
344
+ }
108
345
  /**
109
346
  * Render a regex source fragment matching a phase number against ROADMAP/STATE
110
347
  * prose regardless of zero-padding on either side.
@@ -201,7 +438,25 @@ function comparePhaseNum(a, b) {
201
438
  /**
202
439
  * Extract the phase token from a directory name.
203
440
  */
204
- function extractPhaseToken(dirName) {
441
+ function extractPhaseToken(dirName, convention) {
442
+ // #612 bracket dir form `{CODE}.{MM}-{PP}[.{SS}]-slug` → phase token `PP[.SS]`.
443
+ // GATED on convention === 'bracket' (mirrors getMilestoneFromPhaseId's READING-B
444
+ // decision above). A bracket dir `{CODE}.{MM}-{PP}` is string-INDISTINGUISHABLE
445
+ // from the legacy #2043/#1324 letter-prefixed-decimal family (`P0.3-2`,
446
+ // `P0.12-34`) whenever the project code ends in a digit, so NO string-only
447
+ // discriminator can separate the two conventions — auto-detecting here silently
448
+ // reinterpreted `P0.3-2` → `2` (was `P0.3-2`), a byte-identical-read regression
449
+ // on this CRITICAL 6-caller helper (ADR-2121). Requiring an explicit convention
450
+ // signal keeps every existing (convention-less) call site byte-identical to
451
+ // prior behaviour — see the #2043 numeric-tail characterization in
452
+ // tests/phase-id.test.cjs — while keeping the helper pure (optional param, no
453
+ // config read). The captured token is dot-only (`PP[.SS]`); the milestone↔phase
454
+ // hyphen and any trailing plan/slug are excluded.
455
+ if (convention === 'bracket') {
456
+ const bracketDir = dirName.match(/^[A-Z][A-Z0-9_]*\.\d+-(\d+(?:\.\d+)?)/);
457
+ if (bracketDir)
458
+ return bracketDir[1];
459
+ }
205
460
  const codePrefixMatch = dirName.match(PROJECT_CODE_PREFIX_CAPTURE_RE_I);
206
461
  let prefix = '';
207
462
  let rest = dirName;
@@ -211,9 +466,11 @@ function extractPhaseToken(dirName) {
211
466
  }
212
467
  const segments = rest.split('-');
213
468
  const tokenSegments = [];
214
- // #2043: distinguish a real (zero-padded, ≥2-digit) phase/sub-phase segment
215
- // from a single-digit slug word. A pure-numeric leading segment ("46") only
216
- // continues with ≥2-digit segments, so "46-6-rs-…" yields "46" (the "6" is the
469
+ // #2043: distinguish a real (zero-padded) phase/sub-phase segment from a
470
+ // single-digit slug word. A pure-numeric leading segment ("46") only
471
+ // continues with exactly-2-digit segments (#2232: a ≥3-digit run is a slug
472
+ // word such as a year — "14-2026-photos-…" yields "14", not "14-2026"), so
473
+ // "46-6-rs-…" yields "46" (the "6" is the
217
474
  // slug's first word), not "46-6". Milestone-prefixed ids like "M1-2" reach here
218
475
  // with "M1-" already stripped as a project-code prefix (see
219
476
  // PROJECT_CODE_PREFIX_CAPTURE_RE_I), so "2" is the leading segment and the same
@@ -236,7 +493,7 @@ function extractPhaseToken(dirName) {
236
493
  break;
237
494
  }
238
495
  }
239
- else if (/^\d{2,}/.test(seg) || (firstLetterPrefixed && /^\d/.test(seg))) {
496
+ else if (isPhaseContinuationSegment(seg) || (firstLetterPrefixed && /^\d/.test(seg))) {
240
497
  tokenSegments.push(seg);
241
498
  }
242
499
  else {
@@ -290,9 +547,38 @@ function parsePhaseFromProse(value) {
290
547
  // cannot drive O(n^2) regex backtracking (CPU-exhaustion DoS). A real phase
291
548
  // name is far shorter than the cap.
292
549
  const parenName = str.match(/\(([^)]{1,200})\)/);
293
- const dashName = str.match(/—\s*([^(\n]{1,200}?)(?:\s*\(|$)/);
294
- const rawName = parenName?.[1] ?? dashName?.[1] ?? null;
295
- const name = rawName && !/^(?:complete|executing|not started)$/i.test(rawName.trim())
550
+ // #2736 (the #1695 AC #3 residual): status-keyword-aware precedence. The
551
+ // first-party writer shapes are `N — Name (aside)` (completePhaseCore),
552
+ // `N (Name) — EXECUTING` (beginPhaseCore), `N — COMPLETE`, and the
553
+ // gsd2-import `N (slug) — Milestone: Title`. A blind paren-first read
554
+ // harvests the aside as the name on the first shape; a blind dash-first
555
+ // read harvests the status keyword on the others. Prefer the em-dash name
556
+ // when it is a genuine name, else fall back to the parenthetical. Still
557
+ // lossy for names that themselves contain a parenthetical — transitions
558
+ // that hold the exact name bypass this parser entirely via the
559
+ // syncStateFrontmatter authoritative override.
560
+ //
561
+ // The em-dash separator is searched on a paren-stripped copy, so an em-dash
562
+ // INSIDE a parenthetical name (`16 (Native — Global Hotkey) — EXECUTING`)
563
+ // can never be mistaken for the name separator.
564
+ const strNoParens = str.replace(/\([^)\n]{0,200}\)/g, ' ');
565
+ const dashName = strNoParens.match(/—\s*([^(\n]{1,200}?)\s*$/);
566
+ // The precedence-decision vocabulary is deliberately broader than the final
567
+ // name-nulling filter below: a dash tail that merely LOOKS like a status
568
+ // annotation should lose to a parenthetical name, without changing which
569
+ // extracted names are nulled (that set stays the long-standing three).
570
+ const STATUS_WORD_RE = /^(?:complete|executing|not started)$/i;
571
+ const STATUSY_TAIL_RE = /^(?:completed?|executing|not started|planning|planned|ready(?:\s+to\s+\S.{0,50})?|done|in progress|blocked|paused|verifying)$/i;
572
+ const dashRaw = dashName?.[1]?.trim() ?? null;
573
+ const dashIsName = dashRaw !== null && dashRaw.length > 0
574
+ && !STATUSY_TAIL_RE.test(dashRaw)
575
+ && !/^milestone\s*:/i.test(dashRaw)
576
+ // A lone ALL-CAPS token after the dash reads as a status marker whenever a
577
+ // parenthetical name exists to prefer (the beginPhase writer's systematic
578
+ // `(Name) — STATUS` shape); with no parenthetical it stays the best guess.
579
+ && !(parenName && /^[A-Z][A-Z0-9_-]*$/.test(dashRaw));
580
+ const rawName = dashIsName ? dashRaw : (parenName?.[1] ?? dashRaw ?? null);
581
+ const name = rawName && !STATUS_WORD_RE.test(rawName.trim())
296
582
  ? rawName.trim()
297
583
  : null;
298
584
  return {
@@ -358,10 +644,19 @@ module.exports = {
358
644
  OPTIONAL_PROJECT_CODE_PREFIX_SOURCE,
359
645
  OPTIONAL_PHASE_TAG_SOURCE,
360
646
  PHASE_NUMBER_TOKEN_SOURCE,
647
+ PHASE_CONTINUATION_SEGMENT_SOURCE,
648
+ isPhaseContinuationSegment,
649
+ BRACKET_PHASE_TOKEN_SOURCE,
650
+ PHASE_HEADING_PREFIX_SRC,
361
651
  stripProjectCodePrefix,
362
652
  normalizePhaseName,
363
653
  getMilestoneFromPhaseId,
364
654
  getPhaseDirFromPhaseId,
655
+ parsePhaseId,
656
+ renderPhaseId,
657
+ toDir,
658
+ SENTINEL_RANGES,
659
+ isSentinelPhaseId,
365
660
  phaseMarkdownRegexSource,
366
661
  phaseMarkdownRegexSourceExact,
367
662
  comparePhaseNum,