@opengsd/gsd-core 1.11.0 → 1.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (498) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.opencode/plugins/gsd-core.js +12 -0
  4. package/agents/gsd-code-fixer.md +1 -1
  5. package/agents/gsd-debug-session-manager.md +1 -1
  6. package/agents/gsd-debugger.md +1 -1
  7. package/agents/gsd-dom-verifier.md +169 -0
  8. package/agents/gsd-eval-auditor.md +1 -1
  9. package/agents/gsd-executor.md +78 -42
  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 +0 -1
  13. package/agents/gsd-pattern-mapper.md +11 -0
  14. package/agents/gsd-phase-researcher.md +3 -1
  15. package/agents/gsd-plan-checker.md +91 -112
  16. package/agents/gsd-planner.md +20 -4
  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 +82 -7
  21. package/agents/gsd-ui-researcher.md +70 -3
  22. package/agents/gsd-verifier.md +24 -2
  23. package/bin/install.js +847 -200
  24. package/commands/gsd/discuss-phase.md +1 -1
  25. package/commands/gsd/execute-phase.md +1 -1
  26. package/commands/gsd/import.md +1 -1
  27. package/commands/gsd/ns-workflow.md +2 -1
  28. package/commands/gsd/phase.md +1 -1
  29. package/commands/gsd/quick-batch.md +105 -0
  30. package/commands/gsd/quick.md +8 -4
  31. package/commands/gsd/surface.md +18 -8
  32. package/gsd-core/bin/gsd-tools.cjs +761 -100
  33. package/gsd-core/bin/lib/active-workstream-store.cjs +8 -0
  34. package/gsd-core/bin/lib/adr-parser.cjs +13 -7
  35. package/gsd-core/bin/lib/agent-install-check.cjs +162 -0
  36. package/gsd-core/bin/lib/api-coverage.cjs +30 -9
  37. package/gsd-core/bin/lib/artifacts.cjs +2 -0
  38. package/gsd-core/bin/lib/assumption-delta.cjs +30 -11
  39. package/gsd-core/bin/lib/audit.cjs +163 -41
  40. package/gsd-core/bin/lib/broken-windows.cjs +306 -28
  41. package/gsd-core/bin/lib/capability-activation.cjs +27 -0
  42. package/gsd-core/bin/lib/capability-lock.cjs +10 -4
  43. package/gsd-core/bin/lib/capability-registry.cjs +785 -144
  44. package/gsd-core/bin/lib/capability-state.cjs +25 -4
  45. package/gsd-core/bin/lib/capability-validator.cjs +321 -18
  46. package/gsd-core/bin/lib/capability-writer.cjs +14 -4
  47. package/gsd-core/bin/lib/check-command-router.cjs +229 -6
  48. package/gsd-core/bin/lib/claude-orchestration.cjs +10 -25
  49. package/gsd-core/bin/lib/cli-exit.cjs +496 -10
  50. package/gsd-core/bin/lib/clusters.cjs +1 -0
  51. package/gsd-core/bin/lib/code-review-depth.cjs +288 -0
  52. package/gsd-core/bin/lib/codex-agent-toml.cjs +410 -4
  53. package/gsd-core/bin/lib/command-aliases.cjs +16 -0
  54. package/gsd-core/bin/lib/command-arg-projection.cjs +144 -14
  55. package/gsd-core/bin/lib/command-routing-hub.cjs +31 -2
  56. package/gsd-core/bin/lib/commands.cjs +877 -54
  57. package/gsd-core/bin/lib/complexity-trigger.cjs +26 -6
  58. package/gsd-core/bin/lib/config-loader.cjs +121 -29
  59. package/gsd-core/bin/lib/config.cjs +92 -2
  60. package/gsd-core/bin/lib/configuration.cjs +129 -37
  61. package/gsd-core/bin/lib/core-utils.cjs +118 -14
  62. package/gsd-core/bin/lib/decisions.cjs +213 -1
  63. package/gsd-core/bin/lib/edge-probe.cjs +23 -2
  64. package/gsd-core/bin/lib/estimate-cli.cjs +55 -11
  65. package/gsd-core/bin/lib/exit-code-registry.cjs +98 -0
  66. package/gsd-core/bin/lib/file-overlap-partitioner.cjs +74 -0
  67. package/gsd-core/bin/lib/frontmatter.cjs +975 -326
  68. package/gsd-core/bin/lib/gap-checker.cjs +41 -8
  69. package/gsd-core/bin/lib/git-base-branch.cjs +182 -39
  70. package/gsd-core/bin/lib/health-diagnostic-rules/consistency.cjs +7 -3
  71. package/gsd-core/bin/lib/health-diagnostic-rules/phase-structure.cjs +8 -2
  72. package/gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.cjs +60 -14
  73. package/gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs +75 -22
  74. package/gsd-core/bin/lib/health-diagnostic-rules/worktree-health.cjs +22 -8
  75. package/gsd-core/bin/lib/health-diagnostic.cjs +23 -3
  76. package/gsd-core/bin/lib/host-integration.cjs +96 -11
  77. package/gsd-core/bin/lib/init-command-router.cjs +132 -21
  78. package/gsd-core/bin/lib/init.cjs +252 -56
  79. package/gsd-core/bin/lib/install-engine.cjs +252 -15
  80. package/gsd-core/bin/lib/install-model-override-resolver.cjs +78 -1
  81. package/gsd-core/bin/lib/install-profiles.cjs +100 -18
  82. package/gsd-core/bin/lib/installer-migration-report.cjs +4 -0
  83. package/gsd-core/bin/lib/installer-migrations/010-antigravity-retire-confighome-artifacts.cjs +169 -0
  84. package/gsd-core/bin/lib/installer-migrations.cjs +10 -7
  85. package/gsd-core/bin/lib/intel.cjs +101 -26
  86. package/gsd-core/bin/lib/io.cjs +195 -15
  87. package/gsd-core/bin/lib/learnings.cjs +85 -14
  88. package/gsd-core/bin/lib/legacy-cleanup.cjs +8 -2
  89. package/gsd-core/bin/lib/loop-resolver.cjs +14 -8
  90. package/gsd-core/bin/lib/markdown-table.cjs +175 -4
  91. package/gsd-core/bin/lib/milestone.cjs +112 -7
  92. package/gsd-core/bin/lib/model-catalog.cjs +177 -19
  93. package/gsd-core/bin/lib/model-resolver.cjs +10 -28
  94. package/gsd-core/bin/lib/onboard-projection.cjs +5 -1
  95. package/gsd-core/bin/lib/phase-command-router.cjs +13 -6
  96. package/gsd-core/bin/lib/phase-estimation.cjs +17 -8
  97. package/gsd-core/bin/lib/phase-id.cjs +321 -13
  98. package/gsd-core/bin/lib/phase-lifecycle.cjs +24 -16
  99. package/gsd-core/bin/lib/phase-locator.cjs +138 -17
  100. package/gsd-core/bin/lib/phase.cjs +1175 -115
  101. package/gsd-core/bin/lib/plan-document.cjs +273 -0
  102. package/gsd-core/bin/lib/plan-scan.cjs +13 -2
  103. package/gsd-core/bin/lib/planning-command-router.cjs +61 -0
  104. package/gsd-core/bin/lib/planning-inspect.cjs +1168 -0
  105. package/gsd-core/bin/lib/planning-snapshot.cjs +165 -34
  106. package/gsd-core/bin/lib/planning-workspace.cjs +159 -28
  107. package/gsd-core/bin/lib/probe-core.cjs +4 -1
  108. package/gsd-core/bin/lib/profile-pipeline-command-router.cjs +50 -7
  109. package/gsd-core/bin/lib/profile-pipeline.cjs +6 -3
  110. package/gsd-core/bin/lib/quick-batch-command-router.cjs +285 -0
  111. package/gsd-core/bin/lib/quick-batch-dispatch.cjs +250 -0
  112. package/gsd-core/bin/lib/quick-batch.cjs +840 -0
  113. package/gsd-core/bin/lib/real-home-guard.cjs +419 -0
  114. package/gsd-core/bin/lib/refactor-trigger-command-router.cjs +71 -45
  115. package/gsd-core/bin/lib/review-lane-descriptor.cjs +62 -14
  116. package/gsd-core/bin/lib/review-lane-invocation.cjs +73 -1
  117. package/gsd-core/bin/lib/review-lane-runner.cjs +136 -10
  118. package/gsd-core/bin/lib/roadmap-command-router.cjs +45 -31
  119. package/gsd-core/bin/lib/roadmap-parser.cjs +577 -41
  120. package/gsd-core/bin/lib/roadmap.cjs +248 -64
  121. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +329 -41
  122. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +16 -17
  123. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +320 -109
  124. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +487 -83
  125. package/gsd-core/bin/lib/runtime-identity.cjs +234 -0
  126. package/gsd-core/bin/lib/runtime-slash.cjs +72 -2
  127. package/gsd-core/bin/lib/shell-command-projection.cjs +75 -8
  128. package/gsd-core/bin/lib/smart-entry.cjs +19 -31
  129. package/gsd-core/bin/lib/spec-section.cjs +12 -7
  130. package/gsd-core/bin/lib/state-command-router.cjs +47 -18
  131. package/gsd-core/bin/lib/state-contract.cjs +359 -0
  132. package/gsd-core/bin/lib/state-document.cjs +216 -5
  133. package/gsd-core/bin/lib/state-md-schema.cjs +231 -0
  134. package/gsd-core/bin/lib/state-transition.cjs +850 -145
  135. package/gsd-core/bin/lib/state.cjs +1629 -287
  136. package/gsd-core/bin/lib/surface.cjs +33 -10
  137. package/gsd-core/bin/lib/task-command-router.cjs +111 -1
  138. package/gsd-core/bin/lib/task-content-resolution.cjs +368 -0
  139. package/gsd-core/bin/lib/tdd-red-evidence.cjs +133 -0
  140. package/gsd-core/bin/lib/teams-status.cjs +4 -1
  141. package/gsd-core/bin/lib/uat-predicate.cjs +58 -20
  142. package/gsd-core/bin/lib/uat.cjs +2542 -387
  143. package/gsd-core/bin/lib/ui-consideration-probe.cjs +9 -1
  144. package/gsd-core/bin/lib/ui-safety-gate.cjs +37 -7
  145. package/gsd-core/bin/lib/unusable-input.cjs +13 -0
  146. package/gsd-core/bin/lib/update-context.cjs +6 -2
  147. package/gsd-core/bin/lib/validate-command-router.cjs +2 -2
  148. package/gsd-core/bin/lib/validate.cjs +230 -12
  149. package/gsd-core/bin/lib/vendor/README.md +43 -5
  150. package/gsd-core/bin/lib/vendor/js-yaml.cjs +3014 -0
  151. package/gsd-core/bin/lib/verification-command-router.cjs +2 -1
  152. package/gsd-core/bin/lib/verification.cjs +287 -13
  153. package/gsd-core/bin/lib/verify-command-grounding.cjs +846 -0
  154. package/gsd-core/bin/lib/verify-command-router.cjs +1 -0
  155. package/gsd-core/bin/lib/verify.cjs +441 -56
  156. package/gsd-core/bin/lib/workstream-inventory.cjs +20 -2
  157. package/gsd-core/bin/lib/workstream-name-policy.cjs +25 -4
  158. package/gsd-core/bin/lib/worktree-base-ref.cjs +66 -12
  159. package/gsd-core/bin/lib/worktree-safety.cjs +185 -21
  160. package/gsd-core/bin/shared/config-defaults.manifest.json +7 -1
  161. package/gsd-core/bin/shared/config-schema.manifest.json +13 -0
  162. package/gsd-core/bin/shared/exit-codes.json +8 -0
  163. package/gsd-core/bin/shared/exit-codes.sh +20 -0
  164. package/gsd-core/bin/shared/model-catalog.json +8 -1
  165. package/gsd-core/bin/verify-reapply-patches.cjs +70 -3
  166. package/gsd-core/references/agent-contracts.md +6 -5
  167. package/gsd-core/references/api-coverage.md +24 -2
  168. package/gsd-core/references/autonomous-smart-discuss.md +3 -3
  169. package/gsd-core/references/checkpoints.md +37 -19
  170. package/gsd-core/references/decimal-phase-calculation.md +5 -5
  171. package/gsd-core/references/edge-probe.md +17 -5
  172. package/gsd-core/references/execute-mvp-tdd.md +18 -18
  173. package/gsd-core/references/execute-phase-between-wave-reset.md +9 -12
  174. package/gsd-core/references/execute-phase-response-language.md +6 -0
  175. package/gsd-core/references/execute-phase-wave-guard.md +11 -9
  176. package/gsd-core/references/executor-examples.md +42 -0
  177. package/gsd-core/references/failing-direction.md +78 -0
  178. package/gsd-core/references/few-shot-examples/plan-checker.md +15 -15
  179. package/gsd-core/references/gate-prompts.md +1 -1
  180. package/gsd-core/references/git-integration.md +5 -5
  181. package/gsd-core/references/git-planning-commit.md +3 -3
  182. package/gsd-core/references/gsd-run-resolver.md +1 -1
  183. package/gsd-core/references/loop-hook-dispatch.md +22 -0
  184. package/gsd-core/references/model-profiles.md +1 -1
  185. package/gsd-core/references/mvp-concepts.md +2 -2
  186. package/gsd-core/references/nyquist-compliance.md +74 -0
  187. package/gsd-core/references/offer-next.md +3 -5
  188. package/gsd-core/references/phase-argument-parsing.md +3 -3
  189. package/gsd-core/references/plan-checker-examples.md +41 -0
  190. package/gsd-core/references/planner-antipatterns.md +25 -0
  191. package/gsd-core/references/planner-chunked.md +5 -1
  192. package/gsd-core/references/planner-coupling.md +42 -0
  193. package/gsd-core/references/planner-failing-direction.md +53 -0
  194. package/gsd-core/references/planner-human-verify-mode.md +15 -1
  195. package/gsd-core/references/planner-quick-batch.md +71 -0
  196. package/gsd-core/references/planner-reviews.md +47 -0
  197. package/gsd-core/references/planner-revision.md +76 -3
  198. package/gsd-core/references/planner-verify-command-grounding.md +17 -0
  199. package/gsd-core/references/planning-config.md +39 -9
  200. package/gsd-core/references/response-language-directive.md +9 -0
  201. package/gsd-core/references/reviewer-instances.md +31 -0
  202. package/gsd-core/references/revision-loop.md +118 -11
  203. package/gsd-core/references/runtime-aware-dispatch.md +1 -1
  204. package/gsd-core/references/tdd.md +15 -12
  205. package/gsd-core/references/ui-brand.md +65 -21
  206. package/gsd-core/references/ui-consideration-probe.md +1 -1
  207. package/gsd-core/references/universal-anti-patterns.md +2 -2
  208. package/gsd-core/references/verifier-evidence-gate.md +160 -0
  209. package/gsd-core/references/verify-command-path-resolvability.md +42 -0
  210. package/gsd-core/references/verify-mvp-mode.md +1 -1
  211. package/gsd-core/references/workstream-flag.md +11 -11
  212. package/gsd-core/templates/README.md +1 -1
  213. package/gsd-core/templates/SECURITY.md +3 -3
  214. package/gsd-core/templates/UI-SPEC.md +25 -3
  215. package/gsd-core/templates/VALIDATION.md +3 -3
  216. package/gsd-core/templates/phase-prompt.md +7 -0
  217. package/gsd-core/templates/state.md +7 -0
  218. package/gsd-core/templates/verification-report.md +5 -0
  219. package/gsd-core/workflows/_runtime-launcher.snippet.sh +1 -1
  220. package/gsd-core/workflows/add-backlog.md +3 -1
  221. package/gsd-core/workflows/add-phase.md +5 -3
  222. package/gsd-core/workflows/add-tests.md +4 -9
  223. package/gsd-core/workflows/add-todo.md +2 -2
  224. package/gsd-core/workflows/ai-integration-phase.md +5 -10
  225. package/gsd-core/workflows/analyze-dependencies.md +2 -0
  226. package/gsd-core/workflows/audit-fix.md +14 -3
  227. package/gsd-core/workflows/audit-milestone.md +11 -9
  228. package/gsd-core/workflows/audit-uat.md +19 -2
  229. package/gsd-core/workflows/autonomous/steps/converge-fail-fast.md +2 -2
  230. package/gsd-core/workflows/autonomous.md +12 -26
  231. package/gsd-core/workflows/check-todos.md +2 -2
  232. package/gsd-core/workflows/cleanup.md +3 -3
  233. package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +16 -14
  234. package/gsd-core/workflows/code-review-fix.md +3 -1
  235. package/gsd-core/workflows/code-review.md +192 -69
  236. package/gsd-core/workflows/complete-milestone.md +28 -14
  237. package/gsd-core/workflows/debug.md +6 -4
  238. package/gsd-core/workflows/diagnose-issues.md +17 -7
  239. package/gsd-core/workflows/discuss-phase/modes/advisor.md +3 -1
  240. package/gsd-core/workflows/discuss-phase/modes/all.md +2 -0
  241. package/gsd-core/workflows/discuss-phase/modes/analyze.md +2 -0
  242. package/gsd-core/workflows/discuss-phase/modes/auto.md +2 -0
  243. package/gsd-core/workflows/discuss-phase/modes/batch.md +2 -0
  244. package/gsd-core/workflows/discuss-phase/modes/chain.md +5 -7
  245. package/gsd-core/workflows/discuss-phase/modes/default.md +2 -0
  246. package/gsd-core/workflows/discuss-phase/modes/power.md +2 -0
  247. package/gsd-core/workflows/discuss-phase/modes/text.md +3 -1
  248. package/gsd-core/workflows/discuss-phase/templates/context.md +2 -0
  249. package/gsd-core/workflows/discuss-phase/templates/discussion-log.md +2 -0
  250. package/gsd-core/workflows/discuss-phase-assumptions/steps/auto-advance-dispatch.md +1 -3
  251. package/gsd-core/workflows/discuss-phase-assumptions.md +3 -3
  252. package/gsd-core/workflows/discuss-phase-power.md +2 -0
  253. package/gsd-core/workflows/discuss-phase.md +2 -2
  254. package/gsd-core/workflows/do.md +46 -19
  255. package/gsd-core/workflows/docs-update.md +6 -5
  256. package/gsd-core/workflows/edit-phase.md +3 -1
  257. package/gsd-core/workflows/eval-review.md +5 -10
  258. package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +3 -1
  259. package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +129 -11
  260. package/gsd-core/workflows/execute-phase/steps/gap-closure-artifacts.md +1 -1
  261. package/gsd-core/workflows/execute-phase/steps/partial-wave.md +1 -1
  262. package/gsd-core/workflows/execute-phase/steps/per-plan-executor-routing.md +1 -1
  263. package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +29 -5
  264. package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +2 -2
  265. package/gsd-core/workflows/execute-phase/steps/protected-branch.md +21 -0
  266. package/gsd-core/workflows/execute-phase/steps/regression-gate-run.md +4 -2
  267. package/gsd-core/workflows/execute-phase/steps/tdd-applicability-resolution.md +25 -0
  268. package/gsd-core/workflows/execute-phase/steps/wave-post-gate-hooks.md +39 -0
  269. package/gsd-core/workflows/execute-phase/steps/worktree-recovery-policy.md +2 -0
  270. package/gsd-core/workflows/execute-phase.md +68 -66
  271. package/gsd-core/workflows/execute-plan.md +25 -20
  272. package/gsd-core/workflows/explore.md +3 -1
  273. package/gsd-core/workflows/extract-learnings.md +3 -1
  274. package/gsd-core/workflows/fast.md +8 -2
  275. package/gsd-core/workflows/forensics.md +3 -1
  276. package/gsd-core/workflows/graduation.md +6 -6
  277. package/gsd-core/workflows/health.md +4 -7
  278. package/gsd-core/workflows/help/modes/brief.md +2 -0
  279. package/gsd-core/workflows/help/modes/default.md +2 -0
  280. package/gsd-core/workflows/help/modes/full.md +12 -0
  281. package/gsd-core/workflows/help/modes/topic.md +2 -0
  282. package/gsd-core/workflows/help.md +2 -0
  283. package/gsd-core/workflows/import.md +17 -14
  284. package/gsd-core/workflows/inbox.md +5 -6
  285. package/gsd-core/workflows/ingest-docs.md +45 -12
  286. package/gsd-core/workflows/insert-phase.md +7 -5
  287. package/gsd-core/workflows/list-phase-assumptions.md +2 -0
  288. package/gsd-core/workflows/list-seeds.md +7 -3
  289. package/gsd-core/workflows/list-workspaces.md +3 -1
  290. package/gsd-core/workflows/manager.md +15 -26
  291. package/gsd-core/workflows/map-codebase.md +3 -1
  292. package/gsd-core/workflows/milestone-summary.md +3 -1
  293. package/gsd-core/workflows/mvp-phase.md +3 -3
  294. package/gsd-core/workflows/new-milestone.md +10 -22
  295. package/gsd-core/workflows/new-project/steps/auto-mode-config.md +1 -1
  296. package/gsd-core/workflows/new-project.md +17 -29
  297. package/gsd-core/workflows/new-workspace.md +2 -2
  298. package/gsd-core/workflows/next.md +4 -2
  299. package/gsd-core/workflows/node-repair.md +2 -0
  300. package/gsd-core/workflows/note.md +2 -0
  301. package/gsd-core/workflows/onboard.md +1 -1
  302. package/gsd-core/workflows/pause-work.md +20 -5
  303. package/gsd-core/workflows/plan-phase/steps/adr-ingest-express-path.md +1 -1
  304. package/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md +100 -18
  305. package/gsd-core/workflows/plan-phase/steps/prd-express-path.md +4 -4
  306. package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +12 -3
  307. package/gsd-core/workflows/plan-phase.md +251 -54
  308. package/gsd-core/workflows/plan-review-convergence.md +148 -19
  309. package/gsd-core/workflows/plant-seed.md +3 -3
  310. package/gsd-core/workflows/pr-branch.md +195 -51
  311. package/gsd-core/workflows/profile-user.md +17 -15
  312. package/gsd-core/workflows/progress/steps/forensic-audit.md +1 -1
  313. package/gsd-core/workflows/progress.md +52 -15
  314. package/gsd-core/workflows/quick/steps/discussion-phase.md +1 -3
  315. package/gsd-core/workflows/quick/steps/plan-checker-loop.md +38 -5
  316. package/gsd-core/workflows/quick/steps/quick-verification.md +2 -4
  317. package/gsd-core/workflows/quick/steps/research-phase.md +5 -7
  318. package/gsd-core/workflows/quick/steps/worktree-pre-dispatch-commit.md +3 -3
  319. package/gsd-core/workflows/quick-batch/steps/batch-init.md +55 -0
  320. package/gsd-core/workflows/quick-batch/steps/completion.md +65 -0
  321. package/gsd-core/workflows/quick-batch/steps/merge-wave.md +100 -0
  322. package/gsd-core/workflows/quick-batch/steps/plan-checker-loop.md +147 -0
  323. package/gsd-core/workflows/quick-batch/steps/planner-wave.md +158 -0
  324. package/gsd-core/workflows/quick-batch/steps/research-phase.md +95 -0
  325. package/gsd-core/workflows/quick-batch/steps/resume-mode.md +49 -0
  326. package/gsd-core/workflows/quick-batch/steps/verification-wave.md +73 -0
  327. package/gsd-core/workflows/quick-batch/steps/worktree-dispatch.md +169 -0
  328. package/gsd-core/workflows/quick-batch.md +203 -0
  329. package/gsd-core/workflows/quick.md +33 -32
  330. package/gsd-core/workflows/reapply-patches.md +2 -0
  331. package/gsd-core/workflows/remove-phase.md +6 -4
  332. package/gsd-core/workflows/remove-workspace.md +3 -3
  333. package/gsd-core/workflows/resume-project.md +14 -14
  334. package/gsd-core/workflows/review.md +404 -21
  335. package/gsd-core/workflows/scan.md +3 -1
  336. package/gsd-core/workflows/section-manifest.json +12 -0
  337. package/gsd-core/workflows/secure-phase.md +3 -3
  338. package/gsd-core/workflows/session-report.md +2 -0
  339. package/gsd-core/workflows/settings-advanced.md +9 -9
  340. package/gsd-core/workflows/settings-integrations.md +66 -32
  341. package/gsd-core/workflows/settings.md +4 -6
  342. package/gsd-core/workflows/ship.md +22 -16
  343. package/gsd-core/workflows/sketch-wrap-up.md +13 -17
  344. package/gsd-core/workflows/sketch.md +13 -19
  345. package/gsd-core/workflows/smart-entry.md +4 -6
  346. package/gsd-core/workflows/spec-phase.md +31 -4
  347. package/gsd-core/workflows/spike-wrap-up.md +9 -11
  348. package/gsd-core/workflows/spike.md +21 -32
  349. package/gsd-core/workflows/stats.md +4 -2
  350. package/gsd-core/workflows/sync-skills.md +13 -5
  351. package/gsd-core/workflows/thread.md +13 -7
  352. package/gsd-core/workflows/transition.md +7 -5
  353. package/gsd-core/workflows/ui-phase.md +36 -21
  354. package/gsd-core/workflows/ui-review.md +7 -11
  355. package/gsd-core/workflows/ultraplan-phase.md +7 -13
  356. package/gsd-core/workflows/undo.md +9 -17
  357. package/gsd-core/workflows/update.md +47 -48
  358. package/gsd-core/workflows/validate-phase.md +3 -3
  359. package/gsd-core/workflows/verify-work/steps/automated-ui-verification.md +25 -1
  360. package/gsd-core/workflows/verify-work/steps/mvp-uat-framing.md +1 -1
  361. package/gsd-core/workflows/verify-work.md +106 -21
  362. package/hooks/dist/gsd-agent-isolation-guard.js +77 -38
  363. package/hooks/dist/gsd-check-update-worker.js +19 -2
  364. package/hooks/dist/gsd-config-reload.js +18 -12
  365. package/hooks/dist/gsd-context-monitor.js +302 -22
  366. package/hooks/dist/gsd-cursor-post-tool.js +3 -1
  367. package/hooks/dist/gsd-cursor-pre-tool.js +3 -1
  368. package/hooks/dist/gsd-cursor-session-start.js +2 -1
  369. package/hooks/dist/gsd-cursor-stop.js +2 -1
  370. package/hooks/dist/gsd-cursor-subagent-start.js +28 -23
  371. package/hooks/dist/gsd-cursor-subagent-stop.js +3 -1
  372. package/hooks/dist/gsd-ensure-canonical-path.js +2 -1
  373. package/hooks/dist/gsd-graphify-update.sh +22 -18
  374. package/hooks/dist/gsd-node-runner.sh +77 -0
  375. package/hooks/dist/gsd-phase-boundary.sh +1 -0
  376. package/hooks/dist/gsd-prompt-guard.js +46 -12
  377. package/hooks/dist/gsd-read-guard.js +18 -7
  378. package/hooks/dist/gsd-read-injection-scanner.js +22 -13
  379. package/hooks/dist/gsd-secret-read-guard.js +1079 -0
  380. package/hooks/dist/gsd-session-state.sh +1 -0
  381. package/hooks/dist/gsd-statusline.js +222 -29
  382. package/hooks/dist/gsd-validate-commit.sh +523 -12
  383. package/hooks/dist/gsd-windsurf-pre-command.js +16 -11
  384. package/hooks/dist/gsd-windsurf-pre-write.js +22 -13
  385. package/hooks/dist/gsd-workflow-guard.js +36 -17
  386. package/hooks/dist/gsd-worktree-path-guard.js +36 -21
  387. package/hooks/dist/gsd-write-guard.js +35 -25
  388. package/hooks/dist/lib/cli-exit.js +560 -0
  389. package/hooks/dist/lib/exit-code-registry.js +98 -0
  390. package/hooks/dist/lib/git-cmd.js +210 -1
  391. package/hooks/dist/lib/git-probe.js +84 -0
  392. package/hooks/dist/lib/hook-exit.js +81 -0
  393. package/hooks/dist/lib/injection-patterns.js +36 -6
  394. package/hooks/dist/managed-hooks-registry.cjs +4 -0
  395. package/hooks/gsd-agent-isolation-guard.js +77 -38
  396. package/hooks/gsd-check-update-worker.js +19 -2
  397. package/hooks/gsd-config-reload.js +18 -12
  398. package/hooks/gsd-context-monitor.js +302 -22
  399. package/hooks/gsd-cursor-post-tool.js +3 -1
  400. package/hooks/gsd-cursor-pre-tool.js +3 -1
  401. package/hooks/gsd-cursor-session-start.js +2 -1
  402. package/hooks/gsd-cursor-stop.js +2 -1
  403. package/hooks/gsd-cursor-subagent-start.js +28 -23
  404. package/hooks/gsd-cursor-subagent-stop.js +3 -1
  405. package/hooks/gsd-ensure-canonical-path.js +2 -1
  406. package/hooks/gsd-graphify-update.sh +22 -18
  407. package/hooks/gsd-node-runner.sh +77 -0
  408. package/hooks/gsd-phase-boundary.sh +1 -0
  409. package/hooks/gsd-prompt-guard.js +46 -12
  410. package/hooks/gsd-read-guard.js +18 -7
  411. package/hooks/gsd-read-injection-scanner.js +22 -13
  412. package/hooks/gsd-secret-read-guard.js +1079 -0
  413. package/hooks/gsd-session-state.sh +1 -0
  414. package/hooks/gsd-statusline.js +222 -29
  415. package/hooks/gsd-validate-commit.sh +523 -12
  416. package/hooks/gsd-windsurf-pre-command.js +16 -11
  417. package/hooks/gsd-windsurf-pre-write.js +22 -13
  418. package/hooks/gsd-workflow-guard.js +36 -17
  419. package/hooks/gsd-worktree-path-guard.js +36 -21
  420. package/hooks/gsd-write-guard.js +35 -25
  421. package/hooks/hooks.json +6 -0
  422. package/hooks/lib/cli-exit.js +560 -0
  423. package/hooks/lib/exit-code-registry.js +98 -0
  424. package/hooks/lib/git-cmd.js +210 -1
  425. package/hooks/lib/git-probe.js +84 -0
  426. package/hooks/lib/hook-exit.js +81 -0
  427. package/hooks/lib/injection-patterns.js +36 -6
  428. package/hooks/managed-hooks-registry.cjs +4 -0
  429. package/package.json +14 -9
  430. package/scripts/base64-scan.sh +74 -12
  431. package/scripts/build-hooks.js +12 -0
  432. package/scripts/check-glossary-refs.cjs +77 -15
  433. package/scripts/check-mutation-score-ratchet.cjs +156 -0
  434. package/scripts/ci-check-job-near-cap.cjs +49 -0
  435. package/scripts/ci-pr-mergeability.cjs +262 -0
  436. package/scripts/ci-test-scope.cjs +52 -12
  437. package/scripts/ci-timeout-report.cjs +230 -0
  438. package/scripts/docs-guard-registry.cjs +406 -0
  439. package/scripts/gen-capability-registry.cjs +8 -6
  440. package/scripts/gen-exit-code-docs.cjs +318 -0
  441. package/scripts/gen-exit-code-registry.cjs +891 -0
  442. package/scripts/gen-features.cjs +836 -0
  443. package/scripts/gen-hooks-cli-exit.cjs +239 -0
  444. package/scripts/gen-install-tree-fixtures.cjs +2 -2
  445. package/scripts/gen-loop-host-contract.cjs +189 -4
  446. package/scripts/gen-scripts-cli-exit.cjs +185 -0
  447. package/scripts/gen-state-md-docs.cjs +727 -0
  448. package/scripts/{test-failure-reasons.cjs → gsd-test-gate-reasons.cjs} +6 -0
  449. package/scripts/lib/ci-job-timing.cjs +72 -0
  450. package/scripts/lib/cli-exit.cjs +546 -44
  451. package/scripts/lib/drift-scan.cjs +32 -2
  452. package/scripts/lib/exit-code-registry.cjs +98 -0
  453. package/scripts/lib/ndjson-reporter.cjs +119 -0
  454. package/scripts/lib/shellcheck-fetch.cjs +247 -0
  455. package/scripts/lint-allow-test-rule-refs.allowlist.json +0 -6
  456. package/scripts/lint-allow-test-rule-refs.effective-ceiling.json +1 -1
  457. package/scripts/lint-allow-test-rule-refs.unverified-ceiling.json +1 -1
  458. package/scripts/lint-docs-guard-registration.cjs +495 -0
  459. package/scripts/lint-docs-guard-registration.exempt-baseline.cjs +198 -0
  460. package/scripts/lint-eslint-glob-coverage.allowlist.json +4 -0
  461. package/scripts/{lint-fix-has-regression-test.cjs → lint-fix-has-regression-tests.cjs} +12 -6
  462. package/scripts/lint-health-diagnostic-rule-table.cjs +65 -8
  463. package/scripts/lint-mutation-test-derivation-drift.cjs +86 -0
  464. package/scripts/lint-phase-enumeration-drift.cjs +45 -14
  465. package/scripts/lint-phase-id-drift.cjs +133 -8
  466. package/scripts/lint-planning-prompt-drift.cjs +38 -1
  467. package/scripts/lint-portable-grep.cjs +176 -0
  468. package/scripts/lint-removed-but-needed.cjs +184 -16
  469. package/scripts/lint-response-language-coverage.cjs +524 -0
  470. package/scripts/lint-seam-enforcement.cjs +182 -0
  471. package/scripts/lint-slug-derivation-drift.cjs +921 -0
  472. package/scripts/lint-source-test-name-collision.cjs +241 -0
  473. package/scripts/lint-state-write-path-drift.cjs +337 -432
  474. package/scripts/lint-test-file-count.allowlist.json +124 -4
  475. package/scripts/lint-test-file-count.cjs +25 -3
  476. package/scripts/lint-unreachable-guard-drift.cjs +51 -64
  477. package/scripts/lint-vendored-deps.cjs +208 -35
  478. package/scripts/lint-workflow-shellcheck-baseline.json +1027 -0
  479. package/scripts/lint-workflow-shellcheck.cjs +614 -0
  480. package/scripts/mutation-matrix.cjs +599 -50
  481. package/scripts/npm-audit-baseline.cjs +376 -0
  482. package/scripts/prompt-injection-scan.sh +83 -14
  483. package/scripts/require-issue-link-policy.cjs +16 -1
  484. package/scripts/secret-scan.sh +75 -13
  485. package/scripts/select-docs-guards.cjs +56 -0
  486. package/scripts/sync-runtime-launcher.cjs +22 -3
  487. package/skills/gsd-discuss-phase/SKILL.md +1 -1
  488. package/skills/gsd-execute-phase/SKILL.md +1 -1
  489. package/skills/gsd-import/SKILL.md +1 -1
  490. package/skills/gsd-ns-workflow/SKILL.md +1 -0
  491. package/skills/gsd-phase/SKILL.md +1 -1
  492. package/skills/gsd-quick/SKILL.md +8 -4
  493. package/skills/gsd-quick-batch/SKILL.md +105 -0
  494. package/skills/gsd-surface/SKILL.md +18 -8
  495. package/vscode/package.json +1 -1
  496. package/bin/lib/ui-safety-gate.cjs +0 -109
  497. package/scripts/lint-emitted-drift-ack.cjs +0 -344
  498. package/scripts/state-write-path-drift-baseline.json +0 -19
