@opengsd/gsd-core 1.12.0 → 1.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (286) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.opencode/plugins/gsd-core.js +12 -0
  4. package/agents/gsd-executor.md +63 -35
  5. package/agents/gsd-plan-checker.md +76 -57
  6. package/agents/gsd-planner.md +14 -0
  7. package/agents/gsd-ui-checker.md +19 -3
  8. package/agents/gsd-ui-researcher.md +29 -0
  9. package/agents/gsd-verifier.md +23 -1
  10. package/bin/install.js +239 -67
  11. package/commands/gsd/execute-phase.md +1 -1
  12. package/commands/gsd/ns-workflow.md +2 -1
  13. package/commands/gsd/phase.md +1 -1
  14. package/commands/gsd/quick-batch.md +105 -0
  15. package/commands/gsd/surface.md +18 -8
  16. package/gsd-core/bin/gsd-tools.cjs +195 -50
  17. package/gsd-core/bin/lib/capability-activation.cjs +27 -0
  18. package/gsd-core/bin/lib/capability-registry.cjs +514 -114
  19. package/gsd-core/bin/lib/capability-state.cjs +7 -1
  20. package/gsd-core/bin/lib/capability-validator.cjs +120 -4
  21. package/gsd-core/bin/lib/capability-writer.cjs +14 -4
  22. package/gsd-core/bin/lib/check-command-router.cjs +85 -2
  23. package/gsd-core/bin/lib/claude-orchestration.cjs +10 -25
  24. package/gsd-core/bin/lib/clusters.cjs +1 -0
  25. package/gsd-core/bin/lib/command-aliases.cjs +16 -0
  26. package/gsd-core/bin/lib/commands.cjs +337 -13
  27. package/gsd-core/bin/lib/config-loader.cjs +3 -0
  28. package/gsd-core/bin/lib/core-utils.cjs +34 -7
  29. package/gsd-core/bin/lib/decisions.cjs +213 -1
  30. package/gsd-core/bin/lib/edge-probe.cjs +14 -1
  31. package/gsd-core/bin/lib/file-overlap-partitioner.cjs +74 -0
  32. package/gsd-core/bin/lib/frontmatter.cjs +137 -23
  33. package/gsd-core/bin/lib/gap-checker.cjs +22 -13
  34. package/gsd-core/bin/lib/git-base-branch.cjs +10 -2
  35. package/gsd-core/bin/lib/health-diagnostic-rules/phase-structure.cjs +8 -2
  36. package/gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.cjs +54 -11
  37. package/gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs +75 -22
  38. package/gsd-core/bin/lib/host-integration.cjs +57 -5
  39. package/gsd-core/bin/lib/init-command-router.cjs +14 -0
  40. package/gsd-core/bin/lib/init.cjs +132 -15
  41. package/gsd-core/bin/lib/install-engine.cjs +184 -12
  42. package/gsd-core/bin/lib/install-model-override-resolver.cjs +45 -0
  43. package/gsd-core/bin/lib/install-profiles.cjs +22 -14
  44. package/gsd-core/bin/lib/installer-migration-report.cjs +1 -0
  45. package/gsd-core/bin/lib/io.cjs +35 -0
  46. package/gsd-core/bin/lib/loop-resolver.cjs +14 -8
  47. package/gsd-core/bin/lib/markdown-table.cjs +123 -0
  48. package/gsd-core/bin/lib/milestone.cjs +22 -2
  49. package/gsd-core/bin/lib/phase-command-router.cjs +13 -6
  50. package/gsd-core/bin/lib/phase-id.cjs +251 -9
  51. package/gsd-core/bin/lib/phase.cjs +774 -35
  52. package/gsd-core/bin/lib/plan-document.cjs +10 -0
  53. package/gsd-core/bin/lib/planning-snapshot.cjs +147 -20
  54. package/gsd-core/bin/lib/planning-workspace.cjs +103 -28
  55. package/gsd-core/bin/lib/quick-batch-command-router.cjs +285 -0
  56. package/gsd-core/bin/lib/quick-batch-dispatch.cjs +250 -0
  57. package/gsd-core/bin/lib/quick-batch.cjs +840 -0
  58. package/gsd-core/bin/lib/review-lane-descriptor.cjs +53 -5
  59. package/gsd-core/bin/lib/review-lane-invocation.cjs +73 -1
  60. package/gsd-core/bin/lib/review-lane-runner.cjs +136 -10
  61. package/gsd-core/bin/lib/roadmap-parser.cjs +499 -26
  62. package/gsd-core/bin/lib/roadmap.cjs +187 -58
  63. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +233 -33
  64. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +16 -17
  65. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +286 -108
  66. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +215 -43
  67. package/gsd-core/bin/lib/shell-command-projection.cjs +4 -0
  68. package/gsd-core/bin/lib/smart-entry.cjs +7 -9
  69. package/gsd-core/bin/lib/state-document.cjs +30 -5
  70. package/gsd-core/bin/lib/state-md-schema.cjs +23 -13
  71. package/gsd-core/bin/lib/state-transition.cjs +333 -44
  72. package/gsd-core/bin/lib/state.cjs +684 -125
  73. package/gsd-core/bin/lib/surface.cjs +23 -8
  74. package/gsd-core/bin/lib/tdd-red-evidence.cjs +133 -0
  75. package/gsd-core/bin/lib/uat.cjs +1419 -515
  76. package/gsd-core/bin/lib/update-context.cjs +6 -2
  77. package/gsd-core/bin/lib/validate.cjs +230 -12
  78. package/gsd-core/bin/lib/verification-command-router.cjs +2 -1
  79. package/gsd-core/bin/lib/verification.cjs +273 -12
  80. package/gsd-core/bin/lib/verify-command-router.cjs +1 -0
  81. package/gsd-core/bin/lib/verify.cjs +346 -16
  82. package/gsd-core/bin/lib/workstream-inventory.cjs +20 -2
  83. package/gsd-core/bin/lib/worktree-safety.cjs +8 -0
  84. package/gsd-core/bin/shared/config-schema.manifest.json +8 -0
  85. package/gsd-core/bin/verify-reapply-patches.cjs +70 -3
  86. package/gsd-core/references/agent-contracts.md +3 -3
  87. package/gsd-core/references/edge-probe.md +17 -13
  88. package/gsd-core/references/execute-mvp-tdd.md +18 -16
  89. package/gsd-core/references/execute-phase-response-language.md +6 -0
  90. package/gsd-core/references/executor-examples.md +42 -0
  91. package/gsd-core/references/few-shot-examples/plan-checker.md +15 -15
  92. package/gsd-core/references/mvp-concepts.md +2 -2
  93. package/gsd-core/references/plan-checker-examples.md +41 -0
  94. package/gsd-core/references/planner-antipatterns.md +25 -0
  95. package/gsd-core/references/planner-chunked.md +5 -1
  96. package/gsd-core/references/planner-coupling.md +42 -0
  97. package/gsd-core/references/planner-quick-batch.md +71 -0
  98. package/gsd-core/references/planner-reviews.md +47 -0
  99. package/gsd-core/references/planner-revision.md +75 -2
  100. package/gsd-core/references/planning-config.md +2 -1
  101. package/gsd-core/references/response-language-directive.md +9 -0
  102. package/gsd-core/references/revision-loop.md +118 -11
  103. package/gsd-core/references/tdd.md +14 -9
  104. package/gsd-core/references/verifier-evidence-gate.md +160 -0
  105. package/gsd-core/templates/phase-prompt.md +4 -0
  106. package/gsd-core/templates/verification-report.md +5 -0
  107. package/gsd-core/workflows/add-backlog.md +2 -0
  108. package/gsd-core/workflows/add-phase.md +2 -0
  109. package/gsd-core/workflows/add-tests.md +1 -1
  110. package/gsd-core/workflows/add-todo.md +1 -1
  111. package/gsd-core/workflows/ai-integration-phase.md +1 -1
  112. package/gsd-core/workflows/analyze-dependencies.md +2 -0
  113. package/gsd-core/workflows/audit-fix.md +2 -0
  114. package/gsd-core/workflows/audit-milestone.md +2 -0
  115. package/gsd-core/workflows/audit-uat.md +2 -0
  116. package/gsd-core/workflows/autonomous.md +2 -0
  117. package/gsd-core/workflows/check-todos.md +1 -1
  118. package/gsd-core/workflows/cleanup.md +1 -1
  119. package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +15 -13
  120. package/gsd-core/workflows/code-review-fix.md +2 -0
  121. package/gsd-core/workflows/code-review.md +73 -31
  122. package/gsd-core/workflows/complete-milestone.md +13 -4
  123. package/gsd-core/workflows/debug.md +1 -1
  124. package/gsd-core/workflows/diagnose-issues.md +5 -1
  125. package/gsd-core/workflows/discuss-phase/modes/advisor.md +2 -0
  126. package/gsd-core/workflows/discuss-phase/modes/all.md +2 -0
  127. package/gsd-core/workflows/discuss-phase/modes/analyze.md +2 -0
  128. package/gsd-core/workflows/discuss-phase/modes/auto.md +2 -0
  129. package/gsd-core/workflows/discuss-phase/modes/batch.md +2 -0
  130. package/gsd-core/workflows/discuss-phase/modes/chain.md +2 -0
  131. package/gsd-core/workflows/discuss-phase/modes/default.md +2 -0
  132. package/gsd-core/workflows/discuss-phase/modes/power.md +2 -0
  133. package/gsd-core/workflows/discuss-phase/modes/text.md +2 -0
  134. package/gsd-core/workflows/discuss-phase/templates/context.md +2 -0
  135. package/gsd-core/workflows/discuss-phase/templates/discussion-log.md +2 -0
  136. package/gsd-core/workflows/discuss-phase-assumptions.md +1 -1
  137. package/gsd-core/workflows/discuss-phase-power.md +2 -0
  138. package/gsd-core/workflows/discuss-phase.md +1 -1
  139. package/gsd-core/workflows/do.md +43 -13
  140. package/gsd-core/workflows/docs-update.md +1 -1
  141. package/gsd-core/workflows/edit-phase.md +2 -0
  142. package/gsd-core/workflows/eval-review.md +1 -1
  143. package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +2 -0
  144. package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +17 -1
  145. package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +8 -2
  146. package/gsd-core/workflows/execute-phase/steps/regression-gate-run.md +2 -0
  147. package/gsd-core/workflows/execute-phase/steps/tdd-applicability-resolution.md +25 -0
  148. package/gsd-core/workflows/execute-phase/steps/worktree-recovery-policy.md +2 -0
  149. package/gsd-core/workflows/execute-phase.md +32 -14
  150. package/gsd-core/workflows/execute-plan.md +8 -8
  151. package/gsd-core/workflows/explore.md +2 -0
  152. package/gsd-core/workflows/extract-learnings.md +2 -0
  153. package/gsd-core/workflows/fast.md +6 -0
  154. package/gsd-core/workflows/forensics.md +2 -0
  155. package/gsd-core/workflows/graduation.md +1 -1
  156. package/gsd-core/workflows/health.md +1 -1
  157. package/gsd-core/workflows/help/modes/brief.md +2 -0
  158. package/gsd-core/workflows/help/modes/default.md +2 -0
  159. package/gsd-core/workflows/help/modes/full.md +12 -0
  160. package/gsd-core/workflows/help/modes/topic.md +2 -0
  161. package/gsd-core/workflows/help.md +2 -0
  162. package/gsd-core/workflows/import.md +3 -3
  163. package/gsd-core/workflows/inbox.md +1 -1
  164. package/gsd-core/workflows/ingest-docs.md +1 -1
  165. package/gsd-core/workflows/insert-phase.md +2 -0
  166. package/gsd-core/workflows/list-phase-assumptions.md +2 -0
  167. package/gsd-core/workflows/list-seeds.md +2 -0
  168. package/gsd-core/workflows/list-workspaces.md +2 -0
  169. package/gsd-core/workflows/manager.md +3 -3
  170. package/gsd-core/workflows/map-codebase.md +2 -0
  171. package/gsd-core/workflows/milestone-summary.md +2 -0
  172. package/gsd-core/workflows/mvp-phase.md +1 -1
  173. package/gsd-core/workflows/new-milestone.md +1 -1
  174. package/gsd-core/workflows/new-project.md +5 -3
  175. package/gsd-core/workflows/new-workspace.md +1 -1
  176. package/gsd-core/workflows/next.md +2 -0
  177. package/gsd-core/workflows/node-repair.md +2 -0
  178. package/gsd-core/workflows/note.md +2 -0
  179. package/gsd-core/workflows/onboard.md +1 -1
  180. package/gsd-core/workflows/pause-work.md +19 -4
  181. package/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md +100 -18
  182. package/gsd-core/workflows/plan-phase/steps/prd-express-path.md +2 -0
  183. package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +9 -0
  184. package/gsd-core/workflows/plan-phase.md +130 -12
  185. package/gsd-core/workflows/plan-review-convergence.md +102 -10
  186. package/gsd-core/workflows/plant-seed.md +1 -1
  187. package/gsd-core/workflows/pr-branch.md +11 -3
  188. package/gsd-core/workflows/profile-user.md +1 -1
  189. package/gsd-core/workflows/progress/steps/forensic-audit.md +1 -1
  190. package/gsd-core/workflows/progress.md +25 -3
  191. package/gsd-core/workflows/quick/steps/plan-checker-loop.md +37 -2
  192. package/gsd-core/workflows/quick/steps/research-phase.md +3 -3
  193. package/gsd-core/workflows/quick-batch/steps/batch-init.md +55 -0
  194. package/gsd-core/workflows/quick-batch/steps/completion.md +65 -0
  195. package/gsd-core/workflows/quick-batch/steps/merge-wave.md +100 -0
  196. package/gsd-core/workflows/quick-batch/steps/plan-checker-loop.md +147 -0
  197. package/gsd-core/workflows/quick-batch/steps/planner-wave.md +158 -0
  198. package/gsd-core/workflows/quick-batch/steps/research-phase.md +95 -0
  199. package/gsd-core/workflows/quick-batch/steps/resume-mode.md +49 -0
  200. package/gsd-core/workflows/quick-batch/steps/verification-wave.md +73 -0
  201. package/gsd-core/workflows/quick-batch/steps/worktree-dispatch.md +169 -0
  202. package/gsd-core/workflows/quick-batch.md +203 -0
  203. package/gsd-core/workflows/quick.md +13 -3
  204. package/gsd-core/workflows/reapply-patches.md +2 -0
  205. package/gsd-core/workflows/remove-phase.md +2 -0
  206. package/gsd-core/workflows/remove-workspace.md +1 -1
  207. package/gsd-core/workflows/resume-project.md +6 -2
  208. package/gsd-core/workflows/review.md +215 -10
  209. package/gsd-core/workflows/scan.md +2 -0
  210. package/gsd-core/workflows/section-manifest.json +12 -0
  211. package/gsd-core/workflows/secure-phase.md +1 -1
  212. package/gsd-core/workflows/session-report.md +2 -0
  213. package/gsd-core/workflows/settings-advanced.md +2 -0
  214. package/gsd-core/workflows/settings-integrations.md +9 -8
  215. package/gsd-core/workflows/settings.md +1 -1
  216. package/gsd-core/workflows/ship.md +10 -10
  217. package/gsd-core/workflows/sketch-wrap-up.md +2 -0
  218. package/gsd-core/workflows/sketch.md +1 -1
  219. package/gsd-core/workflows/smart-entry.md +1 -1
  220. package/gsd-core/workflows/spec-phase.md +24 -19
  221. package/gsd-core/workflows/spike-wrap-up.md +2 -0
  222. package/gsd-core/workflows/spike.md +1 -1
  223. package/gsd-core/workflows/stats.md +2 -0
  224. package/gsd-core/workflows/sync-skills.md +12 -4
  225. package/gsd-core/workflows/thread.md +2 -0
  226. package/gsd-core/workflows/transition.md +2 -0
  227. package/gsd-core/workflows/ui-phase.md +26 -5
  228. package/gsd-core/workflows/ui-review.md +1 -1
  229. package/gsd-core/workflows/ultraplan-phase.md +2 -0
  230. package/gsd-core/workflows/undo.md +1 -1
  231. package/gsd-core/workflows/update.md +41 -38
  232. package/gsd-core/workflows/validate-phase.md +1 -1
  233. package/gsd-core/workflows/verify-work.md +49 -3
  234. package/hooks/dist/gsd-check-update-worker.js +19 -2
  235. package/hooks/dist/gsd-context-monitor.js +283 -12
  236. package/hooks/dist/gsd-node-runner.sh +1 -0
  237. package/hooks/dist/gsd-prompt-guard.js +30 -5
  238. package/hooks/dist/gsd-read-guard.js +2 -0
  239. package/hooks/dist/gsd-read-injection-scanner.js +5 -5
  240. package/hooks/dist/gsd-secret-read-guard.js +1079 -0
  241. package/hooks/dist/gsd-statusline.js +7 -3
  242. package/hooks/dist/gsd-validate-commit.sh +444 -7
  243. package/hooks/dist/gsd-workflow-guard.js +2 -1
  244. package/hooks/dist/lib/git-cmd.js +210 -1
  245. package/hooks/dist/lib/injection-patterns.js +36 -6
  246. package/hooks/dist/managed-hooks-registry.cjs +1 -0
  247. package/hooks/gsd-check-update-worker.js +19 -2
  248. package/hooks/gsd-context-monitor.js +283 -12
  249. package/hooks/gsd-node-runner.sh +1 -0
  250. package/hooks/gsd-prompt-guard.js +30 -5
  251. package/hooks/gsd-read-guard.js +2 -0
  252. package/hooks/gsd-read-injection-scanner.js +5 -5
  253. package/hooks/gsd-secret-read-guard.js +1079 -0
  254. package/hooks/gsd-statusline.js +7 -3
  255. package/hooks/gsd-validate-commit.sh +444 -7
  256. package/hooks/gsd-workflow-guard.js +2 -1
  257. package/hooks/hooks.json +6 -0
  258. package/hooks/lib/git-cmd.js +210 -1
  259. package/hooks/lib/injection-patterns.js +36 -6
  260. package/hooks/managed-hooks-registry.cjs +1 -0
  261. package/package.json +5 -5
  262. package/scripts/build-hooks.js +11 -4
  263. package/scripts/ci-test-scope.cjs +7 -0
  264. package/scripts/docs-guard-registry.cjs +10 -0
  265. package/scripts/gen-loop-host-contract.cjs +67 -15
  266. package/scripts/lib/shellcheck-fetch.cjs +247 -0
  267. package/scripts/lint-allow-test-rule-refs.allowlist.json +0 -6
  268. package/scripts/lint-allow-test-rule-refs.effective-ceiling.json +1 -1
  269. package/scripts/lint-allow-test-rule-refs.unverified-ceiling.json +1 -1
  270. package/scripts/lint-docs-guard-registration.exempt-baseline.cjs +5 -0
  271. package/scripts/lint-phase-enumeration-drift.cjs +24 -6
  272. package/scripts/lint-phase-id-drift.cjs +133 -8
  273. package/scripts/lint-portable-grep.cjs +176 -0
  274. package/scripts/lint-response-language-coverage.cjs +524 -0
  275. package/scripts/lint-test-file-count.allowlist.json +3 -1
  276. package/scripts/lint-workflow-shellcheck-baseline.json +1027 -0
  277. package/scripts/lint-workflow-shellcheck.cjs +614 -0
  278. package/scripts/npm-audit-baseline.cjs +376 -0
  279. package/scripts/prompt-injection-scan.sh +8 -0
  280. package/scripts/require-issue-link-policy.cjs +16 -1
  281. package/skills/gsd-execute-phase/SKILL.md +1 -1
  282. package/skills/gsd-ns-workflow/SKILL.md +1 -0
  283. package/skills/gsd-phase/SKILL.md +1 -1
  284. package/skills/gsd-quick-batch/SKILL.md +105 -0
  285. package/skills/gsd-surface/SKILL.md +18 -8
  286. package/vscode/package.json +1 -1
