@opengsd/gsd-core 1.10.0 → 1.12.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 (544) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/agents/gsd-code-fixer.md +1 -1
  4. package/agents/gsd-debug-session-manager.md +12 -1
  5. package/agents/gsd-debugger.md +1 -1
  6. package/agents/gsd-doc-synthesizer.md +2 -4
  7. package/agents/gsd-dom-verifier.md +169 -0
  8. package/agents/gsd-eval-auditor.md +1 -1
  9. package/agents/gsd-executor.md +22 -14
  10. package/agents/gsd-framework-selector.md +1 -3
  11. package/agents/gsd-intel-updater.md +1 -1
  12. package/agents/gsd-mempalace-curator.md +5 -3
  13. package/agents/gsd-pattern-mapper.md +11 -0
  14. package/agents/gsd-phase-researcher.md +23 -2
  15. package/agents/gsd-plan-checker.md +50 -53
  16. package/agents/gsd-planner.md +50 -50
  17. package/agents/gsd-project-researcher.md +1 -1
  18. package/agents/gsd-research-synthesizer.md +2 -2
  19. package/agents/gsd-roadmapper.md +15 -11
  20. package/agents/gsd-ui-checker.md +63 -4
  21. package/agents/gsd-ui-researcher.md +41 -3
  22. package/agents/gsd-user-profiler.md +3 -0
  23. package/agents/gsd-verifier.md +13 -4
  24. package/bin/install.js +1448 -1103
  25. package/commands/gsd/code-review.md +1 -1
  26. package/commands/gsd/discuss-phase.md +1 -1
  27. package/commands/gsd/execute-phase.md +1 -1
  28. package/commands/gsd/import.md +1 -1
  29. package/commands/gsd/map-codebase.md +1 -1
  30. package/commands/gsd/mempalace-capture.md +1 -1
  31. package/commands/gsd/mempalace-recall.md +1 -1
  32. package/commands/gsd/new-milestone.md +1 -1
  33. package/commands/gsd/quick.md +9 -5
  34. package/commands/gsd/review-backlog.md +2 -1
  35. package/commands/gsd/verify-work.md +1 -1
  36. package/gsd-core/bin/gsd-tools.cjs +1035 -138
  37. package/gsd-core/bin/lib/active-workstream-store.cjs +146 -22
  38. package/gsd-core/bin/lib/adr-parser.cjs +13 -7
  39. package/gsd-core/bin/lib/agent-install-check.cjs +392 -32
  40. package/gsd-core/bin/lib/api-coverage.cjs +33 -14
  41. package/gsd-core/bin/lib/artifacts.cjs +5 -0
  42. package/gsd-core/bin/lib/assumption-delta.cjs +32 -15
  43. package/gsd-core/bin/lib/audit-command-router.cjs +9 -2
  44. package/gsd-core/bin/lib/audit.cjs +1026 -268
  45. package/gsd-core/bin/lib/broken-windows.cjs +306 -28
  46. package/gsd-core/bin/lib/capability-consent.cjs +149 -15
  47. package/gsd-core/bin/lib/capability-lifecycle.cjs +45 -0
  48. package/gsd-core/bin/lib/capability-lock.cjs +10 -4
  49. package/gsd-core/bin/lib/capability-registry.cjs +845 -130
  50. package/gsd-core/bin/lib/capability-source.cjs +92 -0
  51. package/gsd-core/bin/lib/capability-state.cjs +18 -3
  52. package/gsd-core/bin/lib/capability-trust.cjs +444 -25
  53. package/gsd-core/bin/lib/capability-validator.cjs +700 -40
  54. package/gsd-core/bin/lib/capability-writer.cjs +3 -2
  55. package/gsd-core/bin/lib/check-command-router.cjs +216 -42
  56. package/gsd-core/bin/lib/claude-orchestration.cjs +56 -3
  57. package/gsd-core/bin/lib/cli-exit.cjs +496 -10
  58. package/gsd-core/bin/lib/code-review-depth.cjs +288 -0
  59. package/gsd-core/bin/lib/codex-agent-toml.cjs +735 -0
  60. package/gsd-core/bin/lib/command-aliases.cjs +22 -0
  61. package/gsd-core/bin/lib/command-arg-projection.cjs +144 -14
  62. package/gsd-core/bin/lib/command-roster.cjs +44 -1
  63. package/gsd-core/bin/lib/command-routing-hub.cjs +31 -2
  64. package/gsd-core/bin/lib/commands.cjs +1172 -108
  65. package/gsd-core/bin/lib/commonjs-marker.cjs +12 -6
  66. package/gsd-core/bin/lib/complexity-trigger.cjs +1192 -0
  67. package/gsd-core/bin/lib/config-loader.cjs +187 -23
  68. package/gsd-core/bin/lib/config.cjs +102 -3
  69. package/gsd-core/bin/lib/configuration.cjs +129 -37
  70. package/gsd-core/bin/lib/core-utils.cjs +208 -33
  71. package/gsd-core/bin/lib/decisions.cjs +23 -0
  72. package/gsd-core/bin/lib/edge-probe.cjs +9 -1
  73. package/gsd-core/bin/lib/estimate-cli.cjs +55 -11
  74. package/gsd-core/bin/lib/exit-code-registry.cjs +98 -0
  75. package/gsd-core/bin/lib/fallow-runner.cjs +20 -44
  76. package/gsd-core/bin/lib/frontmatter.cjs +899 -229
  77. package/gsd-core/bin/lib/gap-checker.cjs +95 -10
  78. package/gsd-core/bin/lib/git-base-branch.cjs +276 -39
  79. package/gsd-core/bin/lib/gsd2-import.cjs +10 -1
  80. package/gsd-core/bin/lib/health-diagnostic-rules/agent-install.cjs +101 -0
  81. package/gsd-core/bin/lib/health-diagnostic-rules/config-validation.cjs +348 -0
  82. package/gsd-core/bin/lib/health-diagnostic-rules/consistency.cjs +149 -0
  83. package/gsd-core/bin/lib/health-diagnostic-rules/install-surface-shadowing.cjs +98 -0
  84. package/gsd-core/bin/lib/health-diagnostic-rules/milestone-archive-hygiene.cjs +100 -0
  85. package/gsd-core/bin/lib/health-diagnostic-rules/phase-structure.cjs +222 -0
  86. package/gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.cjs +268 -0
  87. package/gsd-core/bin/lib/health-diagnostic-rules/root-existence.cjs +161 -0
  88. package/gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs +303 -0
  89. package/gsd-core/bin/lib/health-diagnostic-rules/worktree-health.cjs +187 -0
  90. package/gsd-core/bin/lib/health-diagnostic-types.cjs +68 -0
  91. package/gsd-core/bin/lib/health-diagnostic.cjs +451 -0
  92. package/gsd-core/bin/lib/host-integration.cjs +39 -6
  93. package/gsd-core/bin/lib/host-runtime-detection.cjs +134 -0
  94. package/gsd-core/bin/lib/init-command-router.cjs +118 -21
  95. package/gsd-core/bin/lib/init.cjs +439 -168
  96. package/gsd-core/bin/lib/install-effort-resolver.cjs +73 -30
  97. package/gsd-core/bin/lib/install-engine.cjs +811 -259
  98. package/gsd-core/bin/lib/install-fs-adapter.cjs +262 -0
  99. package/gsd-core/bin/lib/install-model-override-resolver.cjs +235 -0
  100. package/gsd-core/bin/lib/install-profiles.cjs +212 -61
  101. package/gsd-core/bin/lib/install-scope.cjs +270 -0
  102. package/gsd-core/bin/lib/install-shadow-report.cjs +385 -0
  103. package/gsd-core/bin/lib/installed-surface-resolver.cjs +381 -0
  104. package/gsd-core/bin/lib/installer-migration-report.cjs +3 -0
  105. package/gsd-core/bin/lib/installer-migrations/010-antigravity-retire-confighome-artifacts.cjs +169 -0
  106. package/gsd-core/bin/lib/installer-migrations.cjs +148 -38
  107. package/gsd-core/bin/lib/intel.cjs +101 -26
  108. package/gsd-core/bin/lib/io.cjs +170 -15
  109. package/gsd-core/bin/lib/learnings.cjs +85 -14
  110. package/gsd-core/bin/lib/legacy-cleanup.cjs +8 -2
  111. package/gsd-core/bin/lib/markdown-sectionizer.cjs +2 -1
  112. package/gsd-core/bin/lib/markdown-table.cjs +183 -22
  113. package/gsd-core/bin/lib/milestone-lock.cjs +248 -0
  114. package/gsd-core/bin/lib/milestone.cjs +842 -73
  115. package/gsd-core/bin/lib/model-catalog.cjs +232 -16
  116. package/gsd-core/bin/lib/model-resolver.cjs +193 -68
  117. package/gsd-core/bin/lib/normalize-test-command.cjs +1 -1
  118. package/gsd-core/bin/lib/onboard-projection.cjs +5 -1
  119. package/gsd-core/bin/lib/pattern.cjs +122 -0
  120. package/gsd-core/bin/lib/phase-estimation.cjs +18 -9
  121. package/gsd-core/bin/lib/phase-id.cjs +514 -40
  122. package/gsd-core/bin/lib/phase-lifecycle.cjs +52 -19
  123. package/gsd-core/bin/lib/phase-locator.cjs +262 -34
  124. package/gsd-core/bin/lib/phase.cjs +1038 -214
  125. package/gsd-core/bin/lib/plan-dependency-graph.cjs +72 -1
  126. package/gsd-core/bin/lib/plan-document.cjs +263 -0
  127. package/gsd-core/bin/lib/plan-drift-guard.cjs +120 -0
  128. package/gsd-core/bin/lib/plan-scan.cjs +98 -3
  129. package/gsd-core/bin/lib/planning-command-router.cjs +61 -0
  130. package/gsd-core/bin/lib/planning-inspect.cjs +1168 -0
  131. package/gsd-core/bin/lib/planning-scope.cjs +31 -0
  132. package/gsd-core/bin/lib/planning-snapshot.cjs +894 -0
  133. package/gsd-core/bin/lib/planning-workspace.cjs +112 -6
  134. package/gsd-core/bin/lib/probe-core.cjs +5 -2
  135. package/gsd-core/bin/lib/profile-output.cjs +1 -1
  136. package/gsd-core/bin/lib/profile-pipeline-command-router.cjs +50 -7
  137. package/gsd-core/bin/lib/profile-pipeline.cjs +6 -3
  138. package/gsd-core/bin/lib/real-home-guard.cjs +419 -0
  139. package/gsd-core/bin/lib/refactor-trigger-command-router.cjs +766 -0
  140. package/gsd-core/bin/lib/retired-artifact-cleanup.cjs +11 -6
  141. package/gsd-core/bin/lib/review-lane-descriptor.cjs +22 -13
  142. package/gsd-core/bin/lib/review-lane-invocation.cjs +30 -0
  143. package/gsd-core/bin/lib/review-lane-runner.cjs +421 -66
  144. package/gsd-core/bin/lib/review-reviewer-selection.cjs +13 -18
  145. package/gsd-core/bin/lib/roadmap-command-router.cjs +59 -11
  146. package/gsd-core/bin/lib/roadmap-parser.cjs +1006 -184
  147. package/gsd-core/bin/lib/roadmap-upgrade.cjs +37 -10
  148. package/gsd-core/bin/lib/roadmap.cjs +442 -96
  149. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +702 -52
  150. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +14 -2
  151. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +459 -55
  152. package/gsd-core/bin/lib/runtime-config-adapter-registry.cjs +3 -2
  153. package/gsd-core/bin/lib/runtime-homes.cjs +69 -3
  154. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +402 -58
  155. package/gsd-core/bin/lib/runtime-identity.cjs +234 -0
  156. package/gsd-core/bin/lib/runtime-name-policy.cjs +3 -1
  157. package/gsd-core/bin/lib/runtime-slash.cjs +96 -8
  158. package/gsd-core/bin/lib/security.cjs +104 -5
  159. package/gsd-core/bin/lib/shell-command-projection.cjs +342 -7
  160. package/gsd-core/bin/lib/smart-entry.cjs +133 -23
  161. package/gsd-core/bin/lib/spec-section.cjs +12 -7
  162. package/gsd-core/bin/lib/state-command-router.cjs +52 -19
  163. package/gsd-core/bin/lib/state-contract.cjs +359 -0
  164. package/gsd-core/bin/lib/state-document.cjs +338 -8
  165. package/gsd-core/bin/lib/state-md-schema.cjs +221 -0
  166. package/gsd-core/bin/lib/state-transition.cjs +846 -176
  167. package/gsd-core/bin/lib/state.cjs +2589 -369
  168. package/gsd-core/bin/lib/surface.cjs +33 -11
  169. package/gsd-core/bin/lib/task-command-router.cjs +111 -1
  170. package/gsd-core/bin/lib/task-content-resolution.cjs +368 -0
  171. package/gsd-core/bin/lib/teams-status.cjs +4 -1
  172. package/gsd-core/bin/lib/text-lines.cjs +80 -0
  173. package/gsd-core/bin/lib/token-scanner.cjs +76 -0
  174. package/gsd-core/bin/lib/uat-predicate.cjs +67 -23
  175. package/gsd-core/bin/lib/uat.cjs +1761 -167
  176. package/gsd-core/bin/lib/ui-consideration-probe.cjs +9 -1
  177. package/gsd-core/bin/lib/ui-frontend-evidence.cjs +157 -0
  178. package/gsd-core/bin/lib/ui-safety-gate.cjs +51 -12
  179. package/gsd-core/bin/lib/unusable-input.cjs +37 -0
  180. package/gsd-core/bin/lib/update-context.cjs +8 -2
  181. package/gsd-core/bin/lib/user-artifact-staging.cjs +705 -0
  182. package/gsd-core/bin/lib/validate-command-router.cjs +2 -2
  183. package/gsd-core/bin/lib/validate.cjs +20 -6
  184. package/gsd-core/bin/lib/vendor/README.md +75 -0
  185. package/gsd-core/bin/lib/vendor/js-yaml.cjs +3014 -0
  186. package/gsd-core/bin/lib/vendor/re2js.cjs +6480 -0
  187. package/gsd-core/bin/lib/vendor/re2js.d.cts +938 -0
  188. package/gsd-core/bin/lib/verification-command-router.cjs +2 -1
  189. package/gsd-core/bin/lib/verification.cjs +272 -9
  190. package/gsd-core/bin/lib/verify-command-grounding.cjs +846 -0
  191. package/gsd-core/bin/lib/verify.cjs +453 -918
  192. package/gsd-core/bin/lib/workstream-inventory-builder.cjs +53 -32
  193. package/gsd-core/bin/lib/workstream-inventory.cjs +63 -10
  194. package/gsd-core/bin/lib/workstream-name-policy.cjs +25 -4
  195. package/gsd-core/bin/lib/workstream.cjs +2 -2
  196. package/gsd-core/bin/lib/worktree-base-ref.cjs +66 -12
  197. package/gsd-core/bin/lib/worktree-safety.cjs +341 -18
  198. package/gsd-core/bin/shared/config-defaults.manifest.json +8 -1
  199. package/gsd-core/bin/shared/config-schema.manifest.json +12 -1
  200. package/gsd-core/bin/shared/exit-codes.json +8 -0
  201. package/gsd-core/bin/shared/exit-codes.sh +20 -0
  202. package/gsd-core/bin/shared/model-catalog.json +8 -1
  203. package/gsd-core/references/agent-contracts.md +44 -26
  204. package/gsd-core/references/api-coverage.md +24 -2
  205. package/gsd-core/references/autonomous-smart-discuss.md +3 -3
  206. package/gsd-core/references/checkpoints.md +39 -21
  207. package/gsd-core/references/context-budget.md +1 -1
  208. package/gsd-core/references/decimal-phase-calculation.md +5 -5
  209. package/gsd-core/references/dispatch-isolation-gate.md +138 -0
  210. package/gsd-core/references/doc-conflict-engine.md +1 -1
  211. package/gsd-core/references/edge-probe.md +8 -0
  212. package/gsd-core/references/execute-mvp-tdd.md +4 -6
  213. package/gsd-core/references/execute-phase-between-wave-reset.md +15 -14
  214. package/gsd-core/references/execute-phase-context-guard.md +1 -1
  215. package/gsd-core/references/execute-phase-response-language.md +1 -1
  216. package/gsd-core/references/execute-phase-wave-guard.md +17 -11
  217. package/gsd-core/references/failing-direction.md +78 -0
  218. package/gsd-core/references/gate-prompts.md +1 -1
  219. package/gsd-core/references/git-integration.md +5 -5
  220. package/gsd-core/references/git-planning-commit.md +5 -4
  221. package/gsd-core/references/gsd-run-resolver.md +1 -1
  222. package/gsd-core/references/loop-hook-dispatch.md +61 -2
  223. package/gsd-core/references/model-profiles.md +12 -4
  224. package/gsd-core/references/mvp-concepts.md +9 -9
  225. package/gsd-core/references/nyquist-compliance.md +74 -0
  226. package/gsd-core/references/offer-next.md +3 -5
  227. package/gsd-core/references/phase-argument-parsing.md +3 -3
  228. package/gsd-core/references/planner-failing-direction.md +53 -0
  229. package/gsd-core/references/planner-guidance.md +3 -9
  230. package/gsd-core/references/planner-human-verify-mode.md +15 -1
  231. package/gsd-core/references/planner-preconditions.md +1 -1
  232. package/gsd-core/references/planner-reviews.md +1 -1
  233. package/gsd-core/references/planner-revision.md +1 -1
  234. package/gsd-core/references/planner-verify-command-grounding.md +17 -0
  235. package/gsd-core/references/planning-config.md +44 -13
  236. package/gsd-core/references/reviewer-instances.md +31 -0
  237. package/gsd-core/references/revision-loop.md +1 -1
  238. package/gsd-core/references/runtime-aware-dispatch.md +1 -1
  239. package/gsd-core/references/specless-probe-fallback.md +1 -1
  240. package/gsd-core/references/tdd.md +1 -3
  241. package/gsd-core/references/ui-brand.md +65 -21
  242. package/gsd-core/references/ui-consideration-probe.md +1 -1
  243. package/gsd-core/references/universal-anti-patterns.md +5 -5
  244. package/gsd-core/references/verifier-phase-gates.md +192 -0
  245. package/gsd-core/references/verify-command-path-resolvability.md +42 -0
  246. package/gsd-core/references/verify-mvp-mode.md +2 -2
  247. package/gsd-core/references/workstream-flag.md +33 -17
  248. package/gsd-core/templates/README.md +1 -1
  249. package/gsd-core/templates/SECURITY.md +3 -3
  250. package/gsd-core/templates/UI-SPEC.md +25 -3
  251. package/gsd-core/templates/VALIDATION.md +3 -3
  252. package/gsd-core/templates/discussion-log.md +1 -1
  253. package/gsd-core/templates/phase-prompt.md +5 -4
  254. package/gsd-core/templates/state.md +11 -4
  255. package/gsd-core/templates/verification-report.md +9 -1
  256. package/gsd-core/workflows/_runtime-launcher.snippet.sh +1 -1
  257. package/gsd-core/workflows/add-backlog.md +1 -1
  258. package/gsd-core/workflows/add-phase.md +3 -3
  259. package/gsd-core/workflows/add-tests.md +3 -8
  260. package/gsd-core/workflows/add-todo.md +1 -1
  261. package/gsd-core/workflows/ai-integration-phase.md +13 -20
  262. package/gsd-core/workflows/audit-fix.md +12 -3
  263. package/gsd-core/workflows/audit-milestone.md +9 -9
  264. package/gsd-core/workflows/audit-uat.md +17 -2
  265. package/gsd-core/workflows/autonomous/steps/converge-fail-fast.md +2 -2
  266. package/gsd-core/workflows/autonomous.md +11 -27
  267. package/gsd-core/workflows/check-todos.md +1 -1
  268. package/gsd-core/workflows/cleanup.md +64 -5
  269. package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +14 -4
  270. package/gsd-core/workflows/code-review-fix.md +38 -11
  271. package/gsd-core/workflows/code-review.md +159 -52
  272. package/gsd-core/workflows/complete-milestone.md +151 -23
  273. package/gsd-core/workflows/debug.md +12 -8
  274. package/gsd-core/workflows/diagnose-issues.md +47 -15
  275. package/gsd-core/workflows/discuss-phase/modes/advisor.md +1 -1
  276. package/gsd-core/workflows/discuss-phase/modes/chain.md +5 -8
  277. package/gsd-core/workflows/discuss-phase/modes/default.md +1 -1
  278. package/gsd-core/workflows/discuss-phase/modes/text.md +1 -1
  279. package/gsd-core/workflows/discuss-phase-assumptions/steps/auto-advance-dispatch.md +1 -3
  280. package/gsd-core/workflows/discuss-phase-assumptions.md +4 -3
  281. package/gsd-core/workflows/discuss-phase.md +1 -1
  282. package/gsd-core/workflows/do.md +3 -6
  283. package/gsd-core/workflows/docs-update.md +5 -4
  284. package/gsd-core/workflows/edit-phase.md +27 -2
  285. package/gsd-core/workflows/eval-review.md +7 -14
  286. package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +1 -1
  287. package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +142 -15
  288. package/gsd-core/workflows/execute-phase/steps/gap-closure-artifacts.md +1 -1
  289. package/gsd-core/workflows/execute-phase/steps/partial-wave.md +1 -1
  290. package/gsd-core/workflows/execute-phase/steps/per-plan-executor-routing.md +77 -0
  291. package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +24 -4
  292. package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +2 -2
  293. package/gsd-core/workflows/execute-phase/steps/protected-branch.md +21 -0
  294. package/gsd-core/workflows/execute-phase/steps/regression-gate-run.md +2 -2
  295. package/gsd-core/workflows/execute-phase/steps/wave-post-gate-hooks.md +39 -0
  296. package/gsd-core/workflows/execute-phase.md +72 -100
  297. package/gsd-core/workflows/execute-plan.md +52 -15
  298. package/gsd-core/workflows/explore.md +131 -4
  299. package/gsd-core/workflows/extract-learnings.md +1 -1
  300. package/gsd-core/workflows/fast.md +10 -2
  301. package/gsd-core/workflows/forensics.md +1 -1
  302. package/gsd-core/workflows/graduation.md +5 -5
  303. package/gsd-core/workflows/health.md +76 -10
  304. package/gsd-core/workflows/import.md +18 -15
  305. package/gsd-core/workflows/inbox.md +4 -5
  306. package/gsd-core/workflows/ingest-docs.md +49 -16
  307. package/gsd-core/workflows/insert-phase.md +5 -5
  308. package/gsd-core/workflows/list-seeds.md +5 -3
  309. package/gsd-core/workflows/list-workspaces.md +1 -1
  310. package/gsd-core/workflows/manager.md +12 -23
  311. package/gsd-core/workflows/map-codebase.md +1 -1
  312. package/gsd-core/workflows/milestone-summary.md +1 -1
  313. package/gsd-core/workflows/mvp-phase.md +8 -5
  314. package/gsd-core/workflows/new-milestone.md +22 -29
  315. package/gsd-core/workflows/new-project/steps/auto-mode-config.md +1 -1
  316. package/gsd-core/workflows/new-project.md +26 -40
  317. package/gsd-core/workflows/new-workspace.md +1 -1
  318. package/gsd-core/workflows/next.md +14 -2
  319. package/gsd-core/workflows/pause-work.md +1 -1
  320. package/gsd-core/workflows/plan-phase/steps/adr-ingest-express-path.md +1 -1
  321. package/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md +1 -1
  322. package/gsd-core/workflows/plan-phase/steps/prd-express-path.md +2 -4
  323. package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +3 -3
  324. package/gsd-core/workflows/plan-phase.md +162 -59
  325. package/gsd-core/workflows/plan-review-convergence.md +96 -11
  326. package/gsd-core/workflows/plant-seed.md +2 -2
  327. package/gsd-core/workflows/pr-branch.md +187 -51
  328. package/gsd-core/workflows/profile-user.md +16 -14
  329. package/gsd-core/workflows/progress.md +61 -18
  330. package/gsd-core/workflows/quick/steps/discussion-phase.md +1 -3
  331. package/gsd-core/workflows/quick/steps/plan-checker-loop.md +5 -7
  332. package/gsd-core/workflows/quick/steps/quick-verification.md +28 -9
  333. package/gsd-core/workflows/quick/steps/research-phase.md +4 -6
  334. package/gsd-core/workflows/quick/steps/worktree-pre-dispatch-commit.md +3 -3
  335. package/gsd-core/workflows/quick.md +55 -44
  336. package/gsd-core/workflows/remove-phase.md +4 -4
  337. package/gsd-core/workflows/remove-workspace.md +2 -2
  338. package/gsd-core/workflows/resume-project.md +8 -12
  339. package/gsd-core/workflows/review.md +219 -20
  340. package/gsd-core/workflows/scan.md +1 -1
  341. package/gsd-core/workflows/secure-phase.md +3 -3
  342. package/gsd-core/workflows/session-report.md +2 -1
  343. package/gsd-core/workflows/settings-advanced.md +7 -9
  344. package/gsd-core/workflows/settings-integrations.md +64 -31
  345. package/gsd-core/workflows/settings.md +69 -7
  346. package/gsd-core/workflows/ship.md +116 -50
  347. package/gsd-core/workflows/sketch-wrap-up.md +11 -17
  348. package/gsd-core/workflows/sketch.md +12 -18
  349. package/gsd-core/workflows/smart-entry.md +3 -5
  350. package/gsd-core/workflows/spec-phase.md +53 -13
  351. package/gsd-core/workflows/spike-wrap-up.md +7 -11
  352. package/gsd-core/workflows/spike.md +20 -31
  353. package/gsd-core/workflows/stats.md +2 -2
  354. package/gsd-core/workflows/sync-skills.md +64 -9
  355. package/gsd-core/workflows/thread.md +11 -7
  356. package/gsd-core/workflows/transition.md +49 -14
  357. package/gsd-core/workflows/ui-phase.md +15 -21
  358. package/gsd-core/workflows/ui-review.md +8 -12
  359. package/gsd-core/workflows/ultraplan-phase.md +5 -13
  360. package/gsd-core/workflows/undo.md +8 -16
  361. package/gsd-core/workflows/update.md +7 -11
  362. package/gsd-core/workflows/validate-phase.md +3 -3
  363. package/gsd-core/workflows/verify-work/steps/automated-ui-verification.md +25 -1
  364. package/gsd-core/workflows/verify-work/steps/mvp-uat-framing.md +1 -1
  365. package/gsd-core/workflows/verify-work.md +66 -25
  366. package/hooks/dist/gsd-agent-isolation-guard.js +158 -30
  367. package/hooks/dist/gsd-check-update-worker.js +56 -13
  368. package/hooks/dist/gsd-check-update.js +19 -1
  369. package/hooks/dist/gsd-config-reload.js +18 -12
  370. package/hooks/dist/gsd-context-monitor.js +19 -10
  371. package/hooks/dist/gsd-cursor-post-tool.js +3 -1
  372. package/hooks/dist/gsd-cursor-pre-tool.js +2 -3
  373. package/hooks/dist/gsd-cursor-session-start.js +2 -1
  374. package/hooks/dist/gsd-cursor-stop.js +2 -1
  375. package/hooks/dist/gsd-cursor-subagent-start.js +83 -3
  376. package/hooks/dist/gsd-cursor-subagent-stop.js +6 -3
  377. package/hooks/dist/gsd-ensure-canonical-path.js +2 -1
  378. package/hooks/dist/gsd-graphify-update.sh +22 -18
  379. package/hooks/dist/gsd-node-runner.sh +76 -0
  380. package/hooks/dist/gsd-phase-boundary.sh +1 -0
  381. package/hooks/dist/gsd-prompt-guard.js +37 -27
  382. package/hooks/dist/gsd-read-guard.js +16 -7
  383. package/hooks/dist/gsd-read-injection-scanner.js +55 -32
  384. package/hooks/dist/gsd-session-state.sh +1 -0
  385. package/hooks/dist/gsd-statusline.js +231 -24
  386. package/hooks/dist/gsd-update-banner.js +22 -1
  387. package/hooks/dist/gsd-validate-commit.sh +80 -6
  388. package/hooks/dist/gsd-windsurf-pre-command.js +16 -11
  389. package/hooks/dist/gsd-windsurf-pre-write.js +22 -13
  390. package/hooks/dist/gsd-workflow-guard.js +162 -46
  391. package/hooks/dist/gsd-worktree-path-guard.js +36 -21
  392. package/hooks/dist/gsd-write-guard.js +35 -25
  393. package/hooks/dist/lib/cli-exit.js +560 -0
  394. package/hooks/dist/lib/exit-code-registry.js +98 -0
  395. package/hooks/dist/lib/git-cmd.js +92 -59
  396. package/hooks/dist/lib/git-probe.js +84 -0
  397. package/hooks/dist/lib/hook-exit.js +81 -0
  398. package/hooks/dist/lib/injection-patterns.js +45 -0
  399. package/hooks/dist/lib/isolation-deny-reason.js +39 -0
  400. package/hooks/dist/lib/isolation-sentinel.js +9 -0
  401. package/hooks/dist/managed-hooks-registry.cjs +3 -0
  402. package/hooks/gsd-agent-isolation-guard.js +158 -30
  403. package/hooks/gsd-check-update-worker.js +56 -13
  404. package/hooks/gsd-check-update.js +19 -1
  405. package/hooks/gsd-config-reload.js +18 -12
  406. package/hooks/gsd-context-monitor.js +19 -10
  407. package/hooks/gsd-cursor-post-tool.js +3 -1
  408. package/hooks/gsd-cursor-pre-tool.js +2 -3
  409. package/hooks/gsd-cursor-session-start.js +2 -1
  410. package/hooks/gsd-cursor-stop.js +2 -1
  411. package/hooks/gsd-cursor-subagent-start.js +83 -3
  412. package/hooks/gsd-cursor-subagent-stop.js +6 -3
  413. package/hooks/gsd-ensure-canonical-path.js +2 -1
  414. package/hooks/gsd-graphify-update.sh +22 -18
  415. package/hooks/gsd-node-runner.sh +76 -0
  416. package/hooks/gsd-phase-boundary.sh +1 -0
  417. package/hooks/gsd-prompt-guard.js +37 -27
  418. package/hooks/gsd-read-guard.js +16 -7
  419. package/hooks/gsd-read-injection-scanner.js +55 -32
  420. package/hooks/gsd-session-state.sh +1 -0
  421. package/hooks/gsd-statusline.js +231 -24
  422. package/hooks/gsd-update-banner.js +22 -1
  423. package/hooks/gsd-validate-commit.sh +80 -6
  424. package/hooks/gsd-windsurf-pre-command.js +16 -11
  425. package/hooks/gsd-windsurf-pre-write.js +22 -13
  426. package/hooks/gsd-workflow-guard.js +162 -46
  427. package/hooks/gsd-worktree-path-guard.js +36 -21
  428. package/hooks/gsd-write-guard.js +35 -25
  429. package/hooks/lib/cli-exit.js +560 -0
  430. package/hooks/lib/exit-code-registry.js +98 -0
  431. package/hooks/lib/git-cmd.js +92 -59
  432. package/hooks/lib/git-probe.js +84 -0
  433. package/hooks/lib/hook-exit.js +81 -0
  434. package/hooks/lib/injection-patterns.js +45 -0
  435. package/hooks/lib/isolation-deny-reason.js +39 -0
  436. package/hooks/lib/isolation-sentinel.js +9 -0
  437. package/hooks/managed-hooks-registry.cjs +3 -0
  438. package/package.json +28 -11
  439. package/pi/gsd.cjs +19 -5
  440. package/scripts/base64-scan.sh +74 -12
  441. package/scripts/baselines/planning-prompt-drift-baseline.json +4 -0
  442. package/scripts/baselines/planning-snapshot-bypass-baseline.json +12 -0
  443. package/scripts/baselines/unreachable-guard-drift-baseline.json +4 -0
  444. package/scripts/build-hooks.js +5 -0
  445. package/scripts/changeset/lint.cjs +60 -5
  446. package/scripts/check-alias-drift.cjs +7 -43
  447. package/scripts/check-contract-drift.cjs +297 -0
  448. package/scripts/check-glossary-refs.cjs +77 -15
  449. package/scripts/check-mutation-score-ratchet.cjs +156 -0
  450. package/scripts/ci-check-job-near-cap.cjs +49 -0
  451. package/scripts/ci-pr-mergeability.cjs +262 -0
  452. package/scripts/ci-test-scope.cjs +64 -14
  453. package/scripts/ci-timeout-report.cjs +230 -0
  454. package/scripts/command-contract-helpers.cjs +903 -1
  455. package/scripts/docs-guard-registry.cjs +396 -0
  456. package/scripts/gen-adr-index.cjs +728 -38
  457. package/scripts/gen-capability-registry.cjs +11 -21
  458. package/scripts/gen-context-index.cjs +2 -11
  459. package/scripts/gen-exit-code-docs.cjs +318 -0
  460. package/scripts/gen-exit-code-registry.cjs +891 -0
  461. package/scripts/gen-features.cjs +836 -0
  462. package/scripts/gen-health-docs.cjs +390 -0
  463. package/scripts/gen-hooks-cli-exit.cjs +239 -0
  464. package/scripts/gen-install-tree-fixtures.cjs +2 -2
  465. package/scripts/gen-inventory-manifest.cjs +50 -4
  466. package/scripts/gen-loop-host-contract.cjs +138 -25
  467. package/scripts/gen-registry.cjs +3 -14
  468. package/scripts/gen-scripts-cli-exit.cjs +185 -0
  469. package/scripts/gen-state-md-docs.cjs +727 -0
  470. package/scripts/{test-failure-reasons.cjs → gsd-test-gate-reasons.cjs} +6 -0
  471. package/scripts/lib/alias-drift-families.cjs +46 -0
  472. package/scripts/lib/ci-job-timing.cjs +72 -0
  473. package/scripts/lib/cli-exit.cjs +546 -44
  474. package/scripts/lib/drift-scan.cjs +308 -0
  475. package/scripts/lib/exit-code-registry.cjs +98 -0
  476. package/scripts/lib/ndjson-reporter.cjs +119 -0
  477. package/scripts/lint-allow-test-rule-refs.allowlist.json +1 -26
  478. package/scripts/lint-allow-test-rule-refs.effective-ceiling.json +4 -0
  479. package/scripts/lint-allow-test-rule-refs.unverified-ceiling.json +3 -0
  480. package/scripts/lint-canary-version-leak.cjs +73 -0
  481. package/scripts/lint-command-contract.cjs +96 -13
  482. package/scripts/lint-completion-predicate-drift.cjs +933 -0
  483. package/scripts/lint-completion-ratio-drift.cjs +214 -0
  484. package/scripts/lint-default-flip-documentation.cjs +193 -0
  485. package/scripts/lint-docs-guard-registration.cjs +495 -0
  486. package/scripts/lint-docs-guard-registration.exempt-baseline.cjs +193 -0
  487. package/scripts/lint-eslint-glob-coverage.allowlist.json +38 -0
  488. package/scripts/lint-eslint-glob-coverage.cjs +340 -0
  489. package/scripts/{lint-fix-has-regression-test.cjs → lint-fix-has-regression-tests.cjs} +12 -6
  490. package/scripts/lint-frontmatter-scalar-broad-grep.cjs +237 -0
  491. package/scripts/lint-health-diagnostic-rule-table.cjs +461 -0
  492. package/scripts/lint-hooks-runtime-build-seam.cjs +262 -0
  493. package/scripts/lint-milestone-window-drift.cjs +468 -0
  494. package/scripts/lint-mutation-test-derivation-drift.cjs +86 -0
  495. package/scripts/lint-phase-enumeration-drift.cjs +492 -0
  496. package/scripts/lint-plan-count-drift.cjs +318 -0
  497. package/scripts/lint-planning-artifact-writer-drift.cjs +398 -0
  498. package/scripts/lint-planning-prompt-drift.cjs +471 -0
  499. package/scripts/lint-planning-snapshot-bypass-drift.cjs +544 -0
  500. package/scripts/lint-regression-test-names.cjs +15 -13
  501. package/scripts/lint-removed-but-needed.cjs +488 -0
  502. package/scripts/lint-seam-enforcement.cjs +182 -0
  503. package/scripts/lint-slug-derivation-drift.cjs +921 -0
  504. package/scripts/lint-source-test-name-collision.cjs +241 -0
  505. package/scripts/lint-state-field-drift.cjs +805 -0
  506. package/scripts/lint-state-write-path-drift.cjs +950 -0
  507. package/scripts/lint-test-file-count.allowlist.json +137 -8
  508. package/scripts/lint-test-file-count.cjs +25 -3
  509. package/scripts/lint-unreachable-guard-drift.cjs +830 -0
  510. package/scripts/lint-vendored-deps.cjs +297 -0
  511. package/scripts/mutation-matrix.cjs +599 -50
  512. package/scripts/pr-changed-files.cjs +63 -0
  513. package/scripts/pr-template-policy.cjs +14 -4
  514. package/scripts/prompt-injection-scan.sh +100 -14
  515. package/scripts/require-issue-link-policy.cjs +192 -0
  516. package/scripts/secret-scan.sh +75 -13
  517. package/scripts/select-docs-guards.cjs +56 -0
  518. package/scripts/sync-runtime-launcher.cjs +24 -7
  519. package/skills/gsd-autonomous/SKILL.md +0 -1
  520. package/skills/gsd-code-review/SKILL.md +1 -1
  521. package/skills/gsd-discuss-phase/SKILL.md +1 -1
  522. package/skills/gsd-execute-phase/SKILL.md +1 -2
  523. package/skills/gsd-import/SKILL.md +1 -1
  524. package/skills/gsd-map-codebase/SKILL.md +1 -1
  525. package/skills/gsd-mempalace-capture/SKILL.md +1 -1
  526. package/skills/gsd-mempalace-recall/SKILL.md +1 -1
  527. package/skills/gsd-new-milestone/SKILL.md +1 -1
  528. package/skills/gsd-next/SKILL.md +0 -1
  529. package/skills/gsd-plan-phase/SKILL.md +0 -1
  530. package/skills/gsd-progress/SKILL.md +0 -1
  531. package/skills/gsd-quick/SKILL.md +9 -5
  532. package/skills/gsd-review-backlog/SKILL.md +2 -1
  533. package/skills/gsd-stats/SKILL.md +0 -1
  534. package/skills/gsd-verify-work/SKILL.md +1 -1
  535. package/vscode/package.json +1 -1
  536. package/bin/lib/ui-safety-gate.cjs +0 -107
  537. package/gsd-core/workflows/discovery-phase.md +0 -298
  538. package/gsd-core/workflows/plan-milestone-gaps.md +0 -281
  539. package/gsd-core/workflows/verify-phase.md +0 -574
  540. package/scripts/affected-tests-lib.cjs +0 -554
  541. package/scripts/lint-allow-test-rule-refs.cjs +0 -162
  542. package/scripts/lint-emitted-drift-ack.cjs +0 -344
  543. package/scripts/run-affected-tests.cjs +0 -7
  544. package/scripts/run-tests.cjs +0 -1051