@@ -5,12 +5,45 @@
5
5
  * Anti-divergence drift guard for the STATE.md WRITE PATH — epic #3408, issue
6
6
  * #3468, ADR-3408 Decision 5, contract §8.1/§8.2/§8.3
7
7
  * (`docs/adr/3408-state-write-path-preservation.md` is the contract this
8
- * guard enforces; read it first).
8
+ * guard enforces; read it first) — SHRUNK per ADR-3473 §8.6 (issue #3871).
9
9
  *
10
- * TWO AXES, ONE GUARD, because they fail together — a table that is
11
- * bypassed on dispatch and a seam that is bypassed on write are the same
12
- * failure mode ("policy declared, enforcement hand-rolled") applied to two
13
- * different call shapes:
10
+ * WHAT MOVED INTO THE TYPE SYSTEM (ADR-3473 §8.6): `writeStateMd`'s third
11
+ * parameter now REQUIRES a `StateTransaction` of `kind: 'rebuild'`, produced
12
+ * only by `openStateTransaction()` / `rebuildStateTransaction()`
13
+ * (`src/state-transition.cts`) — `writeStateMd` itself throws
14
+ * `STATE_TRANSACTION_KIND_INVALID` for anything else. The two exceptions this
15
+ * guard used to track as a RATCHETED STRING MATCH against
16
+ * `scripts/state-write-path-drift-baseline.json` — `cmdStateSync`
17
+ * (`src/state.cts`, #905's "let the body win") and `REGENERATE_STATE`
18
+ * (`src/health-diagnostic.cts`, `/gsd-health --repair`'s factory reset) — are
19
+ * NO LONGER TRACKED HERE. Both are now a constructor the type system names
20
+ * (`rebuildStateTransaction`), not an entry a human had to remember to keep
21
+ * acknowledging in a baseline file. That baseline file, and the whole
22
+ * ratchet machinery that existed ONLY to support it (loading it, the
23
+ * STALE-entry check, `--baseline` regeneration), is retired along with it —
24
+ * the `writeStateMd(` ARM of the old `findSeamBypasses` axis is gone for
25
+ * good, because the type system now names both of its exceptions.
26
+ *
27
+ * WHAT STAYED, RETITLED, AND MADE TERMINAL (issue #3871 review): the OTHER
28
+ * half of `findSeamBypasses` — every direct `syncStateFrontmatter(` /
29
+ * `applyPostSyncPreservation(` call outside their owner
30
+ * (`syncAndPreserveStateMd`, `src/state.cts`) — is NOT redundant. The type
31
+ * system gates `writeStateMd`'s THIRD PARAMETER; it says nothing about a call
32
+ * site that re-assembles `syncStateFrontmatter` + `applyPostSyncPreservation`
33
+ * itself instead of calling the one write-seam composition. ADR-3408 §8.3:
34
+ * "Assembling the stages at a call site is a re-derivation even when every
35
+ * step calls the owner." #3469 found exactly that shape live in
36
+ * `cmdPhaseComplete`'s atomic-commit adapter (`src/phase.cts`) — every step
37
+ * called an owner, so an owner-level test and this guard's OLD, narrower
38
+ * scan both stayed green while the composition itself drifted from
39
+ * `readModifyWriteStateMd`'s. See `findCompositionBypasses` below. Unlike
40
+ * the retired `writeStateMd(` arm, this one ships TERMINAL, not ratcheted —
41
+ * mirrors `findPromptSeamUses`'s own conversion in this same shrink: no
42
+ * legitimate call site outside the owner exists today, so any occurrence is
43
+ * a violation, not an entry to acknowledge.
44
+ *
45
+ * WHAT THIS GUARD STILL OWNS, because the type system cannot make it
46
+ * unrepresentable:
14
47
  *
15
48
  * AXIS 1 — POLICY DISPATCH (§8.1). `applyStatePreservation` must select
16
49
  * its branch from a `FIELD_CLASSIFICATION` row's `preservation` value,
@@ -27,12 +60,39 @@
27
60
  * undetected by the call-shape check alone). Scoped to the identifier
28
61
  * `field` only; see `FIELD_VAR_EQ_LITERAL_RE`'s own comment for why.
29
62
  *
30
- * AXIS 2 — WRITE SEAM (§8.3), RATCHETED. `readModifyWriteStateMd` is the
31
- * only path meant to write STATE.md. Every direct `writeStateMd(` or
32
- * `syncStateFrontmatter(` call outside the owner's own definitions is a
33
- * bypass that skips preservation and the #948 no-op guard — how #3374 and
34
- * #3350 reproduce. This axis ships RATCHETED (see below), never a bare
35
- * 0-or-fail check.
63
+ * AXIS 2 — RAW STATE WRITE (§8.6, RETAINED). A direct `fs.writeFileSync(`
64
+ * call whose target argument is the state path (`statePath`, or a literal
65
+ * containing `STATE.md`) is a write that skips BOTH the write seam
66
+ * (`writeStateMd`/`syncAndPreserveStateMd`) AND the OS Shell Projection
67
+ * seam (`platformWriteSync`, `src/shell-command-projection.cts`) entirely.
68
+ * No constructor or type can make this unrepresentable — Node's `fs`
69
+ * module is always one `import` away — so this axis stays a plain
70
+ * string-match scan, unratcheted: any occurrence is a violation, because no
71
+ * legitimate call site in this codebase writes STATE.md this way (every
72
+ * real writer goes through `platformWriteSync`).
73
+ *
74
+ * AXIS 3 — FRONTMATTER-SHAPED WRITE (§8.3(b), closed Phase 2 / #3469).
75
+ * `findUnstrippedContentWrites` below flags a `stateReplaceField(` call
76
+ * only when BOTH (a) its field-name argument is a VARIABLE, not a fixed
77
+ * string literal, and (b) its content argument has not been run through
78
+ * `stripFrontmatter` first (a narrow backward-scan approximation, not full
79
+ * dataflow — see the function's own docstring).
80
+ *
81
+ * AXIS 4 — PROMPT-LAYER WRITE (§8.3, Decision 4(d)). Prose in the prompt
82
+ * layer instructing an agent to shell out to a write-side `gsd-tools`
83
+ * subcommand is the same write seam, expressed as markdown rather than
84
+ * TypeScript — a check the type system cannot reach at all, since markdown
85
+ * is never compiled. Any occurrence is a violation.
86
+ *
87
+ * AXIS 5 — COMPOSITION BYPASS (§8.3, RETAINED, issue #3871). A direct
88
+ * `syncStateFrontmatter(` or `applyPostSyncPreservation(` call outside
89
+ * their owner (`syncAndPreserveStateMd`) is a re-assembly of the write-seam
90
+ * composition — the exact shape #3469 found live in `cmdPhaseComplete`.
91
+ * `writeStateMd(` is deliberately NOT scanned here (that arm is retired,
92
+ * §8.6) — `writeStateMd`'s own legitimate direct `syncStateFrontmatter(`
93
+ * call (the sanctioned #905 exception) is instead exempted by function
94
+ * name, same as the composition owner itself; see
95
+ * `SEAM_OWNER_EXEMPT_FUNCTIONS`. Terminal: any occurrence is a violation.
36
96
  *
37
97
  * DESIGN CONSTRAINTS (ADR-3180 Decision 4, adopted verbatim by ADR-3408):
38
98
  * - 4(a) whole-repo scan, never an allowlist. ADR-3180's own phases found
@@ -40,15 +100,9 @@
40
100
  * nothing.
41
101
  * - 4(d) the scan surface is DECLARED and is NOT just `src/` — `src/`
42
102
  * alone is itself an allowlist one directory wide; #1762 traced a wrong
43
- * count to a shell snippet in `gsd-core/workflows/progress.md`. And
44
- * inward: the OWNER FILE (`src/state.cts`) is not exempt, only its
45
- * named canonical FUNCTIONS are (`SEAM_OWNER_EXEMPT_FUNCTIONS` below).
46
- * - 4(e) Axis 2 ships ratcheted because Phase 1 (this file) cannot
47
- * consolidate the write seam — that is Phase 2 (#3469). Landing a
48
- * guard later, against an already-clean tree, is the "found it, wrote
49
- * it down, moved on" posture the epic removes.
103
+ * count to a shell snippet in `gsd-core/workflows/progress.md`.
50
104
  *
51
- * GOODHART, PER ADR-3408 DECISION 5: "0 bypasses" is a LAGGING metric — a
105
+ * GOODHART, PER ADR-3408 DECISION 5: "0 violations" is a LAGGING metric — a
52
106
  * measure about to become a target. This guard's own `_comment` and its
53
107
  * human-readable success message both say so: the zero this guard reports
54
108
  * must NEVER be quoted alone; it is only meaningful beside the behavioral
@@ -62,42 +116,37 @@
62
116
  * to prevent. `stripComments` does not track quoted strings for exactly
63
117
  * this reason — see its own header.
64
118
  *
65
- * AXIS 3 — FRONTMATTER-SHAPED WRITE (§8.3(b)), CLOSED IN PHASE 2 (#3469).
66
- * Phase 1 left this as a DECLARED KNOWN GAP: `patchCore` ran
67
- * `stateReplaceField(` over the WHOLE document (body + frontmatter) instead
68
- * of stripping frontmatter first, the way `updateCore` does, and a naive
69
- * co-occurrence approximation ("does the enclosing function also call
70
- * `stripFrontmatter(`?") measured at 33 occurrences of `stateReplaceField(`,
71
- * of which only 4 were genuine write-seam bypasses and 29 were noise — the
72
- * definition of `stateReplaceField` itself, ~20 calls on `sectionBody` (a
73
- * body slice that is frontmatter-free by construction), and several calls
74
- * inside `readModifyWriteStateMd` callbacks. 29 false positives to 1 true
75
- * positive would have buried the signal.
76
- *
77
- * Phase 2 fixes `patchCore` (it now strips frontmatter first, matching
78
- * `updateCore`) AND closes the gap, using a narrower, two-factor shape that
79
- * does not reproduce that ratio: `findUnstrippedContentWrites` below flags a
80
- * `stateReplaceField(` call only when BOTH (a) its field-name argument is a
81
- * VARIABLE, not a fixed string literal — every OTHER call site in
82
- * `EXECUTOR_FILE` passes a fixed Title-Case literal (`'Phase'`, `'Total
83
- * Plans in Phase'`, ...) that can never collide with a lowercase/snake_case
84
- * YAML frontmatter key, so a literal field name is never a candidate
85
- * regardless of whether its content argument is stripped — and (b) its
86
- * content argument has not been run through `stripFrontmatter` first,
87
- * checked by a simple backward scan (within the same function) for the
88
- * nearest preceding assignment to that argument's variable name. This is
89
- * deliberately NOT full alias/dataflow tracking — see the function's own
90
- * docstring for the narrow, documented limitation this trades for
91
- * tractability.
119
+ * CORRECTION TO ADR-3473 §8.6's TEXT, RECORDED HERE SO A FUTURE READER
120
+ * COMPARING THE ADR TO THIS FILE DOES NOT CONCLUDE THE FILE DRIFTED:
121
+ * §8.6 says this guard "keeps only its raw-write check (`fs.writeFileSync`
122
+ * against the state path), which the type cannot make unrepresentable." That
123
+ * sentence is wrong on both halves. First, no such check existed anywhere in
124
+ * this file before this shrink — `findRawStateWrites` (Axis 2, below) is
125
+ * NET-NEW, written for this shrink, not retained from a prior version.
126
+ * Second, this file did not (and does not) drop to "only" one check: besides
127
+ * the retired `writeStateMd(` arm of the old `findSeamBypasses` axis (the
128
+ * half §8.6 correctly names for removal, since the type system now names
129
+ * both of its exceptions), `findPolicyDispatchDrift`, `findUnimplementedPolicies`,
130
+ * `findUnstrippedContentWrites`, `findPromptSeamUses`, and the RETAINED
131
+ * `syncStateFrontmatter`/`applyPostSyncPreservation` composition-bypass half
132
+ * of `findSeamBypasses` (now `findCompositionBypasses`, terminal — issue
133
+ * #3871 review) all remain, because §8.6 names neither them nor anything
134
+ * that makes what they check unrepresentable — a field-name-keyed dispatch
135
+ * branch, an unimplemented `FieldPreservation` policy, an unstripped
136
+ * frontmatter write, prompt-layer prose shelling out to `gsd-tools`, and a
137
+ * re-assembled write-seam composition are all still exactly as representable
138
+ * in TypeScript (or in markdown, for the prompt-layer one) after the
139
+ * state-transaction constructor as they were before it — the constructor
140
+ * gates `writeStateMd`'s third parameter, nothing about a call site that
141
+ * never goes through `writeStateMd` at all. Do not edit the ADR to match
142
+ * this file; this paragraph is the correction of record.
92
143
  */
93
144
 
94
- const fs = require('node:fs');
95
145
  const path = require('node:path');
96
146
  const { scanTree, sanitizeForReport } = require('./lib/drift-scan.cjs');
97
147
  const { escapeRegex } = require('../gsd-core/bin/lib/pattern.cjs');
98
148
 
99
149
  const REPO_ROOT = path.resolve(__dirname, '..');
100
- const BASELINE_PATH = path.join(__dirname, 'state-write-path-drift-baseline.json');
101
150
 
102
151
  // Frozen REASON enum — mirrors `lint-state-field-drift.cjs`'s house style of
103
152
  // naming every failure shape explicitly rather than reusing one generic
@@ -110,44 +159,52 @@ const REASON = Object.freeze({
110
159
  // with a variable field-name argument whose content argument was not run
111
160
  // through `stripFrontmatter` first — see `findUnstrippedContentWrites`.
112
161
  UNSTRIPPED_CONTENT_WRITE: 'unstripped_content_write',
113
- SEAM_BYPASS_UNRECORDED: 'seam_bypass_unrecorded',
114
- SEAM_BYPASS_COUNT_GREW: 'seam_bypass_count_grew',
115
- SEAM_BYPASS_COUNT_SHRANK: 'seam_bypass_count_shrank',
116
- BASELINE_ENTRY_STALE: 'baseline_entry_stale',
117
- BASELINE_UNREADABLE: 'baseline_unreadable',
162
+ // Axis 2 (§8.6, retained): a raw `fs.writeFileSync(` call targeting the
163
+ // state path — see `findRawStateWrites`.
164
+ RAW_STATE_WRITE: 'raw_state_write',
165
+ // Axis 4 (§8.3, Decision 4(d)): prompt-layer prose shelling out to a
166
+ // write-side `gsd-tools` subcommand — see `findPromptSeamUses`.
167
+ PROMPT_LAYER_STATE_WRITE: 'prompt_layer_state_write',
168
+ // Axis 5 (§8.3, RETAINED, issue #3871): a direct `syncStateFrontmatter(` or
169
+ // `applyPostSyncPreservation(` call outside their owner
170
+ // (`syncAndPreserveStateMd`) — see `findCompositionBypasses`.
171
+ COMPOSITION_BYPASS: 'composition_bypass',
118
172
  });
119
173
 
120
174
  // Scan surface — declared, per Decision 4(d), never inferred from `src/`
121
- // alone. `src/` covers the executor and the write-seam owner; the prompt
122
- // layer covers markdown that can shell out to `state.patch` / `phase.complete`
123
- // and post-process the result outside any TypeScript this guard could see.
175
+ // alone. `src/` covers the executor; the prompt layer covers markdown that
176
+ // can shell out to `state.patch` / `phase.complete` and post-process the
177
+ // result outside any TypeScript this guard could see.
124
178
  const SRC_DIRS = ['src'];
125
179
  const SRC_EXT = new Set(['.cts']);
126
180
  const PROMPT_DIRS = ['gsd-core/workflows', 'commands', 'agents', 'skills'];
127
181
  const PROMPT_EXT = new Set(['.md']);
128
182
 
129
- // The executor (Axis 1) and the write-seam owner (Axis 2). Forward-slash
130
- // literals: every `rel` this guard compares against them is unconditionally
131
- // POSIX-normalized first (`toPosixRel` below) — never gated on
132
- // `process.platform`.
183
+ // The executor (Axis 1 / Axis 3). Forward-slash literal: every `rel` this
184
+ // guard compares against it is unconditionally POSIX-normalized first
185
+ // (`toPosixRel` below) — never gated on `process.platform`.
133
186
  const EXECUTOR_FILE = 'src/state-transition.cts';
187
+
188
+ // The write-seam composition owner (Axis 5). Forward-slash literal, same
189
+ // POSIX-normalization rule as `EXECUTOR_FILE` above.
134
190
  const SEAM_OWNER_FILE = 'src/state.cts';
135
191
 
136
192
  // Per Decision 4(d)'s "owner FILE is not exempt, only its named canonical
137
- // FUNCTIONS are": a `writeStateMd(`/`syncStateFrontmatter(`/
138
- // `applyPostSyncPreservation(` call inside one of these two functions, in
139
- // `SEAM_OWNER_FILE` only, is the seam's own internal plumbing, not a bypass.
140
- // `writeStateMd` is the `cmdStateSync`/`REGENERATE_STATE` path's own I/O
141
- // wrapper calling `syncStateFrontmatter` directly (no preservation, by
142
- // design — §8.3's closed exception list). `syncAndPreserveStateMd` (#3469)
143
- // is the ONE write-seam composition — `syncStateFrontmatter` then
144
- // `applyPostSyncPreservation` — every OTHER caller needing a non-standard
145
- // I/O envelope routes through. Every OTHER function in `state.cts` — and
146
- // every function in every OTHER file — is still scanned and still flagged;
147
- // in particular, `readModifyWriteStateMd` is NOT exempt: after #3469 it no
148
- // longer contains a direct `syncStateFrontmatter(`/`applyPostSyncPreservation(`
149
- // call at all (it calls `syncAndPreserveStateMd` like everyone else), so if
150
- // one reappeared there it would be exactly the re-assembly shape this axis
193
+ // FUNCTIONS are": a `syncStateFrontmatter(`/`applyPostSyncPreservation(` call
194
+ // inside one of these two functions, in `SEAM_OWNER_FILE` only, is the
195
+ // seam's own internal plumbing, not a bypass. `writeStateMd` is the
196
+ // `cmdStateSync`/`REGENERATE_STATE` path's own I/O wrapper calling
197
+ // `syncStateFrontmatter` directly (no preservation, by design — §8.3's
198
+ // closed exception list; ADR-3473 §8.6 gates ITS third parameter, which is
199
+ // an orthogonal, type-level check — this guard's exemption is about which
200
+ // FUNCTION BODY a raw call to the two seam stages is allowed to live in).
201
+ // `syncAndPreserveStateMd` is the ONE write-seam composition — every OTHER
202
+ // caller needing a non-standard I/O envelope routes through it. Every OTHER
203
+ // function in `state.cts` — and every function in every OTHER file — is
204
+ // still scanned and still flagged; in particular `readModifyWriteStateMd` is
205
+ // NOT exempt: it calls `syncAndPreserveStateMd` like everyone else, so if a
206
+ // direct `syncStateFrontmatter(`/`applyPostSyncPreservation(` call
207
+ // reappeared there it would be exactly the re-assembly shape this axis
151
208
  // exists to catch.
152
209
  const SEAM_OWNER_EXEMPT_FUNCTIONS = ['writeStateMd', 'syncAndPreserveStateMd'];
153
210
 
@@ -211,8 +268,9 @@ function stripComments(text) {
211
268
  }
212
269
 
213
270
  // A named function declaration, tolerating `export`/`async` prefixes — the
214
- // SAME shape `enclosingFunction` looks backward for and `findSeamBypasses`
215
- // uses to recognise (and skip) the seam functions' own definitions.
271
+ // SAME shape `nearestPrecedingAssignment` uses as its backward-scan boundary,
272
+ // and `enclosingFunction` below uses to recognise (and skip) the seam
273
+ // functions' own definitions.
216
274
  const FUNCTION_DECL_LINE_RE = /^\s*(?:export\s+)?(?:async\s+)?function\s+([A-Za-z_$][\w$]*)\s*\(/;
217
275
 
218
276
  /**
@@ -323,8 +381,8 @@ function findPolicyDispatchDrift(rel, text) {
323
381
  // `file` is sanitized here, at construction, not just at the human
324
382
  // formatter: `rel` is exactly as attacker-controlled as `source` on a
325
383
  // fork PR (a tracked filename can legally carry C1 bytes or bidi
326
- // overrides), and it reaches the committed baseline and `--json` stdout
327
- // unfiltered otherwise — see `sanitizeForReport`'s own header.
384
+ // overrides), and it reaches `--json` stdout unfiltered otherwise — see
385
+ // `sanitizeForReport`'s own header.
328
386
  FIELD_NAME_DISPATCH_RE.lastIndex = 0;
329
387
  let m;
330
388
  while ((m = FIELD_NAME_DISPATCH_RE.exec(line)) !== null) {
@@ -336,7 +394,7 @@ function findPolicyDispatchDrift(rel, text) {
336
394
  // `field` is captured straight out of a quoted string literal in
337
395
  // repo source — attacker-controlled on the same fork-PR basis as
338
396
  // `file`/`source`, so sanitize it too rather than let it reach
339
- // `--json` stdout / the baseline raw.
397
+ // `--json` stdout.
340
398
  field: sanitizeForReport(m[2]),
341
399
  source: sanitizeForReport(line.trim()),
342
400
  });
@@ -348,10 +406,6 @@ function findPolicyDispatchDrift(rel, text) {
348
406
  axis: 'policy-dispatch',
349
407
  file: sanitizeForReport(rel),
350
408
  line: i + 1,
351
- // `field` is captured straight out of a quoted string literal in
352
- // repo source — attacker-controlled on the same fork-PR basis as
353
- // `file`/`source`, so sanitize it too rather than let it reach
354
- // `--json` stdout / the baseline raw.
355
409
  field: sanitizeForReport(m[2]),
356
410
  source: sanitizeForReport(line.trim()),
357
411
  });
@@ -363,10 +417,6 @@ function findPolicyDispatchDrift(rel, text) {
363
417
  axis: 'policy-dispatch',
364
418
  file: sanitizeForReport(rel),
365
419
  line: i + 1,
366
- // `field` is captured straight out of a quoted string literal in
367
- // repo source — attacker-controlled on the same fork-PR basis as
368
- // `file`/`source`, so sanitize it too rather than let it reach
369
- // `--json` stdout / the baseline raw.
370
420
  field: sanitizeForReport(m[2]),
371
421
  source: sanitizeForReport(line.trim()),
372
422
  });
@@ -429,11 +479,9 @@ function isQuotedLiteralArg(arg) {
429
479
  * The nearest assignment to `varName` (`varName = <expr>` or
430
480
  * `const|let|var varName = <expr>`), scanning `lines` BACKWARD from `index`
431
481
  * (inclusive) and stopping at the nearest preceding named-function
432
- * declaration (mirrors `enclosingFunction`'s own boundary, so the scan
433
- * cannot walk into an unrelated function above the one containing the
434
- * call). Returns the assigned expression's trimmed text, or `null` when no
435
- * such assignment is found before the boundary — meaning `varName` is the
436
- * enclosing function's own untouched parameter.
482
+ * declaration. Returns the assigned expression's trimmed text, or `null`
483
+ * when no such assignment is found before the boundary — meaning `varName`
484
+ * is the enclosing function's own untouched parameter.
437
485
  *
438
486
  * Deliberately single-hop: this reports whatever the NEAREST assignment's
439
487
  * right-hand side literally is, and does not itself follow a further alias
@@ -506,31 +554,115 @@ function findUnstrippedContentWrites(rel, text) {
506
554
  return out;
507
555
  }
508
556
 
509
- // The three write-seam functions, matched only as CALLS (`\(` immediately
510
- // after, modulo whitespace) — never as bare mentions of the name.
511
- // `applyPostSyncPreservation` (#3469) is included alongside
512
- // `writeStateMd`/`syncStateFrontmatter`: after Phase 2, it is ONLY ever
513
- // legitimately called from inside `syncAndPreserveStateMd` (the seam
514
- // composition), so any OTHER call to it is either a re-assembly of the pair
515
- // (Phase 2's Finding 3 shape — a call site invoking both
516
- // `syncStateFrontmatter` and `applyPostSyncPreservation` itself instead of
517
- // the composition) or a bypass calling it alone; either way it belongs on
518
- // this axis.
519
- const SEAM_CALL_RE = /\b(writeStateMd|syncStateFrontmatter|applyPostSyncPreservation)\s*\(/g;
520
- // A line that IS one of the three seam functions' own definitions — skipped
521
- // outright, never counted as a call to itself.
522
- const SEAM_DEF_LINE_RE = /^\s*(?:export\s+)?(?:async\s+)?function\s+(?:writeStateMd|syncStateFrontmatter|applyPostSyncPreservation)\b/;
557
+ // AXIS 2 (§8.6, retained): `fs.writeFileSync(<targetArg>, ...)`, capturing
558
+ // the target-path argument up to the next comma. The type system (ADR-3473
559
+ // §8.6's `StateTransaction` constructors) closes the `writeStateMd`/
560
+ // `syncStateFrontmatter`/`applyPostSyncPreservation` bypass shape this guard
561
+ // used to scan for by function name; it cannot close a call site that skips
562
+ // those functions ENTIRELY and reaches for Node's raw `fs` module directly —
563
+ // that residual risk is what this axis stays alive to catch.
564
+ const RAW_WRITE_CALL_START_RE = /\bfs\.writeFileSync\s*\(/g;
565
+
566
+ /**
567
+ * Capture `fs.writeFileSync`'s first-argument text starting at `startIdx`
568
+ * (the offset right after its opening `(`), stopping at the first TOP-LEVEL
569
+ * comma or the call's own closing paren — bracket/paren/brace depth and
570
+ * string-literal spans are tracked so a nested call in the target expression
571
+ * (e.g. `path.join(cwd, 'STATE.md')`) does not stop the scan at ITS internal
572
+ * comma. A naive `[^,]+` capture (the guard's prior encoding) stopped at
573
+ * `path.join(cwd` for exactly that shape, silently missing every
574
+ * `fs.writeFileSync(path.join(cwd, 'STATE.md'), …)` call in the wild
575
+ * (found via `tests/state-write-path-drift-guard.test.cjs` F1: "guard:
576
+ * fs.writeFileSync against a STATE.md literal is reported").
577
+ */
578
+ function captureFirstArg(line, startIdx) {
579
+ let depth = 0;
580
+ let inStr = null;
581
+ let i = startIdx;
582
+ for (; i < line.length; i++) {
583
+ const c = line[i];
584
+ if (inStr) {
585
+ if (c === '\\') { i++; continue; }
586
+ if (c === inStr) inStr = null;
587
+ continue;
588
+ }
589
+ if (c === '\'' || c === '"' || c === '`') { inStr = c; continue; }
590
+ if (c === '(' || c === '[' || c === '{') { depth++; continue; }
591
+ if (c === ')' || c === ']' || c === '}') {
592
+ if (depth === 0) break; // the call's own closing paren — no comma found
593
+ depth--;
594
+ continue;
595
+ }
596
+ if (c === ',' && depth === 0) break;
597
+ }
598
+ return line.slice(startIdx, i);
599
+ }
600
+
601
+ // True when the captured target-path expression plausibly names the STATE.md
602
+ // path: either the canonical `statePath` identifier this codebase uses at
603
+ // every real write site (see `src/state.cts`'s `writeStateMd`,
604
+ // `readModifyWriteStateMd`, `cmdStateMilestoneSwitch`), or a literal/template
605
+ // segment containing `STATE.md` outright.
606
+ function targetsStatePath(arg) {
607
+ return /\bstatePath\b/.test(arg) || /STATE\.md/.test(arg);
608
+ }
609
+
610
+ /**
611
+ * AXIS 2: every `fs.writeFileSync(` call in `text` whose target argument
612
+ * names the state path is a raw write that bypasses BOTH the write seam
613
+ * (`writeStateMd` / `syncAndPreserveStateMd`) and the OS Shell Projection
614
+ * seam (`platformWriteSync`, `src/shell-command-projection.cts`) — no
615
+ * legitimate call site in this codebase writes STATE.md this way today
616
+ * (every real writer goes through `platformWriteSync`, whose OWN internal
617
+ * `fs.writeFileSync` calls take a generic `filePath`/`tmpPath` argument, not
618
+ * `statePath`, and are therefore never matched by `targetsStatePath` above).
619
+ * Unratcheted, unexempted: any occurrence is a violation.
620
+ */
621
+ function findRawStateWrites(rel, text) {
622
+ const rawLines = text.split('\n');
623
+ const stripped = stripComments(text);
624
+ const out = [];
625
+ for (let i = 0; i < stripped.length; i++) {
626
+ const line = stripped[i];
627
+ if (!line.trim()) continue;
628
+ RAW_WRITE_CALL_START_RE.lastIndex = 0;
629
+ let m;
630
+ while ((m = RAW_WRITE_CALL_START_RE.exec(line)) !== null) {
631
+ const argStart = m.index + m[0].length;
632
+ const targetArg = captureFirstArg(line, argStart).trim();
633
+ if (!targetsStatePath(targetArg)) continue;
634
+ // `file`/`source` sanitized for the same fork-PR reason as every other
635
+ // finding in this guard.
636
+ out.push({
637
+ reason: REASON.RAW_STATE_WRITE,
638
+ axis: 'raw-write',
639
+ file: sanitizeForReport(rel),
640
+ line: i + 1,
641
+ source: sanitizeForReport(rawLines[i].trim()),
642
+ });
643
+ }
644
+ }
645
+ return out;
646
+ }
647
+
648
+ // The two write-seam STAGE functions, matched only as CALLS (`\(`
649
+ // immediately after, modulo whitespace) — never as bare mentions of the
650
+ // name. `writeStateMd(` is deliberately NOT included here (that arm is
651
+ // retired — ADR-3473 §8.6 gates it at the type level instead).
652
+ const SEAM_CALL_RE = /\b(syncStateFrontmatter|applyPostSyncPreservation)\s*\(/g;
653
+ // A line that IS one of the two seam stage functions' own definitions —
654
+ // skipped outright, never counted as a call to itself.
655
+ const SEAM_DEF_LINE_RE = /^\s*(?:export\s+)?(?:async\s+)?function\s+(?:syncStateFrontmatter|applyPostSyncPreservation)\b/;
523
656
 
524
657
  /**
525
- * AXIS 2a: every direct `writeStateMd(`/`syncStateFrontmatter(`/
526
- * `applyPostSyncPreservation(` call in `text`, outside the three functions'
527
- * own definitions and (only inside `SEAM_OWNER_FILE`) outside
528
- * `SEAM_OWNER_EXEMPT_FUNCTIONS`'s own bodies. No `reason` on these
529
- * findings — `applyRatchet` assigns one, since the same observed call site
530
- * is a different failure shape depending on whether the baseline already
531
- * knows about it.
658
+ * AXIS 5 (§8.3, RETAINED, issue #3871): every direct `syncStateFrontmatter(`/
659
+ * `applyPostSyncPreservation(` call in `text`, outside the two functions' own
660
+ * definitions and (only inside `SEAM_OWNER_FILE`) outside
661
+ * `SEAM_OWNER_EXEMPT_FUNCTIONS`'s own bodies. Terminal: unlike the old
662
+ * `findSeamBypasses` this axis descends from, there is no ratchet — any
663
+ * occurrence is `REASON.COMPOSITION_BYPASS` directly.
532
664
  */
533
- function findSeamBypasses(rel, text) {
665
+ function findCompositionBypasses(rel, text) {
534
666
  const rawLines = text.split('\n');
535
667
  const stripped = stripComments(text);
536
668
  const out = [];
@@ -545,13 +677,11 @@ function findSeamBypasses(rel, text) {
545
677
  const fn = enclosingFunction(stripped, i);
546
678
  if (fn && SEAM_OWNER_EXEMPT_FUNCTIONS.includes(fn)) continue;
547
679
  }
548
- // `file` is sanitized here, at construction, not just at the human
549
- // formatter: `rel` is exactly as attacker-controlled as `source` on a
550
- // fork PR (a tracked filename can legally carry C1 bytes or bidi
551
- // overrides), and it reaches the committed baseline and `--json`
552
- // stdout unfiltered otherwise — see `sanitizeForReport`'s own header.
680
+ // `file`/`source` sanitized for the same fork-PR reason as every other
681
+ // finding in this guard.
553
682
  out.push({
554
- axis: 'write-seam',
683
+ reason: REASON.COMPOSITION_BYPASS,
684
+ axis: 'composition-bypass',
555
685
  file: sanitizeForReport(rel),
556
686
  line: i + 1,
557
687
  symbol: m[1],
@@ -600,10 +730,11 @@ function isInsideCodeSpan(line, index) {
600
730
  }
601
731
 
602
732
  /**
603
- * AXIS 2b: every prompt-layer line instructing an agent to shell out to a
604
- * write-side `gsd-tools` subcommand. Same finding shape as
605
- * `findSeamBypasses` (no `reason` — the ratchet assigns it), with a fixed
606
- * `symbol` since there is no single function name to report for prose.
733
+ * AXIS 4: every prompt-layer line instructing an agent to shell out to a
734
+ * write-side `gsd-tools` subcommand. Terminal (unratcheted): any occurrence
735
+ * is a violation — this baseline was always empty for the prompt layer (no
736
+ * prompt-layer entry was ever acknowledged), so removing the ratchet changes
737
+ * nothing observable here.
607
738
  *
608
739
  * A candidate occurrence enclosed in backticks is a MENTION, not an
609
740
  * invocation, and is deliberately not reported — CONTRIBUTING.md's "Every
@@ -614,8 +745,7 @@ function isInsideCodeSpan(line, index) {
614
745
  * wrong: the first `lint-phase-enumeration-drift.cjs` flagged JSDoc that
615
746
  * merely documented the canonical owner as drift, which trains readers to
616
747
  * reflexively exempt documentation instead of trusting the guard — the
617
- * opposite of Decision 4(a)'s intent. All 5 of this guard's original
618
- * prompt-layer baseline entries were exactly this false positive.
748
+ * opposite of Decision 4(a)'s intent.
619
749
  */
620
750
  function findPromptSeamUses(rel, text) {
621
751
  const lines = text.split('\n');
@@ -626,10 +756,11 @@ function findPromptSeamUses(rel, text) {
626
756
  let m;
627
757
  while ((m = PROMPT_SEAM_RE.exec(line)) !== null) {
628
758
  if (isInsideCodeSpan(line, m.index)) continue;
629
- // `file` is sanitized here for the same reason as `findSeamBypasses`
630
- // above: `rel` is attacker-controlled on a fork PR, exactly like
631
- // `source`.
759
+ // `file` is sanitized here for the same reason as every other finding
760
+ // in this guard: `rel` is attacker-controlled on a fork PR, exactly
761
+ // like `source`.
632
762
  out.push({
763
+ reason: REASON.PROMPT_LAYER_STATE_WRITE,
633
764
  axis: 'write-seam',
634
765
  file: sanitizeForReport(rel),
635
766
  line: i + 1,
@@ -642,149 +773,23 @@ function findPromptSeamUses(rel, text) {
642
773
  }
643
774
 
644
775
  /**
645
- * Ratchet key for one write-seam finding — `(file, TRIMMED source text)`,
646
- * NEVER a line number, which churns on any unrelated edit to the same file
647
- * (mirrors `qa-smell-ratchet.cjs`'s own key shape). `v.source` is already
648
- * the trimmed, sanitized line text by the time it reaches this function.
649
- */
650
- function ratchetKey(v) {
651
- return `${v.file} ${v.source}`;
652
- }
653
-
654
- /**
655
- * Read `BASELINE_PATH`. Returns `{ entries: [] }` when the file is ABSENT
656
- * (`ENOENT` — first run, or a fully-shrunk Phase 4 baseline that deleted the
657
- * file — ADR-3408 §8.3's roster foresees exactly this end state); returns
658
- * `{ entries: null, code }` when the file is present but could not be read
659
- * OR could not be parsed/shaped (missing/malformed `entries` array) — `code`
660
- * carries the underlying `fs` error code (e.g. `'EACCES'`) when the failure
661
- * happened at the read step, `null` when it happened at the parse/shape
662
- * step, so the caller can fail closed rather than silently ratcheting
663
- * against nothing. Returns the parsed object when the read+parse succeed.
776
+ * Run both scan passes (the `src/` tree for Axis 1 + Axis 2 + Axis 3 + Axis 5,
777
+ * the prompt layer for Axis 4) and return the flat, already-terminal finding
778
+ * list — every finding this guard produces carries its own `reason`; there
779
+ * is no longer a ratcheted axis needing a second pass against a baseline.
664
780
  *
665
- * Absent-vs-unreadable is deliberately NOT collapsed into one arm. This
666
- * guard exists to catch write paths whose failure and success are
667
- * output-identical (ADR-3180 / ADR-3408, "The failure mode that hides all
668
- * of it") — a `catch { return { entries: [] } }` around the read would
669
- * reproduce exactly that shape in the tool built to detect it: an
670
- * unreadable baseline (EACCES, EISDIR, EIO, ...) would be silently
671
- * indistinguishable from a legitimate absent one. Do not simplify this back
672
- * into a single catch arm.
781
+ * `root` defaults to `REPO_ROOT` (this repo) so every existing caller —
782
+ * `npm run lint:ci`, a bare `node scripts/lint-state-write-path-drift.cjs`,
783
+ * every other module that `require`s `collect` with no argument — is
784
+ * byte-identical to before this parameter existed. It is overridable so a
785
+ * test can exercise the guard's real scanning/reporting logic against a
786
+ * throwaway synthetic tree instead of mutating this repository's own `src/`
787
+ * to prove the guard can fail (see `--root` on the CLI, and F2 in
788
+ * `tests/state-write-path-drift-guard.test.cjs`).
673
789
  */
674
- function loadBaseline() {
675
- let raw;
676
- try {
677
- raw = fs.readFileSync(BASELINE_PATH, 'utf8');
678
- } catch (err) {
679
- if (err && err.code === 'ENOENT') return { entries: [] };
680
- return { entries: null, code: err && err.code ? err.code : 'UNKNOWN' };
681
- }
682
- let doc;
683
- try {
684
- doc = JSON.parse(raw);
685
- } catch {
686
- return { entries: null, code: null };
687
- }
688
- if (!doc || typeof doc !== 'object' || !Array.isArray(doc.entries)) return { entries: null, code: null };
689
- return doc;
690
- }
691
-
692
- /**
693
- * The ratchet — mirrors `scripts/qa-smell-ratchet.cjs`'s four invariants,
694
- * applied to write-seam bypass COUNTS instead of QA-smell fingerprints:
695
- *
696
- * 1. An observed key absent from the baseline is a brand-new,
697
- * unacknowledged bypass — `SEAM_BYPASS_UNRECORDED`.
698
- * 2. An observed count greater than the acknowledged count is a NEW copy
699
- * landing beside an already-acknowledged one — `SEAM_BYPASS_COUNT_GREW`.
700
- * 3. An observed count less than the acknowledged count is a PARTIAL
701
- * migration — some but not all call sites at this exact key were
702
- * removed, and the baseline still claims the old, larger number —
703
- * `SEAM_BYPASS_COUNT_SHRANK`.
704
- * 4. A baseline key with zero current observations is a STALE
705
- * acknowledgment: an entry may never outlive what it describes, and
706
- * the baseline may only shrink (via `--baseline`, regenerated) —
707
- * `BASELINE_ENTRY_STALE`.
708
- *
709
- * The occurrence COUNT (not just key presence) is what makes a partial
710
- * migration visible at all: two byte-identical call sites in one file are
711
- * otherwise a single indistinguishable key, so removing one of the two
712
- * would silently vanish from a presence-only check while the acknowledgment
713
- * still describes two.
714
- *
715
- * Every returned finding carries both `observed` and `acknowledged` counts.
716
- */
717
- function applyRatchet(observed, baseline) {
718
- const observedByKey = new Map();
719
- for (const finding of observed) {
720
- const key = ratchetKey(finding);
721
- let group = observedByKey.get(key);
722
- if (!group) {
723
- group = { count: 0, sample: finding };
724
- observedByKey.set(key, group);
725
- }
726
- group.count += 1;
727
- }
728
-
729
- const baselineByKey = new Map();
730
- for (const entry of baseline.entries) {
731
- baselineByKey.set(`${entry.file} ${entry.source}`, entry);
732
- }
733
-
734
- const out = [];
735
- for (const [key, group] of observedByKey) {
736
- const entry = baselineByKey.get(key);
737
- const acknowledged = entry && typeof entry.count === 'number' ? entry.count : 0;
738
- let reason = null;
739
- if (!entry) {
740
- reason = REASON.SEAM_BYPASS_UNRECORDED;
741
- } else if (group.count > acknowledged) {
742
- reason = REASON.SEAM_BYPASS_COUNT_GREW;
743
- } else if (group.count < acknowledged) {
744
- reason = REASON.SEAM_BYPASS_COUNT_SHRANK;
745
- }
746
- if (!reason) continue;
747
- out.push({
748
- reason,
749
- axis: 'write-seam',
750
- file: group.sample.file,
751
- line: group.sample.line,
752
- symbol: group.sample.symbol,
753
- source: group.sample.source,
754
- observed: group.count,
755
- acknowledged,
756
- });
757
- }
758
-
759
- for (const [key, entry] of baselineByKey) {
760
- if (observedByKey.has(key)) continue;
761
- out.push({
762
- reason: REASON.BASELINE_ENTRY_STALE,
763
- axis: 'write-seam',
764
- file: entry.file,
765
- line: 0,
766
- symbol: entry.symbol,
767
- source: entry.source,
768
- observed: 0,
769
- acknowledged: typeof entry.count === 'number' ? entry.count : 0,
770
- });
771
- }
772
-
773
- return out;
774
- }
775
-
776
- /**
777
- * Run both scan passes (the `src/` tree for Axis 1 + Axis 2a + Axis 3, the
778
- * prompt layer for Axis 2b) and split the combined findings by `axis` into
779
- * `{ policyFindings, seamFindings }`. `policyFindings` are already terminal
780
- * (each carries its own `reason`) — this bucket is every axis EXCEPT
781
- * `write-seam` (Axis 2), which alone is ratcheted; `seamFindings` are raw
782
- * write-seam observations — `applyRatchet` is what turns them into (or
783
- * clears them of) a finding.
784
- */
785
- function collect() {
790
+ function collect(root = REPO_ROOT) {
786
791
  const srcFindings = scanTree({
787
- root: REPO_ROOT,
792
+ root,
788
793
  scanDirs: SRC_DIRS,
789
794
  scanExt: SRC_EXT,
790
795
  onFile(rel, text) {
@@ -795,13 +800,14 @@ function collect() {
795
800
  found.push(...findUnimplementedPolicies(text, relPosix));
796
801
  found.push(...findUnstrippedContentWrites(relPosix, text));
797
802
  }
798
- found.push(...findSeamBypasses(relPosix, text));
803
+ found.push(...findRawStateWrites(relPosix, text));
804
+ found.push(...findCompositionBypasses(relPosix, text));
799
805
  return found;
800
806
  },
801
807
  });
802
808
 
803
809
  const promptFindings = scanTree({
804
- root: REPO_ROOT,
810
+ root,
805
811
  scanDirs: PROMPT_DIRS,
806
812
  scanExt: PROMPT_EXT,
807
813
  onFile(rel, text) {
@@ -809,96 +815,11 @@ function collect() {
809
815
  },
810
816
  });
811
817
 
812
- const all = [...srcFindings, ...promptFindings];
813
- return {
814
- policyFindings: all.filter((f) => f.axis !== 'write-seam'),
815
- seamFindings: all.filter((f) => f.axis === 'write-seam'),
816
- };
817
- }
818
-
819
- /**
820
- * Group `seamFindings` by `ratchetKey` into the baseline entry shape
821
- * (`{file, source, symbol, count, owner}`), sorted by `file+source`.
822
- *
823
- * `owner` is NEVER invented by this mechanical regeneration — the guard can
824
- * observe WHERE a bypass is and HOW MANY there are, but not which issue owns
825
- * removing it; inventing one would violate the same "never guess" discipline
826
- * `qa-smell-ratchet.cjs` applies to its own `issue` field (its `--update`
827
- * never invents an issue number either). ADR-3408 §8.3 requires every
828
- * shipped entry to carry "a named ratchet entry carrying the issue that owns
829
- * its removal, never an unrecorded pass" — that owner is HUMAN-CURATED and
830
- * must be recorded before the entry ships.
831
- *
832
- * Because `--baseline` overwrites `BASELINE_PATH` wholesale, a naive
833
- * mechanical regeneration would silently re-null every curated `owner` on
834
- * each run. To avoid that, `existingEntries` (the baseline as it stood
835
- * BEFORE this regeneration, i.e. `loadBaseline().entries`) is optional and,
836
- * when supplied, its `owner` values are merged forward onto matching new
837
- * entries keyed on `(file, source)` — the same key `ratchetKey` /
838
- * `applyRatchet` use to identify a bypass. A key with no prior entry (a
839
- * brand-new bypass) still gets `owner: null`, exactly as before; only
840
- * already-curated owners survive the regeneration. Re-running `--baseline`
841
- * twice in a row is therefore idempotent with respect to `owner`.
842
- */
843
- function buildBaselineEntries(seamFindings, existingEntries) {
844
- const priorOwnerByKey = new Map();
845
- if (Array.isArray(existingEntries)) {
846
- for (const entry of existingEntries) {
847
- priorOwnerByKey.set(ratchetKey(entry), entry.owner);
848
- }
849
- }
850
-
851
- const groups = new Map();
852
- for (const finding of seamFindings) {
853
- const key = ratchetKey(finding);
854
- let group = groups.get(key);
855
- if (!group) {
856
- group = {
857
- file: finding.file,
858
- source: finding.source,
859
- symbol: finding.symbol,
860
- count: 0,
861
- owner: priorOwnerByKey.has(key) ? priorOwnerByKey.get(key) : null,
862
- };
863
- groups.set(key, group);
864
- }
865
- group.count += 1;
866
- }
867
- return [...groups.values()].sort((a, b) => ratchetKey(a).localeCompare(ratchetKey(b)));
868
- }
869
-
870
- const BASELINE_COMMENT =
871
- 'ADR-3408 Decision 5 write-seam ratchet baseline (issue #3468, Phase 1; Phase 2 / #3469 lands the ' +
872
- 'single write seam and Amendment 2). Every entry here is a `writeStateMd(`/`syncStateFrontmatter(`/' +
873
- '`applyPostSyncPreservation(` bypass this guard found by a whole-repo scan (Decision 4(a)) — it is ' +
874
- 'ACKNOWLEDGED, not endorsed: acknowledgment is in writing (this file), with the issue owning its ' +
875
- 'removal recorded in the entry\'s "owner" field. This baseline is SHRINK-ONLY — an entry that stops ' +
876
- 'firing goes STALE and fails the plain run until `--baseline` is re-run to drop it (ADR-3180 ' +
877
- 'Decision 4(e)\'s "the baseline may only shrink", adopted verbatim by ADR-3408). Phase 2 (#3469) ' +
878
- 'removed the `cmdPhaseComplete` (`src/phase.cts`) and `cmdMilestoneComplete` (`src/milestone.cts`) ' +
879
- 'entries by routing both through the single write-seam composition (`syncAndPreserveStateMd`, ' +
880
- '`src/state.cts`). ADR-3408 Amendment 2: "0 bypasses" was never this baseline\'s target — TWO ' +
881
- 'entries are SANCTIONED PERMANENT, not debt, and Phase 4 (#3471) does NOT drive this file to empty: ' +
882
- '`cmdStateSync` (`src/state.cts`) exists precisely to let the body win (#905 — `state sync` ' +
883
- 're-derives frontmatter FROM the body), so routing it through preservation would invert the command ' +
884
- 'rather than fix a bug; `REGENERATE_STATE` (`src/health-diagnostic.cts`) is `/gsd-health --repair`\'s ' +
885
- 'factory reset, which rebuilds STATE.md from scratch, so preservation would restore exactly the ' +
886
- 'values it was invoked to discard. Neither entry may be "consolidated" away — a guard reporting them ' +
887
- 'is reporting correctly, and a change that removes one is a regression, not progress.';
888
-
889
- function writeBaseline(seamFindings) {
890
- const priorBaseline = loadBaseline();
891
- const entries = buildBaselineEntries(
892
- seamFindings,
893
- Array.isArray(priorBaseline.entries) ? priorBaseline.entries : null,
894
- );
895
- const doc = { _comment: BASELINE_COMMENT, entries };
896
- fs.writeFileSync(BASELINE_PATH, `${JSON.stringify(doc, null, 2)}\n`, 'utf8');
897
- return entries;
818
+ return { findings: [...srcFindings, ...promptFindings] };
898
819
  }
899
820
 
900
821
  const GOODHART_NOTE =
901
- 'Goodhart note (ADR-3408 Decision 5): this "0 write-path bypasses" is a LAGGING metric — report ' +
822
+ 'Goodhart note (ADR-3408 Decision 5): this "0 write-path violations" is a LAGGING metric — report ' +
902
823
  'it only alongside the behavioral identity test\'s result (asserted at the consumer\'s output), ' +
903
824
  'never alone.';
904
825
 
@@ -910,81 +831,66 @@ function printFindings(findings) {
910
831
  }
911
832
 
912
833
  /**
913
- * `argv`: `--baseline` regenerates `BASELINE_PATH` from a fresh scan and
914
- * exits 0; `--json` (check mode only) prints the machine-readable finding
915
- * set instead of the human-readable report. Exit codes: 0 clean, 1 drift
916
- * (policy-dispatch violation, ratchet violation, or an unreadable
917
- * baseline), 2 usage error.
834
+ * Parse `argv` into `{ root, wantJson, unknown }`. `--root <dir>` overrides
835
+ * the scan root (default `REPO_ROOT`, resolved relative to `process.cwd()`
836
+ * when given); `--json` is a bare flag. Anything else lands in `unknown` so
837
+ * `main` can report a usage error rather than silently ignoring a typo.
918
838
  */
919
- function main(argv) {
839
+ function parseArgs(argv) {
920
840
  const args = argv || [];
921
- const recognized = new Set(['--baseline', '--json']);
922
- const unknown = args.filter((a) => !recognized.has(a));
841
+ let root = REPO_ROOT;
842
+ let wantJson = false;
843
+ const unknown = [];
844
+ for (let i = 0; i < args.length; i++) {
845
+ const a = args[i];
846
+ if (a === '--json') {
847
+ wantJson = true;
848
+ continue;
849
+ }
850
+ if (a === '--root') {
851
+ const value = args[i + 1];
852
+ if (typeof value !== 'string' || value.length === 0) {
853
+ return { error: '--root requires a directory argument' };
854
+ }
855
+ root = path.resolve(value);
856
+ i += 1;
857
+ continue;
858
+ }
859
+ unknown.push(a);
860
+ }
861
+ return { root, wantJson, unknown };
862
+ }
863
+
864
+ /**
865
+ * `argv`: `--json` prints the machine-readable finding set instead of the
866
+ * human-readable report; `--root <dir>` overrides the scan root (defaults to
867
+ * this repo — see `parseArgs`'s own docstring). Exit codes: 0 clean, 1 a
868
+ * finding was reported, 2 usage error.
869
+ */
870
+ function main(argv) {
871
+ const parsed = parseArgs(argv);
872
+ if (parsed.error) {
873
+ process.stderr.write(`lint-state-write-path-drift: ${parsed.error}\n`);
874
+ process.exitCode = 2;
875
+ return;
876
+ }
877
+ const { root, wantJson, unknown } = parsed;
923
878
  if (unknown.length > 0) {
924
879
  process.stderr.write(
925
880
  `lint-state-write-path-drift: unrecognized argument(s): ${unknown.map((a) => sanitizeForReport(a)).join(', ')} ` +
926
- '(expected --baseline and/or --json)\n',
881
+ '(expected --json and/or --root <dir>)\n',
927
882
  );
928
883
  process.exitCode = 2;
929
884
  return;
930
885
  }
931
886
 
932
- if (args.includes('--baseline')) {
933
- const { seamFindings } = collect();
934
- const entries = writeBaseline(seamFindings);
935
- process.stdout.write(`lint-state-write-path-drift: wrote ${entries.length} entries to ${BASELINE_PATH}\n`);
936
- process.exitCode = 0;
937
- return;
938
- }
939
-
940
- const wantJson = args.includes('--json');
941
- const baseline = loadBaseline();
942
-
943
- if (baseline.entries === null) {
944
- const finding = {
945
- reason: REASON.BASELINE_UNREADABLE,
946
- axis: 'write-seam',
947
- file: path.relative(REPO_ROOT, BASELINE_PATH),
948
- line: 0,
949
- symbol: null,
950
- code: baseline.code,
951
- source: sanitizeForReport(
952
- baseline.code
953
- ? `${BASELINE_PATH} is present but could not be read (${baseline.code}) — run \`node ${__filename} --baseline\` to regenerate it`
954
- : `${BASELINE_PATH} is present but unparseable — run \`node ${__filename} --baseline\` to regenerate it`,
955
- ),
956
- };
957
- if (wantJson) {
958
- process.stdout.write(
959
- `${JSON.stringify(
960
- {
961
- ok: false,
962
- findings: [finding],
963
- summary: { policyDispatchViolations: 0, seamBypassesObserved: 0, seamBypassesAcknowledged: 0, ratchetViolations: 0 },
964
- },
965
- null,
966
- 2,
967
- )}\n`,
968
- );
969
- } else {
970
- printFindings([finding]);
971
- }
972
- process.exitCode = 1;
973
- return;
974
- }
975
-
976
- const { policyFindings, seamFindings } = collect();
977
- const ratchetFindings = applyRatchet(seamFindings, baseline);
978
- const findings = [...policyFindings, ...ratchetFindings];
979
- const acknowledgedTotal = baseline.entries.reduce(
980
- (sum, e) => sum + (typeof e.count === 'number' ? e.count : 0),
981
- 0,
982
- );
887
+ const { findings } = collect(root);
983
888
  const summary = {
984
- policyDispatchViolations: policyFindings.length,
985
- seamBypassesObserved: seamFindings.length,
986
- seamBypassesAcknowledged: acknowledgedTotal,
987
- ratchetViolations: ratchetFindings.length,
889
+ policyDispatchViolations: findings.filter((f) => f.axis === 'policy-dispatch').length,
890
+ frontmatterWriteViolations: findings.filter((f) => f.axis === 'frontmatter-write').length,
891
+ rawWriteViolations: findings.filter((f) => f.axis === 'raw-write').length,
892
+ writeSeamViolations: findings.filter((f) => f.axis === 'write-seam').length,
893
+ compositionBypassViolations: findings.filter((f) => f.axis === 'composition-bypass').length,
988
894
  };
989
895
 
990
896
  if (wantJson) {
@@ -995,8 +901,8 @@ function main(argv) {
995
901
 
996
902
  if (findings.length === 0) {
997
903
  process.stdout.write(
998
- 'ok state-write-path-drift: no policy-dispatch violations, no unrecorded/grown/shrunk/stale ' +
999
- 'write-seam entries against the acknowledged baseline\n',
904
+ 'ok state-write-path-drift: no policy-dispatch, frontmatter-write, raw-write, prompt-layer, or ' +
905
+ 'composition-bypass violations found\n',
1000
906
  );
1001
907
  process.stdout.write(`${GOODHART_NOTE}\n`);
1002
908
  process.exitCode = 0;
@@ -1004,8 +910,9 @@ function main(argv) {
1004
910
  }
1005
911
 
1006
912
  process.stderr.write(
1007
- 'state-write-path-drift: policy-dispatch and/or write-seam divergence found (ADR-3408 §8.1/§8.3). ' +
1008
- 'See docs/adr/3408-state-write-path-preservation.md for the contract:\n',
913
+ 'state-write-path-drift: violation(s) found (ADR-3408 §8.1/§8.3, ADR-3473 §8.6). See ' +
914
+ 'docs/adr/3408-state-write-path-preservation.md and docs/adr/3473-enforcement-by-construction.md ' +
915
+ 'for the contract:\n',
1009
916
  );
1010
917
  printFindings(findings);
1011
918
  process.exitCode = 1;
@@ -1016,7 +923,6 @@ if (require.main === module) main(process.argv.slice(2));
1016
923
  module.exports = {
1017
924
  REASON,
1018
925
  REPO_ROOT,
1019
- BASELINE_PATH,
1020
926
  SRC_DIRS,
1021
927
  SRC_EXT,
1022
928
  PROMPT_DIRS,
@@ -1033,13 +939,12 @@ module.exports = {
1033
939
  findUnstrippedContentWrites,
1034
940
  isQuotedLiteralArg,
1035
941
  nearestPrecedingAssignment,
1036
- findSeamBypasses,
942
+ findRawStateWrites,
943
+ targetsStatePath,
944
+ findCompositionBypasses,
1037
945
  findPromptSeamUses,
1038
946
  isInsideCodeSpan,
1039
- ratchetKey,
1040
- loadBaseline,
1041
- applyRatchet,
947
+ parseArgs,
1042
948
  collect,
1043
- buildBaselineEntries,
1044
949
  main,
1045
950
  };