@@ -143,6 +143,7 @@ function parseXmlTasks(content) {
143
143
  acceptanceCriteria: [],
144
144
  done: null,
145
145
  trackerId: null,
146
+ tdd: null,
146
147
  };
147
148
  }
148
149
  return {
@@ -154,6 +155,7 @@ function parseXmlTasks(content) {
154
155
  acceptanceCriteria: splitCriteria(elementBody(block, 'acceptance_criteria')),
155
156
  done: collapseWhitespace(elementBody(block, 'done')),
156
157
  trackerId: tagAttribute(openTag, 'tracker-id'),
158
+ tdd: tagAttribute(openTag, 'tdd'),
157
159
  };
158
160
  });
159
161
  }
@@ -172,6 +174,7 @@ function parseMarkdownTasks(content) {
172
174
  acceptanceCriteria: [],
173
175
  done: null,
174
176
  trackerId: null,
177
+ tdd: null,
175
178
  }));
176
179
  }
177
180
  // ─── Objective ────────────────────────────────────────────────────────────────
@@ -247,8 +250,15 @@ function parsePlanDocument(content, planPath = '') {
247
250
  const hintStr = String(fmAgentHint).trim();
248
251
  agentHint = hintStr !== '' ? hintStr : null;
249
252
  }
253
+ let planType = null;
254
+ const fmType = fm['type'];
255
+ if (fmType !== undefined) {
256
+ // eslint-disable-next-line @typescript-eslint/no-base-to-string -- FrontmatterValue scalar-to-string
257
+ planType = String(fmType);
258
+ }
250
259
  return {
251
260
  objective: extractObjective(content) || fm['objective'] || null,
261
+ type: planType,
252
262
  declaredWave,
253
263
  dependsOn,
254
264
  autonomous,
@@ -37,7 +37,11 @@ const { isPhaseComplete } = verificationMod;
37
37
  const scanPhasePlans = require("./plan-scan.cjs");
38
38
  // eslint-disable-next-line @typescript-eslint/no-require-imports
39
39
  const planningWorkspace = require("./planning-workspace.cjs");
40
- const { planningPaths, planningRoot } = planningWorkspace;
40
+ // #612: `resolvePhaseIdConvention` is the federated (workstream -> root)
41
+ // `phase_id_convention` reader, from the same §7 owner module `planningPaths`
42
+ // comes from. Resolved once in `buildPlanningSnapshot` — see the
43
+ // `phaseIdConvention` field's comment for why one resolution point matters.
44
+ const { planningPaths, planningRoot, resolvePhaseIdConvention } = planningWorkspace;
41
45
  const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
42
46
  // eslint-disable-next-line @typescript-eslint/no-require-imports
43
47
  const frontmatterMod = require("./frontmatter.cjs");
@@ -64,7 +68,15 @@ const configLoaderMod = require("./config-loader.cjs");
64
68
  const { isGitIgnored } = configLoaderMod;
65
69
  // eslint-disable-next-line @typescript-eslint/no-require-imports
66
70
  const phaseIdMod = require("./phase-id.cjs");
67
- const { PHASE_NUMBER_TOKEN_SOURCE, OPTIONAL_PHASE_TAG_SOURCE, stripProjectCodePrefix, scopeToPhase } = phaseIdMod;
71
+ // #612: `phaseHeadingPrefixSrcFor`/`PHASE_HEADING_BASELINE` SELECT a heading
72
+ // intro by convention (a convention-less call compiles the byte-identical base
73
+ // source the literal it replaced spelled); `isSentinelPhaseId` gets its bracket
74
+ // reading only when handed the convention explicitly.
75
+ const { PHASE_NUMBER_TOKEN_SOURCE, OPTIONAL_PHASE_TAG_SOURCE, stripProjectCodePrefix, phaseHeadingPrefixSrcFor, PHASE_HEADING_BASELINE, isSentinelPhaseId, scopeToPhase, } = phaseIdMod;
76
+ // #612: `phaseTokenFromDir` is the convention-SELECTED counterpart of
77
+ // `PHASE_TOKEN_FROM_DIR_RE` — handed no convention it delegates to that very
78
+ // regex, so a legacy repo's tokenization is unchanged. `checkBracketCoherence`
79
+ // re-homed into `validate.cts` when #3309 deleted its `verify.cts` neighbours.
68
80
  const validate_cjs_1 = require("./validate.cjs");
69
81
  // ─── worstScope — the one new piece of coordination logic ───────────────────
70
82
  /**
@@ -462,18 +474,31 @@ function buildProjectSectionsField(cwd) {
462
474
  * only the mismatches" to "record every attribution" — this field exposes
463
475
  * the parsed fact; the future W021/W026 rules make the mismatch judgment.
464
476
  */
465
- function buildRoadmapDeclaredPhasesField(roadmapPath) {
477
+ function buildRoadmapDeclaredPhasesField(roadmapPath, convention) {
466
478
  if (!node_fs_1.default.existsSync(roadmapPath)) {
467
- return { value: [], scope: SCOPE.UNREADABLE };
479
+ return {
480
+ declared: { value: [], scope: SCOPE.UNREADABLE },
481
+ sentinelTokens: { value: [], scope: SCOPE.UNREADABLE },
482
+ };
468
483
  }
469
484
  let content;
470
485
  try {
471
486
  content = node_fs_1.default.readFileSync(roadmapPath, 'utf-8');
472
487
  }
473
488
  catch {
474
- return { value: [], scope: SCOPE.UNREADABLE };
489
+ return {
490
+ declared: { value: [], scope: SCOPE.UNREADABLE },
491
+ sentinelTokens: { value: [], scope: SCOPE.UNREADABLE },
492
+ };
475
493
  }
476
- const { roadmapPhases } = (0, validate_cjs_1.buildRoadmapPhaseVariants)(content);
494
+ // #612: the declared-phase scan is SELECTED by the resolved convention — a
495
+ // non-bracket repo compiles the byte-identical pattern sources this call
496
+ // compiled before, so its declared set is unchanged. `sentinelPhases` is the
497
+ // same call's third output (empty off the bracket convention) and is surfaced
498
+ // rather than filtered in place: `roadmapPhases` feeds both a membership check
499
+ // (W002's valid-phase set) and a missing-directory warning (W006), and only
500
+ // the latter should ignore an icebox item.
501
+ const { roadmapPhases, sentinelPhases } = (0, validate_cjs_1.buildRoadmapPhaseVariants)(content, convention);
477
502
  const milestoneByPhase = new Map();
478
503
  const sectionRx = /^#{1,3}\s+(?:\[[^\]]{1,200}\]\s*)?.*v(\d+\.\d+)/gim;
479
504
  const sections = [];
@@ -497,7 +522,10 @@ function buildRoadmapDeclaredPhasesField(roadmapPath) {
497
522
  phaseId,
498
523
  milestone: milestoneByPhase.get(phaseId) ?? null,
499
524
  }));
500
- return { value, scope: SCOPE.COMPLETE };
525
+ return {
526
+ declared: { value, scope: SCOPE.COMPLETE },
527
+ sentinelTokens: { value: [...sentinelPhases], scope: SCOPE.COMPLETE },
528
+ };
501
529
  }