@@ -0,0 +1,1168 @@
1
+ "use strict";
2
+ /**
3
+ * Planning Inspect Module — the schema-v1 canonical planning snapshot (#2790).
4
+ *
5
+ * `planning.inspect` is READ-ONLY: it opens `.planning/` documents and writes
6
+ * nothing, anywhere, ever. Downstream harness UIs (gsd-code Phase 12, plan
7
+ * mission-control) consume it instead of parsing ROADMAP/REQUIREMENTS/PLAN/
8
+ * SUMMARY markdown a second time — gsd-core is the single source of `.planning/`
9
+ * truth.
10
+ *
11
+ * COMPOSED, NOT RE-DERIVED. Every ADR-3180 §7 derivation arrives from its
12
+ * declared owner: milestone identity and windowing from `getMilestoneInfo`
13
+ * (§7.2) and phase enumeration from `listMilestonePhaseDirs` (§7.3), both via
14
+ * `buildPlanningSnapshot`; phase completion from `isPhaseComplete` (§7.4,
15
+ * disk-strict); live-plan counting from `scanPhasePlans` (§7.5); the
16
+ * fraction→percent arithmetic from `clampPercent` (§7.6). Plan bodies come from
17
+ * `parsePlanDocument` (`src/plan-document.cts`), requirement IDs from
18
+ * `parseRequirements` (`src/gap-checker.cts`), UAT items from `parseUatItems`
19
+ * (`src/uat.cts`). This module introduces no second answer to any of those
20
+ * questions.
21
+ *
22
+ * WHY THIS DOES NOT SERIALIZE `PlanningSnapshot` DIRECTLY. `PlanningSnapshot`
23
+ * is the §8.1 *diagnostic-rule subject* — explicitly additive and still growing
24
+ * (4 fields at Phase 10, 20+ by Phase 12). schema-v1 is a frozen EXTERNAL
25
+ * contract. Handing an internal, churning shape to external consumers is a
26
+ * Hyrum's-Law break waiting to happen, so this module declares its own flat
27
+ * schema and maps into it. Adding a field to `PlanningSnapshot` must never
28
+ * change what `planning.inspect` emits.
29
+ *
30
+ * NEVER INFERS. Where evidence is absent or two sources disagree, the value is
31
+ * `null` / `unknown` and a diagnostic names why. It is never reconciled, never
32
+ * guessed, and never filled from a plausible default. Keys are ALWAYS present —
33
+ * omitting a key on a non-answer is itself an observable a consumer would bind
34
+ * to.
35
+ *
36
+ * NOT a diagnostic rule, and deliberately NOT registered in
37
+ * `scripts/lint-planning-snapshot-bypass-drift.cjs`: that guard is
38
+ * `DIAGNOSTIC_RULE_FUNCTIONS`-scoped and must be prunable to zero when #3309
39
+ * lands. This is a query command.
40
+ *
41
+ * ADR-457 build-at-publish: source in src/planning-inspect.cts, compiled to
42
+ * gsd-core/bin/lib/planning-inspect.cjs (gitignored).
43
+ */
44
+ var __importDefault = (this && this.__importDefault) || function (mod) {
45
+ return (mod && mod.__esModule) ? mod : { "default": mod };
46
+ };
47
+ const node_fs_1 = __importDefault(require("node:fs"));
48
+ const node_path_1 = __importDefault(require("node:path"));
49
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
50
+ const planningSnapshotMod = require("./planning-snapshot.cjs");
51
+ const { buildPlanningSnapshot, worstScope } = planningSnapshotMod;
52
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
53
+ const planningWorkspaceMod = require("./planning-workspace.cjs");
54
+ const { planningPaths } = planningWorkspaceMod;
55
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
56
+ const planScan = require("./plan-scan.cjs");
57
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
58
+ const planDocumentMod = require("./plan-document.cjs");
59
+ const { parsePlanDocument, TASK_KIND, planIdFromFile } = planDocumentMod;
60
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
61
+ const gapCheckerMod = require("./gap-checker.cjs");
62
+ const { parseRequirements } = gapCheckerMod;
63
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
64
+ const uatMod = require("./uat.cjs");
65
+ const { parseUatItemsWithStats, selectPhaseUatFiles } = uatMod;
66
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
67
+ const phaseLifecycleMod = require("./phase-lifecycle.cjs");
68
+ const { clampPercent } = phaseLifecycleMod;
69
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
70
+ const planningScopeMod = require("./planning-scope.cjs");
71
+ const { SCOPE } = planningScopeMod;
72
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
73
+ const verificationMod = require("./verification.cjs");
74
+ const { readVerificationStatus } = verificationMod;
75
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
76
+ const phaseIdMod = require("./phase-id.cjs");
77
+ const { phaseKeyFromDir, phaseKeyFromToken, phaseMarkdownRegexSource } = phaseIdMod;
78
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
79
+ const roadmapParserMod = require("./roadmap-parser.cjs");
80
+ const { extractCurrentMilestone } = roadmapParserMod;
81
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
82
+ const io = require("./io.cjs");
83
+ const { output } = io;
84
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
85
+ const frontmatterMod = require("./frontmatter.cjs");
86
+ const { extractFrontmatter, stripFrontmatter } = frontmatterMod;
87
+ const state_document_cjs_1 = require("./state-document.cjs");
88
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
89
+ const markdownSectionizer = require("./markdown-sectionizer.cjs");
90
+ const { collectSection, iterateBullets } = markdownSectionizer;
91
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
92
+ const markdownTable = require("./markdown-table.cjs");
93
+ const { parseMarkdownTable, matchTableSchema } = markdownTable;
94
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
95
+ const coreUtilsMod = require("./core-utils.cjs");
96
+ const { normalizeLineEndings } = coreUtilsMod;
97
+ /**
98
+ * The wire schema version. A consumer MUST reject any value other than this
99
+ * one rather than best-effort-parsing an unknown shape.
100
+ */
101
+ const PLANNING_INSPECT_SCHEMA_VERSION = 1;
102
+ /**
103
+ * Frozen diagnostic vocabulary. Adding a member is the repo's standard three
104
+ * coordinated changes: this enum, the emitting site, and the test that locks
105
+ * `Object.keys(INSPECT_DIAGNOSTIC).sort()`.
106
+ */
107
+ const INSPECT_DIAGNOSTIC = Object.freeze({
108
+ PLANNING_ROOT_ABSENT: 'planning_root_absent',
109
+ ROADMAP_UNSCOPED: 'roadmap_unscoped',
110
+ REQUIREMENTS_ABSENT: 'requirements_absent',
111
+ REQUIREMENTS_UNREADABLE: 'requirements_unreadable',
112
+ REQUIREMENT_DUPLICATE: 'requirement_duplicate',
113
+ REQUIREMENT_UNMAPPED: 'requirement_unmapped',
114
+ REQUIREMENT_PHASE_UNKNOWN: 'requirement_phase_unknown',
115
+ REQUIREMENT_COMPLETION_UNKNOWN: 'requirement_completion_unknown',
116
+ ORPHAN_PHASE_DIR: 'orphan_phase_dir',
117
+ PHASE_SCOPE_DEGRADED: 'phase_scope_degraded',
118
+ PLAN_UNREADABLE: 'plan_unreadable',
119
+ SUMMARY_UNREADABLE: 'summary_unreadable',
120
+ TASK_SHAPE_CHECKPOINT: 'task_shape_checkpoint',
121
+ TASK_CHANGED_FILES_PLAN_SCOPED: 'task_changed_files_plan_scoped',
122
+ TASK_CHANGED_FILES_CONFLICTING: 'task_changed_files_conflicting',
123
+ UAT_ABSENT: 'uat_absent',
124
+ UAT_UNREADABLE: 'uat_unreadable',
125
+ PERCENT_WITHHELD: 'percent_withheld',
126
+ });
127
+ /** Whether a task has a completion record. `unknown` is a first-class answer. */
128
+ const TASK_STATUS = Object.freeze({
129
+ DONE: 'done',
130
+ PENDING: 'pending',
131
+ UNKNOWN: 'unknown',
132
+ });
133
+ /**
134
+ * How precisely a changed-file list is attributed. `plan_scoped` is the common
135
+ * case and is NOT an error — SUMMARY.md's `## Files Created/Modified` is a
136
+ * plan-level section, so spreading it across a plan's tasks would be inference.
137
+ */
138
+ const PROVENANCE = Object.freeze({
139
+ TASK_SCOPED: 'task_scoped',
140
+ PLAN_SCOPED: 'plan_scoped',
141
+ ABSENT: 'absent',
142
+ });
143
+ /** Whether planned and changed file sets agree. Never reconciled. */
144
+ const AGREEMENT = Object.freeze({
145
+ AGREED: 'agreed',
146
+ CONFLICTING: 'conflicting',
147
+ UNKNOWN: 'unknown',
148
+ });
149
+ // ─── Small shared helpers ─────────────────────────────────────────────────────
150
+ /**
151
+ * Path separators are normalised UNCONDITIONALLY, never via `path.sep` — a
152
+ * backslash-bearing path can arrive on Linux too, so a platform-gated
153
+ * normaliser leaves the non-Windows case untested and broken.
154
+ */
155
+ function toPosix(value) {
156
+ return value.replace(/\\/g, '/');
157
+ }
158
+ /**
159
+ * Read a UTF-8 file, distinguishing absent from unreadable.
160
+ *
161
+ * `root` is a containment boundary, not a convenience default: every
162
+ * document this module reads arrives as UNTRUSTED input — a clone, a PR
163
+ * branch, a teammate's working tree — and the assembled payload is handed
164
+ * to downstream tooling verbatim (see module doc). A `*-PLAN.md` (or any
165
+ * other document under `.planning/`) that is a SYMLINK resolving outside
166
+ * `root` is therefore an exfiltration path, not a convenience: reading it
167
+ * would let a planted symlink smuggle arbitrary readable file content into
168
+ * the emitted payload. Both `filePath` and `root` are resolved with
169
+ * `fs.realpathSync` — never a raw string prefix check on the unresolved
170
+ * path — so a project that legitimately symlinks its whole `.planning/`
171
+ * directory, or a single phase directory, elsewhere on disk keeps working;
172
+ * only a resolved target that ends up OUTSIDE the resolved root is
173
+ * rejected. An escape is reported as unreadable (same shape as any other
174
+ * unreadable document) so existing per-document degradation and
175
+ * diagnostics apply unchanged.
176
+ */
177
+ /**
178
+ * Path-boundary-safe comparison of two ALREADY-RESOLVED absolute paths — the
179
+ * ONE containment comparison every containment check in this module shares
180
+ * (`readDocument`'s file-level guard, `isPathContained` below for
181
+ * directories and the verification-status `fs` seam), so none of them can
182
+ * drift apart. `target` must equal `root`, or begin with `root` plus a path
183
+ * separator; a bare `startsWith(root)` would wrongly accept a sibling
184
+ * directory like `.planning-evil/` that merely shares a string prefix. Pure
185
+ * string comparison, no I/O — callers own their own `fs.realpathSync` call
186
+ * (and its own not-found/broken-symlink handling).
187
+ */
188
+ function isWithinRoot(resolvedTarget, resolvedRoot) {
189
+ return resolvedTarget === resolvedRoot || resolvedTarget.startsWith(resolvedRoot + node_path_1.default.sep);
190
+ }
191
+ /**
192
+ * Containment check for a path (file OR directory) that resolves its own
193
+ * `fs.realpathSync`, then delegates the actual boundary comparison to
194
+ * `isWithinRoot`. Used where the caller does not need to distinguish "target
195
+ * vanished / broken symlink" from "target resolved but escapes root" — both
196
+ * degrade the same way at every call site that uses this (an escaped or
197
+ * unresolvable phase directory is treated identically to an unreadable one).
198
+ * `readDocument` below needs that distinction for its own exists/readable
199
+ * tri-state, so it keeps its own inline `realpathSync` calls and calls
200
+ * `isWithinRoot` directly instead of this wrapper.
201
+ */
202
+ function isPathContained(target, root) {
203
+ let realTarget;
204
+ let realRoot;
205
+ try {
206
+ realTarget = node_fs_1.default.realpathSync(target);
207
+ realRoot = node_fs_1.default.realpathSync(root);
208
+ }
209
+ catch {
210
+ return false;
211
+ }
212
+ return isWithinRoot(realTarget, realRoot);
213
+ }
214
+ function readDocument(filePath, root) {
215
+ let stat;
216
+ try {
217
+ stat = node_fs_1.default.statSync(filePath);
218
+ }
219
+ catch {
220
+ return { text: null, exists: false, readable: false };
221
+ }
222
+ // A directory, socket, or symlink resolving to a device in a document
223
+ // position is not a document. Reject before any open (cf. #2378/#2383).
224
+ if (!stat.isFile())
225
+ return { text: null, exists: true, readable: false };
226
+ let realTarget;
227
+ let realRoot;
228
+ try {
229
+ realTarget = node_fs_1.default.realpathSync(filePath);
230
+ realRoot = node_fs_1.default.realpathSync(root);
231
+ }
232
+ catch {
233
+ // A broken symlink, or the path vanished between the stat above and
234
+ // here — the same non-answer `readDocument` already gives "not exists".
235
+ return { text: null, exists: false, readable: false };
236
+ }
237
+ if (!isWithinRoot(realTarget, realRoot)) {
238
+ return { text: null, exists: true, readable: false };
239
+ }
240
+ try {
241
+ // #3707-CR follow-up MAJOR: normalize line endings HERE, at this module's
242
+ // one shared document-read seam, so a lone-CR document (CommonMark line
243
+ // ending; a document using it renders as separate lines to a human
244
+ // reader) is normalized by construction for every current and future
245
+ // caller of `readDocument` (`buildRequirements`, `buildUatRows`, the
246
+ // ROADMAP.md read above) — not only the ones a parser author remembered
247
+ // to fix individually. Mirrors `src/uat.cts`'s `readNormalizedDocument`,
248
+ // the equivalent boundary for `cmdAuditUat`; both now delegate to the
249
+ // same `normalizeLineEndings` in `core-utils.cts`.
250
+ return { text: normalizeLineEndings(node_fs_1.default.readFileSync(filePath, 'utf-8')), exists: true, readable: true };
251
+ }
252
+ catch {
253
+ return { text: null, exists: true, readable: false };
254
+ }
255
+ }
256
+ /**
257
+ * Containment-enforcing `fs` seam for `readVerificationStatus`
258
+ * (`src/verification.cts`), passed through that function's existing
259
+ * `opts.fs` injection point — GAP 2 of the #2790 follow-up security review.
260
+ *
261
+ * `readVerificationStatus` is a SHARED owner consumed by many commands
262
+ * (init, roadmap, phase, ship, …), so it must not gain a containment
263
+ * parameter of its own; the boundary is enforced HERE, at this consumer's
264
+ * call site, instead. Each method below delegates to the real `node:fs`
265
+ * ONLY after the given path passes `isPathContained` against
266
+ * `planningRoot` — otherwise it throws. `readVerificationStatus` already
267
+ * treats every one of these calls' failures as its no-throw "missing"
268
+ * degradation (see its own try/catch around `readdirSync`/`readFileSync`,
269
+ * and `findStaleVerificationSummary`'s around `readdirSync`/`statSync`), so
270
+ * a `*-VERIFICATION.md` symlinked outside the planning root — or a phase
271
+ * directory that is itself such a symlink — now degrades to the ordinary
272
+ * 'missing' status instead of leaking frontmatter content (or the escaped
273
+ * directory's filenames) into the payload.
274
+ */
275
+ function containmentEnforcingVerificationFs(planningRoot) {
276
+ function assertContained(target) {
277
+ if (!isPathContained(target, planningRoot)) {
278
+ throw new Error(`planning-inspect: path escapes planning root: ${toPosix(target)}`);
279
+ }
280
+ }
281
+ return {
282
+ readdirSync(dir) {
283
+ assertContained(dir);
284
+ return node_fs_1.default.readdirSync(dir);
285
+ },
286
+ readFileSync(filePath, encoding) {
287
+ assertContained(filePath);
288
+ return node_fs_1.default.readFileSync(filePath, encoding);
289
+ },
290
+ statSync(filePath) {
291
+ assertContained(filePath);
292
+ return node_fs_1.default.statSync(filePath);
293
+ },
294
+ };
295
+ }
296
+ function sortedUnique(values) {
297
+ return [...new Set(values)].sort();
298
+ }
299
+ /**
300
+ * Prefix-agnostic requirement-ID format shared with `parseRequirements`
301
+ * (`src/gap-checker.cts`) — REQ-01, TST-01, BACK-07, INSP-04, etc. Bold
302
+ * markers (`**ID**`) are optional decoration, matching how `gap-checker.cts`
303
+ * pulls the ID out of a checkbox bullet's text.
304
+ */
305
+ const ID_PATTERN = '[A-Z][A-Z0-9]*-[A-Za-z0-9_-]+';
306
+ /**
307
+ * A Traceability table's `Requirement` cell holds ONLY the ID (mod optional
308
+ * bold + surrounding whitespace) — full-match, mirroring the pipe-bounded
309
+ * anchoring the prior hand-rolled `ID_CELL` regex enforced.
310
+ */
311
+ const CELL_ID_RE = new RegExp(`^\\*{0,2}(${ID_PATTERN})\\*{0,2}$`);
312
+ /**
313
+ * A checkbox bullet's leading `**ID**` — prefix match only (no end anchor):
314
+ * trailing prose (`: description`, ` some text`) is not required to match,
315
+ * mirroring the prior hand-rolled `BULLET` regex.
316
+ */
317
+ const BULLET_ID_RE = new RegExp(`^\\*{0,2}(${ID_PATTERN})\\*{0,2}`);
318
+ /**
319
+ * Parse the `## Traceability` table's `Requirement | Phase | Status` rows via
320
+ * the canonical `markdown-table` seam (ADR-2143) — never a hand-rolled
321
+ * table/row regex. A missing/malformed section or table, or a header that
322
+ * doesn't match the `RequirementsTraceability` schema, yields an EMPTY map: a
323
+ * malformed table is a non-answer, and every requirement then falls through
324
+ * to the caller's own `REQUIREMENT_UNMAPPED` diagnostic.
325
+ */
326
+ function parseTraceability(reqMd) {
327
+ const byId = new Map();
328
+ const section = collectSection(reqMd, (h) => /^traceability$/i.test(h.text.trim()));
329
+ if (!section)
330
+ return byId;
331
+ const parsed = parseMarkdownTable(section.body);
332
+ if (!parsed.ok)
333
+ return byId;
334
+ const schema = matchTableSchema(parsed.value.columns);
335
+ if (!schema || schema.id !== 'RequirementsTraceability')
336
+ return byId;
337
+ for (const row of parsed.value.rows) {
338
+ const idMatch = CELL_ID_RE.exec((row.Requirement ?? '').trim());
339
+ if (!idMatch)
340
+ continue;
341
+ const id = idMatch[1];
342
+ // `Phase 1`, `1`, `Phase 1, Phase 2` — the token is what a consumer can
343
+ // match against a phase id; the surrounding word is decoration. This is
344
+ // value-level parsing of ONE already-addressed cell, not markdown parsing.
345
+ const tokens = [...(row.Phase ?? '').matchAll(/\d+(?:\.\d+)*/g)].map((t) => t[0]);
346
+ const existing = byId.get(id);
347
+ if (existing)
348
+ existing.push(...tokens);
349
+ else
350
+ byId.set(id, tokens);
351
+ }
352
+ return byId;
353
+ }
354
+ /**
355
+ * Checkbox completion state per requirement ID, from the `- [x] **ID**`
356
+ * bullets — driven by the canonical `iterateBullets` seam, same bold-ID
357
+ * extraction style as `parseRequirements` (`src/gap-checker.cts`). A
358
+ * requirement with no bullet has no checkbox answer — reported as `unknown`,
359
+ * never defaulted to `false`.
360
+ */
361
+ function parseCheckboxStates(reqMd) {
362
+ const states = new Map();
363
+ for (const bullet of iterateBullets(reqMd)) {
364
+ if (bullet.marker !== 'checkbox-checked' && bullet.marker !== 'checkbox-unchecked')
365
+ continue;
366
+ const m = BULLET_ID_RE.exec(bullet.text);
367
+ if (!m)
368
+ continue;
369
+ // First occurrence wins, matching parseRequirements' own `seen` set.
370
+ if (!states.has(m[1]))
371
+ states.set(m[1], bullet.marker === 'checkbox-checked');
372
+ }
373
+ return states;
374
+ }
375
+ /** IDs appearing more than once in the checkbox bullets, in document order. */
376
+ function findDuplicateIds(reqMd) {
377
+ const seen = new Set();
378
+ const dupes = [];
379
+ for (const bullet of iterateBullets(reqMd)) {
380
+ if (bullet.marker !== 'checkbox-checked' && bullet.marker !== 'checkbox-unchecked')
381
+ continue;
382
+ const m = BULLET_ID_RE.exec(bullet.text);
383
+ if (!m)
384
+ continue;
385
+ if (seen.has(m[1]))
386
+ dupes.push(m[1]);
387
+ else
388
+ seen.add(m[1]);
389
+ }
390
+ return dupes;
391
+ }
392
+ function buildRequirements(requirementsPath, knownPhaseKeys, diagnostics, planningRoot) {
393
+ const doc = readDocument(requirementsPath, planningRoot);
394
+ if (!doc.exists) {
395
+ diagnostics.push({
396
+ code: INSPECT_DIAGNOSTIC.REQUIREMENTS_ABSENT,
397
+ subject: toPosix(requirementsPath),
398
+ detail: 'REQUIREMENTS.md does not exist; no requirement rows are available.',
399
+ });
400
+ return { rows: [], scope: SCOPE.UNSCOPED };
401
+ }
402
+ if (!doc.readable || doc.text === null) {
403
+ diagnostics.push({
404
+ code: INSPECT_DIAGNOSTIC.REQUIREMENTS_UNREADABLE,
405
+ subject: toPosix(requirementsPath),
406
+ detail: 'REQUIREMENTS.md exists but could not be read; zero rows is not a reliable answer.',
407
+ });
408
+ return { rows: [], scope: SCOPE.UNREADABLE };
409
+ }
410
+ const reqMd = doc.text;
411
+ const items = parseRequirements(reqMd);
412
+ const traceability = parseTraceability(reqMd);
413
+ const checkboxes = parseCheckboxStates(reqMd);
414
+ const dupeIds = findDuplicateIds(reqMd);
415
+ for (const dupe of dupeIds) {
416
+ diagnostics.push({
417
+ code: INSPECT_DIAGNOSTIC.REQUIREMENT_DUPLICATE,
418
+ subject: dupe,
419
+ detail: 'Requirement ID appears more than once; the first occurrence is authoritative.',
420
+ });
421
+ }
422
+ const duplicateIdSet = new Set(dupeIds);
423
+ const rows = items.map((item) => {
424
+ const mappedPhases = sortedUnique(traceability.get(item.id) ?? []);
425
+ const hasCheckbox = checkboxes.has(item.id);
426
+ const complete = hasCheckbox ? checkboxes.get(item.id) : 'unknown';
427
+ const rowDiagnostics = [];
428
+ if (duplicateIdSet.has(item.id)) {
429
+ rowDiagnostics.push(INSPECT_DIAGNOSTIC.REQUIREMENT_DUPLICATE);
430
+ }
431
+ if (mappedPhases.length === 0) {
432
+ diagnostics.push({
433
+ code: INSPECT_DIAGNOSTIC.REQUIREMENT_UNMAPPED,
434
+ subject: item.id,
435
+ detail: 'No Traceability row maps this requirement to a phase.',
436
+ });
437
+ rowDiagnostics.push(INSPECT_DIAGNOSTIC.REQUIREMENT_UNMAPPED);
438
+ }
439
+ for (const token of mappedPhases) {
440
+ // `phaseKeyFromDir` and `phaseKeyFromToken` are the canonical pair for
441
+ // phase-IDENTITY equality: both map a directory name and a document
442
+ // token onto the same canonical zero-padded key, so `01-auth` /
443
+ // `1-auth` / `Phase 1` / `Phase 01` are recognized as one phase, and
444
+ // decimal phases (`1.1`) compare correctly. A raw string compare gets
445
+ // the padding case wrong; `phaseTokenMatches` is the wrong primitive
446
+ // here too — it is a FILE-MEMBERSHIP predicate (#3511) for aggregate
447
+ // `*-UAT.md` / `*-VERIFICATION.md` scans, not a phase-identity equality
448
+ // test, and is unreliable for decimal phases.
449
+ if (!knownPhaseKeys.has(phaseKeyFromToken(token))) {
450
+ diagnostics.push({
451
+ code: INSPECT_DIAGNOSTIC.REQUIREMENT_PHASE_UNKNOWN,
452
+ subject: `${item.id}->${token}`,
453
+ detail: 'Traceability maps this requirement to a phase that is not present on disk.',
454
+ });
455
+ rowDiagnostics.push(INSPECT_DIAGNOSTIC.REQUIREMENT_PHASE_UNKNOWN);
456
+ }
457
+ }
458
+ if (complete === 'unknown') {
459
+ diagnostics.push({
460
+ code: INSPECT_DIAGNOSTIC.REQUIREMENT_COMPLETION_UNKNOWN,
461
+ subject: item.id,
462
+ detail: 'Requirement has no checkbox bullet; completion is unknown, not incomplete.',
463
+ });
464
+ rowDiagnostics.push(INSPECT_DIAGNOSTIC.REQUIREMENT_COMPLETION_UNKNOWN);
465
+ }
466
+ return {
467
+ id: item.id,
468
+ text: item.text && item.text.length > 0 ? item.text : null,
469
+ complete,
470
+ mappedPhases,
471
+ diagnostics: rowDiagnostics,
472
+ scope: SCOPE.COMPLETE,
473
+ };
474
+ });
475
+ return { rows, scope: SCOPE.COMPLETE };
476
+ }
477
+ /**
478
+ * Parse a SUMMARY.md body for file provenance.
479
+ *
480
+ * Two DIFFERENT scopes live in this document and conflating them is the whole
481
+ * hazard: `## Files Created/Modified` describes the PLAN, while a deviation
482
+ * block's `**Files modified:**` is attributed to the task its `**Found during:**
483
+ * Task N` line names. Only the latter is task-scoped.
484
+ */
485
+ function parseSummaryProvenance(content) {
486
+ const planFiles = [];
487
+ const byTask = new Map();
488
+ // `## Files Created/Modified` — a PLAN-level bullet list. Bounded to the
489
+ // section body via collectSection + iterateBullets; absent is not an error.
490
+ const filesSection = collectSection(content, (h) => /^files\s+created\/modified\s*$/i.test(h.text.trim()));
491
+ if (filesSection) {
492
+ for (const bullet of iterateBullets(filesSection.body)) {
493
+ // `- \`path/to/file.ts\` - What it does`
494
+ const m = /^`([^`]+)`/.exec(bullet.text);
495
+ if (m)
496
+ planFiles.push(m[1].trim());
497
+ }
498
+ }
499
+ // `## Deviations from Plan` (also matches the `(Auto-fixed)` variant) — the
500
+ // `**Found during:** Task N` / `**Files modified:**` scan is a regex
501
+ // CONFINED to this already-collected section body (ADR-2143 §4 sanctioned
502
+ // pattern), never a document-wide heading walk.
503
+ const deviationsSection = collectSection(content, (h) => /^deviations\s+from\s+plan/i.test(h.text.trim()));
504
+ if (deviationsSection) {
505
+ let currentTask = null;
506
+ for (const line of deviationsSection.body.split(/\r?\n/)) {
507
+ const foundDuring = /\*\*Found during:\*\*\s*Task\s*(\d+)/i.exec(line);
508
+ if (foundDuring) {
509
+ currentTask = parseInt(foundDuring[1], 10);
510
+ continue;
511
+ }
512
+ const filesModified = /\*\*Files modified:\*\*\s*(.+)$/i.exec(line);
513
+ if (filesModified && currentTask !== null) {
514
+ const files = filesModified[1]
515
+ .split(',')
516
+ .map((f) => f.trim().replace(/^`|`$/g, ''))
517
+ .filter((f) => f.length > 0);
518
+ const existing = byTask.get(currentTask);
519
+ if (existing)
520
+ existing.push(...files);
521
+ else
522
+ byTask.set(currentTask, files);
523
+ }
524
+ }
525
+ }
526
+ return { planFiles: sortedUnique(planFiles), byTask };
527
+ }
528
+ /** Pair a plan file with its SUMMARY by the canonical id embedded in the name. */
529
+ function summaryForPlan(planFile, summaryFiles) {
530
+ const base = node_path_1.default.basename(planFile);
531
+ const key = base.replace(/-?PLAN/i, '').replace(/\.md$/i, '');
532
+ const dir = planFile.includes('/') ? planFile.slice(0, planFile.lastIndexOf('/') + 1) : '';
533
+ for (const candidate of summaryFiles) {
534
+ if (!candidate.startsWith(dir))
535
+ continue;
536
+ const candidateKey = node_path_1.default.basename(candidate).replace(/-?SUMMARY/i, '').replace(/\.md$/i, '');
537
+ if (candidateKey === key)
538
+ return candidate;
539
+ }
540
+ return null;
541
+ }
542
+ function buildTaskRows(planFile, parsed, provenance, diagnostics) {
543
+ return parsed.tasks.map((task) => {
544
+ if (task.kind === TASK_KIND.CHECKPOINT) {
545
+ diagnostics.push({
546
+ code: INSPECT_DIAGNOSTIC.TASK_SHAPE_CHECKPOINT,
547
+ subject: `${toPosix(planFile)}#${task.index}`,
548
+ detail: 'Checkpoint task: the grammar carries no name/files/acceptance elements.',
549
+ });
550
+ }
551
+ if (provenance === null) {
552
+ return {
553
+ index: task.index,
554
+ kind: task.kind,
555
+ type: task.type,
556
+ name: task.name,
557
+ plannedFiles: task.plannedFiles,
558
+ acceptanceCriteria: task.acceptanceCriteria,
559
+ done: task.done,
560
+ changedFiles: null,
561
+ provenance: PROVENANCE.ABSENT,
562
+ agreement: AGREEMENT.UNKNOWN,
563
+ status: TASK_STATUS.PENDING,
564
+ };
565
+ }
566
+ const attributed = provenance.byTask.get(task.index);
567
+ if (attributed === undefined) {
568
+ // A SUMMARY exists but says nothing about THIS task. The plan-level file
569
+ // list is not evidence about a task — attributing it would be inference.
570
+ diagnostics.push({
571
+ code: INSPECT_DIAGNOSTIC.TASK_CHANGED_FILES_PLAN_SCOPED,
572
+ subject: `${toPosix(planFile)}#${task.index}`,
573
+ detail: 'SUMMARY carries only a plan-level file list; task-scoped changed files are unknown.',
574
+ });
575
+ return {
576
+ index: task.index,
577
+ kind: task.kind,
578
+ type: task.type,
579
+ name: task.name,
580
+ plannedFiles: task.plannedFiles,
581
+ acceptanceCriteria: task.acceptanceCriteria,
582
+ done: task.done,
583
+ changedFiles: null,
584
+ provenance: PROVENANCE.PLAN_SCOPED,
585
+ agreement: AGREEMENT.UNKNOWN,
586
+ status: TASK_STATUS.UNKNOWN,
587
+ };
588
+ }
589
+ const changed = sortedUnique(attributed);
590
+ const planned = sortedUnique(task.plannedFiles);
591
+ let agreement = AGREEMENT.UNKNOWN;
592
+ if (planned.length > 0) {
593
+ const same = planned.length === changed.length && planned.every((f, i) => f === changed[i]);
594
+ agreement = same ? AGREEMENT.AGREED : AGREEMENT.CONFLICTING;
595
+ if (!same) {
596
+ diagnostics.push({
597
+ code: INSPECT_DIAGNOSTIC.TASK_CHANGED_FILES_CONFLICTING,
598
+ subject: `${toPosix(planFile)}#${task.index}`,
599
+ detail: 'Planned and changed file sets disagree; both are reported verbatim, unreconciled.',
600
+ });
601
+ }
602
+ }
603
+ return {
604
+ index: task.index,
605
+ kind: task.kind,
606
+ type: task.type,
607
+ name: task.name,
608
+ plannedFiles: task.plannedFiles,
609
+ acceptanceCriteria: task.acceptanceCriteria,
610
+ done: task.done,
611
+ changedFiles: changed,
612
+ provenance: PROVENANCE.TASK_SCOPED,
613
+ agreement,
614
+ status: TASK_STATUS.DONE,
615
+ };
616
+ });
617
+ }
618
+ function buildPlanRows(phaseDir, diagnostics, planningRoot) {
619
+ // GAP 1 (#2790 follow-up security review): `scanPhasePlans` (`plan-scan.cjs`)
620
+ // does its own `readdirSync(phaseDir)`, which FOLLOWS a directory symlink —
621
+ // so a phase directory that is itself a symlink escaping `planningRoot`
622
+ // would have its EXTERNAL filenames enumerated and surfaced via `file:
623
+ // toPosix(planFile)` below, even though the per-file `readDocument` guard
624
+ // already rejects the CONTENT. Contained before any enumeration happens, so
625
+ // an escaped phase directory contributes zero rows and zero filenames — the
626
+ // same degraded shape (`scope: unreadable`, empty `rows`) `scanPhasePlans`
627
+ // already returns when the directory cannot be listed at all, via the same
628
+ // `isPathContained` comparison `readDocument` uses for files (never a
629
+ // second, hand-rolled boundary check).
630
+ if (!isPathContained(phaseDir, planningRoot)) {
631
+ return { rows: [], scope: SCOPE.UNREADABLE };
632
+ }
633
+ const scan = planScan(phaseDir);
634
+ const supersededSet = new Set(scan.allPlanFiles.filter((f) => !scan.planFiles.includes(f)));
635
+ const rows = scan.allPlanFiles.map((planFile) => {
636
+ const doc = readDocument(node_path_1.default.join(phaseDir, planFile), planningRoot);
637
+ if (doc.text === null) {
638
+ diagnostics.push({
639
+ code: INSPECT_DIAGNOSTIC.PLAN_UNREADABLE,
640
+ subject: toPosix(planFile),
641
+ detail: 'Plan file could not be read; its body is unknown. Sibling plans are unaffected.',
642
+ });
643
+ return {
644
+ id: planIdFromFile(planFile),
645
+ file: toPosix(planFile),
646
+ superseded: supersededSet.has(planFile),
647
+ objective: null,
648
+ wave: null,
649
+ dependsOn: [],
650
+ autonomous: true,
651
+ agentHint: null,
652
+ plannedFiles: [],
653
+ changedFiles: null,
654
+ hasSummary: false,
655
+ tasks: [],
656
+ scope: SCOPE.UNREADABLE,
657
+ };
658
+ }
659
+ const parsed = parsePlanDocument(doc.text);
660
+ const summaryFile = summaryForPlan(planFile, scan.summaryFiles);
661
+ let provenance = null;
662
+ if (summaryFile !== null) {
663
+ const summaryDoc = readDocument(node_path_1.default.join(phaseDir, summaryFile), planningRoot);
664
+ if (summaryDoc.text === null) {
665
+ diagnostics.push({
666
+ code: INSPECT_DIAGNOSTIC.SUMMARY_UNREADABLE,
667
+ subject: toPosix(summaryFile),
668
+ detail: 'Summary file could not be read; file provenance for this plan is unknown.',
669
+ });
670
+ }
671
+ else {
672
+ provenance = parseSummaryProvenance(summaryDoc.text);
673
+ }
674
+ }
675
+ return {
676
+ id: planIdFromFile(planFile),
677
+ file: toPosix(planFile),
678
+ superseded: supersededSet.has(planFile),
679
+ objective: parsed.objective,
680
+ wave: parsed.declaredWave,
681
+ dependsOn: parsed.dependsOn,
682
+ autonomous: parsed.autonomous,
683
+ agentHint: parsed.agentHint,
684
+ plannedFiles: parsed.filesModified,
685
+ changedFiles: provenance === null ? null : provenance.planFiles,
686
+ hasSummary: summaryFile !== null,
687
+ tasks: buildTaskRows(planFile, parsed, provenance, diagnostics),
688
+ scope: SCOPE.COMPLETE,
689
+ };
690
+ });
691
+ return { rows, scope: scan.scope };
692
+ }
693
+ // ─── UAT ──────────────────────────────────────────────────────────────────────
694
+ // `scope` and `foldScope` are, by decision, identical at every return site in
695
+ // this function as of #3078 round-8 — they are not accidentally in sync. The
696
+ // two-field shape is kept anyway because it lets `scope` (the row's own
697
+ // honest answer) and `foldScope` (what the caller folds into the milestone)
698
+ // diverge again later without a signature change, should some future gap
699
+ // class need to be reported on the row but exempted from the fold, or vice
700
+ // versa. If you find yourself "simplifying" this to one field, don't.
701
+ function buildUatRows(phasesDir, phaseDirName, diagnostics, planningRoot) {
702
+ const phaseDir = node_path_1.default.join(phasesDir, phaseDirName);
703
+ // GAP 1 (#2790 follow-up security review): same rationale as
704
+ // `buildPlanRows` above — `readdirSync` below FOLLOWS a directory symlink,
705
+ // so an escaped phase directory must be rejected before enumeration, not
706
+ // after. Reuses the existing `UAT_UNREADABLE` diagnostic and degraded
707
+ // shape (the pre-existing "directory could not be listed" path below),
708
+ // rather than inventing a new diagnostic code for what is, from a
709
+ // consumer's perspective, the same non-answer.
710
+ if (!isPathContained(phaseDir, planningRoot)) {
711
+ diagnostics.push({
712
+ code: INSPECT_DIAGNOSTIC.UAT_UNREADABLE,
713
+ subject: phaseDirName,
714
+ detail: 'Phase directory could not be listed; UAT presence is unknown.',
715
+ });
716
+ return { items: [], scope: SCOPE.UNREADABLE, foldScope: SCOPE.UNREADABLE };
717
+ }
718
+ let entries;
719
+ try {
720
+ entries = node_fs_1.default.readdirSync(phaseDir);
721
+ }
722
+ catch {
723
+ diagnostics.push({
724
+ code: INSPECT_DIAGNOSTIC.UAT_UNREADABLE,
725
+ subject: phaseDirName,
726
+ detail: 'Phase directory could not be listed; UAT presence is unknown.',
727
+ });
728
+ return { items: [], scope: SCOPE.UNREADABLE, foldScope: SCOPE.UNREADABLE };
729
+ }
730
+ const uatFiles = selectPhaseUatFiles(entries, phaseDirName);
731
+ if (uatFiles.length === 0) {
732
+ diagnostics.push({
733
+ code: INSPECT_DIAGNOSTIC.UAT_ABSENT,
734
+ subject: phaseDirName,
735
+ detail: 'No UAT document for this phase. This does not affect phase acceptance.',
736
+ });
737
+ return { items: [], scope: SCOPE.COMPLETE, foldScope: SCOPE.COMPLETE };
738
+ }
739
+ const items = [];
740
+ let scope = SCOPE.COMPLETE;
741
+ let foldScope = SCOPE.COMPLETE;
742
+ for (const file of uatFiles) {
743
+ const doc = readDocument(node_path_1.default.join(phaseDir, file), planningRoot);
744
+ if (doc.text === null) {
745
+ diagnostics.push({
746
+ code: INSPECT_DIAGNOSTIC.UAT_UNREADABLE,
747
+ subject: `${phaseDirName}/${file}`,
748
+ detail: 'UAT document exists but could not be read.',
749
+ });
750
+ scope = SCOPE.TRUNCATED;
751
+ foldScope = SCOPE.TRUNCATED;
752
+ continue;
753
+ }
754
+ // #3707-class false-clean, second surface (security review finding 1):
755
+ // `parseUatItemsWithStats`'s `headingsSeen` counts `### N.` test blocks
756
+ // that yielded ZERO items (a row missing its `result:` line, or otherwise
757
+ // unrecognised) — the audit-uat side (`cmdAuditUat`, above) already flags
758
+ // this as `parse_gap`. Reading the file successfully is not the same as
759
+ // deriving every row from it, so the gap is ALWAYS REPORTED.
760
+ //
761
+ // #3078 round-8 HIGH — NO FRONTMATTER KILL SWITCH. There is deliberately
762
+ // no `status !== 'complete'` term here. `cmdAuditUat` dropped its own such
763
+ // guard (see the long rationale at src/uat.cts, above `headingsSeen > 0`):
764
+ // a terminal status is an ASSERTION BY THE AUTHOR, and an assertion must
765
+ // not be able to switch off the detector that would contradict it. A guard
766
+ // here let one word of frontmatter turn a fence-straddled `result: blocked`
767
+ // into an affirmative `scope: "complete"` with ZERO diagnostics. The
768
+ // condition is `headingsSeen > 0` alone, matching src/uat.cts's own check
769
+ // line for line. Do not re-add a status term to "align the surfaces" — the
770
+ // surfaces are aligned precisely BY its absence.
771
+ //
772
+ // #3078 round-8 — REPORTING THE GAP AND WITHHOLDING THE PERCENTAGE ARE TWO
773
+ // DIFFERENT DECISIONS, so they get two different fields. `scope` is what
774
+ // this phase's own `uat.scope` reports: it stays honest and goes TRUNCATED
775
+ // for every gap, because a document that did not yield all its rows must
776
+ // never carry an affirmative `"complete"`. `foldScope` is what is handed to
777
+ // `worstScope` in the caller, and it is the one with teeth — a non-COMPLETE
778
+ // fold raises `phase_scope_degraded` AND, via `phaseScope`/`makeFraction`,
779
+ // withholds BOTH progress percentages for the WHOLE milestone.
780
+ //
781
+ // The two now agree: EVERY gap class degrades both, including the
782
+ // fence-suppression shortfall. `shortfallBlocks` is not exempted here
783
+ // because it is a single tally incremented at ONE site in the scan and
784
+ // spans BOTH a harmless closed-fence documentation sample AND a genuinely
785
+ // fence-straddled `result: blocked` row — exempting the tally cannot
786
+ // exempt only the harmless case, it also publishes a milestone percentage
787
+ // over a real unread outstanding row. `SCOPE.TRUNCATED` means the scan
788
+ // could not SEE part of the evidence (src/planning-scope.cts), which is
789
+ // exactly the fence-straddled case. The accepted over-report itself is
790
+ // unchanged and still documented at src/uat.cts; what changed is only
791
+ // that it no longer buys an exemption from the fold.
792
+ const { items: fileItems, headingsSeen } = parseUatItemsWithStats(doc.text);
793
+ items.push(...fileItems);
794
+ if (headingsSeen > 0) {
795
+ diagnostics.push({
796
+ code: INSPECT_DIAGNOSTIC.UAT_UNREADABLE,
797
+ subject: `${phaseDirName}/${file}`,
798
+ detail: `UAT document has ${headingsSeen} test block(s) with no parseable result; unresolved is not a complete answer.`,
799
+ });
800
+ scope = SCOPE.TRUNCATED;
801
+ foldScope = SCOPE.TRUNCATED;
802
+ }
803
+ }
804
+ return { items, scope, foldScope };
805
+ }
806
+ // ─── Progress ─────────────────────────────────────────────────────────────────
807
+ /**
808
+ * ADR-3180 §7.6: the arithmetic is `clampPercent`'s alone (rule 1), a
809
+ * non-positive denominator is 0 not 100 (rule 2), numerator and denominator
810
+ * come from one scoped set (rule 3), and a scope other than COMPLETE renders NO
811
+ * percentage at all (rule 4).
812
+ */
813
+ function makeFraction(completed, total, scope, subject, diagnostics) {
814
+ if (scope !== SCOPE.COMPLETE) {
815
+ diagnostics.push({
816
+ code: INSPECT_DIAGNOSTIC.PERCENT_WITHHELD,
817
+ subject,
818
+ detail: `Scope is "${scope}"; a percentage derived from an incomplete read would be a confident wrong answer.`,
819
+ });
820
+ return { completed, total, percent: null, scope };
821
+ }
822
+ return { completed, total, percent: clampPercent(completed, total), scope };
823
+ }
824
+ /**
825
+ * The active plan position, from STATE.md's `## Current Position` block.
826
+ *
827
+ * ADR-3180 §7.7: `stateFieldValue` owns the #1760 frontmatter-then-body
828
+ * fallback ladder — this composes it, it does not re-derive it. The section
829
+ * slice is load-bearing: `Plan:` canonically lives under `## Current Position`,
830
+ * and a whole-document search would match a historical `Plan:` line in an
831
+ * archive section instead (#2956). A missing section is therefore reported as
832
+ * UNSCOPED rather than silently widened to the whole body.
833
+ */
834
+ function buildActivePlan(statePath, planningRoot) {
835
+ const doc = readDocument(statePath, planningRoot);
836
+ if (!doc.exists)
837
+ return { value: null, scope: SCOPE.UNSCOPED };
838
+ if (!doc.readable || doc.text === null)
839
+ return { value: null, scope: SCOPE.UNREADABLE };
840
+ const fm = extractFrontmatter(doc.text, statePath);
841
+ const body = stripFrontmatter(doc.text);
842
+ const slice = (0, state_document_cjs_1.stateCurrentPositionSlice)(body);
843
+ return (0, state_document_cjs_1.stateFieldValue)(fm, slice ?? body, 'plan', 'Plan', {
844
+ scope: slice === null ? SCOPE.UNSCOPED : SCOPE.COMPLETE,
845
+ });
846
+ }
847
+ /**
848
+ * The same `**Depends on:**` line `src/phase.cts`'s phase-insert path writes
849
+ * (`\n**Depends on:** Phase ${afterPhase}`) and `init.cts`'s own
850
+ * phase-listing scan reads (`init.cts:2359`) — mirrored verbatim here rather
851
+ * than re-derived.
852
+ */
853
+ const DEPENDS_ON_LINE_RE = /\*\*Depends on(?::\*\*|\*\*:)\s*([^\n]+)/i;
854
+ /**
855
+ * A bold-annotation line — `**Label:** …` (colon inside the bold run) or
856
+ * `**Label**: …` (colon immediately after it). Matches `**Depends on:**`,
857
+ * `**Plans**:`, `**Goal:**`, and `**Cross-cutting constraints:**` alike.
858
+ */
859
+ const BOLD_ANNOTATION_LINE_RE = /^\*\*(?:[^*\n]*:[^*\n]*\*\*|[^*\n]*\*\*:)/;
860
+ /** The bare `Plans:` checklist header `cmdRoadmapAnnotateDependencies` emits. */
861
+ const PLANS_CHECKLIST_HEADER_RE = /^plans:$/i;
862
+ /**
863
+ * The prose immediately under a `### Phase N: Name` heading, stopping at the
864
+ * first line that is METADATA rather than prose — issue #2790's own
865
+ * definition of per-phase "goal". The boundary matters because this payload
866
+ * already surfaces every one of those metadata lines in its own typed field
867
+ * (`**Depends on:**` -> `dependencies`, `**Plans**:` / `Plans:` + `- [ ]`
868
+ * rows -> `plans`, wave headers and `**Cross-cutting constraints:**` -> the
869
+ * per-plan rows) — folding them into `goal` too would both duplicate the
870
+ * data and hand a consumer raw Markdown to render. A sub-heading boundary is
871
+ * belt-and-braces: `collectSection` already bounds the body at the next
872
+ * heading.
873
+ *
874
+ * `null` when the section carries no such prose before hitting metadata (a
875
+ * real "no goal", not a failure).
876
+ */
877
+ function extractGoalProse(sectionBody) {
878
+ const proseLines = [];
879
+ for (const line of sectionBody.split(/\r?\n/)) {
880
+ const trimmed = line.trim();
881
+ if (/^\s{0,3}#{1,6}\s/.test(line))
882
+ break;
883
+ if (/^\s*(?:[-*+]|\d+[.)])\s/.test(line))
884
+ break;
885
+ if (BOLD_ANNOTATION_LINE_RE.test(trimmed))
886
+ break;
887
+ if (PLANS_CHECKLIST_HEADER_RE.test(trimmed))
888
+ break;
889
+ proseLines.push(line);
890
+ }
891
+ const text = proseLines.join('\n').trim();
892
+ return text.length > 0 ? text : null;
893
+ }
894
+ /**
895
+ * Phase tokens off a `**Depends on:**` line — value-level token extraction
896
+ * of ONE already-addressed line (mirrors `parseTraceability`'s Phase-cell
897
+ * token scan above), never a document-wide scan. `[]` when the line is
898
+ * absent.
899
+ */
900
+ function extractDependencyTokens(sectionBody) {
901
+ const m = DEPENDS_ON_LINE_RE.exec(sectionBody);
902
+ if (!m)
903
+ return [];
904
+ return sortedUnique([...m[1].matchAll(/\d+(?:\.\d+)*/g)].map((t) => t[0]));
905
+ }
906
+ /**
907
+ * This phase's own ROADMAP.md section body — milestone-scoped via the SAME
908
+ * `extractCurrentMilestone` seam `planning-snapshot.cts`'s own ROADMAP
909
+ * consumers use (`planning-snapshot.cts:918`), never a document-wide walk.
910
+ * `collectSection` (ADR-2143) owns the heading walk; the predicate is built
911
+ * from the canonical `phaseMarkdownRegexSource` (`phase-id.cts`) anchor —
912
+ * the same start-of-heading anchor `roadmap-parser.cts`'s own phase-section
913
+ * lookups (`withPhaseSection`, `findRoadmapPhaseInContent`) use, so a
914
+ * sibling phase whose TITLE merely mentions this phase's number is never
915
+ * hijacked.
916
+ */
917
+ function findPhaseRoadmapSection(cwd, roadmapText, phaseId) {
918
+ const scoped = extractCurrentMilestone(roadmapText, cwd);
919
+ const headingRe = new RegExp(`^(?:\\[[^\\]]{1,200}\\]\\s*)?Phase\\s+${phaseMarkdownRegexSource(phaseId)}(?=[\\s:(]|$)`, 'i');
920
+ const section = collectSection(scoped, (h) => headingRe.test(h.text));
921
+ return section ? section.body : null;
922
+ }
923
+ /**
924
+ * Issue #2790's Summary names "per-phase goal/dependency" as spec elements
925
+ * distinct from the STATUS evidence (`verification` / `roadmap_acceptance` /
926
+ * `uat`) phase rows already carry. Both fields fold the SAME three-way scope
927
+ * every other value in this module carries: `complete` when the section was
928
+ * found and parsed, `unscoped` when ROADMAP.md has no section for this
929
+ * phase, `unreadable` when ROADMAP.md itself could not be read. Never
930
+ * inferred — an absent `**Depends on:**` line is `[]`, not a degraded scope.
931
+ *
932
+ * `getRoadmapPhaseWithFallback` (`roadmap.cts`) and `findRoadmapPhaseInContent`
933
+ * (`roadmap-parser.cts`) were considered first (per this module's own
934
+ * "COMPOSED, NOT RE-DERIVED" rule) but both perform their OWN internal file
935
+ * read, bypassing this module's `readDocument` tri-state (exists/readable/
936
+ * text) — the exact distinction `unreadable` vs `unscoped` needs here, and
937
+ * the one every other section of this module gets via the same seam. Their
938
+ * `goal` extraction also targets an explicit `**Goal:**` bold line, not the
939
+ * free prose immediately under the heading the issue's own fixture expects.
940
+ * `collectSection` + `extractCurrentMilestone`, fed by this module's own
941
+ * `readDocument(paths.roadmap)` read, keeps both the read path and the
942
+ * "found vs missing vs unreadable" distinction singular.
943
+ */
944
+ function buildPhaseGoalAndDependencies(cwd, roadmapDoc, phaseId, phaseDirLabel, diagnostics) {
945
+ if (!roadmapDoc.readable || roadmapDoc.text === null) {
946
+ diagnostics.push({
947
+ code: INSPECT_DIAGNOSTIC.ROADMAP_UNSCOPED,
948
+ subject: phaseDirLabel,
949
+ detail: 'ROADMAP.md could not be read; phase goal and dependencies are unknown, not empty.',
950
+ });
951
+ return {
952
+ goal: { value: null, scope: SCOPE.UNREADABLE },
953
+ dependencies: { value: [], scope: SCOPE.UNREADABLE },
954
+ };
955
+ }
956
+ if (phaseId === null) {
957
+ diagnostics.push({
958
+ code: INSPECT_DIAGNOSTIC.ROADMAP_UNSCOPED,
959
+ subject: phaseDirLabel,
960
+ detail: 'Phase directory name carries no recognizable phase number; ROADMAP section cannot be located.',
961
+ });
962
+ return {
963
+ goal: { value: null, scope: SCOPE.UNSCOPED },
964
+ dependencies: { value: [], scope: SCOPE.UNSCOPED },
965
+ };
966
+ }
967
+ const sectionBody = findPhaseRoadmapSection(cwd, roadmapDoc.text, phaseId);
968
+ if (sectionBody === null) {
969
+ diagnostics.push({
970
+ code: INSPECT_DIAGNOSTIC.ROADMAP_UNSCOPED,
971
+ subject: phaseDirLabel,
972
+ detail: 'ROADMAP.md has no section for this phase; goal and dependencies are non-answers, not empty.',
973
+ });
974
+ return {
975
+ goal: { value: null, scope: SCOPE.UNSCOPED },
976
+ dependencies: { value: [], scope: SCOPE.UNSCOPED },
977
+ };
978
+ }
979
+ return {
980
+ goal: { value: extractGoalProse(sectionBody), scope: SCOPE.COMPLETE },
981
+ dependencies: { value: extractDependencyTokens(sectionBody), scope: SCOPE.COMPLETE },
982
+ };
983
+ }
984
+ // ─── Entry points ─────────────────────────────────────────────────────────────
985
+ function buildPlanningInspect(cwd) {
986
+ const diagnostics = [];
987
+ const paths = planningPaths(cwd);
988
+ const planningExists = node_fs_1.default.existsSync(paths.planning);
989
+ if (!planningExists) {
990
+ diagnostics.push({
991
+ code: INSPECT_DIAGNOSTIC.PLANNING_ROOT_ABSENT,
992
+ subject: toPosix(paths.planning),
993
+ detail: 'No .planning/ directory; every section below is an empty non-answer, not an empty project.',
994
+ });
995
+ }
996
+ const snapshot = buildPlanningSnapshot(cwd);
997
+ if (snapshot.milestone.scope !== SCOPE.COMPLETE) {
998
+ diagnostics.push({
999
+ code: INSPECT_DIAGNOSTIC.ROADMAP_UNSCOPED,
1000
+ subject: toPosix(paths.roadmap),
1001
+ detail: `Milestone identity scope is "${snapshot.milestone.scope}"; no version is invented to stand in for it.`,
1002
+ });
1003
+ }
1004
+ const milestoneValue = snapshot.milestone.value;
1005
+ // Phase rows come from the WINDOWED set (this milestone's phases). A dir on
1006
+ // disk that the roadmap never declares is an orphan, reported separately —
1007
+ // it is not silently promoted into `phases`, and it is not silently dropped.
1008
+ const windowed = snapshot.phaseDirs.value;
1009
+ const windowedSet = new Set(windowed);
1010
+ const orphans = snapshot.allPhaseDirNames.value
1011
+ .filter((dir) => !windowedSet.has(dir))
1012
+ .sort();
1013
+ for (const orphan of orphans) {
1014
+ diagnostics.push({
1015
+ code: INSPECT_DIAGNOSTIC.ORPHAN_PHASE_DIR,
1016
+ subject: orphan,
1017
+ detail: 'Phase directory exists on disk but is not declared in the current milestone window.',
1018
+ });
1019
+ }
1020
+ const checkboxes = snapshot.roadmapPhaseCheckboxes.value;
1021
+ // ROADMAP checkboxes arrive keyed by the BARE phase token the ROADMAP prose
1022
+ // carries ("1"), while phase rows are keyed by on-disk directory name
1023
+ // ("01-auth"). Comparing them raw makes this field null for every
1024
+ // real-world directory — an evidence channel that silently never fires.
1025
+ // Both sides go through the phase-id owners so "1", "01" and "01-auth" are
1026
+ // one phase.
1027
+ const checkboxByPhaseKey = new Map();
1028
+ for (const [token, ticked] of Object.entries(checkboxes)) {
1029
+ checkboxByPhaseKey.set(phaseKeyFromToken(token), ticked);
1030
+ }
1031
+ const phaseSnapshots = snapshot.phases.value;
1032
+ // Canonical per-directory identity keys (`phase-id.cts`'s
1033
+ // `phaseKeyFromDir`), not a raw regex scrape — this is the set
1034
+ // `buildRequirements` below matches Traceability tokens against via
1035
+ // `phaseKeyFromToken`.
1036
+ const knownPhaseKeys = new Set(windowed.map((dir) => phaseKeyFromDir(dir)));
1037
+ // Read once, shared across every phase row's goal/dependencies lookup —
1038
+ // the same `readDocument` seam every other document read in this module
1039
+ // uses, never a second file-reading path.
1040
+ const roadmapDoc = readDocument(paths.roadmap, paths.planning);
1041
+ const phaseRows = phaseSnapshots.map((phase) => {
1042
+ const phaseDir = node_path_1.default.join(paths.phases, phase.dir);
1043
+ const plans = buildPlanRows(phaseDir, diagnostics, paths.planning);
1044
+ const uat = buildUatRows(paths.phases, phase.dir, diagnostics, paths.planning);
1045
+ // GAP 2 (#2790 follow-up security review): `readVerificationStatus`
1046
+ // (`src/verification.cts`) is a shared owner with its own unguarded
1047
+ // `readFileSync` — a `*-VERIFICATION.md` symlinked outside the planning
1048
+ // root would leak an unrecognized `status:` value verbatim via its
1049
+ // "Unexpected verification status '<value>'" `next_action` string. Fixed
1050
+ // from THIS consumer's side via the injectable `opts.fs` seam that
1051
+ // function already exposes, never by touching its signature — see
1052
+ // `containmentEnforcingVerificationFs`'s doc comment. This same seam's
1053
+ // `readdirSync(phaseDir)` guard also independently covers the
1054
+ // escaped-phase-DIRECTORY case for this call site (GAP 1 above only
1055
+ // gates `buildPlanRows`/`buildUatRows`, not this one).
1056
+ //
1057
+ // `src/plan-scan.cts`'s `isPlanSuperseded` similarly reads
1058
+ // symlink-followed content with no containment of its own, but it is
1059
+ // reached only via `scanPhasePlans(phaseDir)` inside `buildPlanRows`
1060
+ // above (never touched here), leaks only a derived boolean
1061
+ // (`superseded`) rather than document text, and GAP 1's directory
1062
+ // containment check already covers the escaped-DIRECTORY case for it —
1063
+ // so it needs no fix of its own.
1064
+ const verification = readVerificationStatus(phaseDir, {
1065
+ fs: containmentEnforcingVerificationFs(paths.planning),
1066
+ });
1067
+ const token = /^(\d+(?:\.\d+)*)/.exec(phase.dir);
1068
+ const phaseId = token ? token[1] : null;
1069
+ const { goal, dependencies } = buildPhaseGoalAndDependencies(cwd, roadmapDoc, phaseId, phase.dir, diagnostics);
1070
+ // `uat.foldScope`, NOT `uat.scope` (#3078 round-8). The two currently
1071
+ // agree at every call site — see `buildUatRows` for why the field is
1072
+ // still kept separate — so folding either one here produces the same
1073
+ // result today. `foldScope` is used because it is the field with teeth:
1074
+ // it is what `worstScope` folds into the phase's overall scope, and a
1075
+ // non-COMPLETE result here raises `phase_scope_degraded` and, via
1076
+ // `makeFraction`, withholds the milestone's percentages. A phase whose
1077
+ // UAT document could not be fully read must not contribute an
1078
+ // affirmative completion to the milestone.
1079
+ const folded = worstScope(phase.scope, plans.scope, uat.foldScope, goal.scope, dependencies.scope);
1080
+ if (folded !== SCOPE.COMPLETE) {
1081
+ diagnostics.push({
1082
+ code: INSPECT_DIAGNOSTIC.PHASE_SCOPE_DEGRADED,
1083
+ subject: phase.dir,
1084
+ detail: `Phase evidence is incomplete (scope "${folded}").`,
1085
+ });
1086
+ }
1087
+ return {
1088
+ dir: phase.dir,
1089
+ phase_id: phaseId,
1090
+ complete: phase.complete,
1091
+ goal,
1092
+ dependencies,
1093
+ // The three evidence sources are reported SIDE BY SIDE and never folded
1094
+ // into one verdict — folding them is precisely the confidently-wrong
1095
+ // composite ADR-3180 exists to remove.
1096
+ verification: {
1097
+ status: verification.status,
1098
+ next_action: verification.next_action ?? null,
1099
+ },
1100
+ roadmap_acceptance: {
1101
+ checkbox: checkboxByPhaseKey.has(phaseKeyFromDir(phase.dir))
1102
+ ? checkboxByPhaseKey.get(phaseKeyFromDir(phase.dir))
1103
+ : null,
1104
+ // ADR-3180 §7.4: a ticked checkbox is a human annotation with no
1105
+ // machine authority. Stated in the payload so a consumer cannot
1106
+ // mistake it for a completion signal.
1107
+ authoritative: false,
1108
+ },
1109
+ uat: { unresolved: uat.items, scope: uat.scope },
1110
+ plan_count: phase.planCount,
1111
+ summary_count: phase.summaryCount,
1112
+ plans: plans.rows,
1113
+ scope: folded,
1114
+ };
1115
+ });
1116
+ const requirements = buildRequirements(paths.requirements, knownPhaseKeys, diagnostics, paths.planning);
1117
+ const phaseScope = worstScope(snapshot.phaseDirs.scope, snapshot.phases.scope, ...phaseRows.map((p) => p.scope));
1118
+ const acceptedPhases = makeFraction(phaseRows.filter((p) => p.complete).length, phaseRows.length, phaseScope, 'progress.accepted_phases', diagnostics);
1119
+ const completedPlans = makeFraction(phaseRows.reduce((sum, p) => sum + p.summary_count, 0), phaseRows.reduce((sum, p) => sum + p.plan_count, 0), phaseScope, 'progress.completed_plans', diagnostics);
1120
+ return {
1121
+ schema_version: PLANNING_INSPECT_SCHEMA_VERSION,
1122
+ generated_from: {
1123
+ cwd: toPosix(cwd),
1124
+ planning_root: planningExists ? toPosix(paths.planning) : null,
1125
+ },
1126
+ milestone: {
1127
+ version: milestoneValue && milestoneValue.version ? milestoneValue.version : null,
1128
+ name: milestoneValue && milestoneValue.name ? milestoneValue.name : null,
1129
+ scope: snapshot.milestone.scope,
1130
+ },
1131
+ // Three DISTINCT STATE.md facts, each from its own source. Collapsing any
1132
+ // two of them is the confidently-wrong composite this schema exists to
1133
+ // avoid: `Status:` is a lifecycle label, `Plan:` is a position.
1134
+ active: {
1135
+ phase: { value: snapshot.currentPhaseLabel.value, scope: snapshot.currentPhaseLabel.scope },
1136
+ plan: buildActivePlan(paths.state, paths.planning),
1137
+ status: { value: snapshot.stateStatus.value, scope: snapshot.stateStatus.scope },
1138
+ },
1139
+ phases: phaseRows,
1140
+ orphan_phase_dirs: orphans,
1141
+ requirements: requirements.rows,
1142
+ progress: {
1143
+ accepted_phases: acceptedPhases,
1144
+ completed_plans: completedPlans,
1145
+ },
1146
+ diagnostics,
1147
+ };
1148
+ }
1149
+ /**
1150
+ * `planning inspect` — emit the schema-v1 snapshot.
1151
+ *
1152
+ * `output()` is the spill seam: a payload over 50 KB is written to a tmpfile and
1153
+ * returned as `@file:<path>`, which `gsd-tools`' `resolveAtFileOutput` resolves
1154
+ * transparently on stdout. Bypassing `output()` would lose that for free.
1155
+ */
1156
+ function cmdPlanningInspect(cwd, raw) {
1157
+ output(buildPlanningInspect(cwd), raw);
1158
+ }
1159
+ const planningInspect = {
1160
+ PLANNING_INSPECT_SCHEMA_VERSION,
1161
+ INSPECT_DIAGNOSTIC,
1162
+ TASK_STATUS,
1163
+ PROVENANCE,
1164
+ AGREEMENT,
1165
+ buildPlanningInspect,
1166
+ cmdPlanningInspect,
1167
+ };
1168
+ module.exports = planningInspect;