@opengsd/gsd-core 1.13.0 → 1.14.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 (257) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/agents/gsd-advisor-researcher.compact.md +85 -0
  4. package/agents/gsd-ai-researcher.compact.md +96 -0
  5. package/agents/gsd-assumptions-analyzer.compact.md +81 -0
  6. package/agents/gsd-code-fixer.compact.md +458 -0
  7. package/agents/gsd-code-fixer.md +5 -5
  8. package/agents/gsd-code-reviewer.compact.md +269 -0
  9. package/agents/gsd-code-reviewer.md +15 -3
  10. package/agents/gsd-codebase-mapper.compact.md +760 -0
  11. package/agents/gsd-debug-session-manager.compact.md +345 -0
  12. package/agents/gsd-doc-classifier.compact.md +192 -0
  13. package/agents/gsd-doc-synthesizer.compact.md +200 -0
  14. package/agents/gsd-doc-verifier.compact.md +143 -0
  15. package/agents/gsd-doc-writer.compact.md +440 -0
  16. package/agents/gsd-dom-verifier.compact.md +138 -0
  17. package/agents/gsd-domain-researcher.compact.md +141 -0
  18. package/agents/gsd-eval-auditor.compact.md +160 -0
  19. package/agents/gsd-eval-planner.compact.md +137 -0
  20. package/agents/gsd-framework-selector.compact.md +82 -0
  21. package/agents/gsd-integration-checker.compact.md +245 -0
  22. package/agents/gsd-intel-updater.compact.md +226 -0
  23. package/agents/gsd-mempalace-curator.compact.md +45 -0
  24. package/agents/gsd-nyquist-auditor.compact.md +179 -0
  25. package/agents/gsd-pattern-mapper.compact.md +275 -0
  26. package/agents/gsd-project-researcher.compact.md +587 -0
  27. package/agents/gsd-research-synthesizer.compact.md +212 -0
  28. package/agents/gsd-roadmapper.compact.md +454 -0
  29. package/agents/gsd-roadmapper.md +13 -0
  30. package/agents/gsd-security-auditor.compact.md +162 -0
  31. package/agents/gsd-ui-auditor.compact.md +404 -0
  32. package/agents/gsd-ui-checker.compact.md +277 -0
  33. package/agents/gsd-ui-researcher.compact.md +282 -0
  34. package/agents/gsd-user-profiler.compact.md +108 -0
  35. package/bin/install.js +206 -68
  36. package/commands/gsd/cleanup.md +1 -0
  37. package/commands/gsd/code-review.md +2 -1
  38. package/commands/gsd/complete-milestone.md +1 -0
  39. package/commands/gsd/config.md +1 -0
  40. package/commands/gsd/debug.md +1 -0
  41. package/commands/gsd/graphify.md +1 -0
  42. package/commands/gsd/health.md +1 -0
  43. package/commands/gsd/mempalace-capture.md +1 -0
  44. package/commands/gsd/mempalace-recall.md +1 -0
  45. package/commands/gsd/new-milestone.md +1 -0
  46. package/commands/gsd/new-project.md +1 -0
  47. package/commands/gsd/next.md +1 -0
  48. package/commands/gsd/pause-work.md +1 -0
  49. package/commands/gsd/phase.md +1 -0
  50. package/commands/gsd/pr-branch.md +1 -0
  51. package/commands/gsd/resume-work.md +1 -0
  52. package/commands/gsd/review-backlog.md +1 -0
  53. package/commands/gsd/settings.md +2 -1
  54. package/commands/gsd/stats.md +1 -0
  55. package/commands/gsd/thread.md +1 -0
  56. package/commands/gsd/workspace.md +1 -0
  57. package/commands/gsd/workstreams.md +1 -0
  58. package/gsd-core/bin/check-latest-version.cjs +8 -3
  59. package/gsd-core/bin/gsd-tools.cjs +338 -125
  60. package/gsd-core/bin/lib/adr-parser.cjs +1 -1
  61. package/gsd-core/bin/lib/artifacts.cjs +2 -1
  62. package/gsd-core/bin/lib/audit.cjs +39 -22
  63. package/gsd-core/bin/lib/broken-windows.cjs +168 -49
  64. package/gsd-core/bin/lib/capability-lifecycle.cjs +10 -6
  65. package/gsd-core/bin/lib/capability-loader.cjs +135 -1
  66. package/gsd-core/bin/lib/capability-registry.cjs +79 -67
  67. package/gsd-core/bin/lib/capability-source.cjs +19 -2
  68. package/gsd-core/bin/lib/capability-validator.cjs +14 -1
  69. package/gsd-core/bin/lib/check-command-router.cjs +113 -36
  70. package/gsd-core/bin/lib/code-review-depth.cjs +2 -2
  71. package/gsd-core/bin/lib/commands.cjs +650 -72
  72. package/gsd-core/bin/lib/config-loader.cjs +1 -0
  73. package/gsd-core/bin/lib/config.cjs +153 -38
  74. package/gsd-core/bin/lib/coverage.cjs +1 -1
  75. package/gsd-core/bin/lib/decisions.cjs +137 -34
  76. package/gsd-core/bin/lib/external-descriptor-trust.cjs +29 -14
  77. package/gsd-core/bin/lib/gsd2-import.cjs +1 -2
  78. package/gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs +12 -1
  79. package/gsd-core/bin/lib/health-diagnostic-rules/worktree-health.cjs +1 -1
  80. package/gsd-core/bin/lib/init.cjs +409 -47
  81. package/gsd-core/bin/lib/install-engine.cjs +16 -3
  82. package/gsd-core/bin/lib/install-profiles.cjs +14 -0
  83. package/gsd-core/bin/lib/installer-migrations.cjs +33 -4
  84. package/gsd-core/bin/lib/loop-resolver.cjs +50 -31
  85. package/gsd-core/bin/lib/mcp-catalog.cjs +2 -2
  86. package/gsd-core/bin/lib/milestone.cjs +19 -8
  87. package/gsd-core/bin/lib/model-resolver.cjs +101 -10
  88. package/gsd-core/bin/lib/phase-command-router.cjs +7 -1
  89. package/gsd-core/bin/lib/phase-id.cjs +161 -22
  90. package/gsd-core/bin/lib/phase-lifecycle.cjs +61 -0
  91. package/gsd-core/bin/lib/phase.cjs +167 -63
  92. package/gsd-core/bin/lib/planning-inspect.cjs +34 -18
  93. package/gsd-core/bin/lib/planning-snapshot.cjs +61 -12
  94. package/gsd-core/bin/lib/planning-workspace.cjs +50 -1
  95. package/gsd-core/bin/lib/pristine-baseline.cjs +182 -0
  96. package/gsd-core/bin/lib/prohibition-enforcement.cjs +91 -4
  97. package/gsd-core/bin/lib/quick-batch.cjs +1 -1
  98. package/gsd-core/bin/lib/refactor-trigger-command-router.cjs +61 -2
  99. package/gsd-core/bin/lib/research-store.cjs +11 -12
  100. package/gsd-core/bin/lib/review-lane-invocation.cjs +23 -0
  101. package/gsd-core/bin/lib/reviewer-step-dispatch.cjs +337 -0
  102. package/gsd-core/bin/lib/roadmap-parser.cjs +56 -15
  103. package/gsd-core/bin/lib/roadmap.cjs +108 -14
  104. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +27 -10
  105. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +12 -3
  106. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +13 -5
  107. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +193 -4
  108. package/gsd-core/bin/lib/security.cjs +126 -7
  109. package/gsd-core/bin/lib/state-document.cjs +130 -28
  110. package/gsd-core/bin/lib/state-md-schema.cjs +21 -14
  111. package/gsd-core/bin/lib/state-transition.cjs +142 -28
  112. package/gsd-core/bin/lib/state.cjs +223 -27
  113. package/gsd-core/bin/lib/surface.cjs +60 -2
  114. package/gsd-core/bin/lib/task-command-router.cjs +12 -6
  115. package/gsd-core/bin/lib/uat.cjs +1 -1
  116. package/gsd-core/bin/lib/update-context.cjs +30 -24
  117. package/gsd-core/bin/lib/vendor/js-yaml.cjs +11 -3
  118. package/gsd-core/bin/lib/verification.cjs +47 -15
  119. package/gsd-core/bin/lib/verify-command-grounding.cjs +1 -1
  120. package/gsd-core/bin/lib/verify.cjs +188 -23
  121. package/gsd-core/bin/lib/workstream-inventory.cjs +1 -0
  122. package/gsd-core/bin/lib/worktree-safety.cjs +13 -7
  123. package/gsd-core/bin/shared/config-defaults.manifest.json +1 -0
  124. package/gsd-core/bin/shared/config-schema.manifest.json +5 -0
  125. package/gsd-core/bin/verify-reapply-patches.cjs +439 -80
  126. package/gsd-core/references/compact-content-gate.md +66 -0
  127. package/gsd-core/references/loop-hook-dispatch.md +18 -0
  128. package/gsd-core/references/model-profiles.md +12 -3
  129. package/gsd-core/references/planning-config.md +3 -0
  130. package/gsd-core/references/tdd.md +5 -2
  131. package/gsd-core/references/thinking-models-planning.md +18 -2
  132. package/gsd-core/references/verification-patterns.md +17 -4
  133. package/gsd-core/references/worktree-path-safety.md +112 -2
  134. package/gsd-core/templates/README.md +7 -1
  135. package/gsd-core/templates/state.md +6 -3
  136. package/gsd-core/templates/summary.compact.md +212 -0
  137. package/gsd-core/templates/user-setup.compact.md +199 -0
  138. package/gsd-core/templates/user-setup.md +0 -9
  139. package/gsd-core/workflows/add-todo.md +3 -2
  140. package/gsd-core/workflows/autonomous.md +13 -10
  141. package/gsd-core/workflows/check-todos.md +4 -2
  142. package/gsd-core/workflows/cleanup.md +3 -1
  143. package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +7 -0
  144. package/gsd-core/workflows/code-review-fix.md +3 -3
  145. package/gsd-core/workflows/code-review.md +156 -30
  146. package/gsd-core/workflows/complete-milestone/detail/elaboration.md +274 -0
  147. package/gsd-core/workflows/complete-milestone.md +39 -262
  148. package/gsd-core/workflows/docs-update/detail/elaboration.md +179 -0
  149. package/gsd-core/workflows/docs-update.md +14 -155
  150. package/gsd-core/workflows/execute-phase/detail/elaboration.md +124 -0
  151. package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +18 -3
  152. package/gsd-core/workflows/execute-phase/steps/completion-reconciliation.md +56 -0
  153. package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +7 -2
  154. package/gsd-core/workflows/execute-phase/steps/executor-progress-policy.md +43 -0
  155. package/gsd-core/workflows/execute-phase/steps/sequential-root-pin.md +35 -0
  156. package/gsd-core/workflows/execute-phase.md +53 -152
  157. package/gsd-core/workflows/execute-plan.md +20 -7
  158. package/gsd-core/workflows/help/modes/full.compact.md +398 -0
  159. package/gsd-core/workflows/help.md +1 -1
  160. package/gsd-core/workflows/map-codebase.md +50 -3
  161. package/gsd-core/workflows/new-milestone.md +54 -12
  162. package/gsd-core/workflows/new-project/detail/elaboration.md +216 -0
  163. package/gsd-core/workflows/new-project.md +32 -202
  164. package/gsd-core/workflows/plan-phase/detail/elaboration.md +209 -0
  165. package/gsd-core/workflows/plan-phase.md +22 -181
  166. package/gsd-core/workflows/pr-branch.md +19 -7
  167. package/gsd-core/workflows/quick.md +8 -1
  168. package/gsd-core/workflows/reapply-patches.md +77 -3
  169. package/gsd-core/workflows/settings.md +18 -5
  170. package/gsd-core/workflows/update.md +7 -5
  171. package/gsd-core/workflows/verify-work/detail/elaboration.md +230 -0
  172. package/gsd-core/workflows/verify-work.md +20 -180
  173. package/hooks/dist/gsd-agent-isolation-guard.js +42 -16
  174. package/hooks/dist/gsd-context-monitor.js +88 -15
  175. package/hooks/dist/gsd-cursor-subagent-start.js +34 -14
  176. package/hooks/dist/gsd-secret-read-guard.js +44 -18
  177. package/hooks/dist/gsd-statusline.js +11 -7
  178. package/hooks/dist/gsd-validate-commit.sh +34 -4
  179. package/hooks/dist/gsd-worktree-path-guard.js +25 -14
  180. package/hooks/dist/gsd-write-guard.js +46 -1
  181. package/hooks/dist/lib/dispatch-identity.js +187 -0
  182. package/hooks/dist/lib/filename-classification.js +64 -0
  183. package/hooks/dist/lib/isolation-deny-reason.js +53 -1
  184. package/hooks/dist/lib/isolation-sentinel.js +58 -19
  185. package/hooks/gsd-agent-isolation-guard.js +42 -16
  186. package/hooks/gsd-context-monitor.js +88 -15
  187. package/hooks/gsd-cursor-subagent-start.js +34 -14
  188. package/hooks/gsd-secret-read-guard.js +44 -18
  189. package/hooks/gsd-statusline.js +11 -7
  190. package/hooks/gsd-validate-commit.sh +34 -4
  191. package/hooks/gsd-worktree-path-guard.js +25 -14
  192. package/hooks/gsd-write-guard.js +46 -1
  193. package/hooks/lib/dispatch-identity.js +187 -0
  194. package/hooks/lib/filename-classification.js +64 -0
  195. package/hooks/lib/isolation-deny-reason.js +53 -1
  196. package/hooks/lib/isolation-sentinel.js +58 -19
  197. package/package.json +10 -6
  198. package/scripts/benchmark-compact-content-variants.cjs +298 -0
  199. package/scripts/benchmark-compact-content.cjs +368 -0
  200. package/scripts/check-contract-drift.cjs +4 -1
  201. package/scripts/check-env.cjs +36 -8
  202. package/scripts/check-glossary-refs.cjs +25 -21
  203. package/scripts/ci-next-health.cjs +271 -0
  204. package/scripts/ci-prepare-test-scope.cjs +7 -7
  205. package/scripts/ci-test-scope.cjs +126 -20
  206. package/scripts/ci-timeout-report.cjs +1 -1
  207. package/scripts/diff-touches-shipped-paths.cjs +1 -1
  208. package/scripts/docs-guard-registry.cjs +7 -2
  209. package/scripts/gen-adr-index.cjs +8 -2
  210. package/scripts/gen-inventory-manifest.cjs +12 -0
  211. package/scripts/gen-platform-conformance-tier.cjs +557 -0
  212. package/scripts/lib/drift-scan.cjs +1 -1
  213. package/scripts/lib/macos-conformance-tier.generated.cjs +210 -0
  214. package/scripts/lib/npm-version-check-diagnosis.cjs +59 -0
  215. package/scripts/lib/platform-conformance-tier.generated.cjs +276 -0
  216. package/scripts/lib/suite-detection.cjs +32 -0
  217. package/scripts/lint-allowed-tools-parity.cjs +221 -0
  218. package/scripts/lint-docs-guard-registration.exempt-baseline.cjs +19 -2
  219. package/scripts/lint-phase-id-drift.cjs +338 -13
  220. package/scripts/lint-response-language-coverage.cjs +9 -3
  221. package/scripts/lint-source-test-name-collision.cjs +1 -1
  222. package/scripts/lint-test-file-count.allowlist.json +1 -0
  223. package/scripts/lint-vendored-deps.cjs +128 -17
  224. package/scripts/lint-workflow-shellcheck-baseline.json +85 -0
  225. package/scripts/prompt-injection-scan.sh +14 -0
  226. package/scripts/workflow-size.cjs +139 -0
  227. package/skills/gsd-cleanup/SKILL.md +1 -0
  228. package/skills/gsd-code-review/SKILL.md +2 -1
  229. package/skills/gsd-complete-milestone/SKILL.md +1 -0
  230. package/skills/gsd-config/SKILL.md +1 -0
  231. package/skills/gsd-debug/SKILL.md +1 -0
  232. package/skills/gsd-graphify/SKILL.md +1 -0
  233. package/skills/gsd-health/SKILL.md +1 -0
  234. package/skills/gsd-mempalace-capture/SKILL.md +1 -0
  235. package/skills/gsd-mempalace-recall/SKILL.md +1 -0
  236. package/skills/gsd-new-milestone/SKILL.md +1 -0
  237. package/skills/gsd-new-project/SKILL.md +1 -0
  238. package/skills/gsd-next/SKILL.md +1 -0
  239. package/skills/gsd-pause-work/SKILL.md +1 -0
  240. package/skills/gsd-phase/SKILL.md +1 -0
  241. package/skills/gsd-pr-branch/SKILL.md +1 -0
  242. package/skills/gsd-resume-work/SKILL.md +1 -0
  243. package/skills/gsd-review-backlog/SKILL.md +1 -0
  244. package/skills/gsd-settings/SKILL.md +2 -1
  245. package/skills/gsd-stats/SKILL.md +1 -0
  246. package/skills/gsd-thread/SKILL.md +1 -0
  247. package/skills/gsd-workspace/SKILL.md +1 -0
  248. package/skills/gsd-workstreams/SKILL.md +1 -0
  249. package/vscode/package.json +1 -1
  250. package/gsd-core/templates/claude-md.md +0 -145
  251. package/gsd-core/templates/codebase/concerns.md +0 -310
  252. package/gsd-core/templates/codebase/conventions.md +0 -307
  253. package/gsd-core/templates/codebase/integrations.md +0 -280
  254. package/gsd-core/templates/codebase/structure.md +0 -285
  255. package/gsd-core/templates/codebase/testing.md +0 -480
  256. package/gsd-core/templates/debug-subagent-prompt.md +0 -91
  257. package/gsd-core/templates/discovery.md +0 -146