502
530
  /**
503
531
  * Resolve `roadmapPhaseCheckboxes` — parsed `[x]`/`[ ]` checkbox state per
@@ -518,7 +546,7 @@ function buildRoadmapDeclaredPhasesField(roadmapPath) {
518
546
  * diagnostic (W011) whose entire purpose is flagging when the two DISAGREE —
519
547
  * reading the data is not re-litigating who is authoritative.
520
548
  */
521
- function buildRoadmapPhaseCheckboxesField(roadmapPath) {
549
+ function buildRoadmapPhaseCheckboxesField(roadmapPath, convention) {
522
550
  if (!node_fs_1.default.existsSync(roadmapPath)) {
523
551
  return { value: {}, scope: SCOPE.UNREADABLE };
524
552
  }
@@ -529,7 +557,15 @@ function buildRoadmapPhaseCheckboxesField(roadmapPath) {
529
557
  catch {
530
558
  return { value: {}, scope: SCOPE.UNREADABLE };
531
559
  }
532
- const checkboxRe = new RegExp(`-\\s*\\[([xX ])\\].*?Phase\\s+0*(${PHASE_NUMBER_TOKEN_SOURCE})${OPTIONAL_PHASE_TAG_SOURCE}[:\\s]`, 'gi');
560
+ // #612: the `Phase\s+` label intro is SELECTED, exactly as
561
+ // `buildNotStartedPhaseVariants` (`validate.cts`) selects it for the same
562
+ // ROADMAP checklist shape — this field is what W006's not-started exclusion
563
+ // now reads instead of that helper, so the two must recognize the same
564
+ // checklist lines or a bracket repo's `- [ ] **[GSD.02] 05: Name**` entries
565
+ // vanish from the exclusion set and every unstarted bracket phase gains a
566
+ // W006. NON-capturing (`capturing` defaults false), so the phase token stays
567
+ // group 2 and the legacy repo compiles a byte-identical source.
568
+ const checkboxRe = new RegExp(`-\\s*\\[([xX ])\\].*?${phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.LABEL_ONLY, convention)}0*(${PHASE_NUMBER_TOKEN_SOURCE})${OPTIONAL_PHASE_TAG_SOURCE}[:\\s]`, 'gi');
533
569
  const value = {};
534
570
  let m;
535
571
  while ((m = checkboxRe.exec(content)) !== null) {
@@ -537,6 +573,42 @@ function buildRoadmapPhaseCheckboxesField(roadmapPath) {
537
573
  }
538
574
  return { value, scope: SCOPE.COMPLETE };
539
575
  }
576
+ /**
577
+ * Resolve `roadmapBracketIncoherences` — W021's bracket half (#612). Delegates
578
+ * wholly to `checkBracketCoherence` (`validate.cjs`), which is pure and owns
579
+ * both sub-checks; this builder only supplies the ROADMAP text and the
580
+ * convention gate.
581
+ *
582
+ * GATED, not merely filtered downstream: off the bracket convention the ROADMAP
583
+ * is never parsed for this at all and the field is a `COMPLETE`-scoped empty
584
+ * list. Inferring 'bracket' from the SHAPE of a matched heading would run a
585
+ * repo-failing check against a repo that never opted in — a legacy ROADMAP
586
+ * containing `### [RFC.2119] 5:` is legal legacy content, and that is the exact
587
+ * regression PR-2's round 1 killed the original ungated design over.
588
+ */
589
+ function buildRoadmapBracketIncoherencesField(roadmapPath, convention) {
590
+ // File-readability is decided FIRST, so `scope` means the same thing on every
591
+ // repo: UNREADABLE iff ROADMAP.md could not be read, never "this convention
592
+ // was skipped." Ordering the convention gate first would have made an absent
593
+ // ROADMAP.md report COMPLETE on a legacy repo and UNREADABLE on a bracket one
594
+ // — the same "empty, nothing to say" state wearing two different scopes, which
595
+ // is precisely the non-answer/answer distinction ADR-3180 §8.1 gives `scope`
596
+ // to carry.
597
+ if (!node_fs_1.default.existsSync(roadmapPath))
598
+ return { value: [], scope: SCOPE.UNREADABLE };
599
+ // A non-bracket repo has no bracket incoherences BY DEFINITION — a real,
600
+ // COMPLETE answer, not a skipped read.
601
+ if (convention !== 'bracket')
602
+ return { value: [], scope: SCOPE.COMPLETE };
603
+ let content;
604
+ try {
605
+ content = node_fs_1.default.readFileSync(roadmapPath, 'utf-8');
606
+ }
607
+ catch {
608
+ return { value: [], scope: SCOPE.UNREADABLE };
609
+ }
610
+ return { value: (0, validate_cjs_1.checkBracketCoherence)(content), scope: SCOPE.COMPLETE };
611
+ }
540
612
  /**
541
613
  * Resolve `researchValidationStatus` — per phase directory, whether its
542
614
  * `*-RESEARCH.md` contains the literal heading `## Validation Architecture`,
@@ -699,7 +771,7 @@ function buildAllPhaseDirNamesField(phasesDir) {
699
771
  * present-but-unreadable per-archive-dir entry is silently skipped, mirroring
700
772
  * `forEachArchivedPhaseToken`'s own per-directory `catch { /* absent/unreadable *\/ }`.
701
773
  */
702
- function buildArchivedPhaseTokensField(planBase) {
774
+ function buildArchivedPhaseTokensField(planBase, convention) {
703
775
  const milestonesDir = node_path_1.default.join(planBase, 'milestones');
704
776
  let archiveDirs;
705
777
  try {
@@ -720,9 +792,19 @@ function buildArchivedPhaseTokensField(planBase) {
720
792
  for (const e of entries) {
721
793
  if (!e.isDirectory())
722
794
  continue;
723
- const m = e.name.match(validate_cjs_1.PHASE_TOKEN_FROM_DIR_RE);
724
- if (m)
725
- value.push(stripProjectCodePrefix(m[1]));
795
+ // #612: composed, not chosen. The convention-aware extractor decides
796
+ // WHICH directory shapes are recognized (so an archived
797
+ // `{CODE}.{MM}-{PP}-slug` is seen at all — `PHASE_TOKEN_FROM_DIR_RE`
798
+ // rejects it outright, which is why every archived bracket phase used
799
+ // to still draw a W006/W002); `stripProjectCodePrefix` then normalizes
800
+ // the token it returns. The strip is a no-op on every bracket token
801
+ // (`01`, `01.02` — the `{CODE}.{MM}` prefix is not part of the token)
802
+ // and does the #2528 work on legacy ones (`MEM-05` -> `05`), so neither
803
+ // side loses its case. Handed no convention, `phaseTokenFromDir`
804
+ // delegates to `PHASE_TOKEN_FROM_DIR_RE` itself — legacy is unchanged.
805
+ const token = (0, validate_cjs_1.phaseTokenFromDir)(e.name, convention);
806
+ if (token)
807
+ value.push(stripProjectCodePrefix(token));
726
808
  }
727
809
  }
728
810
  catch {
@@ -740,7 +822,7 @@ function buildArchivedPhaseTokensField(planBase) {
740
822
  * ROADMAP.md degrades to an empty list, mirroring every other
741
823
  * ROADMAP-sourced field's absent-file handling.
742
824
  */
743
- function buildCurrentMilestoneRoadmapPhaseIdsField(cwd, roadmapPath) {
825
+ function buildCurrentMilestoneRoadmapPhaseIdsField(cwd, roadmapPath, convention) {
744
826
  if (!node_fs_1.default.existsSync(roadmapPath))
745
827
  return { value: [], scope: SCOPE.UNREADABLE };
746
828
  let content;
@@ -753,8 +835,32 @@ function buildCurrentMilestoneRoadmapPhaseIdsField(cwd, roadmapPath) {
753
835
  const scoped = extractCurrentMilestone(content, cwd);
754
836
  // #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal
755
837
  // mirror of OPTIONAL_PHASE_TAG_SOURCE) — verbatim from `verify.cts:2366`.
756
- const phasePattern = new RegExp(`#{2,4}\\s*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:`, 'gi');
757
- const value = [...scoped.matchAll(phasePattern)].map((m) => m[1]);
838
+ //
839
+ // #612: this scan is convention-AGNOSTIC in POSTURE — W026 is ungated, and
840
+ // bug-557 pins it with an empty config so it fires on every repo — but its
841
+ // heading grammar is still SELECTED, never widened. Under bracket the intro
842
+ // CAPTURES, so the phase token moves to group 2 and `bracketGroup` carries
843
+ // that offset; off bracket the source is byte-identical to the literal above
844
+ // and `bracketGroup` is 0. Inferring the convention from a matched bracket's
845
+ // shape would run a repo-failing check against a repo that never opted in.
846
+ const bracketGroup = convention === 'bracket' ? 1 : 0;
847
+ const phasePattern = new RegExp(`#{2,4}\\s*${phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.LABEL_ONLY, convention, Boolean(bracketGroup))}(${PHASE_NUMBER_TOKEN_SOURCE})(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:`, 'gi');
848
+ const value = [];
849
+ for (const m of scoped.matchAll(phasePattern)) {
850
+ const bracketId = bracketGroup ? m[1] : undefined;
851
+ const phaseNum = m[1 + bracketGroup];
852
+ // A bracket sentinel is an ICEBOX item, not an unstarted phase — it
853
+ // legitimately has no directory, so leaving it in this list makes W026
854
+ // ("STATE says milestone complete but ROADMAP lists an unstarted phase")
855
+ // fire on every bracket repo that keeps an icebox. Filtered here rather
856
+ // than in `RULE_W026` because the bracket id is only visible at the match:
857
+ // the emitted token is `07`, and sentinel-ness lives in the `[GSD.999]`
858
+ // milestone this scan just discarded. This field's only consumer is W026
859
+ // (see its own doc comment above).
860
+ if (bracketId && isSentinelPhaseId(`${bracketId}-${phaseNum}`, 'bracket'))
861
+ continue;
862
+ value.push(phaseNum);
863
+ }
758
864
  return { value, scope: SCOPE.COMPLETE };
759
865
  }
760
866
  /**
@@ -853,11 +959,29 @@ function buildPerPhasePlanScanFields(phasesDir, phaseDirNames, enumerationScope)
853
959
  */
854
960
  function buildPlanningSnapshot(cwd) {
855
961
  const paths = planningPaths(cwd);
962
+ // #612: ONE federated (workstream -> root) resolution for the whole snapshot.
963
+ // See the `phaseIdConvention` field's comment for why one resolution point is
964
+ // load-bearing rather than a micro-optimisation.
965
+ const phaseIdConvention = resolvePhaseIdConvention(cwd) ?? null;
856
966
  const milestone = getMilestoneInfo(cwd);
967
+ // #612: deliberately LEFT to `listMilestonePhaseDirs`'s own lazy resolve —
968
+ // this call is byte-identical to upstream's.
969
+ //
970
+ // Passing `phaseIdConvention` here would NOT be a no-op, which is exactly why
971
+ // it is not passed. The lazy path resolves `resolvePhaseIdConvention(cwd, ws)`
972
+ // with this call's `ws`, which defaults to `null` — the PROJECT-only reading,
973
+ // with no root fallback. The field above is resolved with `ws` undefined,
974
+ // i.e. the FEDERATED workstream -> root reading. On a workstream repo whose
975
+ // root opts into bracket while the workstream config does not, the two answers
976
+ // genuinely differ, and substituting one for the other would silently re-scope
977
+ // `phaseDirs` — a change this PR does not need and no test covers. The
978
+ // federation guarantee PR-2 exists to deliver is delivered where it is
979
+ // observable: in the rules that read `snapshot.phaseIdConvention`.
857
980
  const phaseDirs = listMilestonePhaseDirs(paths.phases, { cwd });
858
981
  const phasesValue = phaseDirs.value.map((dir) => buildPhaseSnapshot(paths.phases, dir));
859
982
  const stateFields = buildStateFields(paths.state);
860
983
  const allPhaseDirNames = buildAllPhaseDirNamesField(paths.phases);
984
+ const roadmapDeclared = buildRoadmapDeclaredPhasesField(paths.roadmap, phaseIdConvention);
861
985
  const perPhasePlanScanFields = buildPerPhasePlanScanFields(paths.phases, allPhaseDirNames.value, allPhaseDirNames.scope);
862
986
  return {
863
987
  cwd: node_path_1.default.resolve(cwd),
@@ -875,17 +999,20 @@ function buildPlanningSnapshot(cwd) {
875
999
  projectSections: buildProjectSectionsField(cwd),
876
1000
  statePhaseTokens: stateFields.statePhaseTokens,
877
1001
  stateStatus: stateFields.stateStatus,
878
- roadmapDeclaredPhases: buildRoadmapDeclaredPhasesField(paths.roadmap),
879
- roadmapPhaseCheckboxes: buildRoadmapPhaseCheckboxesField(paths.roadmap),
1002
+ roadmapDeclaredPhases: roadmapDeclared.declared,
1003
+ roadmapPhaseCheckboxes: buildRoadmapPhaseCheckboxesField(paths.roadmap, phaseIdConvention),
880
1004
  researchValidationStatus: buildResearchValidationStatusField(paths.phases, phaseDirs.value, phaseDirs.scope),
881
1005
  milestoneArchiveStatus: buildMilestoneArchiveStatusField(cwd),
882
1006
  planningRootFiles: buildPlanningRootFilesField(cwd),
883
1007
  allPhaseDirNames,
884
- archivedPhaseTokens: buildArchivedPhaseTokensField(paths.planning),
885
- currentMilestoneRoadmapPhaseIds: buildCurrentMilestoneRoadmapPhaseIdsField(cwd, paths.roadmap),
1008
+ archivedPhaseTokens: buildArchivedPhaseTokensField(paths.planning, phaseIdConvention),
1009
+ currentMilestoneRoadmapPhaseIds: buildCurrentMilestoneRoadmapPhaseIdsField(cwd, paths.roadmap, phaseIdConvention),
886
1010
  perPhasePlanNumbering: perPhasePlanScanFields.perPhasePlanNumbering,
887
1011
  perPhaseOrphanSummaries: perPhasePlanScanFields.perPhaseOrphanSummaries,
888
1012
  perPhaseWaveMissingPlans: perPhasePlanScanFields.perPhaseWaveMissingPlans,
1013
+ phaseIdConvention,
1014
+ roadmapSentinelPhaseTokens: roadmapDeclared.sentinelTokens,
1015
+ roadmapBracketIncoherences: buildRoadmapBracketIncoherencesField(paths.roadmap, phaseIdConvention),
889
1016
  };
890
1017
  }
891
1018
  module.exports = {
@@ -21,6 +21,9 @@ const node_path_1 = __importDefault(require("node:path"));
21
21
  const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
22
22
  const clock_cjs_1 = require("./clock.cjs");
23
23
  // eslint-disable-next-line @typescript-eslint/no-require-imports
24
+ const planningScopeMod = require("./planning-scope.cjs");
25
+ const { SCOPE } = planningScopeMod;
26
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
24
27
  const activeWorkstreamStore = require("./active-workstream-store.cjs");
25
28
  const { createSharedPointerAdapter, createSessionScopedPointerAdapter, createMemoryPointerAdapter, getActiveWorkstream: getStoredActiveWorkstream, peekActiveWorkstream: peekStoredActiveWorkstream, setActiveWorkstream: setStoredActiveWorkstream, clearActiveWorkstream: clearStoredActiveWorkstream, diagnoseUnresolvedActiveWorkstream: diagnoseUnresolvedStoredActiveWorkstream, } = activeWorkstreamStore;
26
29
  // Track .planning/.lock files held by this process so they can be removed on exit.
@@ -175,6 +178,87 @@ function worktreesOptedOutUnguarded(cwd) {
175
178
  }
176
179
  return false;
177
180
  }
181
+ /**
182
+ * #612: resolve `phase_id_convention` with the SAME workstream->root federation
183
+ * config-loader uses (config-loader.cts:618/:649) — the workstream config wins,
184
+ * the root config is the fallback.
185
+ *
186
+ * Why this exists rather than `loadConfig(cwd)['phase_id_convention']`: as of
187
+ * #2997 (aa7697fe, in `next`), loadConfig surfaces `phase_id_convention` in
188
+ * its resolved `_baseConfig` — the "loadConfig drops keys it does not know"
189
+ * rationale this comment used to give is stale. The surviving reasons for the
190
+ * direct read are (1) the workstream->root federation below, a standalone
191
+ * resolution this function needs to run against a GIVEN cwd rather than
192
+ * whatever base a `loadConfig(cwd)` call elsewhere would federate from, and
193
+ * (2) convention-ENUM validation, which is still #612 PR-4 work — this
194
+ * function returns the raw string unvalidated, same as the now-surfaced
195
+ * resolved key would. #2997 surfacing the key makes consuming it from
196
+ * resolved config (instead of re-reading config.json here) a natural PR-4
197
+ * consolidation, not this PR's scope. Cycles were never the obstacle.
198
+ *
199
+ * Why federation matters here specifically: the phase-id readers were splitting
200
+ * on this value from two different bases — one resolving from the workstream
201
+ * directory, one from the root — so a workstream repo got the widened ROADMAP
202
+ * read with the narrow directory read, or the reverse, and reported every phase
203
+ * either missing from disk or malformed on disk. One resolver, one answer.
204
+ *
205
+ * The workstream is `planningDir`'s own `ws` parameter, forwarded, so this
206
+ * shares the canonical resolution (and its GSD_PROJECT/GSD_WORKSTREAM
207
+ * handling). Root is consulted as a fallback only when a workstream is active,
208
+ * matching config-loader; a project-scoped directory stands alone. Returns null
209
+ * when unset, absent, or unreadable — every caller treats null as "not the
210
+ * bracket convention".
211
+ *
212
+ * #2761 B1 (trek-e review): `ws` is a PARAMETER, not read from the environment
213
+ * here. It was omitted at first on the reasoning that "the active workstream is
214
+ * whatever planningDir resolves" — true only for the env-driven caller. A
215
+ * caller that iterates workstreams passes the name as an ARGUMENT (it cannot
216
+ * set `GSD_WORKSTREAM` per iteration), and `planningDir` falls back to the env
217
+ * only when `ws` is `undefined`, so an argument-driven call resolved this
218
+ * convention from the ROOT config while reading that workstream's ROADMAP. Two
219
+ * consequences, both reproduced: a workstream that explicitly declares its OWN
220
+ * convention had it ignored — the root's value decided how the workstream's
221
+ * roadmap was parsed, so flipping ONLY the root config changed which milestone
222
+ * a workstream extracted; and `--workstream foo` disagreed with
223
+ * `GSD_WORKSTREAM=foo` on the same repo.
224
+ *
225
+ * `undefined` (the default) keeps `planningDir`'s env fallback, so every
226
+ * pre-#2761 call site is byte-identical; `null` means "explicitly no
227
+ * workstream". Same discriminator `planningDir` and `getMilestonePhaseFilter`
228
+ * already carry.
229
+ *
230
+ * SCOPE: this governs the #612 bracket-selection reads ONLY. The shipped
231
+ * milestone-prefixed W021 gate keeps its own root-only read — re-basing a
232
+ * legacy convention's gate onto a different config is a behaviour change to a
233
+ * shipped check, in both directions, and is not part of read tolerance.
234
+ */
235
+ function resolvePhaseIdConvention(cwd, ws) {
236
+ const readFrom = (dir) => {
237
+ const configPath = node_path_1.default.join(dir, 'config.json');
238
+ if (!node_fs_1.default.existsSync(configPath))
239
+ return null;
240
+ try {
241
+ const parsed = JSON.parse(node_fs_1.default.readFileSync(configPath, 'utf-8'));
242
+ const value = parsed['phase_id_convention'];
243
+ return typeof value === 'string' && value !== '' ? value : null;
244
+ }
245
+ catch {
246
+ return null;
247
+ }
248
+ };
249
+ const scoped = planningDir(cwd, ws);
250
+ const root = planningRoot(cwd);
251
+ if (scoped === root)
252
+ return readFrom(root);
253
+ // Root is a fallback only when a WORKSTREAM is active — config-loader falls
254
+ // back to the root config under `if (ws)` and not otherwise, so a
255
+ // project-scoped directory stands alone. Detected by suppressing the
256
+ // workstream segment rather than re-reading the environment.
257
+ const projectOnly = planningDir(cwd, null);
258
+ if (scoped === projectOnly)
259
+ return readFrom(scoped);
260
+ return readFrom(scoped) ?? readFrom(root);
261
+ }
178
262
  // Sorted list of workstream directory names under `<root>/.planning/workstreams`,
179
263
  // or `[]` when the project is flat (no workstreams dir). Single source of truth
180
264
  // for the "workstream mode" detection shared by the #1912/#2028 fail-safe guards
@@ -449,40 +533,30 @@ function describeUnresolvedWorkstreamReason(reason) {
449
533
  return 'the name is not a valid workstream name';
450
534
  return "its workstream directory doesn't exist (it may have been renamed or removed)";
451
535
  }
452
- /**
453
- * Locate the CONTEXT.md file in a phase directory, handling both the bare
454
- * form (`CONTEXT.md`) and the padded-prefix convention (`NN-CONTEXT.md`,
455
- * `NN.N-CONTEXT.md`, etc.) used by gsd-discuss-phase output.
456
- *
457
- * Returns the filename (not the full path) of the first match, or null if
458
- * no CONTEXT.md exists in the directory.
459
- *
460
- * Canonical dual-form predicate extracted here to eliminate the 5-site
461
- * duplication that previously existed across init.cjs, roadmap.cjs,
462
- * core.cjs, gap-checker.cjs (#3739).
463
- *
464
- * @param absDirOrFiles - Absolute path to the phase directory,
465
- * OR an already-read files array (avoids a redundant readdirSync at call sites
466
- * that already hold a directory listing).
467
- */
468
536
  function findContextMdIn(absDirOrFiles) {
469
- try {
470
- const files = Array.isArray(absDirOrFiles)
471
- ? absDirOrFiles
472
- : node_fs_1.default.readdirSync(absDirOrFiles);
537
+ const matchIn = (files) => {
473
538
  if (files.includes('CONTEXT.md'))
474
539
  return 'CONTEXT.md';
475
540
  return files.find((f) => f.endsWith('-CONTEXT.md')) ?? null;
541
+ };
542
+ if (Array.isArray(absDirOrFiles)) {
543
+ return matchIn(absDirOrFiles);
544
+ }
545
+ try {
546
+ const files = node_fs_1.default.readdirSync(absDirOrFiles);
547
+ return { file: matchIn(files), files, scope: SCOPE.COMPLETE };
476
548
  }
477
549
  catch (err) {
478
- // #1883: distinguish genuine absence from a permission/I-O failure. ENOENT
479
- // ("nothing there") keeps the long-standing null contract the callers rely
480
- // on; every other error (EACCES, EIO, …) is a real read failure that must
481
- // propagate — otherwise an unreadable phase dir is silently reported as
482
- // "no CONTEXT.md" and the discuss/plan gates wrongly skip context.
483
- if (err.code === 'ENOENT')
484
- return null;
485
- throw err;
550
+ // #1883 / #4014: distinguish genuine absence from a permission/I-O
551
+ // failure. ENOENT ("nothing there") keeps the long-standing "real empty"
552
+ // contract callers rely on; every other error (EACCES, EIO, …) is a real
553
+ // read failure — reported as SCOPE.UNREADABLE rather than thrown, so a
554
+ // caller no longer needs its own try/catch to keep an unreadable phase
555
+ // dir from being silently reported the same as "no CONTEXT.md".
556
+ if (err.code === 'ENOENT') {
557
+ return { file: null, files: [], scope: SCOPE.COMPLETE };
558
+ }
559
+ return { file: null, files: [], scope: SCOPE.UNREADABLE };
486
560
  }
487
561
  }
488
562
  module.exports = {
@@ -493,6 +567,7 @@ module.exports = {
493
567
  createMemoryPointerAdapter,
494
568
  planningDir,
495
569
  planningRoot,
570
+ resolvePhaseIdConvention,
496
571
  listAvailableWorkstreams,
497
572
  planningPaths,
498
573
  quickDirFrom,