@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,836 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * Generates `docs/FEATURES.md` — BOTH its table of contents and every feature
6
+ * section body — from per-feature fragments under `docs/features/`.
7
+ *
8
+ * WHY THIS EXISTS (#3840). `docs/FEATURES.md` was hand-maintained, and every
9
+ * feature PR had to write into TWO shared mutable cells in it: the `### N.`
10
+ * heading (whose integer was hand-allocated at authoring time, so concurrent
11
+ * PRs all picked the same next number) and the hand-maintained table of
12
+ * contents (which collides even between PRs that picked DIFFERENT numbers).
13
+ * #3831 was renumbered 165 -> 166 -> 167 -> 168 across successive rebases, the
14
+ * last collision landing mid-verification; because every rebase invalidates the
15
+ * sha-keyed pass marker, each collision also cost a full remote matrix run.
16
+ *
17
+ * The fix is the pattern this repo already uses twice for exactly this problem
18
+ * (`.changeset/` for CHANGELOG.md, `tests/emitted-drift-acks/` for #2914): one
19
+ * file per contribution, consolidated by a generator. A contributor adds ONE
20
+ * new file under `docs/features/` and touches no shared file, so there is
21
+ * nothing to collide on. `docs/FEATURES.md` itself becomes a DERIVED artifact:
22
+ * when two branches both regenerate it the conflict is resolved by re-running
23
+ * `--write`, not by hand-renumbering and re-editing a TOC.
24
+ *
25
+ * NUMBER ALLOCATION. `id` is declared in the fragment's own frontmatter and is
26
+ * FROZEN once merged, so inbound `#N-slug` anchors from other docs keep
27
+ * resolving. It does NOT have to be contiguous or maximal — the corpus already
28
+ * skips 58, 113 and 131 and carries the non-integer ids `6.5`, `27a` and `27b`.
29
+ * The only rule is uniqueness, which `--check` enforces with a typed violation
30
+ * rather than leaving a human to notice. Because any unique id is legal, an
31
+ * author can use their issue number and never revisit the choice after a
32
+ * rebase: the number-chase is gone, not merely serialized.
33
+ *
34
+ * GROUPS ARE DERIVED TOO. A fragment names its `group` (the `##` heading) and
35
+ * groups are ordered by their lowest-ordered member, so there is no shared
36
+ * group list to edit either. Optional per-group prose lives in
37
+ * `docs/features/_groups/<slug>.md`, which a feature-adding PR never touches.
38
+ *
39
+ * Invariants enforced (see CONTRIBUTING.md "Adding a feature to
40
+ * `docs/FEATURES.md`"):
41
+ * 1. Every fragment declares `id`, `title` and `group` in its frontmatter.
42
+ * 2. Ids are unique across the corpus, and so are the anchors they generate.
43
+ * 3. A fragment body never opens a heading at depth <= 3 — `##` would forge a
44
+ * group and `###` would forge a sibling section, both silently.
45
+ * 4. Every `_groups/` note names a group that some fragment is a member of.
46
+ * 5. Every inbound `FEATURES.md#anchor` link from the repo's own markdown
47
+ * resolves to a heading this generator emits.
48
+ * 6. The committed `docs/FEATURES.md` equals the generated one.
49
+ *
50
+ * Invariant 5 is what makes the number freeze REAL rather than a promise. This
51
+ * repo has no link checker, so broken anchors shipped silently: the migration
52
+ * found `FEATURES.md#runtime-identity` (never a heading) and
53
+ * `#143-spec-phase-edge-completeness-probe` (off by one) already live on `next`.
54
+ * Since a fragment can now change its own `id` in a one-line edit, an unchecked
55
+ * anchor would be a much easier thing to break than it was to break by hand.
56
+ *
57
+ * Usage:
58
+ * node scripts/gen-features.cjs # print the generated region to stdout
59
+ * node scripts/gen-features.cjs --write # rewrite the region in docs/FEATURES.md
60
+ * node scripts/gen-features.cjs --check # exit 1 if stale or invalid
61
+ * node scripts/gen-features.cjs --json # --check semantics; JSON report on stdout
62
+ * node scripts/gen-features.cjs --write --force # write despite violations
63
+ */
64
+
65
+ const fs = require('node:fs');
66
+ const path = require('node:path');
67
+
68
+ const { ExitError, runMain } = require('./lib/cli-exit.cjs');
69
+
70
+ const ROOT = path.resolve(__dirname, '..');
71
+ const FEATURES_DIR = path.join(ROOT, 'docs', 'features');
72
+ const GROUP_NOTES_DIR = path.join(FEATURES_DIR, '_groups');
73
+ const FEATURES_PATH = path.join(ROOT, 'docs', 'FEATURES.md');
74
+
75
+ const START_MARKER =
76
+ '<!-- FEATURES:START — generated by scripts/gen-features.cjs; do not edit by hand -->';
77
+ const END_MARKER = '<!-- FEATURES:END -->';
78
+
79
+ /**
80
+ * The shallowest heading depth a fragment BODY may open.
81
+ *
82
+ * `##` and `###` are structural in the rendered document: the generator emits
83
+ * `## <group>` and `### <id>. <title>` itself, so a body line at either depth
84
+ * would forge a group or a sibling section that has no fragment, no id and no
85
+ * TOC entry — and would do it silently, because markdown renders it fine.
86
+ * `####` and deeper nest INSIDE a section and are always legal.
87
+ */
88
+ const MIN_BODY_HEADING_DEPTH = 4;
89
+
90
+ /**
91
+ * Stable reason codes for every violation this gate can emit.
92
+ *
93
+ * Tests assert `assert.equal(v.reason, REASON.X)` over the `--json`
94
+ * `violations` array rather than regex-matching stderr prose — see
95
+ * CONTRIBUTING.md "Prohibited: Raw Text Matching on Test Outputs".
96
+ */
97
+ const REASON = Object.freeze({
98
+ FILENAME_INVALID: 'filename_invalid',
99
+ FRONTMATTER_MISSING: 'frontmatter_missing',
100
+ FIELD_MISSING: 'field_missing',
101
+ FIELD_UNKNOWN: 'field_unknown',
102
+ ID_INVALID: 'id_invalid',
103
+ ID_DUPLICATE: 'id_duplicate',
104
+ ORDER_INVALID: 'order_invalid',
105
+ ANCHOR_DUPLICATE: 'anchor_duplicate',
106
+ BODY_EMPTY: 'body_empty',
107
+ BODY_HEADING_TOO_SHALLOW: 'body_heading_too_shallow',
108
+ GROUP_NOTE_ORPHAN: 'group_note_orphan',
109
+ GROUP_NOTE_DUPLICATE: 'group_note_duplicate',
110
+ INBOUND_ANCHOR_UNRESOLVED: 'inbound_anchor_unresolved',
111
+ BODY_FORGES_REGION_MARKER: 'body_forges_region_marker',
112
+ DIRENT_NOT_REGULAR_FILE: 'dirent_not_regular_file',
113
+ DIRENT_UNREADABLE: 'dirent_unreadable',
114
+ });
115
+
116
+ /**
117
+ * Substrings a fragment body may never contain.
118
+ *
119
+ * `spliceIntoFeatures` finds the region boundaries by scanning `docs/FEATURES.md`
120
+ * for these markers. A fragment that PLANTS one gets it rendered verbatim into
121
+ * the generated region, where it becomes an earlier (or later) match than the
122
+ * real boundary on the NEXT run — so a subsequent `--write` splices against the
123
+ * forged boundary and silently freezes everything past it as "hand-authored",
124
+ * a corruption that survives deleting the offending fragment. Fragments arrive
125
+ * through fork PRs, so this is attacker-reachable input, not a typo class.
126
+ *
127
+ * Defence is in depth: this rejects the forgery at the source, and
128
+ * `spliceIntoFeatures` independently anchors on the LAST end marker so a
129
+ * marker that reaches the document some other way still cannot shrink the
130
+ * region it governs.
131
+ */
132
+ const FORBIDDEN_BODY_SUBSTRINGS = Object.freeze(['<!-- FEATURES:START', '<!-- FEATURES:END']);
133
+
134
+ /** Which forbidden marker (if any) `body` contains. */
135
+ function forgedRegionMarker(body) {
136
+ return FORBIDDEN_BODY_SUBSTRINGS.find((marker) => String(body).includes(marker)) || null;
137
+ }
138
+
139
+ /**
140
+ * Directories the inbound-anchor scan walks, relative to the repo root.
141
+ *
142
+ * Deliberately does NOT include `.planning/`, `node_modules/`, or anything
143
+ * outside version control. Locale subtrees under `docs/` ARE walked, but their
144
+ * links are filtered by RESOLVED TARGET (below), so `docs/ja-JP/x.md` pointing
145
+ * at its own sibling `docs/ja-JP/FEATURES.md` is correctly ignored — those
146
+ * translations carry different section counts and are not in this gate's scope.
147
+ */
148
+ const LINK_SCAN_DIRS = Object.freeze(['docs']);
149
+ const LINK_SCAN_ROOT_FILES = Object.freeze(['README.md', 'CONTRIBUTING.md', 'CONTEXT.md']);
150
+
151
+ /** `[text](path/FEATURES.md#anchor)` — the only inbound form this gate checks. */
152
+ const INBOUND_LINK_RE = /\(([^()\s]*FEATURES\.md)#([A-Za-z0-9._-]+)\)/g;
153
+
154
+ /** Frontmatter fields a feature fragment may declare. */
155
+ const FRAGMENT_FIELDS = Object.freeze(['id', 'title', 'group', 'order']);
156
+ /** Frontmatter fields a group-note fragment may declare. */
157
+ const GROUP_NOTE_FIELDS = Object.freeze(['group']);
158
+
159
+ /**
160
+ * A legal feature id: an integer, optionally followed by a single lowercase
161
+ * letter (`27a`) or a single decimal part (`6.5`). All three shapes are LIVE in
162
+ * the corpus today — freezing the migrated numbers required accepting them, and
163
+ * the letter/decimal forms are exactly how a feature gets inserted between two
164
+ * already-published numbers without renumbering either.
165
+ */
166
+ const ID_RE = /^(?:0|[1-9][0-9]*)(?:\.[0-9]+|[a-z])?$/;
167
+
168
+ /**
169
+ * A legal explicit `order`: an optionally-signed decimal literal.
170
+ *
171
+ * `order` was the only field validated by COERCION rather than by shape, and
172
+ * `Number()` is far more liberal than a docs ordering field has reason to be:
173
+ * `Number('')` and `Number(' ')` are 0, and `0x10`, `0b11`, `0o17`, `1e3`, `1.`
174
+ * and `.5` all coerce to finite numbers. A fragment declaring `order:` with
175
+ * nothing after it therefore sorted to position 0 — ahead of every real
176
+ * feature — with no violation and exit 0, in a gate whose whole contract is a
177
+ * typed violation rather than a silent guess. Shape first, then coerce,
178
+ * mirroring how ID_RE guards `id`.
179
+ */
180
+ const ORDER_RE = /^[+-]?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?$/;
181
+
182
+ /** The fragment filename shape: a kebab slug, mirroring `docs/adr/`'s rule. */
183
+ const FRAGMENT_FILENAME_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*\.md$/;
184
+
185
+ const FRONTMATTER_KEY_RE = /^[a-z][a-z0-9_-]*$/;
186
+
187
+ /**
188
+ * GitHub's heading-anchor algorithm, as applied to the FULL rendered heading
189
+ * text (`27b. Existing Codebase Onboarding` -> `27b-existing-codebase-onboarding`).
190
+ *
191
+ * Deriving the anchor from the same string that is rendered is the whole point:
192
+ * a hand-written TOC could disagree with its heading and nothing would notice,
193
+ * which is how `docs/FEATURES.md` came to be missing TOC entries for `6.5`,
194
+ * `27a` and every section from 163 up. Here the two cannot diverge.
195
+ *
196
+ * Reproduces GitHub's rule: lowercase, drop everything that is not a word
197
+ * character, a space or a hyphen, then map spaces to hyphens. Note the drops
198
+ * happen BEFORE the space->hyphen mapping, which is why `Token Count & Git`
199
+ * yields the double hyphen in `#152-statusline-token-count--git-segment`.
200
+ */
201
+ function slugify(headingText) {
202
+ return String(headingText)
203
+ .toLowerCase()
204
+ .replace(/[^\w\s-]/g, '')
205
+ .replace(/\s/g, '-');
206
+ }
207
+
208
+ /**
209
+ * Render a frontmatter value.
210
+ *
211
+ * JSON-quoted only when a bare form would not survive the round trip: an empty
212
+ * value, one with significant leading/trailing whitespace, one that would be
213
+ * mistaken for a quoted value, or one carrying a newline (which would forge a
214
+ * second frontmatter line). Everything else stays bare so the common case
215
+ * reads as ordinary YAML.
216
+ */
217
+ function renderScalar(value) {
218
+ const s = String(value);
219
+ if (s === '' || s !== s.trim() || s.startsWith('"') || /[\r\n]/.test(s)) return JSON.stringify(s);
220
+ return s;
221
+ }
222
+
223
+ /** Inverse of `renderScalar`. */
224
+ function parseScalar(raw) {
225
+ if (raw.startsWith('"')) {
226
+ try {
227
+ const parsed = JSON.parse(raw);
228
+ if (typeof parsed === 'string') return parsed;
229
+ } catch {
230
+ // Fall through: an unparseable quoted-looking value is taken literally
231
+ // rather than crashing the whole corpus scan on one bad fragment.
232
+ }
233
+ }
234
+ return raw;
235
+ }
236
+
237
+ /**
238
+ * Serialize `{key: string}` into a fragment's `---`-delimited frontmatter block
239
+ * plus body. The exact inverse of `parseFrontmatter`; the pair is property
240
+ * tested for bijectivity.
241
+ */
242
+ function renderFrontmatter(data, body) {
243
+ const lines = ['---'];
244
+ for (const [key, value] of Object.entries(data)) lines.push(`${key}: ${renderScalar(value)}`);
245
+ lines.push('---', '');
246
+ return `${lines.join('\n')}\n${body}`;
247
+ }
248
+
249
+ /**
250
+ * Split fragment text into `{data, body}`.
251
+ *
252
+ * Returns `data: null` when the document does not open with a `---` fence — the
253
+ * caller turns that into a typed FRONTMATTER_MISSING violation rather than
254
+ * guessing at a body-only fragment.
255
+ */
256
+ function parseFrontmatter(text) {
257
+ const normalized = String(text).replace(/\r\n/g, '\n');
258
+ if (!normalized.startsWith('---\n')) return { data: null, body: normalized };
259
+
260
+ const end = normalized.indexOf('\n---\n', 3);
261
+ if (end === -1) return { data: null, body: normalized };
262
+
263
+ const data = {};
264
+ for (const line of normalized.slice(4, end + 1).split('\n')) {
265
+ if (line.trim() === '') continue;
266
+ const sep = line.indexOf(':');
267
+ if (sep === -1) continue;
268
+ const key = line.slice(0, sep).trim();
269
+ if (!FRONTMATTER_KEY_RE.test(key)) continue;
270
+ data[key] = parseScalar(line.slice(sep + 1).trim());
271
+ }
272
+
273
+ // `+5` clears "\n---\n"; the blank line renderFrontmatter emits after the
274
+ // closing fence is consumed here so body text starts at its first real line.
275
+ let body = normalized.slice(end + 5);
276
+ if (body.startsWith('\n')) body = body.slice(1);
277
+ return { data, body };
278
+ }
279
+
280
+ /**
281
+ * Line numbers (1-based, relative to `body`) of every heading opened at a depth
282
+ * shallower than `MIN_BODY_HEADING_DEPTH`, ignoring fenced code blocks.
283
+ *
284
+ * Fence tracking is not decoration: several migrated bodies embed shell and
285
+ * markdown samples, and a `## ` inside a fenced sample is content, not a
286
+ * forged group heading. Both ``` and ~~~ fences are honored, and a fence only
287
+ * closes on a marker of the same character at least as long as the opener —
288
+ * the CommonMark rule, so a ```` ``` ```` inside a ```` ```` ```` block does
289
+ * not end it.
290
+ */
291
+ function shallowBodyHeadings(body) {
292
+ const hits = [];
293
+ let fenceChar = null;
294
+ let fenceLen = 0;
295
+
296
+ String(body)
297
+ .split('\n')
298
+ .forEach((line, i) => {
299
+ const fence = /^\s{0,3}(`{3,}|~{3,})/.exec(line);
300
+ if (fence) {
301
+ const [char, len] = [fence[1][0], fence[1].length];
302
+ if (fenceChar === null) {
303
+ fenceChar = char;
304
+ fenceLen = len;
305
+ return;
306
+ }
307
+ if (char === fenceChar && len >= fenceLen && line.slice(fence[0].length).trim() === '') {
308
+ fenceChar = null;
309
+ fenceLen = 0;
310
+ }
311
+ return;
312
+ }
313
+ if (fenceChar !== null) return;
314
+ const heading = /^(#{1,6})\s/.exec(line);
315
+ if (heading && heading[1].length < MIN_BODY_HEADING_DEPTH) {
316
+ hits.push({ line: i + 1, depth: heading[1].length });
317
+ }
318
+ });
319
+
320
+ return hits;
321
+ }
322
+
323
+ /**
324
+ * Default sort key for a fragment that declares no explicit `order`: the id's
325
+ * numeric part. `27a` and `6.5` therefore land where a reader expects without
326
+ * anyone writing an `order`; only a fragment whose frozen position CONTRADICTS
327
+ * its number (`27b` precedes `27a` in the published document) needs one.
328
+ */
329
+ function defaultOrder(id) {
330
+ return Number.parseFloat(id);
331
+ }
332
+
333
+ /** Every `*.md` directly under a directory, sorted, tolerating a missing dir. */
334
+ function markdownFilesIn(dir) {
335
+ let entries;
336
+ try {
337
+ entries = fs.readdirSync(dir, { withFileTypes: true });
338
+ } catch {
339
+ return [];
340
+ }
341
+ return entries
342
+ .filter((e) => e.name.endsWith('.md'))
343
+ .map((e) => ({ name: e.name, regular: e.isFile() && !e.isSymbolicLink() }))
344
+ .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
345
+ }
346
+
347
+ /**
348
+ * Whether a `docs/features/` entry may be READ at all.
349
+ *
350
+ * `readdirSync(..., {withFileTypes:true})` reports dirent types WITHOUT
351
+ * following symlinks (it is `lstat`-shaped), so `isFile()` is already false for
352
+ * a symlink — but relying on that alone reads as an accident. This states the
353
+ * rule outright: only a regular file is a fragment.
354
+ *
355
+ * The threat is concrete. A fork PR can commit `docs/features/evil.md` as a
356
+ * symlink to any path the process can read (`~/.ssh/id_rsa`, a CI secret file,
357
+ * anything outside the repo). The generator INLINES a fragment's bytes into the
358
+ * committed `docs/FEATURES.md`, so following one link would exfiltrate that
359
+ * file's contents into a public document on the next `regen:derived`. Refusing
360
+ * non-regular entries is what keeps the fragment corpus to files a reviewer can
361
+ * actually see in the diff.
362
+ */
363
+ function refuseNonRegular(entry, rel, add) {
364
+ if (entry.regular) return false;
365
+ add(REASON.DIRENT_NOT_REGULAR_FILE, rel, {});
366
+ return true;
367
+ }
368
+
369
+ /**
370
+ * Read every fragment and group note off disk into the intermediate
371
+ * representation the renderer and the validator both consume.
372
+ *
373
+ * `--json` exposes this IR's violations, and the test suite asserts against the
374
+ * IR rather than against rendered markdown text (CONTRIBUTING.md forbids
375
+ * grepping generated output): the parse result is the contract, the markdown is
376
+ * one projection of it.
377
+ */
378
+ function readCorpus() {
379
+ const violations = [];
380
+ const add = (reason, file, detail) => violations.push({ reason, file, ...detail });
381
+
382
+ const fragments = [];
383
+ for (const entry of markdownFilesIn(FEATURES_DIR)) {
384
+ const name = entry.name;
385
+ const rel = path.posix.join('docs/features', name);
386
+ if (!FRAGMENT_FILENAME_RE.test(name)) {
387
+ add(REASON.FILENAME_INVALID, rel, {});
388
+ continue;
389
+ }
390
+ if (refuseNonRegular(entry, rel, add)) continue;
391
+ let text;
392
+ try {
393
+ text = fs.readFileSync(path.join(FEATURES_DIR, name), 'utf8');
394
+ } catch {
395
+ add(REASON.DIRENT_UNREADABLE, rel, {});
396
+ continue;
397
+ }
398
+
399
+ const { data, body } = parseFrontmatter(text);
400
+ if (data === null) {
401
+ add(REASON.FRONTMATTER_MISSING, rel, {});
402
+ continue;
403
+ }
404
+ for (const key of Object.keys(data)) {
405
+ if (!FRAGMENT_FIELDS.includes(key)) add(REASON.FIELD_UNKNOWN, rel, { field: key });
406
+ }
407
+ let missing = false;
408
+ for (const field of ['id', 'title', 'group']) {
409
+ if (!data[field]) {
410
+ add(REASON.FIELD_MISSING, rel, { field });
411
+ missing = true;
412
+ }
413
+ }
414
+ if (missing) continue;
415
+
416
+ if (!ID_RE.test(data.id)) {
417
+ add(REASON.ID_INVALID, rel, { id: data.id });
418
+ continue;
419
+ }
420
+
421
+ let order = defaultOrder(data.id);
422
+ if (data.order !== undefined) {
423
+ order = Number(data.order);
424
+ // Both guards are load-bearing: the regex rejects the shapes `Number`
425
+ // would silently accept, and `isFinite` still catches a well-shaped
426
+ // literal long enough to overflow to Infinity.
427
+ if (!ORDER_RE.test(data.order) || !Number.isFinite(order)) {
428
+ add(REASON.ORDER_INVALID, rel, { order: data.order });
429
+ continue;
430
+ }
431
+ }
432
+
433
+ const trimmed = body.replace(/\s+$/, '');
434
+ if (trimmed === '') {
435
+ add(REASON.BODY_EMPTY, rel, {});
436
+ continue;
437
+ }
438
+ for (const hit of shallowBodyHeadings(trimmed)) {
439
+ add(REASON.BODY_HEADING_TOO_SHALLOW, rel, { line: hit.line, depth: hit.depth });
440
+ }
441
+ const forged = forgedRegionMarker(trimmed);
442
+ if (forged !== null) {
443
+ add(REASON.BODY_FORGES_REGION_MARKER, rel, { marker: forged });
444
+ continue;
445
+ }
446
+
447
+ fragments.push({
448
+ file: rel,
449
+ id: data.id,
450
+ title: data.title,
451
+ group: data.group,
452
+ order,
453
+ explicitOrder: data.order !== undefined,
454
+ body: trimmed,
455
+ anchor: slugify(`${data.id}. ${data.title}`),
456
+ });
457
+ }
458
+
459
+ const notes = new Map();
460
+ for (const entry of markdownFilesIn(GROUP_NOTES_DIR)) {
461
+ const name = entry.name;
462
+ const rel = path.posix.join('docs/features/_groups', name);
463
+ if (refuseNonRegular(entry, rel, add)) continue;
464
+ let text;
465
+ try {
466
+ text = fs.readFileSync(path.join(GROUP_NOTES_DIR, name), 'utf8');
467
+ } catch {
468
+ add(REASON.DIRENT_UNREADABLE, rel, {});
469
+ continue;
470
+ }
471
+ const { data, body } = parseFrontmatter(text);
472
+ if (data === null) {
473
+ add(REASON.FRONTMATTER_MISSING, rel, {});
474
+ continue;
475
+ }
476
+ for (const key of Object.keys(data)) {
477
+ if (!GROUP_NOTE_FIELDS.includes(key)) add(REASON.FIELD_UNKNOWN, rel, { field: key });
478
+ }
479
+ if (!data.group) {
480
+ add(REASON.FIELD_MISSING, rel, { field: 'group' });
481
+ continue;
482
+ }
483
+ if (notes.has(data.group)) {
484
+ add(REASON.GROUP_NOTE_DUPLICATE, rel, { group: data.group });
485
+ continue;
486
+ }
487
+ const trimmed = body.replace(/\s+$/, '');
488
+ if (trimmed === '') {
489
+ add(REASON.BODY_EMPTY, rel, {});
490
+ continue;
491
+ }
492
+ const forgedNote = forgedRegionMarker(trimmed);
493
+ if (forgedNote !== null) {
494
+ add(REASON.BODY_FORGES_REGION_MARKER, rel, { marker: forgedNote });
495
+ continue;
496
+ }
497
+ notes.set(data.group, { file: rel, group: data.group, body: trimmed });
498
+ }
499
+
500
+ return { fragments, notes, violations };
501
+ }
502
+
503
+ /** Every `*.md` under `dir`, recursively, as repo-relative POSIX paths. */
504
+ function markdownFilesUnder(dir) {
505
+ const found = [];
506
+ const walk = (abs) => {
507
+ let entries;
508
+ try {
509
+ entries = fs.readdirSync(abs, { withFileTypes: true });
510
+ } catch {
511
+ return;
512
+ }
513
+ for (const e of entries) {
514
+ const joined = path.join(abs, e.name);
515
+ if (e.isDirectory()) walk(joined);
516
+ else if (e.isFile() && e.name.endsWith('.md')) {
517
+ found.push(path.relative(ROOT, joined).split(path.sep).join('/'));
518
+ }
519
+ }
520
+ };
521
+ walk(path.join(ROOT, dir));
522
+ return found.sort();
523
+ }
524
+
525
+ /**
526
+ * Every inbound `docs/FEATURES.md#anchor` reference in the repo's own markdown,
527
+ * with the anchors this generator actually emits subtracted.
528
+ *
529
+ * Links are matched by RESOLVED TARGET, not by textual prefix: a link is only
530
+ * checked when the path it names resolves to `docs/FEATURES.md` itself. That is
531
+ * what keeps the locale trees out of scope without a hardcoded skip list —
532
+ * `docs/ja-JP/README.md` linking `FEATURES.md#…` resolves to
533
+ * `docs/ja-JP/FEATURES.md` and is left alone.
534
+ */
535
+ function checkInboundAnchors(emittedAnchors) {
536
+ const violations = [];
537
+ const files = [
538
+ ...LINK_SCAN_DIRS.flatMap((d) => markdownFilesUnder(d)),
539
+ ...LINK_SCAN_ROOT_FILES.filter((f) => fs.existsSync(path.join(ROOT, f))),
540
+ ];
541
+
542
+ for (const rel of files) {
543
+ let text;
544
+ try {
545
+ text = fs.readFileSync(path.join(ROOT, rel), 'utf8');
546
+ } catch {
547
+ violations.push({ reason: REASON.DIRENT_UNREADABLE, file: rel });
548
+ continue;
549
+ }
550
+ const dir = path.posix.dirname(rel);
551
+ for (const m of text.matchAll(INBOUND_LINK_RE)) {
552
+ const target = path.posix.normalize(path.posix.join(dir, m[1]));
553
+ if (target !== 'docs/FEATURES.md') continue;
554
+ if (!emittedAnchors.has(m[2])) {
555
+ violations.push({ reason: REASON.INBOUND_ANCHOR_UNRESOLVED, file: rel, anchor: m[2] });
556
+ }
557
+ }
558
+ }
559
+ return violations;
560
+ }
561
+
562
+ /**
563
+ * Corpus-wide checks that need every fragment in hand, plus the assembled
564
+ * group/section ordering the renderer walks.
565
+ *
566
+ * Sections sort by `order`, ties broken by id string, so the arrangement is a
567
+ * total order that does not depend on readdir sequence. Groups sort by their
568
+ * lowest-ordered member — which is what removes the last shared list: nobody
569
+ * has to edit a group registry to add a release bucket.
570
+ */
571
+ function buildCorpus() {
572
+ const { fragments, notes, violations } = readCorpus();
573
+
574
+ const byId = new Map();
575
+ for (const f of fragments) {
576
+ const prior = byId.get(f.id);
577
+ if (prior) violations.push({ reason: REASON.ID_DUPLICATE, file: f.file, id: f.id, first: prior.file });
578
+ else byId.set(f.id, f);
579
+ }
580
+
581
+ const byAnchor = new Map();
582
+ for (const f of fragments) {
583
+ const prior = byAnchor.get(f.anchor);
584
+ if (prior && prior.id !== f.id) {
585
+ violations.push({ reason: REASON.ANCHOR_DUPLICATE, file: f.file, anchor: f.anchor, first: prior.file });
586
+ } else if (!prior) {
587
+ byAnchor.set(f.anchor, f);
588
+ }
589
+ }
590
+
591
+ const grouped = new Map();
592
+ for (const f of fragments) {
593
+ if (!grouped.has(f.group)) grouped.set(f.group, []);
594
+ grouped.get(f.group).push(f);
595
+ }
596
+
597
+ for (const note of notes.values()) {
598
+ if (!grouped.has(note.group)) {
599
+ violations.push({ reason: REASON.GROUP_NOTE_ORPHAN, file: note.file, group: note.group });
600
+ }
601
+ }
602
+
603
+ const bySection = (a, b) => a.order - b.order || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
604
+ const groups = [...grouped.entries()]
605
+ .map(([title, sections]) => {
606
+ sections.sort(bySection);
607
+ const note = notes.get(title);
608
+ return {
609
+ title,
610
+ anchor: slugify(title),
611
+ order: sections[0].order,
612
+ note: note ? note.body : null,
613
+ sections,
614
+ };
615
+ })
616
+ .sort((a, b) => a.order - b.order || (a.title < b.title ? -1 : a.title > b.title ? 1 : 0));
617
+
618
+ const emitted = new Set([...groups.map((g) => g.anchor), ...fragments.map((f) => f.anchor)]);
619
+ violations.push(...checkInboundAnchors(emitted));
620
+
621
+ return { fragments, groups, notes, anchors: emitted, violations };
622
+ }
623
+
624
+ /** Human one-liners for the `--check` stderr report, keyed off the typed reason. */
625
+ function describeViolation(v) {
626
+ switch (v.reason) {
627
+ case REASON.FILENAME_INVALID:
628
+ return `${v.file}: filename must be a kebab-case slug, e.g. runtime-identity.md`;
629
+ case REASON.FRONTMATTER_MISSING:
630
+ return `${v.file}: missing the '---' frontmatter block`;
631
+ case REASON.FIELD_MISSING:
632
+ return `${v.file}: frontmatter is missing required field '${v.field}'`;
633
+ case REASON.FIELD_UNKNOWN:
634
+ return `${v.file}: unknown frontmatter field '${v.field}'`;
635
+ case REASON.ID_INVALID:
636
+ return `${v.file}: id '${v.id}' is not an integer, integer+letter (27a) or decimal (6.5)`;
637
+ case REASON.ID_DUPLICATE:
638
+ return `${v.file}: id '${v.id}' is already used by ${v.first} — pick another (any unique id is legal)`;
639
+ case REASON.ORDER_INVALID:
640
+ return `${v.file}: order '${v.order}' is not a decimal number (an optionally-signed integer or decimal)`;
641
+ case REASON.ANCHOR_DUPLICATE:
642
+ return `${v.file}: anchor '#${v.anchor}' collides with ${v.first}`;
643
+ case REASON.BODY_EMPTY:
644
+ return `${v.file}: body is empty`;
645
+ case REASON.BODY_HEADING_TOO_SHALLOW:
646
+ return `${v.file}: body line ${v.line} opens an h${v.depth}; use h${MIN_BODY_HEADING_DEPTH} or deeper inside a feature`;
647
+ case REASON.GROUP_NOTE_ORPHAN:
648
+ return `${v.file}: names group '${v.group}', which no fragment belongs to`;
649
+ case REASON.GROUP_NOTE_DUPLICATE:
650
+ return `${v.file}: a second note for group '${v.group}'`;
651
+ case REASON.INBOUND_ANCHOR_UNRESOLVED:
652
+ return `${v.file}: links docs/FEATURES.md#${v.anchor}, which no heading provides`;
653
+ case REASON.BODY_FORGES_REGION_MARKER:
654
+ return `${v.file}: body contains the generated-region marker '${v.marker}'`;
655
+ case REASON.DIRENT_NOT_REGULAR_FILE:
656
+ return `${v.file}: not a regular file (a symlink is never followed)`;
657
+ case REASON.DIRENT_UNREADABLE:
658
+ return `${v.file}: unreadable`;
659
+ default:
660
+ return `${v.file}: ${v.reason}`;
661
+ }
662
+ }
663
+
664
+ /** Render the generated region: the table of contents, then every section. */
665
+ function renderFeatures(corpus) {
666
+ const out = [START_MARKER, '', '## Table of Contents', ''];
667
+
668
+ for (const g of corpus.groups) {
669
+ out.push(`- [${g.title}](#${g.anchor})`);
670
+ for (const s of g.sections) out.push(` - [${s.title}](#${s.anchor})`);
671
+ }
672
+
673
+ for (const g of corpus.groups) {
674
+ out.push('', '---', '', `## ${g.title}`, '');
675
+ if (g.note) out.push(g.note, '');
676
+ g.sections.forEach((s, i) => {
677
+ if (i > 0) out.push('---', '');
678
+ out.push(`### ${s.id}. ${s.title}`, '', s.body, '');
679
+ });
680
+ }
681
+
682
+ out.push(
683
+ '---',
684
+ '',
685
+ '_Generated by `scripts/gen-features.cjs` — add a fragment under `docs/features/` and run `--write`._',
686
+ '',
687
+ END_MARKER,
688
+ );
689
+ return out.join('\n');
690
+ }
691
+
692
+ /**
693
+ * Replace the generated region of `doc` with `region`.
694
+ *
695
+ * The end boundary is the LAST occurrence, not the first. With `indexOf`, a
696
+ * forged `<!-- FEATURES:END -->` anywhere inside the region would become the de
697
+ * facto boundary and everything after it would be preserved as if hand-authored
698
+ * — permanently, since the next run splices against the same forged marker.
699
+ * `lastIndexOf` makes the true trailing marker win, so the generated region can
700
+ * only ever GROW to swallow a forgery, never shrink to be governed by one.
701
+ * `FORBIDDEN_BODY_SUBSTRINGS` rejects the forgery upstream; this is the second
702
+ * layer, and it also covers a marker that reached the document by hand.
703
+ */
704
+ function spliceIntoFeatures(doc, region) {
705
+ const start = doc.indexOf(START_MARKER);
706
+ const end = doc.lastIndexOf(END_MARKER);
707
+ if (start === -1 || end === -1) {
708
+ throw new ExitError(
709
+ 1,
710
+ `docs/FEATURES.md is missing the generated-region markers.\nExpected:\n ${START_MARKER}\n ${END_MARKER}\n`,
711
+ );
712
+ }
713
+ return doc.slice(0, start) + region + doc.slice(end + END_MARKER.length);
714
+ }
715
+
716
+ /**
717
+ * Parse CLI flags. FAIL-CLOSED on an unrecognized flag: falling through to the
718
+ * no-flags "print the region" behavior would mask a typo (`--wirte`) as a clean
719
+ * run. Mirrors `scripts/gen-adr-index.cjs`.
720
+ */
721
+ function parseArgs(argv) {
722
+ const opts = { write: false, check: false, json: false, force: false };
723
+ for (const arg of argv) {
724
+ if (arg === '--write') opts.write = true;
725
+ else if (arg === '--check') opts.check = true;
726
+ else if (arg === '--json') opts.json = true;
727
+ else if (arg === '--force') opts.force = true;
728
+ else {
729
+ throw new ExitError(
730
+ 1,
731
+ `unknown flag: ${arg}\nRecognized flags: --write, --check, --json, --force.`,
732
+ );
733
+ }
734
+ }
735
+ return opts;
736
+ }
737
+
738
+ function main() {
739
+ const { write, check, json, force } = parseArgs(process.argv.slice(2));
740
+
741
+ const corpus = buildCorpus();
742
+ const { violations } = corpus;
743
+ const region = renderFeatures(corpus);
744
+
745
+ if (json) {
746
+ // `--json` implies `--check` semantics but always computes BOTH facts
747
+ // (violations AND staleness) rather than short-circuiting, so a consumer
748
+ // gets the complete picture in one shot.
749
+ const doc = fs.readFileSync(FEATURES_PATH, 'utf8');
750
+ const stale = spliceIntoFeatures(doc, region) !== doc;
751
+ const ok = violations.length === 0 && !stale;
752
+ process.stdout.write(
753
+ JSON.stringify({
754
+ ok,
755
+ featureCount: corpus.fragments.length,
756
+ groupCount: corpus.groups.length,
757
+ indexStale: stale,
758
+ violations,
759
+ }) + '\n',
760
+ );
761
+ return ok ? 0 : 1;
762
+ }
763
+
764
+ // FAIL-CLOSED. `--write` used to render the region regardless, warning only
765
+ // on stderr and exiting 0 — so a `--write && git commit` chain would happily
766
+ // commit a docs/FEATURES.md carrying two colliding `### 7.` sections. Every
767
+ // other gate in this repo refuses rather than degrades, and a generator that
768
+ // emits a known-corrupt artifact is worse than one that emits none: the
769
+ // corruption is what gets reviewed. `--force` remains for the deliberate
770
+ // "write it anyway so I can see what it looks like" case, and says so.
771
+ if (violations.length > 0 && !(write && force)) {
772
+ process.stderr.write(
773
+ `docs/features/ has ${violations.length} fragment violation(s).\n` +
774
+ 'See CONTRIBUTING.md "Adding a feature to `docs/FEATURES.md`" for the contract.\n' +
775
+ (write ? 'Refusing to write a corrupt docs/FEATURES.md; pass --force to override.\n' : '') +
776
+ '\n',
777
+ );
778
+ for (const v of violations) process.stderr.write(` ✗ ${describeViolation(v)}\n`);
779
+ process.stderr.write('\n');
780
+ throw new ExitError(1);
781
+ }
782
+
783
+ // `--write` takes precedence over a co-supplied `--check`, matching
784
+ // gen-adr-index.cjs: the flags are documented to be used one at a time.
785
+ if (write) {
786
+ const doc = fs.readFileSync(FEATURES_PATH, 'utf8');
787
+ fs.writeFileSync(FEATURES_PATH, spliceIntoFeatures(doc, region));
788
+ process.stdout.write(
789
+ `Wrote ${corpus.fragments.length} features in ${corpus.groups.length} groups into ${FEATURES_PATH}.\n`,
790
+ );
791
+ if (violations.length > 0) {
792
+ // Only reachable under --force; the guard above rejects otherwise.
793
+ process.stderr.write(
794
+ `\n${violations.length} violation(s) written anyway under --force — --check will fail:\n\n`,
795
+ );
796
+ for (const v of violations) process.stderr.write(` ✗ ${describeViolation(v)}\n`);
797
+ }
798
+ } else if (check) {
799
+ const doc = fs.readFileSync(FEATURES_PATH, 'utf8');
800
+ if (spliceIntoFeatures(doc, region) !== doc) {
801
+ process.stderr.write(
802
+ 'docs/FEATURES.md is stale. Run:\n node scripts/gen-features.cjs --write\n\n',
803
+ );
804
+ throw new ExitError(1);
805
+ }
806
+ process.stdout.write(
807
+ `docs/FEATURES.md is up to date (${corpus.fragments.length} features, ${corpus.groups.length} groups).\n`,
808
+ );
809
+ } else {
810
+ process.stdout.write(region + '\n');
811
+ }
812
+ }
813
+
814
+ // Guarded: the test suite requires this module for its pure parser/renderer, so
815
+ // loading it must not also run the generator.
816
+ if (require.main === module) runMain(main);
817
+
818
+ module.exports = {
819
+ REASON,
820
+ MIN_BODY_HEADING_DEPTH,
821
+ FRAGMENT_FIELDS,
822
+ START_MARKER,
823
+ END_MARKER,
824
+ slugify,
825
+ parseFrontmatter,
826
+ renderFrontmatter,
827
+ shallowBodyHeadings,
828
+ forgedRegionMarker,
829
+ FORBIDDEN_BODY_SUBSTRINGS,
830
+ defaultOrder,
831
+ checkInboundAnchors,
832
+ buildCorpus,
833
+ renderFeatures,
834
+ spliceIntoFeatures,
835
+ describeViolation,
836
+ };