@@ -884,19 +884,42 @@ function extractPhaseToken(dirName, convention) {
884
884
  * cannot resolve a genuinely different phase's artifact (`02.1-...` still
885
885
  * fails every `01`-rooted candidate).
886
886
  *
887
- * BRACKET CONVENTION (review item 7): a letter-prefixed-decimal dir
888
- * (`P0.3-2-slug`) is string-INDISTINGUISHABLE from a bracket-dir token
889
- * (`extractPhaseToken` above, gated on `convention === 'bracket'`) without an
890
- * explicit convention signal — and this predicate is never given one: none
891
- * of its 9 call sites thread `convention`/config through today. Rather than
892
- * guess a reading it cannot know is active and risk excluding the phase's OWN
893
- * artifact (the exact defect class this rework exists to fix), this family
894
- * (`firstLetterPrefixed` dirs) falls into the same include-everything
895
- * fail-safe as the zero-segment case below — a documented, deliberate
896
- * widening (it also stops excluding a genuine stray from a DIFFERENT
897
- * letter-prefixed-decimal phase, narrowly) accepted in trade for never
898
- * dropping the phase's own report. Convention-aware scoping for this family
899
- * is deferred to whenever a call site actually threads `convention` through.
887
+ * BRACKET CONVENTION (review item 7; reworked by #612/#2761): a
888
+ * letter-prefixed-decimal dir (`P0.3-2-slug`) is string-INDISTINGUISHABLE
889
+ * from a bracket-dir token (`extractPhaseToken` above, gated on
890
+ * `convention === 'bracket'`) without an explicit convention signal. This
891
+ * predicate now takes the same optional trailing `convention` its sibling
892
+ * helpers do (ADR-2121 additive shape): when a call site threads
893
+ * `'bracket'` and the dir is a WELL-FORMED bracket dir
894
+ * (`BRACKET_DIR_TOKEN_RE` — the one grammar owner, shared with
895
+ * `extractPhaseToken`'s bracket branch), the dir's real phase token is
896
+ * readable and membership is scoped through the identical candidate
897
+ * comparison its legacy twin gets — so `GSD.02-02-two` reads exactly what
898
+ * `02-two` reads, which is PR-2's core invariant. Convention-less calls,
899
+ * bracket-MALFORMED names (1-digit milestone, letter-suffixed token — the
900
+ * shapes W005 reports), and legacy-shaped dirs inside a bracket repo all
901
+ * resolve to the unchanged body below.
902
+ *
903
+ * A filename carrying its own bracket-qualified stem is compared on the
904
+ * qualified key — see the branch body. An M-NN-stem artifact name
905
+ * (`02-01-VERIFICATION.md` in `GSD.02-01-one`) is excluded under the bracket
906
+ * convention, deliberately: that is exactly what the legacy twin does with
907
+ * the same file, twin-parity is this PR's contract, and migration-window
908
+ * artifact renaming belongs to the migrator slice per ADR-612 — the trade is
909
+ * pinned in tests/adr-612-bracket-phase-counting.test.cjs.
910
+ *
911
+ * For those unthreaded/unparseable cases the include-everything trade
912
+ * stands: rather than guess a reading it cannot know is active and risk
913
+ * excluding the phase's OWN artifact (the exact defect class this rework
914
+ * exists to fix), the ambiguous families widen to include-everything,
915
+ * accepted in trade for never dropping the phase's own report. Note the
916
+ * bracket family splits across BOTH fail-safes, not just one: a short
917
+ * digit-bearing project code (`A1.02-…`) lands in `firstLetterPrefixed`,
918
+ * but a 3+-letter code (`GSD.02-…`) derives ZERO token segments —
919
+ * `PROJECT_CODE_PREFIX_CAPTURE_RE_I` strips `{CODE}-`, not `{CODE}.` — so
920
+ * the zero-token fail-safe fires first. A fixer patching only the
921
+ * `firstLetterPrefixed` branch covers neither; the convention gate above
922
+ * covers both.
900
923
  *
901
924
  * FAIL-SAFE (#3511, unchanged): when dirName's own leading segment carries no
902
925
  * phase-number token at all (`derivePhaseTokenSegments` finds zero segments —
@@ -933,7 +956,44 @@ function extractPhaseToken(dirName, convention) {
933
956
  * the original pre-#3357 "alphabetically first of ALL dashed candidates"
934
957
  * behavior, exactly as it always did for that directory shape.
935
958
  */
936
- function isPhaseArtifact(fileName, phaseDirName) {
959
+ function isPhaseArtifact(fileName, phaseDirName, convention) {
960
+ // #612/#2761: the bracket branch — see "BRACKET CONVENTION" in the docblock.
961
+ // Gated on the same explicit signal AND the same grammar
962
+ // (`BRACKET_DIR_TOKEN_RE`) as `extractPhaseToken`'s bracket branch, so the
963
+ // two surfaces can never disagree about which names are bracket dirs. A
964
+ // name that fails the grammar falls through to the unchanged body below.
965
+ if (convention === 'bracket') {
966
+ const bracketDir = phaseDirName.match(BRACKET_DIR_TOKEN_RE);
967
+ if (bracketDir) {
968
+ // The convention must gate the FILENAME reading, not just the directory
969
+ // reading — a milestone-qualified artifact name (the layout the
970
+ // read-tolerance suite models) derives zero legacy token segments, so
971
+ // without this it enters FIX 2's token-less containment and any bracket
972
+ // dir admits it. Compared on the qualified key: the phase's own
973
+ // qualified artifact stays included — the exact key, and the dotted
974
+ // sub-phase continuation (`GSD.02-05.1-…` is `GSD.02-05`'s own file,
975
+ // the same dash-OR-dot rule matchesPhaseTokenCandidates applies; a
976
+ // dash-continuation never reaches this comparison, the phase group's
977
+ // `(?=-|$)` boundary already stopped the key before it) — while a
978
+ // WELL-FORMED qualified stem naming a different phase or milestone is
979
+ // excluded. A qualified-SHAPED but grammar-malformed stem (letter
980
+ // suffix, 1-digit milestone, 2-level dot — the W005 shapes) yields a
981
+ // null fileKey and keeps the module's documented include-everything
982
+ // fail-safe via FIX 2 below, same as every other undecidable name.
983
+ const fileKey = bracketQualifiedKey(fileName, convention);
984
+ if (fileKey !== null) {
985
+ const dirKey = bracketQualifiedKey(phaseDirName, convention);
986
+ if (dirKey !== null)
987
+ return fileKey === dirKey || fileKey.startsWith(`${dirKey}.`);
988
+ }
989
+ // Reached when the filename carries no well-formed qualified stem (the
990
+ // common case — every unqualified name), or when the DIR's own key is
991
+ // null despite matching the dir grammar (an unsafe-integer milestone or
992
+ // phase — bracketQualifiedKey refuses both rather than collide).
993
+ // Either way the candidate path decides, deliberately.
994
+ return matchesPhaseTokenCandidates(fileName, [bracketDir[1]]);
995
+ }
996
+ }
937
997
  const { tokenSegments, firstLetterPrefixed } = derivePhaseTokenSegments(phaseDirName);
938
998
  if (tokenSegments.length === 0)
939
999
  return true;
@@ -942,6 +1002,22 @@ function isPhaseArtifact(fileName, phaseDirName) {
942
1002
  const strippedToken = strippedDir !== phaseDirName ? extractPhaseToken(strippedDir) : literalToken;
943
1003
  const leadingRunMatch = strippedDir.match(LEADING_DIGIT_RUN_RE);
944
1004
  const rawCandidates = [literalToken, strippedToken, leadingRunMatch?.[1]].filter((t) => Boolean(t));
1005
+ if (matchesPhaseTokenCandidates(fileName, rawCandidates))
1006
+ return true;
1007
+ // Bracket-convention ambiguity fail-safe — see docblock above.
1008
+ if (firstLetterPrefixed)
1009
+ return true;
1010
+ return false;
1011
+ }
1012
+ /**
1013
+ * Candidate-comparison core shared by `isPhaseArtifact`'s two entry paths —
1014
+ * the legacy segment-derived readings and the #612 bracket-dir token. One
1015
+ * comparison rule, not two that agree today and drift tomorrow: the padded/
1016
+ * de-padded expansion, the separator class, and FIX 2 apply identically
1017
+ * whichever path produced the candidates, which is what makes a bracket dir
1018
+ * read exactly what its legacy twin reads.
1019
+ */
1020
+ function matchesPhaseTokenCandidates(fileName, rawCandidates) {
945
1021
  // Each reading is compared in BOTH its padded and de-padded form: files are
946
1022
  // written padded by `normalizePhaseName` (`cmdScaffold`) while directories
947
1023
  // are often not (`1-unpadded`), and legacy trees carry the reverse pairing.
@@ -981,12 +1057,7 @@ function isPhaseArtifact(fileName, phaseDirName) {
981
1057
  }
982
1058
  // FIX 2: token-less filename (bare "VERIFICATION.md"/"UAT.md") — containment
983
1059
  // in this phase's own directory listing is sufficient.
984
- if (derivePhaseTokenSegments(fileName).tokenSegments.length === 0)
985
- return true;
986
- // Bracket-convention ambiguity fail-safe — see docblock above.
987
- if (firstLetterPrefixed)
988
- return true;
989
- return false;
1060
+ return derivePhaseTokenSegments(fileName).tokenSegments.length === 0;
990
1061
  }
991
1062
  /**
992
1063
  * #3511: scope `fileNames` to the subset that passes
@@ -1035,9 +1106,19 @@ function isPhaseArtifact(fileName, phaseDirName) {
1035
1106
  * re-derived per site (CLAUDE.md's Generative Fix Divergence class).
1036
1107
  * `isPhaseArtifact` stays exported for single-item membership questions and
1037
1108
  * its own unit tests.
1109
+ *
1110
+ * #612/#2761: both helpers take the same optional trailing `convention` their
1111
+ * sibling primitives (`extractPhaseToken`, `phaseTokenMatches`) do — the
1112
+ * ADR-2121 additive shape, so every existing two-argument call resolves to
1113
+ * the unchanged legacy body. A call site that already holds the resolved
1114
+ * convention threads it and bracket dirs scope exactly like their legacy
1115
+ * twins; convention-less call sites keep the documented include-everything
1116
+ * fail-safes and are the follow-up-slice work (they cannot scope a bracket
1117
+ * dir until they can resolve the convention, and guessing is the one thing
1118
+ * this predicate must never do).
1038
1119
  */
1039
- function scopeToPhase(fileNames, phaseDirName) {
1040
- return fileNames.filter((f) => isPhaseArtifact(f, phaseDirName));
1120
+ function scopeToPhase(fileNames, phaseDirName, convention) {
1121
+ return fileNames.filter((f) => isPhaseArtifact(f, phaseDirName, convention));
1041
1122
  }
1042
1123
  /**
1043
1124
  * Canonical comparable key for a milestone-qualified bracket id or dir name.
@@ -1386,6 +1467,63 @@ function isForeignPrefixedPhaseQuery(phase, projectCode) {
1386
1467
  * form so a canonical heading (`### Phase 117:`) is preferred over a drifted
1387
1468
  * prefixed one (`### Phase MANIFOLD-117:`) when both exist in one ROADMAP.
1388
1469
  */
1470
+ // Separator characters a branch-name template may plausibly use to join
1471
+ // `{slug}` to its neighbours — the four this repo's shipped templates and
1472
+ // docs/CONFIGURATION.md's custom-template examples use (`/`, `-`, `_`, `.`).
1473
+ const BRANCH_TEMPLATE_SEP_RE = /[-_./]/;
1474
+ const BRANCH_TEMPLATE_SEP_RUN_RE = /([-_./])\1+/g;
1475
+ const BRANCH_TEMPLATE_EDGE_SEP_RE = /^[-_./]+|[-_./]+$/g;
1476
+ /**
1477
+ * #4126: the ONE branch-name-template renderer for the `phase` branching
1478
+ * strategy, shared by `cmdCommit` (src/commands.cts) and
1479
+ * `cmdInitExecutePhase` (src/init.cts) so the two call sites cannot diverge
1480
+ * on how an undeliverable `phaseSlug` degrades (they previously each
1481
+ * independently substituted the literal word `phase`, producing a
1482
+ * non-identifying branch name like `gsd/phase-08-phase` that contradicts an
1483
+ * honestly-reported `phase_slug: null`).
1484
+ *
1485
+ * `{project}` is deliberately NOT handled here — init.cts substitutes it
1486
+ * separately, before calling this, because it is a config-level field with
1487
+ * its own fallback (`''`), not a phase-derived one.
1488
+ *
1489
+ * When `phaseSlug` is a non-empty string, `{slug}` substitutes normally
1490
+ * (unchanged from prior behavior). When it is empty or not a string (e.g.
1491
+ * `null`, `undefined`, a number), the `{slug}` token is DROPPED — along with
1492
+ * one adjacent separator character — rather than replaced with a placeholder
1493
+ * word: a dropped token keeps the branch name honest about what it does not
1494
+ * know, where a placeholder reads as a real (but wrong) name. The result is
1495
+ * always a syntactically plausible git-ref fragment: no leading, trailing, or
1496
+ * doubled separator, for both the shipped default template
1497
+ * (`gsd/phase-{phase}-{slug}`) and a differently-shaped custom one (this
1498
+ * field is user-configurable per docs/CONFIGURATION.md).
1499
+ */
1500
+ function renderPhaseBranchName(template, phaseNumber, phaseSlug) {
1501
+ const withPhase = template.replace('{phase}', normalizePhaseName(phaseNumber));
1502
+ const slug = typeof phaseSlug === 'string' ? phaseSlug : '';
1503
+ if (slug)
1504
+ return withPhase.replace('{slug}', slug);
1505
+ const at = withPhase.indexOf('{slug}');
1506
+ if (at === -1)
1507
+ return withPhase;
1508
+ let before = withPhase.slice(0, at);
1509
+ let after = withPhase.slice(at + '{slug}'.length);
1510
+ // Drop exactly ONE adjacent separator — preferring the one immediately
1511
+ // before the token — so `a-{slug}` and `{slug}-a` both degrade to `a`
1512
+ // rather than leaving a dangling `a-` / `-a`.
1513
+ if (before && BRANCH_TEMPLATE_SEP_RE.test(before[before.length - 1])) {
1514
+ before = before.slice(0, -1);
1515
+ }
1516
+ else if (after && BRANCH_TEMPLATE_SEP_RE.test(after[0])) {
1517
+ after = after.slice(1);
1518
+ }
1519
+ // Collapse any doubled separator the drop can leave behind (e.g. a
1520
+ // `feature//{slug}` template dropping to `feature/`→`feature`), then trim
1521
+ // a resulting leading/trailing separator — valid for the shipped default
1522
+ // template AND any user-configured shape, not just the one this repo ships.
1523
+ const joined = before + after;
1524
+ const collapsed = joined.replace(BRANCH_TEMPLATE_SEP_RUN_RE, '$1').replace(BRANCH_TEMPLATE_EDGE_SEP_RE, '');
1525
+ return collapsed || null;
1526
+ }
1389
1527
  function roadmapPhaseLookupSources(phaseNum) {
1390
1528
  const sources = [];
1391
1529
  const exactSource = phaseMarkdownRegexSourceExact(phaseNum);
@@ -1445,4 +1583,5 @@ module.exports = {
1445
1583
  stripConfiguredProjectCodePrefix,
1446
1584
  isForeignPrefixedPhaseQuery,
1447
1585
  roadmapPhaseLookupSources,
1586
+ renderPhaseBranchName,
1448
1587
  };
@@ -25,6 +25,8 @@ exports.locateProgressTable = locateProgressTable;
25
25
  exports.deriveProgressFromRoadmap = deriveProgressFromRoadmap;
26
26
  exports.clampPercentFromFraction = clampPercentFromFraction;
27
27
  exports.clampPercent = clampPercent;
28
+ exports.progressBarFilledCells = progressBarFilledCells;
29
+ exports.renderProgressBar = renderProgressBar;
28
30
  const markdown_table_cjs_1 = require("./markdown-table.cjs");
29
31
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- phase-id.cjs is an export= CommonJS module
30
32
  const phaseIdMod = require("./phase-id.cjs");
@@ -140,3 +142,62 @@ function clampPercent(completed, total) {
140
142
  return 0;
141
143
  return clampPercentFromFraction(completed / total);
142
144
  }
145
+ /**
146
+ * How many cells of a `width`-cell progress bar are filled for `percent`.
147
+ *
148
+ * #4294: the RENDER half's kernel — the counterpart of `clampPercentFromFraction`
149
+ * one layer down. That function owns `fraction -> integer percent`; this one
150
+ * owns `integer percent -> filled cells`, and is the single place that rounding
151
+ * is expressed. Five inline copies of the rounding carried it before this
152
+ * change — three in `commands.cts`, and one each in `gsd2-import.cts` and
153
+ * `formatProgressMachineSegment` here; the latter two used `/ 10` with the
154
+ * width already substituted (`pct` in `gsd2-import.cts`, `clamped` in the
155
+ * formatter). (#4294 counts SIX call sites because it counts
156
+ * `cmdStateUpdateProgress` and `syncCore` separately; #4231 had already routed
157
+ * both through `formatProgressMachineSegment` — as it does
158
+ * `applyPostSyncPreservation` — so by this branch's base they share one copy.)
159
+ * Every copy saturated: at width 10 that rounds to a full bar from 95 up, at
160
+ * width 20 from 98 up, so a project at 19/20 plans drew the same bar as a
161
+ * shipped one beside a number that said otherwise.
162
+ *
163
+ * Contract:
164
+ * - A FULL bar is reserved for an actual 100. Below 100 the fill is held one
165
+ * cell short of the width. This is the only departure from the old formula:
166
+ * at width 10 exactly 95-99 move (10 -> 9), at width 20 exactly 98-99
167
+ * (20 -> 19); every other percent in 0-100 rounds as before.
168
+ * - `null` / `undefined` / non-finite renders an empty bar, matching the
169
+ * `percent === null ? 0 : ...` guard the `progress` renderers already carried.
170
+ * - Out-of-range input is clamped to 0-100 before rounding, so the count is
171
+ * always within `[0, width]` and a `'░'.repeat(width - filled)` can never
172
+ * be handed a negative count (the old inline form threw `RangeError` at
173
+ * 120%). A non-positive width yields 0.
174
+ *
175
+ * Callers wanting the glyph run call `renderProgressBar`; this is exported so
176
+ * the rounding rule can be pinned directly against the legacy curve.
177
+ */
178
+ function progressBarFilledCells(percent, width) {
179
+ const cells = Number.isFinite(width) && width > 0 ? Math.floor(width) : 0;
180
+ if (cells === 0)
181
+ return 0;
182
+ if (typeof percent !== 'number' || !Number.isFinite(percent))
183
+ return 0;
184
+ const clamped = Math.max(0, Math.min(100, percent));
185
+ if (clamped >= 100)
186
+ return cells;
187
+ // Scale by the WIDTH, not by 100 — this is cells-from-percent, not the
188
+ // completion-ratio derivation lint-completion-ratio-drift.cjs guards.
189
+ const rounded = Math.round((clamped / 100) * cells);
190
+ return Math.min(rounded, cells - 1);
191
+ }
192
+ /**
193
+ * Render the glyph run of a `width`-cell progress bar for `percent` —
194
+ * `'█'` for each filled cell, `'░'` for the rest, always exactly `width`
195
+ * glyphs (an empty string for a non-positive width). Brackets, the printed
196
+ * percent and any suffix stay with the caller; the fill rule is
197
+ * `progressBarFilledCells` (#4294).
198
+ */
199
+ function renderProgressBar(percent, width) {
200
+ const filled = progressBarFilledCells(percent, width);
201
+ const cells = Number.isFinite(width) && width > 0 ? Math.floor(width) : 0;
202
+ return '█'.repeat(filled) + '░'.repeat(cells - filled);
203
+ }
@@ -45,10 +45,19 @@ const { normalizePhaseName, phaseMarkdownRegexSource, comparePhaseNum, matchPhas
45
45
  const pattern_cjs_1 = require("./pattern.cjs");
46
46
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- phase-locator.cjs is an export= CommonJS module
47
47
  const phaseLocatorMod = require("./phase-locator.cjs");
48
- const { findPhaseInternal, getArchivedPhaseDirs, listMilestonePhaseDirs } = phaseLocatorMod;
48
+ const { findPhaseInternal, getArchivedPhaseDirs, listMilestonePhaseDirs, listAllPhaseDirs } = phaseLocatorMod;
49
+ // eslint-disable-next-line @typescript-eslint/no-require-imports -- planning-scope.cjs is an export= CommonJS module
50
+ const planningScopeMod = require("./planning-scope.cjs");
51
+ const { SCOPE } = planningScopeMod;
49
52
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- roadmap-parser.cjs is an export= CommonJS module
50
53
  const roadmapParserMod = require("./roadmap-parser.cjs");
51
54
  const { stripShippedMilestones, extractCurrentMilestone, currentMilestoneRawRanges, withPhaseSection, findMilestoneScopeHeadingLines } = roadmapParserMod;
55
+ // #4129: the single owner of "count the ROADMAP's milestone Complete rows"
56
+ // (pure computation, no I/O — no cycle on this path) for the intent-first
57
+ // progress counters the phase-complete transaction passes downstream.
58
+ // eslint-disable-next-line @typescript-eslint/no-require-imports -- phase-lifecycle.cjs is an export= CommonJS module
59
+ const phaseLifecycleMod = require("./phase-lifecycle.cjs");
60
+ const { deriveProgressFromRoadmap: deriveProgressFromRoadmapForIntent, clampPercent: clampPercentForIntent } = phaseLifecycleMod;
52
61
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- planning-workspace.cjs is an export= CommonJS module
53
62
  const planningWorkspace = require("./planning-workspace.cjs");
54
63
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- frontmatter.cjs is an export= CommonJS module
@@ -287,25 +296,26 @@ function cmdPhaseNextDecimal(cwd, basePhase, raw) {
287
296
  const entries = node_fs_1.default.readdirSync(phasesDir, { withFileTypes: true });
288
297
  const dirs = entries.filter((e) => e.isDirectory()).map((e) => e.name);
289
298
  baseExists = matchPhaseDirs(dirs, normalized).matches.length > 0;
290
- const dirPattern = new RegExp(`^${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}${(0, pattern_cjs_1.escapeRegex)(normalized)}\\.(\\d+)`);
291
- for (const dir of dirs) {
292
- const match = dir.match(dirPattern);
293
- if (match)
294
- decimalSet.add(parseInt(match[1], 10));
295
- }
296
299
  }
297
300
  const roadmapPath = node_path_1.default.join(planningDir(cwd), 'ROADMAP.md');
298
301
  if (node_fs_1.default.existsSync(roadmapPath)) {
299
302
  try {
300
303
  const roadmapContent = node_fs_1.default.readFileSync(roadmapPath, 'utf-8');
301
- const phasePattern = new RegExp(`#{2,4}\\s*Phase\\s+${phaseMarkdownRegexSource(normalized)}\\.(\\d+)${OPTIONAL_PHASE_TAG_SOURCE}\\s*:`, 'gi');
302
- let pm;
303
- while ((pm = phasePattern.exec(roadmapContent)) !== null) {
304
- decimalSet.add(parseInt(pm[1], 10));
304
+ for (const n of scanExistingDecimalPhaseNumbers(phasesDir, roadmapContent, normalized)) {
305
+ decimalSet.add(n);
305
306
  }
306
307
  }
307
308
  catch {
308
- /* ROADMAP.md read failure is non-fatal */
309
+ // ROADMAP.md read failure is non-fatal — fall back to the directory-only
310
+ // scan (empty rawContent) so on-disk decimal directories are still counted.
311
+ for (const n of scanExistingDecimalPhaseNumbers(phasesDir, '', normalized)) {
312
+ decimalSet.add(n);
313
+ }
314
+ }
315
+ }
316
+ else {
317
+ for (const n of scanExistingDecimalPhaseNumbers(phasesDir, '', normalized)) {
318
+ decimalSet.add(n);
309
319
  }
310
320
  }
311
321
  const existingDecimals = Array.from(decimalSet)
@@ -1061,12 +1071,24 @@ function assertDescriptionPreservesMilestoneScope(cwd, description, command) {
1061
1071
  * before any directory does; milestone-scoping is wrong here because a number
1062
1072
  * used under any milestone on another branch is still taken).
1063
1073
  *
1074
+ * #4225 — the horizon must track the ALLOCATION scope. When the allocation is
1075
+ * workstream-scoped (`--ws`/`GSD_WORKSTREAM`, resolved into the env before
1076
+ * dispatch), the sibling's copy of the SAME workstream is what carries that
1077
+ * scope's independent numbering; the sibling's ROOT roadmap and phases/
1078
+ * belong to a different numbering universe (docs/FEATURES.md §51 REQ-WS-01 —
1079
+ * workstream state is isolated in `.planning/workstreams/{name}/`) and must
1080
+ * not contribute. `planningDir(wt, ws)` reuses the canonical resolver, so the
1081
+ * sibling scope matches the local scope's own resolution (env workstream plus
1082
+ * env project segment) by construction; `ws === null` (no workstream active)
1083
+ * keeps the #3849 root-scope horizon byte-for-byte.
1084
+ *
1064
1085
  * Widen, never refuse: a missing `.planning/`, an unreadable sibling, a
1065
1086
  * non-git cwd, or an unavailable git binary each leave `used` untouched —
1066
1087
  * allocation then behaves exactly as it did before this horizon existed.
1067
- * Sentinels reuse the canonical `isSentinelPhaseId`; the dir pattern is the
1068
- * same one the on-disk scan uses, so decimal sub-phases (`411.1-foo`) are
1069
- * correctly not integers.
1088
+ * A sibling that simply lacks the active workstream's directory is the same
1089
+ * fail-open case: it contributes nothing. Sentinels reuse the canonical
1090
+ * `isSentinelPhaseId`; the dir pattern is the same one the on-disk scan uses,
1091
+ * so decimal sub-phases (`411.1-foo`) are correctly not integers.
1070
1092
  */
1071
1093
  function collectSiblingWorktreePhaseNums(cwd, used) {
1072
1094
  let porcelain;
@@ -1084,6 +1106,13 @@ function collectSiblingWorktreePhaseNums(cwd, used) {
1084
1106
  catch {
1085
1107
  return; // not a git repo / git unavailable — unchanged behavior
1086
1108
  }
1109
+ // #4225: the env workstream, read once with planningDir's own discriminator
1110
+ // (`?? null` = deliberately no workstream — never re-derived per sibling).
1111
+ // A poisoned value would already have thrown at the local `planningDir(cwd)`
1112
+ // call every allocator makes before reaching this horizon; the per-sibling
1113
+ // try/catch below still keeps any resolution failure fail-open.
1114
+ const ws = process.env['GSD_WORKSTREAM'] ?? null;
1115
+ const siblingPlanningDir = (wt) => planningDir(wt, ws);
1087
1116
  const dirNumPattern = /^(?:[A-Z][A-Z0-9]*-)?(\d+)-/;
1088
1117
  // Same header shape the allocators scan locally (#1729 tag tolerance).
1089
1118
  const headerPattern = /#{2,4}\s*Phase\s+(\d+)[A-Z]?(?:\.\d+)*(?:\s*\([^)\n]{0,200}\))?:/gi;
@@ -1094,7 +1123,7 @@ function collectSiblingWorktreePhaseNums(cwd, used) {
1094
1123
  if (!wt || node_path_1.default.resolve(wt) === node_path_1.default.resolve(cwd))
1095
1124
  continue;
1096
1125
  try {
1097
- for (const entry of node_fs_1.default.readdirSync(node_path_1.default.join(wt, '.planning', 'phases'))) {
1126
+ for (const entry of node_fs_1.default.readdirSync(node_path_1.default.join(siblingPlanningDir(wt), 'phases'))) {
1098
1127
  const match = entry.match(dirNumPattern);
1099
1128
  if (!match)
1100
1129
  continue;
@@ -1104,10 +1133,10 @@ function collectSiblingWorktreePhaseNums(cwd, used) {
1104
1133
  }
1105
1134
  }
1106
1135
  catch {
1107
- /* worktree has no .planning — normal, contributes nothing */
1136
+ /* worktree has no .planning (or no copy of this scope) — normal, contributes nothing */
1108
1137
  }
1109
1138
  try {
1110
- const content = node_fs_1.default.readFileSync(node_path_1.default.join(wt, '.planning', 'ROADMAP.md'), 'utf-8');
1139
+ const content = node_fs_1.default.readFileSync(node_path_1.default.join(siblingPlanningDir(wt), 'ROADMAP.md'), 'utf-8');
1111
1140
  let m;
1112
1141
  headerPattern.lastIndex = 0;
1113
1142
  while ((m = headerPattern.exec(content)) !== null) {
@@ -1117,7 +1146,7 @@ function collectSiblingWorktreePhaseNums(cwd, used) {
1117
1146
  }
1118
1147
  }
1119
1148
  catch {
1120
- /* no roadmap in that worktree — normal, contributes nothing */
1149
+ /* no roadmap in that worktree (or scope) — normal, contributes nothing */
1121
1150
  }
1122
1151
  }
1123
1152
  }
@@ -1343,7 +1372,63 @@ function cmdPhaseAddBatch(cwd, descriptions, raw) {
1343
1372
  // found')` above, which terminates the process and never reaches here.
1344
1373
  publishStateContract(cwd);
1345
1374
  }
1346
- function cmdPhaseInsert(cwd, afterPhase, description, raw) {
1375
+ // #4569: scans all three representations of an existing decimal sub-phase
1376
+ // under `base` — on-disk `phases/` directories, `### Phase BASE.N:` headings,
1377
+ // and `- [ ] Phase BASE.N:` roadmap SUMMARY CHECKLIST bullets. A bullet-only
1378
+ // roadmap with no heading yet and no on-disk directory yet must still be
1379
+ // seen, or an allocator can silently reallocate an already-used decimal
1380
+ // number. Shared by `cmdPhaseInsert`'s normalized-base scan and its
1381
+ // sibling-allocation parent-base scan so the two never drift apart.
1382
+ function scanExistingDecimalPhaseNumbers(phasesDir, rawContent, base) {
1383
+ const decimalSet = new Set();
1384
+ // #2245 audit: existsSync-guarded, mirroring cmdPhaseNextDecimal's identical
1385
+ // scan above — a missing phasesDir (no decimal sub-phases yet) is the
1386
+ // expected, silent case (empty decimalSet). A readdirSync failure once the
1387
+ // dir is confirmed to EXIST is a genuine anomaly; swallowing it used to let
1388
+ // `phase insert` proceed with an incomplete decimalSet and risk writing a
1389
+ // decimal phase number that collides with an existing on-disk directory
1390
+ // the scan simply never saw — surfaced loud instead, like the sibling.
1391
+ //
1392
+ // #4634 (lint-phase-enumeration-drift): routed through the canonical
1393
+ // PHYSICAL-set owner (`listAllPhaseDirs`, phase-locator.cts) instead of a
1394
+ // hand-rolled `readdirSync`. This scan — like its sibling `cmdPhaseNextDecimal`
1395
+ // and its caller `cmdPhaseInsert` (both exempted in the drift guard for the
1396
+ // same reason) — must see EVERY on-disk decimal sub-phase directory
1397
+ // regardless of the current milestone window, so `listMilestonePhaseDirs`
1398
+ // (windowed) is the wrong owner here; `includeSentinels: true` preserves this
1399
+ // function's pre-existing behavior of never sentinel-filtering (the decimal
1400
+ // regex below only ever matches `base.N`-shaped names, so sentinel inclusion
1401
+ // is a no-op either way).
1402
+ if (node_fs_1.default.existsSync(phasesDir)) {
1403
+ const { value: dirs, scope } = listAllPhaseDirs(phasesDir, { includeSentinels: true });
1404
+ if (scope === SCOPE.UNREADABLE) {
1405
+ // The dir EXISTS but could not be read (EACCES/EIO) — a genuine anomaly,
1406
+ // not the expected empty-decimalSet case above. Surfaced loud, matching
1407
+ // this function's pre-migration `readdirSync` catch: swallowing it would
1408
+ // let `phase insert` proceed with an incomplete decimalSet and collide
1409
+ // with an existing on-disk decimal directory the scan never saw.
1410
+ error(`Failed to scan phase directories for existing decimal phases: unable to read ${phasesDir}`);
1411
+ }
1412
+ const decimalPattern = new RegExp(`^${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}${(0, pattern_cjs_1.escapeRegex)(base)}\\.(\\d+)`);
1413
+ for (const dir of dirs) {
1414
+ const dm = dir.match(decimalPattern);
1415
+ if (dm)
1416
+ decimalSet.add(parseInt(dm[1], 10));
1417
+ }
1418
+ }
1419
+ const rmPhasePattern = new RegExp(`#{2,4}\\s*Phase\\s+${phaseMarkdownRegexSource(base)}\\.(\\d+)${OPTIONAL_PHASE_TAG_SOURCE}\\s*:`, 'gi');
1420
+ let rmMatch;
1421
+ while ((rmMatch = rmPhasePattern.exec(rawContent)) !== null) {
1422
+ decimalSet.add(parseInt(rmMatch[1], 10));
1423
+ }
1424
+ const checklistDecimalPattern = new RegExp(`-\\s*\\[[ x]\\]\\s*(?:\\*\\*)?Phase\\s+${phaseMarkdownRegexSource(base)}\\.(\\d+)${OPTIONAL_PHASE_TAG_SOURCE}[:\\s]`, 'gi');
1425
+ let clMatch;
1426
+ while ((clMatch = checklistDecimalPattern.exec(rawContent)) !== null) {
1427
+ decimalSet.add(parseInt(clMatch[1], 10));
1428
+ }
1429
+ return decimalSet;
1430
+ }
1431
+ function cmdPhaseInsert(cwd, afterPhase, description, raw, allocation = 'nested') {
1347
1432
  if (!afterPhase || !description) {
1348
1433
  error('after-phase and description required for phase insert');
1349
1434
  }
@@ -1373,43 +1458,20 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw) {
1373
1458
  }
1374
1459
  const phasesDir = node_path_1.default.join(planningDir(cwd), 'phases');
1375
1460
  const normalizedBase = normalizePhaseName(afterPhase);
1376
- const decimalSet = new Set();
1377
- // #2245 audit: existsSync-guarded, mirroring cmdPhaseNextDecimal's identical
1378
- // scan above — a missing phasesDir (no decimal sub-phases yet) is the
1379
- // expected, silent case (empty decimalSet). A readdirSync failure once the
1380
- // dir is confirmed to EXIST is a genuine anomaly; swallowing it used to let
1381
- // `phase insert` proceed with an incomplete decimalSet and risk writing a
1382
- // decimal phase number that collides with an existing on-disk directory
1383
- // the scan simply never saw — surfaced loud instead, like the sibling.
1384
- if (node_fs_1.default.existsSync(phasesDir)) {
1385
- // Initialized (not just declared) so TS's definite-assignment check is
1386
- // satisfied without relying on control-flow narrowing through error()'s
1387
- // `never` return, which TS does not propagate through a destructured
1388
- // module-property function reference — error() still halts the process
1389
- // before `dirs` below is ever computed from this placeholder value.
1390
- let entries = [];
1391
- try {
1392
- entries = node_fs_1.default.readdirSync(phasesDir, { withFileTypes: true });
1393
- }
1394
- catch (e) {
1395
- const msg = e instanceof Error ? e.message : String(e);
1396
- error(`Failed to scan phase directories for existing decimal phases: ${msg}`);
1397
- }
1398
- const dirs = entries.filter((e) => e.isDirectory()).map((e) => e.name);
1399
- const decimalPattern = new RegExp(`^${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}${(0, pattern_cjs_1.escapeRegex)(normalizedBase)}\\.(\\d+)`);
1400
- for (const dir of dirs) {
1401
- const dm = dir.match(decimalPattern);
1402
- if (dm)
1403
- decimalSet.add(parseInt(dm[1], 10));
1404
- }
1405
- }
1406
- const rmPhasePattern = new RegExp(`#{2,4}\\s*Phase\\s+${phaseMarkdownRegexSource(normalizedBase)}\\.(\\d+)${OPTIONAL_PHASE_TAG_SOURCE}\\s*:`, 'gi');
1407
- let rmMatch;
1408
- while ((rmMatch = rmPhasePattern.exec(rawContent)) !== null) {
1409
- decimalSet.add(parseInt(rmMatch[1], 10));
1410
- }
1461
+ const decimalSet = scanExistingDecimalPhaseNumbers(phasesDir, rawContent, normalizedBase);
1411
1462
  const nextDecimal = decimalSet.size === 0 ? 1 : Math.max(...decimalSet) + 1;
1412
- const _decimalPhase = `${normalizedBase}.${nextDecimal}`;
1463
+ let _decimalPhase = `${normalizedBase}.${nextDecimal}`;
1464
+ // #4569: sibling allocation joins afterPhase's PARENT level instead of nesting
1465
+ // one level deeper under afterPhase itself. A top-level phase (no existing
1466
+ // decimal segment) has no sibling level to join; nested is the only sensible
1467
+ // allocation, so we silently fall back for that case.
1468
+ const lastDotIndex = normalizedBase.lastIndexOf('.');
1469
+ if (allocation === 'sibling' && lastDotIndex !== -1) {
1470
+ const parentBase = normalizedBase.slice(0, lastDotIndex);
1471
+ const siblingDecimalSet = scanExistingDecimalPhaseNumbers(phasesDir, rawContent, parentBase);
1472
+ const siblingNextDecimal = siblingDecimalSet.size === 0 ? 1 : Math.max(...siblingDecimalSet) + 1;
1473
+ _decimalPhase = `${parentBase}.${siblingNextDecimal}`;
1474
+ }
1413
1475
  const insertConfig = loadConfig(cwd);
1414
1476
  const projectCode = insertConfig.project_code || '';
1415
1477
  const pfx = projectCode ? `${projectCode}-` : '';
@@ -3022,7 +3084,10 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
3022
3084
  // #2617: pass the project's runtime so the blocked-completion error below
3023
3085
  // suggests the command surface this runtime actually installs
3024
3086
  // ($gsd-… on Codex) rather than a hard-coded Claude-style string.
3025
- const verificationStatus = readVerificationStatus(phaseFullDir, { runtime: (0, runtime_slash_cjs_1.resolveRuntime)(cwd) });
3087
+ const verificationStatus = readVerificationStatus(phaseFullDir, {
3088
+ runtime: (0, runtime_slash_cjs_1.resolveRuntime)(cwd),
3089
+ convention: resolvePhaseIdConvention(cwd),
3090
+ });
3026
3091
  // #3057 B3: the staleness check inside readVerificationStatus can itself
3027
3092
  // fail (fs / scanPhasePlans / clock error), in which case `status` above
3028
3093
  // was routed as if nothing were stale (unchanged fail-open routing) — but
@@ -3767,14 +3832,53 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
3767
3832
  const fmBody = frontmatterMod.stripFrontmatter(stateContent);
3768
3833
  const bodyHasPhaseField = stateExtractField(fmBody, 'Current Phase') != null ||
3769
3834
  stateExtractField(fmBody, 'Phase') != null;
3770
- const authoritativeFm = nextPhaseDisplayName
3771
- ? bodyHasPhaseField || !nextPhaseNum
3772
- ? { current_phase_name: nextPhaseDisplayName }
3773
- : {
3774
- current_phase: String(nextPhaseNum),
3775
- current_phase_name: nextPhaseDisplayName,
3835
+ // #4129: the POST-completion progress counters, derived from the very
3836
+ // ROADMAP this transaction just mutated (still in memory — it hits disk
3837
+ // only at writePlanningFileSet, AFTER this content was assembled).
3838
+ // buildStateFrontmatter's disk scan inside syncAndPreserveStateMd
3839
+ // reads the PRE-completion ROADMAP (and any stale-dated sibling
3840
+ // verification), so without this intent the persisted counter failed
3841
+ // to increment on the completing phase's own transaction. Routed
3842
+ // through the #2736 authoritativeFm seam's object direction: the
3843
+ // pre-preservation merge makes it the derived truth the ratchet
3844
+ // compares, and the post-preservation re-assert (completedOnlyRaise)
3845
+ // is a floor no preservation branch can drop below. clampPercent is
3846
+ // completePhaseCore's own percent formula (state-transition.cts),
3847
+ // reused so the frontmatter and the body `Progress:` line agree.
3848
+ const postCompletionRoadmapScope = roadmapContent !== null
3849
+ ? extractCurrentMilestone(roadmapContent, cwd)
3850
+ : null;
3851
+ const postCompletionRoadmapProgress = postCompletionRoadmapScope !== null
3852
+ ? deriveProgressFromRoadmapForIntent(postCompletionRoadmapScope)
3853
+ : null;
3854
+ const authoritativeProgress = postCompletionRoadmapProgress && postCompletionRoadmapProgress.completedPhases !== null
3855
+ ? postCompletionRoadmapProgress.totalPhases !== null && postCompletionRoadmapProgress.totalPhases > 0
3856
+ ? {
3857
+ completed_phases: postCompletionRoadmapProgress.completedPhases,
3858
+ percent: clampPercentForIntent(postCompletionRoadmapProgress.completedPhases, postCompletionRoadmapProgress.totalPhases),
3776
3859
  }
3860
+ : { completed_phases: postCompletionRoadmapProgress.completedPhases }
3777
3861
  : undefined;
3862
+ const authoritativeFm = authoritativeProgress
3863
+ ? {
3864
+ ...(nextPhaseDisplayName
3865
+ ? bodyHasPhaseField || !nextPhaseNum
3866
+ ? { current_phase_name: nextPhaseDisplayName }
3867
+ : {
3868
+ current_phase: String(nextPhaseNum),
3869
+ current_phase_name: nextPhaseDisplayName,
3870
+ }
3871
+ : {}),
3872
+ progress: authoritativeProgress,
3873
+ }
3874
+ : nextPhaseDisplayName
3875
+ ? bodyHasPhaseField || !nextPhaseNum
3876
+ ? { current_phase_name: nextPhaseDisplayName }
3877
+ : {
3878
+ current_phase: String(nextPhaseNum),
3879
+ current_phase_name: nextPhaseDisplayName,
3880
+ }
3881
+ : undefined;
3778
3882
  // ADR-3408 §8.3 / #3469: this deliberately bypasses
3779
3883
  // readModifyWriteStateMd (STATE.md is committed atomically with
3780
3884
  // ROADMAP/REQUIREMENTS), so it calls the single write-seam