@opengsd/gsd-core 1.9.1 → 1.11.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 (426) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +2 -3
  3. package/.opencode/plugins/gsd-core.js +8 -1
  4. package/agents/gsd-code-fixer.md +27 -3
  5. package/agents/gsd-debug-session-manager.md +11 -0
  6. package/agents/gsd-debugger.md +12 -246
  7. package/agents/gsd-doc-synthesizer.md +2 -4
  8. package/agents/gsd-executor.md +12 -10
  9. package/agents/gsd-integration-checker.md +3 -0
  10. package/agents/gsd-mempalace-curator.md +5 -2
  11. package/agents/gsd-phase-researcher.md +20 -1
  12. package/agents/gsd-plan-checker.md +46 -0
  13. package/agents/gsd-planner.md +49 -54
  14. package/agents/gsd-roadmapper.md +21 -3
  15. package/agents/gsd-user-profiler.md +3 -0
  16. package/agents/gsd-verifier.md +26 -73
  17. package/bin/install.js +1272 -1238
  18. package/bin/lib/ui-safety-gate.cjs +2 -0
  19. package/commands/gsd/code-review.md +1 -1
  20. package/commands/gsd/execute-phase.md +1 -1
  21. package/commands/gsd/map-codebase.md +1 -1
  22. package/commands/gsd/mempalace-capture.md +2 -2
  23. package/commands/gsd/mempalace-recall.md +1 -1
  24. package/commands/gsd/new-milestone.md +2 -2
  25. package/commands/gsd/plan-phase.md +1 -1
  26. package/commands/gsd/quick.md +1 -1
  27. package/commands/gsd/review-backlog.md +2 -1
  28. package/commands/gsd/verify-work.md +1 -1
  29. package/gsd-core/bin/gsd-tools.cjs +1009 -115
  30. package/gsd-core/bin/lib/active-workstream-store.cjs +153 -12
  31. package/gsd-core/bin/lib/agent-install-check.cjs +268 -38
  32. package/gsd-core/bin/lib/api-coverage.cjs +123 -5
  33. package/gsd-core/bin/lib/artifacts.cjs +3 -0
  34. package/gsd-core/bin/lib/assumption-delta.cjs +2 -4
  35. package/gsd-core/bin/lib/audit-command-router.cjs +9 -2
  36. package/gsd-core/bin/lib/audit.cjs +926 -202
  37. package/gsd-core/bin/lib/broken-windows.cjs +36 -6
  38. package/gsd-core/bin/lib/capability-consent.cjs +149 -15
  39. package/gsd-core/bin/lib/capability-lifecycle.cjs +45 -0
  40. package/gsd-core/bin/lib/capability-registry.cjs +608 -148
  41. package/gsd-core/bin/lib/capability-source.cjs +92 -0
  42. package/gsd-core/bin/lib/capability-trust.cjs +444 -25
  43. package/gsd-core/bin/lib/capability-validator.cjs +507 -24
  44. package/gsd-core/bin/lib/capability-writer.cjs +3 -2
  45. package/gsd-core/bin/lib/check-command-router.cjs +114 -38
  46. package/gsd-core/bin/lib/claude-orchestration.cjs +56 -3
  47. package/gsd-core/bin/lib/codex-agent-toml.cjs +329 -0
  48. package/gsd-core/bin/lib/command-aliases.cjs +94 -0
  49. package/gsd-core/bin/lib/command-roster.cjs +44 -1
  50. package/gsd-core/bin/lib/commands.cjs +665 -99
  51. package/gsd-core/bin/lib/commonjs-marker.cjs +142 -0
  52. package/gsd-core/bin/lib/complexity-trigger.cjs +1172 -0
  53. package/gsd-core/bin/lib/config-loader.cjs +76 -0
  54. package/gsd-core/bin/lib/config.cjs +22 -2
  55. package/gsd-core/bin/lib/context-composer.cjs +278 -0
  56. package/gsd-core/bin/lib/context-predicates.cjs +506 -0
  57. package/gsd-core/bin/lib/core-utils.cjs +217 -40
  58. package/gsd-core/bin/lib/decisions.cjs +23 -0
  59. package/gsd-core/bin/lib/docs.cjs +3 -2
  60. package/gsd-core/bin/lib/external-job.cjs +19 -4
  61. package/gsd-core/bin/lib/fallow-runner.cjs +20 -44
  62. package/gsd-core/bin/lib/frontmatter.cjs +239 -32
  63. package/gsd-core/bin/lib/gap-checker.cjs +68 -7
  64. package/gsd-core/bin/lib/gate-predicate-evaluator.cjs +57 -6
  65. package/gsd-core/bin/lib/git-base-branch.cjs +160 -15
  66. package/gsd-core/bin/lib/graphify.cjs +142 -27
  67. package/gsd-core/bin/lib/gsd2-import.cjs +37 -5
  68. package/gsd-core/bin/lib/health-diagnostic-rules/agent-install.cjs +101 -0
  69. package/gsd-core/bin/lib/health-diagnostic-rules/config-validation.cjs +348 -0
  70. package/gsd-core/bin/lib/health-diagnostic-rules/consistency.cjs +145 -0
  71. package/gsd-core/bin/lib/health-diagnostic-rules/install-surface-shadowing.cjs +98 -0
  72. package/gsd-core/bin/lib/health-diagnostic-rules/milestone-archive-hygiene.cjs +100 -0
  73. package/gsd-core/bin/lib/health-diagnostic-rules/phase-structure.cjs +222 -0
  74. package/gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.cjs +265 -0
  75. package/gsd-core/bin/lib/health-diagnostic-rules/root-existence.cjs +161 -0
  76. package/gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs +303 -0
  77. package/gsd-core/bin/lib/health-diagnostic-rules/worktree-health.cjs +173 -0
  78. package/gsd-core/bin/lib/health-diagnostic-types.cjs +68 -0
  79. package/gsd-core/bin/lib/health-diagnostic.cjs +431 -0
  80. package/gsd-core/bin/lib/host-integration.cjs +13 -1
  81. package/gsd-core/bin/lib/host-runtime-detection.cjs +134 -0
  82. package/gsd-core/bin/lib/init-command-router.cjs +83 -8
  83. package/gsd-core/bin/lib/init.cjs +1325 -169
  84. package/gsd-core/bin/lib/install-effort-resolver.cjs +73 -30
  85. package/gsd-core/bin/lib/install-engine.cjs +805 -264
  86. package/gsd-core/bin/lib/install-fs-adapter.cjs +262 -0
  87. package/gsd-core/bin/lib/install-model-override-resolver.cjs +203 -0
  88. package/gsd-core/bin/lib/install-profiles.cjs +160 -57
  89. package/gsd-core/bin/lib/install-scope.cjs +270 -0
  90. package/gsd-core/bin/lib/install-shadow-report.cjs +385 -0
  91. package/gsd-core/bin/lib/installed-surface-resolver.cjs +381 -0
  92. package/gsd-core/bin/lib/installer-migration-authoring.cjs +3 -1
  93. package/gsd-core/bin/lib/installer-migration-report.cjs +4 -0
  94. package/gsd-core/bin/lib/installer-migrations/007-retire-config-root-commonjs-marker.cjs +149 -0
  95. package/gsd-core/bin/lib/installer-migrations/008-cursor-retire-commands-surface.cjs +55 -0
  96. package/gsd-core/bin/lib/installer-migrations/009-pi-retire-reserved-hooks-dir.cjs +199 -0
  97. package/gsd-core/bin/lib/installer-migrations.cjs +206 -13
  98. package/gsd-core/bin/lib/io.cjs +38 -3
  99. package/gsd-core/bin/lib/markdown-sectionizer.cjs +8 -1
  100. package/gsd-core/bin/lib/markdown-table.cjs +133 -20
  101. package/gsd-core/bin/lib/mcp-catalog.cjs +518 -0
  102. package/gsd-core/bin/lib/mcp-server.cjs +135 -3
  103. package/gsd-core/bin/lib/milestone-lock.cjs +248 -0
  104. package/gsd-core/bin/lib/milestone.cjs +821 -109
  105. package/gsd-core/bin/lib/model-catalog.cjs +59 -1
  106. package/gsd-core/bin/lib/model-resolver.cjs +183 -40
  107. package/gsd-core/bin/lib/normalize-test-command.cjs +1 -1
  108. package/gsd-core/bin/lib/pattern.cjs +122 -0
  109. package/gsd-core/bin/lib/phase-estimation.cjs +1 -1
  110. package/gsd-core/bin/lib/phase-id.cjs +507 -36
  111. package/gsd-core/bin/lib/phase-lifecycle.cjs +28 -3
  112. package/gsd-core/bin/lib/phase-locator.cjs +258 -58
  113. package/gsd-core/bin/lib/phase.cjs +891 -156
  114. package/gsd-core/bin/lib/plan-dependency-graph.cjs +303 -0
  115. package/gsd-core/bin/lib/plan-drift-guard.cjs +120 -0
  116. package/gsd-core/bin/lib/plan-scan.cjs +86 -2
  117. package/gsd-core/bin/lib/planning-scope.cjs +31 -0
  118. package/gsd-core/bin/lib/planning-snapshot.cjs +890 -0
  119. package/gsd-core/bin/lib/planning-workspace.cjs +60 -6
  120. package/gsd-core/bin/lib/probe-core.cjs +1 -1
  121. package/gsd-core/bin/lib/profile-output.cjs +1 -1
  122. package/gsd-core/bin/lib/prompt-budget.cjs +128 -165
  123. package/gsd-core/bin/lib/refactor-trigger-command-router.cjs +740 -0
  124. package/gsd-core/bin/lib/retired-artifact-cleanup.cjs +85 -0
  125. package/gsd-core/bin/lib/review-lane-descriptor.cjs +108 -0
  126. package/gsd-core/bin/lib/review-lane-invocation.cjs +30 -0
  127. package/gsd-core/bin/lib/review-lane-runner.cjs +447 -68
  128. package/gsd-core/bin/lib/review-reviewer-selection.cjs +13 -18
  129. package/gsd-core/bin/lib/roadmap-command-router.cjs +76 -9
  130. package/gsd-core/bin/lib/roadmap-parser.cjs +1035 -194
  131. package/gsd-core/bin/lib/roadmap-upgrade.cjs +37 -10
  132. package/gsd-core/bin/lib/roadmap.cjs +405 -84
  133. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +795 -100
  134. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +14 -2
  135. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +440 -57
  136. package/gsd-core/bin/lib/runtime-config-adapter-registry.cjs +3 -2
  137. package/gsd-core/bin/lib/runtime-homes.cjs +220 -41
  138. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +220 -44
  139. package/gsd-core/bin/lib/runtime-name-policy.cjs +3 -1
  140. package/gsd-core/bin/lib/runtime-slash.cjs +27 -9
  141. package/gsd-core/bin/lib/section-manifest.cjs +209 -0
  142. package/gsd-core/bin/lib/security.cjs +104 -5
  143. package/gsd-core/bin/lib/shell-command-projection.cjs +388 -30
  144. package/gsd-core/bin/lib/smart-entry.cjs +154 -22
  145. package/gsd-core/bin/lib/state-command-router.cjs +5 -1
  146. package/gsd-core/bin/lib/state-document.cjs +152 -8
  147. package/gsd-core/bin/lib/state-transition.cjs +424 -105
  148. package/gsd-core/bin/lib/state.cjs +1927 -401
  149. package/gsd-core/bin/lib/surface.cjs +35 -10
  150. package/gsd-core/bin/lib/text-lines.cjs +80 -0
  151. package/gsd-core/bin/lib/token-scanner.cjs +76 -0
  152. package/gsd-core/bin/lib/uat-predicate.cjs +20 -4
  153. package/gsd-core/bin/lib/uat.cjs +706 -64
  154. package/gsd-core/bin/lib/ui-frontend-evidence.cjs +157 -0
  155. package/gsd-core/bin/lib/ui-safety-gate.cjs +14 -5
  156. package/gsd-core/bin/lib/unusable-input.cjs +33 -0
  157. package/gsd-core/bin/lib/update-context.cjs +8 -2
  158. package/gsd-core/bin/lib/user-artifact-staging.cjs +705 -0
  159. package/gsd-core/bin/lib/validate.cjs +20 -6
  160. package/gsd-core/bin/lib/vendor/README.md +37 -0
  161. package/gsd-core/bin/lib/vendor/re2js.cjs +6480 -0
  162. package/gsd-core/bin/lib/vendor/re2js.d.cts +938 -0
  163. package/gsd-core/bin/lib/verification-command-router.cjs +2 -1
  164. package/gsd-core/bin/lib/verification.cjs +287 -20
  165. package/gsd-core/bin/lib/verify.cjs +368 -880
  166. package/gsd-core/bin/lib/workflow-fragments.cjs +557 -0
  167. package/gsd-core/bin/lib/workstream-inventory-builder.cjs +203 -19
  168. package/gsd-core/bin/lib/workstream-inventory.cjs +576 -31
  169. package/gsd-core/bin/lib/workstream.cjs +8 -2
  170. package/gsd-core/bin/lib/worktree-base-ref.cjs +50 -6
  171. package/gsd-core/bin/lib/worktree-safety.cjs +450 -125
  172. package/gsd-core/bin/shared/config-defaults.manifest.json +1 -0
  173. package/gsd-core/bin/shared/config-schema.manifest.json +9 -1
  174. package/gsd-core/references/agent-contracts.md +43 -26
  175. package/gsd-core/references/artifact-types.md +10 -3
  176. package/gsd-core/references/autonomous-ui-design-contract.md +42 -0
  177. package/gsd-core/references/checkpoints.md +2 -2
  178. package/gsd-core/references/context-budget.md +1 -1
  179. package/gsd-core/references/debugger-techniques.md +255 -0
  180. package/gsd-core/references/dispatch-isolation-gate.md +138 -0
  181. package/gsd-core/references/doc-conflict-engine.md +1 -1
  182. package/gsd-core/references/execute-mvp-tdd.md +3 -3
  183. package/gsd-core/references/execute-phase-between-wave-reset.md +6 -2
  184. package/gsd-core/references/execute-phase-context-guard.md +1 -1
  185. package/gsd-core/references/execute-phase-response-language.md +1 -1
  186. package/gsd-core/references/execute-phase-wave-guard.md +6 -2
  187. package/gsd-core/references/gate-prompts.md +1 -1
  188. package/gsd-core/references/git-planning-commit.md +2 -1
  189. package/gsd-core/references/loop-hook-dispatch.md +39 -2
  190. package/gsd-core/references/model-profiles.md +12 -4
  191. package/gsd-core/references/mvp-concepts.md +9 -9
  192. package/gsd-core/references/planner-guidance.md +3 -9
  193. package/gsd-core/references/planner-preconditions.md +1 -1
  194. package/gsd-core/references/planner-reviews.md +1 -1
  195. package/gsd-core/references/planning-config.md +8 -6
  196. package/gsd-core/references/research-documentation-lookup.md +5 -3
  197. package/gsd-core/references/revision-loop.md +1 -1
  198. package/gsd-core/references/specless-probe-fallback.md +8 -7
  199. package/gsd-core/references/universal-anti-patterns.md +3 -3
  200. package/gsd-core/references/verifier-phase-gates.md +192 -0
  201. package/gsd-core/references/verifier-wiring-patterns.md +100 -0
  202. package/gsd-core/references/verify-mvp-mode.md +1 -1
  203. package/gsd-core/references/workstream-flag.md +22 -6
  204. package/gsd-core/references/worktree-branch-check.md +2 -2
  205. package/gsd-core/templates/discussion-log.md +1 -1
  206. package/gsd-core/templates/phase-prompt.md +2 -4
  207. package/gsd-core/templates/state.md +4 -4
  208. package/gsd-core/templates/summary-complex.md +2 -0
  209. package/gsd-core/templates/summary-minimal.md +2 -0
  210. package/gsd-core/templates/summary-standard.md +2 -0
  211. package/gsd-core/templates/summary.md +2 -0
  212. package/gsd-core/templates/verification-report.md +9 -1
  213. package/gsd-core/workflows/ai-integration-phase.md +9 -11
  214. package/gsd-core/workflows/audit-milestone.md +3 -0
  215. package/gsd-core/workflows/autonomous/steps/converge-banner.md +1 -0
  216. package/gsd-core/workflows/autonomous/steps/converge-dispatch-bg.md +11 -0
  217. package/gsd-core/workflows/autonomous/steps/converge-dispatch-inline.md +7 -0
  218. package/gsd-core/workflows/autonomous/steps/converge-fail-fast.md +21 -0
  219. package/gsd-core/workflows/autonomous/steps/converge-loop.md +7 -0
  220. package/gsd-core/workflows/autonomous.md +33 -70
  221. package/gsd-core/workflows/cleanup.md +62 -3
  222. package/gsd-core/workflows/code-review/steps/dispatch-fix.md +39 -0
  223. package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +93 -0
  224. package/gsd-core/workflows/code-review-fix.md +37 -10
  225. package/gsd-core/workflows/code-review.md +74 -166
  226. package/gsd-core/workflows/complete-milestone/steps/git-tag.md +29 -0
  227. package/gsd-core/workflows/complete-milestone.md +160 -95
  228. package/gsd-core/workflows/debug.md +16 -17
  229. package/gsd-core/workflows/diagnose-issues.md +56 -8
  230. package/gsd-core/workflows/discuss-phase/modes/chain.md +2 -1
  231. package/gsd-core/workflows/discuss-phase/modes/default.md +1 -1
  232. package/gsd-core/workflows/discuss-phase-assumptions/steps/auto-advance-dispatch.md +15 -0
  233. package/gsd-core/workflows/discuss-phase-assumptions.md +7 -17
  234. package/gsd-core/workflows/docs-update/steps/dispatch-monorepo-packages.md +51 -0
  235. package/gsd-core/workflows/docs-update.md +8 -51
  236. package/gsd-core/workflows/edit-phase.md +26 -1
  237. package/gsd-core/workflows/eval-review.md +3 -5
  238. package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +64 -7
  239. package/gsd-core/workflows/execute-phase/steps/gap-closure-artifacts.md +50 -0
  240. package/gsd-core/workflows/execute-phase/steps/partial-wave.md +31 -0
  241. package/gsd-core/workflows/execute-phase/steps/per-plan-executor-routing.md +77 -0
  242. package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +21 -0
  243. package/gsd-core/workflows/execute-phase/steps/regression-gate-run.md +42 -0
  244. package/gsd-core/workflows/execute-phase/steps/regression-gate.md +43 -37
  245. package/gsd-core/workflows/execute-phase.md +103 -187
  246. package/gsd-core/workflows/execute-plan.md +36 -4
  247. package/gsd-core/workflows/explore.md +131 -4
  248. package/gsd-core/workflows/fast.md +10 -2
  249. package/gsd-core/workflows/health.md +73 -4
  250. package/gsd-core/workflows/help/modes/full.md +6 -1
  251. package/gsd-core/workflows/import.md +4 -4
  252. package/gsd-core/workflows/ingest-docs.md +7 -6
  253. package/gsd-core/workflows/mvp-phase.md +6 -3
  254. package/gsd-core/workflows/new-milestone/steps/project-md-milestone-write.md +16 -0
  255. package/gsd-core/workflows/new-milestone/steps/reset-phase-safety.md +19 -0
  256. package/gsd-core/workflows/new-milestone.md +35 -47
  257. package/gsd-core/workflows/new-project/steps/auto-mode-config.md +176 -0
  258. package/gsd-core/workflows/new-project/steps/auto-mode-detection.md +32 -0
  259. package/gsd-core/workflows/new-project/steps/codebase-map-offer.md +18 -0
  260. package/gsd-core/workflows/new-project.md +27 -240
  261. package/gsd-core/workflows/next.md +12 -0
  262. package/gsd-core/workflows/plan-phase/steps/adr-ingest-express-path.md +15 -0
  263. package/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md +110 -0
  264. package/gsd-core/workflows/plan-phase/steps/prd-express-gate.md +8 -0
  265. package/gsd-core/workflows/plan-phase/steps/research-only-early-exit.md +17 -0
  266. package/gsd-core/workflows/plan-phase/steps/research-only-modifiers.md +16 -0
  267. package/gsd-core/workflows/plan-phase/steps/reviews-prerequisite.md +17 -0
  268. package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +149 -0
  269. package/gsd-core/workflows/plan-phase.md +89 -209
  270. package/gsd-core/workflows/plan-review-convergence.md +50 -2
  271. package/gsd-core/workflows/progress/steps/forensic-audit.md +125 -0
  272. package/gsd-core/workflows/progress/steps/mvp-display.md +18 -0
  273. package/gsd-core/workflows/progress.md +45 -159
  274. package/gsd-core/workflows/quick/steps/discussion-phase.md +124 -0
  275. package/gsd-core/workflows/quick/steps/plan-checker-loop.md +111 -0
  276. package/gsd-core/workflows/quick/steps/quick-verification.md +67 -0
  277. package/gsd-core/workflows/quick/steps/research-phase.md +72 -0
  278. package/gsd-core/workflows/quick/steps/worktree-pre-dispatch-commit.md +37 -0
  279. package/gsd-core/workflows/quick.md +55 -405
  280. package/gsd-core/workflows/resume-project.md +3 -0
  281. package/gsd-core/workflows/review/steps/reviewer-instances-note-1.md +4 -0
  282. package/gsd-core/workflows/review/steps/reviewer-instances-note-2.md +3 -0
  283. package/gsd-core/workflows/review.md +41 -13
  284. package/gsd-core/workflows/section-manifest.json +219 -0
  285. package/gsd-core/workflows/secure-phase.md +1 -1
  286. package/gsd-core/workflows/session-report.md +2 -1
  287. package/gsd-core/workflows/settings.md +66 -2
  288. package/gsd-core/workflows/ship.md +104 -44
  289. package/gsd-core/workflows/sketch.md +1 -1
  290. package/gsd-core/workflows/spec-phase.md +41 -20
  291. package/gsd-core/workflows/spike-wrap-up.md +20 -5
  292. package/gsd-core/workflows/spike.md +50 -16
  293. package/gsd-core/workflows/sync-skills.md +106 -13
  294. package/gsd-core/workflows/transition/steps/workstream-collision-check.md +17 -0
  295. package/gsd-core/workflows/transition.md +53 -31
  296. package/gsd-core/workflows/ui-phase.md +13 -12
  297. package/gsd-core/workflows/ui-review.md +2 -2
  298. package/gsd-core/workflows/update/steps/channel-banner.md +7 -0
  299. package/gsd-core/workflows/update.md +19 -8
  300. package/gsd-core/workflows/validate-phase.md +1 -1
  301. package/gsd-core/workflows/verify-work/steps/automated-ui-verification.md +36 -0
  302. package/gsd-core/workflows/verify-work/steps/mvp-uat-framing.md +21 -0
  303. package/gsd-core/workflows/verify-work.md +17 -65
  304. package/hooks/dist/gsd-agent-isolation-guard.js +517 -0
  305. package/hooks/dist/gsd-check-update-worker.js +64 -12
  306. package/hooks/dist/gsd-check-update.js +19 -1
  307. package/hooks/dist/gsd-cursor-pre-tool.js +0 -3
  308. package/hooks/dist/gsd-cursor-subagent-start.js +607 -26
  309. package/hooks/dist/gsd-cursor-subagent-stop.js +3 -2
  310. package/hooks/dist/gsd-prompt-guard.js +21 -20
  311. package/hooks/dist/gsd-read-injection-scanner.js +45 -24
  312. package/hooks/dist/gsd-statusline.js +90 -6
  313. package/hooks/dist/gsd-update-banner.js +22 -1
  314. package/hooks/dist/gsd-workflow-guard.js +134 -36
  315. package/hooks/dist/gsd-worktree-path-guard.js +2 -1
  316. package/hooks/dist/gsd-write-guard.js +359 -0
  317. package/hooks/dist/lib/git-cmd.js +92 -59
  318. package/hooks/dist/lib/injection-patterns.js +45 -0
  319. package/hooks/dist/lib/isolation-deny-reason.js +39 -0
  320. package/hooks/dist/lib/isolation-sentinel.js +277 -0
  321. package/hooks/dist/managed-hooks-registry.cjs +2 -0
  322. package/hooks/gsd-agent-isolation-guard.js +517 -0
  323. package/hooks/gsd-check-update-worker.js +64 -12
  324. package/hooks/gsd-check-update.js +19 -1
  325. package/hooks/gsd-cursor-pre-tool.js +0 -3
  326. package/hooks/gsd-cursor-subagent-start.js +607 -26
  327. package/hooks/gsd-cursor-subagent-stop.js +3 -2
  328. package/hooks/gsd-prompt-guard.js +21 -20
  329. package/hooks/gsd-read-injection-scanner.js +45 -24
  330. package/hooks/gsd-statusline.js +90 -6
  331. package/hooks/gsd-update-banner.js +22 -1
  332. package/hooks/gsd-workflow-guard.js +134 -36
  333. package/hooks/gsd-worktree-path-guard.js +2 -1
  334. package/hooks/gsd-write-guard.js +359 -0
  335. package/hooks/hooks.json +12 -0
  336. package/hooks/lib/git-cmd.js +92 -59
  337. package/hooks/lib/injection-patterns.js +45 -0
  338. package/hooks/lib/isolation-deny-reason.js +39 -0
  339. package/hooks/lib/isolation-sentinel.js +277 -0
  340. package/hooks/managed-hooks-registry.cjs +2 -0
  341. package/package.json +31 -10
  342. package/pi/gsd.cjs +71 -12
  343. package/scripts/baselines/planning-prompt-drift-baseline.json +4 -0
  344. package/scripts/baselines/planning-snapshot-bypass-baseline.json +12 -0
  345. package/scripts/baselines/unreachable-guard-drift-baseline.json +4 -0
  346. package/scripts/build-hooks.js +9 -0
  347. package/scripts/changeset/lint.cjs +68 -6
  348. package/scripts/changeset/serialize.cjs +5 -1
  349. package/scripts/check-alias-drift.cjs +7 -43
  350. package/scripts/check-contract-drift.cjs +297 -0
  351. package/scripts/ci-test-scope.cjs +19 -2
  352. package/scripts/command-contract-helpers.cjs +903 -1
  353. package/scripts/gen-adr-index.cjs +728 -38
  354. package/scripts/gen-capability-matrix.cjs +1 -1
  355. package/scripts/gen-capability-registry.cjs +3 -15
  356. package/scripts/gen-context-index.cjs +439 -0
  357. package/scripts/gen-health-docs.cjs +390 -0
  358. package/scripts/gen-inventory-manifest.cjs +150 -4
  359. package/scripts/gen-loop-host-contract.cjs +4 -24
  360. package/scripts/gen-prompt-budget-parity-corpus.cjs +645 -0
  361. package/scripts/gen-registry.cjs +3 -14
  362. package/scripts/gen-section-manifest.cjs +638 -0
  363. package/scripts/generate-package-identity.cjs +4 -2
  364. package/scripts/lib/alias-drift-families.cjs +46 -0
  365. package/scripts/lib/drift-scan.cjs +278 -0
  366. package/scripts/lint-allow-test-rule-refs.allowlist.json +15 -54
  367. package/scripts/lint-allow-test-rule-refs.effective-ceiling.json +4 -0
  368. package/scripts/lint-allow-test-rule-refs.unverified-ceiling.json +3 -0
  369. package/scripts/lint-canary-version-leak.cjs +73 -0
  370. package/scripts/lint-command-contract.cjs +96 -13
  371. package/scripts/lint-compiled-artifact-sync.cjs +6 -1
  372. package/scripts/lint-completion-predicate-drift.cjs +933 -0
  373. package/scripts/lint-completion-ratio-drift.cjs +214 -0
  374. package/scripts/lint-default-flip-documentation.cjs +193 -0
  375. package/scripts/lint-docs-command-form.cjs +195 -0
  376. package/scripts/lint-docs-required.cjs +9 -1
  377. package/scripts/lint-emitted-drift-ack.cjs +215 -20
  378. package/scripts/lint-eslint-glob-coverage.allowlist.json +34 -0
  379. package/scripts/lint-eslint-glob-coverage.cjs +340 -0
  380. package/scripts/lint-example-parser-parity.cjs +395 -0
  381. package/scripts/lint-frontmatter-scalar-broad-grep.cjs +237 -0
  382. package/scripts/lint-health-diagnostic-rule-table.cjs +404 -0
  383. package/scripts/lint-hooks-runtime-build-seam.cjs +262 -0
  384. package/scripts/lint-milestone-window-drift.cjs +468 -0
  385. package/scripts/lint-phase-enumeration-drift.cjs +479 -0
  386. package/scripts/lint-plan-count-drift.cjs +318 -0
  387. package/scripts/lint-planning-artifact-writer-drift.cjs +398 -0
  388. package/scripts/lint-planning-prompt-drift.cjs +434 -0
  389. package/scripts/lint-planning-snapshot-bypass-drift.cjs +544 -0
  390. package/scripts/lint-regression-test-names.cjs +15 -13
  391. package/scripts/lint-removed-but-needed.cjs +320 -0
  392. package/scripts/lint-state-field-drift.cjs +805 -0
  393. package/scripts/lint-state-write-path-drift.cjs +1045 -0
  394. package/scripts/lint-test-file-count.allowlist.json +40 -3
  395. package/scripts/lint-unreachable-guard-drift.cjs +843 -0
  396. package/scripts/lint-vendored-deps.cjs +124 -0
  397. package/scripts/mutation-matrix.cjs +13 -0
  398. package/scripts/pr-changed-files.cjs +63 -0
  399. package/scripts/pr-template-policy.cjs +14 -4
  400. package/scripts/prompt-injection-scan.sh +52 -6
  401. package/scripts/require-issue-link-policy.cjs +192 -0
  402. package/scripts/state-write-path-drift-baseline.json +19 -0
  403. package/scripts/sync-runtime-launcher.cjs +2 -4
  404. package/skills/gsd-autonomous/SKILL.md +0 -1
  405. package/skills/gsd-code-review/SKILL.md +1 -1
  406. package/skills/gsd-execute-phase/SKILL.md +1 -2
  407. package/skills/gsd-map-codebase/SKILL.md +1 -1
  408. package/skills/gsd-mempalace-capture/SKILL.md +2 -2
  409. package/skills/gsd-mempalace-recall/SKILL.md +1 -1
  410. package/skills/gsd-new-milestone/SKILL.md +2 -2
  411. package/skills/gsd-next/SKILL.md +0 -1
  412. package/skills/gsd-plan-phase/SKILL.md +1 -2
  413. package/skills/gsd-progress/SKILL.md +0 -1
  414. package/skills/gsd-quick/SKILL.md +1 -1
  415. package/skills/gsd-review-backlog/SKILL.md +2 -1
  416. package/skills/gsd-stats/SKILL.md +0 -1
  417. package/skills/gsd-verify-work/SKILL.md +1 -1
  418. package/vscode/package.json +1 -1
  419. package/gsd-core/workflows/discovery-phase.md +0 -298
  420. package/gsd-core/workflows/plan-milestone-gaps.md +0 -281
  421. package/gsd-core/workflows/verify-phase.md +0 -577
  422. package/scripts/affected-tests-lib.cjs +0 -554
  423. package/scripts/gen-emitted-baseline.cjs +0 -145
  424. package/scripts/lint-allow-test-rule-refs.cjs +0 -162
  425. package/scripts/run-affected-tests.cjs +0 -7
  426. package/scripts/run-tests.cjs +0 -1050
@@ -0,0 +1,1045 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * Anti-divergence drift guard for the STATE.md WRITE PATH — epic #3408, issue
6
+ * #3468, ADR-3408 Decision 5, contract §8.1/§8.2/§8.3
7
+ * (`docs/adr/3408-state-write-path-preservation.md` is the contract this
8
+ * guard enforces; read it first).
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:
14
+ *
15
+ * AXIS 1 — POLICY DISPATCH (§8.1). `applyStatePreservation` must select
16
+ * its branch from a `FIELD_CLASSIFICATION` row's `preservation` value,
17
+ * never from a field NAME. Every `getFieldClassification('<string
18
+ * literal>')` inside `src/state-transition.cts` is a field-name-keyed
19
+ * branch — the shape that let four declared rows go unimplemented until
20
+ * #3258, and that leaves `derive` and `clear` with no executor today. A
21
+ * VARIABLE argument (`getFieldClassification(field)`) is the CORRECT
22
+ * table-driven shape and is deliberately NOT matched. Also matched: a
23
+ * direct `field === '<literal>'` / `field !== '<literal>'` / `'<literal>'
24
+ * === field` comparison of the dispatch loop's own `field` variable — the
25
+ * same prohibited shape routed AROUND `getFieldClassification` instead of
26
+ * through it (#3468 found this exact form live in `applyPreserveIfPlaceholder`,
27
+ * undetected by the call-shape check alone). Scoped to the identifier
28
+ * `field` only; see `FIELD_VAR_EQ_LITERAL_RE`'s own comment for why.
29
+ *
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.
36
+ *
37
+ * DESIGN CONSTRAINTS (ADR-3180 Decision 4, adopted verbatim by ADR-3408):
38
+ * - 4(a) whole-repo scan, never an allowlist. ADR-3180's own phases found
39
+ * 26/5/54 copies where their epics scoped 3/3/4 — a scoped guard earns
40
+ * nothing.
41
+ * - 4(d) the scan surface is DECLARED and is NOT just `src/` — `src/`
42
+ * 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.
50
+ *
51
+ * GOODHART, PER ADR-3408 DECISION 5: "0 bypasses" is a LAGGING metric — a
52
+ * measure about to become a target. This guard's own `_comment` and its
53
+ * human-readable success message both say so: the zero this guard reports
54
+ * must NEVER be quoted alone; it is only meaningful beside the behavioral
55
+ * identity test's result (the consumer-output assertion Decision 5's gaming
56
+ * table names as the actual defense).
57
+ *
58
+ * String literals are matched, never parsed as an AST — deliberately, per
59
+ * `scripts/lint-state-field-drift.cjs`'s own precedent: over-reporting
60
+ * (flagging a comment or a string that merely looks like a call) is safe;
61
+ * under-reporting (missing a real bypass) is the failure this guard exists
62
+ * to prevent. `stripComments` does not track quoted strings for exactly
63
+ * this reason — see its own header.
64
+ *
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.
92
+ */
93
+
94
+ const fs = require('node:fs');
95
+ const path = require('node:path');
96
+ const { scanTree, sanitizeForReport } = require('./lib/drift-scan.cjs');
97
+ const { escapeRegex } = require('../gsd-core/bin/lib/pattern.cjs');
98
+
99
+ const REPO_ROOT = path.resolve(__dirname, '..');
100
+ const BASELINE_PATH = path.join(__dirname, 'state-write-path-drift-baseline.json');
101
+
102
+ // Frozen REASON enum — mirrors `lint-state-field-drift.cjs`'s house style of
103
+ // naming every failure shape explicitly rather than reusing one generic
104
+ // "violation" string, so a reader can `grep` a reason string straight back
105
+ // to the paragraph of this header (or of the ADR) that explains it.
106
+ const REASON = Object.freeze({
107
+ FIELD_NAME_DISPATCH: 'field_name_dispatch',
108
+ UNIMPLEMENTED_POLICY: 'unimplemented_policy',
109
+ // Axis 3 (§8.3(b), closed Phase 2 / #3469): a `stateReplaceField(` call
110
+ // with a variable field-name argument whose content argument was not run
111
+ // through `stripFrontmatter` first — see `findUnstrippedContentWrites`.
112
+ 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',
118
+ });
119
+
120
+ // 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.
124
+ const SRC_DIRS = ['src'];
125
+ const SRC_EXT = new Set(['.cts']);
126
+ const PROMPT_DIRS = ['gsd-core/workflows', 'commands', 'agents', 'skills'];
127
+ const PROMPT_EXT = new Set(['.md']);
128
+
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`.
133
+ const EXECUTOR_FILE = 'src/state-transition.cts';
134
+ const SEAM_OWNER_FILE = 'src/state.cts';
135
+
136
+ // 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
151
+ // exists to catch.
152
+ const SEAM_OWNER_EXEMPT_FUNCTIONS = ['writeStateMd', 'syncAndPreserveStateMd'];
153
+
154
+ // Unconditional path-separator normalization (never gated on
155
+ // `process.platform` — a Windows-authored fork PR must be judged by the
156
+ // same POSIX-relative rule as everything else this guard reads).
157
+ function toPosixRel(rel) {
158
+ return rel.split(path.sep).join('/');
159
+ }
160
+
161
+ /**
162
+ * Strip `//` line comments and `/* ... *\/` block comments from `text`,
163
+ * returning one entry PER INPUT LINE so line numbers computed against the
164
+ * result stay correct against the original file. Block comments are tracked
165
+ * across lines (`inBlock`); line comments only ever affect their own line.
166
+ *
167
+ * Deliberately does NOT parse string/template literal contents — a `//` or
168
+ * `/*` embedded inside a quoted string is treated exactly like real source,
169
+ * which can occasionally UNDER-strip (leaving a would-be-comment's text
170
+ * live) but never OVER-strips real code into invisibility. Per this guard's
171
+ * header and `lint-state-field-drift.cjs`'s own precedent: over-reporting a
172
+ * documentation paragraph that merely DESCRIBES a call (ADR-3180 Amendment
173
+ * 3's exact false positive) is the failure this exists to prevent; a rare
174
+ * miss on an adversarial one-line string is an accepted, narrower risk in
175
+ * the opposite (safe) direction — under-reporting, never over-reporting.
176
+ */
177
+ function stripComments(text) {
178
+ const lines = text.split('\n');
179
+ const out = new Array(lines.length);
180
+ let inBlock = false;
181
+ for (let i = 0; i < lines.length; i++) {
182
+ const line = lines[i];
183
+ let result = '';
184
+ let j = 0;
185
+ while (j < line.length) {
186
+ if (inBlock) {
187
+ const close = line.indexOf('*/', j);
188
+ if (close === -1) {
189
+ j = line.length;
190
+ break;
191
+ }
192
+ j = close + 2;
193
+ inBlock = false;
194
+ continue;
195
+ }
196
+ if (line[j] === '/' && line[j + 1] === '/') {
197
+ j = line.length; // rest of line is a line comment
198
+ break;
199
+ }
200
+ if (line[j] === '/' && line[j + 1] === '*') {
201
+ inBlock = true;
202
+ j += 2;
203
+ continue;
204
+ }
205
+ result += line[j];
206
+ j++;
207
+ }
208
+ out[i] = result;
209
+ }
210
+ return out;
211
+ }
212
+
213
+ // 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.
216
+ const FUNCTION_DECL_LINE_RE = /^\s*(?:export\s+)?(?:async\s+)?function\s+([A-Za-z_$][\w$]*)\s*\(/;
217
+
218
+ /**
219
+ * Nearest preceding named-function declaration, scanning `lines` BACKWARD
220
+ * from `index`. Scopes an exemption to a FUNCTION, never a FILE — a whole-
221
+ * file owner exemption is precisely how `getMilestoneInfo` stayed invisible
222
+ * to an earlier drift guard (ADR-3180 Decision 4(d)'s own cautionary case,
223
+ * cross-referenced by this guard's header).
224
+ */
225
+ function enclosingFunction(lines, index) {
226
+ for (let i = index; i >= 0; i--) {
227
+ const m = FUNCTION_DECL_LINE_RE.exec(lines[i]);
228
+ if (m) return m[1];
229
+ }
230
+ return null;
231
+ }
232
+
233
+ // One member of the `FieldPreservation` union, e.g. `'preserve-always'` —
234
+ // lowercase-with-dashes, single-quoted.
235
+ const POLICY_UNION_START_RE = /export\s+type\s+FieldPreservation\s*=/;
236
+ const POLICY_UNION_MEMBER_RE = /'([a-z][a-z-]*)'/g;
237
+
238
+ /**
239
+ * Parse the members of `export type FieldPreservation = 'a' | 'b' | ...;`
240
+ * straight out of the executor's own source, so this guard cannot drift
241
+ * from the type it polices (a hardcoded copy of the union would be exactly
242
+ * the "declared here, enforced somewhere else" shape ADR-3408 exists to
243
+ * remove — this time inside the GUARD). In `src/state-transition.cts` the
244
+ * union is declared across several lines, each shaped like
245
+ * ` | 'clear'; // remove the field entirely`. Scans from the declaration
246
+ * line forward, collecting every quoted token on each line, and stops at
247
+ * the first line whose (comment-INCLUDING) text still carries a `;` — the
248
+ * statement terminator ends the union regardless of any trailing comment.
249
+ */
250
+ function readPolicyUnion(text) {
251
+ const lines = text.split('\n');
252
+ const members = [];
253
+ let inUnion = false;
254
+ for (const line of lines) {
255
+ if (!inUnion) {
256
+ if (!POLICY_UNION_START_RE.test(line)) continue;
257
+ inUnion = true;
258
+ }
259
+ POLICY_UNION_MEMBER_RE.lastIndex = 0;
260
+ let m;
261
+ while ((m = POLICY_UNION_MEMBER_RE.exec(line)) !== null) {
262
+ members.push(m[1]);
263
+ }
264
+ if (line.includes(';')) break;
265
+ }
266
+ return members;
267
+ }
268
+
269
+ // `getFieldClassification(` called with a quoted string-literal argument —
270
+ // the field-name-keyed dispatch shape. `getFieldClassification(variable)`
271
+ // (a bare identifier, no quote) never matches this pattern, by construction
272
+ // (the quote-character backreference requires an opening quote immediately
273
+ // inside the parens) — the correct, table-driven shape is silent here.
274
+ const FIELD_NAME_DISPATCH_RE = /getFieldClassification\s*\(\s*(['"`])([^'"`]+)\1\s*\)/g;
275
+
276
+ // `field === '<literal>'` / `field !== '<literal>'`, and the reversed
277
+ // `'<literal>' === field` — the field-name-keyed BRANCH shape (as opposed to
278
+ // `FIELD_NAME_DISPATCH_RE`'s field-name-keyed CALL shape above; both report
279
+ // the same `REASON.FIELD_NAME_DISPATCH`, since both are "a branch selected
280
+ // by field name", ADR-3408 §8.1's exact prohibition). This is the shape a
281
+ // bypass takes when it routes AROUND `getFieldClassification` entirely
282
+ // rather than through it — ADR-3408 Decision 5's "route the bypass through
283
+ // a wrapper or a differently-named local" gaming route.
284
+ //
285
+ // Deliberately scoped to ONLY an identifier literally named `field` — the
286
+ // dispatch loop's own loop variable declared at
287
+ // `for (const field of Object.keys(FIELD_CLASSIFICATION))` a few dozen lines
288
+ // below in this same file. This is a DECLARED, narrow limitation, not a
289
+ // silent one: a rename of the loop variable would evade this detector
290
+ // entirely, and an unrelated local elsewhere in this file that happens to
291
+ // also be named `field` would false-positive. Both risks are accepted
292
+ // in trade for avoiding a name-agnostic match, which would flag every
293
+ // unrelated `===`/`!==` string comparison in the file (there are many —
294
+ // e.g. `derivedName !== MILESTONE_PLACEHOLDER`-shaped guards) and bury the
295
+ // real signal in noise; per this guard's own header, over-reporting a
296
+ // comment is an accepted risk but over-reporting live code this broadly is
297
+ // not.
298
+ //
299
+ // The reversed `!==` form (`'<literal>' !== field`) is deliberately NOT
300
+ // matched — not observed anywhere in this codebase, and left out rather
301
+ // than speculatively widened past what was found in practice.
302
+ const FIELD_VAR_EQ_LITERAL_RE = /\bfield\s*(?:===|!==)\s*(['"`])([^'"`]+)\1/g;
303
+ const LITERAL_EQ_FIELD_VAR_RE = /(['"`])([^'"`]+)\1\s*===\s*\bfield\b/g;
304
+
305
+ /**
306
+ * AXIS 1a: every `getFieldClassification('<literal>')` CALL, and every
307
+ * `field === '<literal>'` / `field !== '<literal>'` / `'<literal>' ===
308
+ * field` BRANCH, inside the executor is a field-name-keyed branch (§8.1).
309
+ * Only ever called against `EXECUTOR_FILE` — `collect()` gates the call
310
+ * site, mirroring `findPolicyDispatchDrift`'s own "only when rel ===
311
+ * EXECUTOR_FILE" rule from the spec this guard was authored against.
312
+ * `preservation === '<member>'` comparisons — the CORRECT policy-dispatch
313
+ * shape `findUnimplementedPolicies` requires to exist — are unaffected: the
314
+ * identifier compared there is `preservation`, never `field`, so
315
+ * `FIELD_VAR_EQ_LITERAL_RE`'s `\bfield\b` anchor does not reach them.
316
+ */
317
+ function findPolicyDispatchDrift(rel, text) {
318
+ const out = [];
319
+ const stripped = stripComments(text);
320
+ for (let i = 0; i < stripped.length; i++) {
321
+ const line = stripped[i];
322
+ if (!line.trim()) continue;
323
+ // `file` is sanitized here, at construction, not just at the human
324
+ // formatter: `rel` is exactly as attacker-controlled as `source` on a
325
+ // 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.
328
+ FIELD_NAME_DISPATCH_RE.lastIndex = 0;
329
+ let m;
330
+ while ((m = FIELD_NAME_DISPATCH_RE.exec(line)) !== null) {
331
+ out.push({
332
+ reason: REASON.FIELD_NAME_DISPATCH,
333
+ axis: 'policy-dispatch',
334
+ file: sanitizeForReport(rel),
335
+ line: i + 1,
336
+ // `field` is captured straight out of a quoted string literal in
337
+ // repo source — attacker-controlled on the same fork-PR basis as
338
+ // `file`/`source`, so sanitize it too rather than let it reach
339
+ // `--json` stdout / the baseline raw.
340
+ field: sanitizeForReport(m[2]),
341
+ source: sanitizeForReport(line.trim()),
342
+ });
343
+ }
344
+ FIELD_VAR_EQ_LITERAL_RE.lastIndex = 0;
345
+ while ((m = FIELD_VAR_EQ_LITERAL_RE.exec(line)) !== null) {
346
+ out.push({
347
+ reason: REASON.FIELD_NAME_DISPATCH,
348
+ axis: 'policy-dispatch',
349
+ file: sanitizeForReport(rel),
350
+ 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
+ field: sanitizeForReport(m[2]),
356
+ source: sanitizeForReport(line.trim()),
357
+ });
358
+ }
359
+ LITERAL_EQ_FIELD_VAR_RE.lastIndex = 0;
360
+ while ((m = LITERAL_EQ_FIELD_VAR_RE.exec(line)) !== null) {
361
+ out.push({
362
+ reason: REASON.FIELD_NAME_DISPATCH,
363
+ axis: 'policy-dispatch',
364
+ file: sanitizeForReport(rel),
365
+ 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
+ field: sanitizeForReport(m[2]),
371
+ source: sanitizeForReport(line.trim()),
372
+ });
373
+ }
374
+ }
375
+ return out;
376
+ }
377
+
378
+ /**
379
+ * AXIS 1b: every `FieldPreservation` member (read from `text` via
380
+ * `readPolicyUnion`, so the check cannot itself drift from the union) that
381
+ * has no `preservation === '<member>'` comparison anywhere in the
382
+ * comment-stripped executor source is a declared policy with no executor —
383
+ * §8.1's mirror defect, one level up (a whole MEMBER unimplemented, not just
384
+ * one dispatch call keyed on a field name). `derive` and `clear` are the
385
+ * live instances ADR-3408 §8.6 names.
386
+ */
387
+ function findUnimplementedPolicies(text, rel) {
388
+ const members = readPolicyUnion(text);
389
+ const strippedText = stripComments(text).join('\n');
390
+ const out = [];
391
+ for (const member of members) {
392
+ const memberRe = new RegExp(`preservation\\s*===\\s*'${escapeRegex(member)}'`);
393
+ if (memberRe.test(strippedText)) continue;
394
+ // `file` and `policy` are sanitized here for the same reason as
395
+ // `findPolicyDispatchDrift` above: both `rel` and a `FieldPreservation`
396
+ // union member are attacker-controlled on a fork PR, exactly like
397
+ // `source`.
398
+ out.push({
399
+ reason: REASON.UNIMPLEMENTED_POLICY,
400
+ axis: 'policy-dispatch',
401
+ file: sanitizeForReport(rel),
402
+ line: 0,
403
+ policy: sanitizeForReport(member),
404
+ source: sanitizeForReport(`FieldPreservation member '${member}' has no executor`),
405
+ });
406
+ }
407
+ return out;
408
+ }
409
+
410
+ // AXIS 3 (§8.3(b), closed Phase 2 / #3469): `stateReplaceField(<contentArg>,
411
+ // <fieldArg>, ...)` on a single line, capturing both argument expressions.
412
+ // `contentArg` must be a bare identifier (a call expression or property
413
+ // access as the first argument is not matched — silently out of scope, per
414
+ // this axis's own narrow-limitation note below) so its assignments can be
415
+ // tracked; `fieldArg` is everything up to the next comma, trimmed, so its
416
+ // literal-vs-variable shape can be read off directly.
417
+ const STATE_REPLACE_FIELD_CALL_RE = /\bstateReplaceField\s*\(\s*([A-Za-z_$][\w$]*)\s*,\s*([^,()]+),/g;
418
+
419
+ // True when `arg` (already trimmed) is a fixed string/template literal —
420
+ // the safe shape, since every literal field name this codebase actually
421
+ // uses is a Title-Case body label that cannot collide with a lowercase/
422
+ // snake_case YAML frontmatter key.
423
+ function isQuotedLiteralArg(arg) {
424
+ const t = arg.trim();
425
+ return t.startsWith("'") || t.startsWith('"') || t.startsWith('`');
426
+ }
427
+
428
+ /**
429
+ * The nearest assignment to `varName` (`varName = <expr>` or
430
+ * `const|let|var varName = <expr>`), scanning `lines` BACKWARD from `index`
431
+ * (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.
437
+ *
438
+ * Deliberately single-hop: this reports whatever the NEAREST assignment's
439
+ * right-hand side literally is, and does not itself follow a further alias
440
+ * (`let body = someOtherVar;` is reported as `"someOtherVar"`, not resolved
441
+ * further). Every real call site in this file assigns its body variable
442
+ * directly from `stripFrontmatter(content)` with no intermediate alias
443
+ * (`updateCore`, `patchCore`, `beginPhaseCore`'s `tryField` helper) — a
444
+ * future call site that introduces one extra hop of aliasing would evade
445
+ * this check. A declared, narrow limitation, not a silent one — mirrors
446
+ * this file's existing precedent (`FIELD_VAR_EQ_LITERAL_RE`'s own
447
+ * documented scope) of accepting a bounded risk in trade for not chasing
448
+ * full dataflow, which is exactly what made the Phase 1 approximation
449
+ * unusable (29 false positives to 1 true positive).
450
+ */
451
+ function nearestPrecedingAssignment(lines, index, varName) {
452
+ const assignRe = new RegExp(`(?:^|[^.\\w$])(?:const|let|var)?\\s*${escapeRegex(varName)}\\s*=\\s*([^=].*)$`);
453
+ for (let i = index; i >= 0; i--) {
454
+ if (FUNCTION_DECL_LINE_RE.test(lines[i])) return null;
455
+ const m = assignRe.exec(lines[i]);
456
+ if (m) return m[1].trim();
457
+ }
458
+ return null;
459
+ }
460
+
461
+ /**
462
+ * AXIS 3: every `stateReplaceField(` call in `EXECUTOR_FILE` whose field-name
463
+ * argument is a VARIABLE (not a quoted literal) — the only shape that can
464
+ * ever rewrite YAML frontmatter, since `stateReplaceField`'s `^field:` line
465
+ * pattern is case-insensitive and matches any line starting with that name,
466
+ * literal or not — AND whose content argument was not assigned from
467
+ * `stripFrontmatter(` at the nearest preceding assignment. A literal
468
+ * field-name argument is never flagged regardless of stripping: every fixed
469
+ * string this file's `stateReplaceField` calls use is a Title-Case body
470
+ * label (`'Phase'`, `'Total Plans in Phase'`, ...) that cannot collide with
471
+ * a lowercase/snake_case frontmatter key by construction, so checking its
472
+ * content argument would only add false positives on the ~20 already-safe
473
+ * `sectionBody`-scoped calls this axis must NOT report (mirrors
474
+ * `updateCore`'s strip-then-replace shape, and `beginPhaseCore`'s
475
+ * `stateReplaceField(body, name, value)`, both legitimately unflagged).
476
+ */
477
+ function findUnstrippedContentWrites(rel, text) {
478
+ const rawLines = text.split('\n');
479
+ const stripped = stripComments(text);
480
+ const out = [];
481
+ for (let i = 0; i < stripped.length; i++) {
482
+ const line = stripped[i];
483
+ if (!line.trim()) continue;
484
+ STATE_REPLACE_FIELD_CALL_RE.lastIndex = 0;
485
+ let m;
486
+ while ((m = STATE_REPLACE_FIELD_CALL_RE.exec(line)) !== null) {
487
+ const contentArg = m[1];
488
+ const fieldArg = m[2];
489
+ if (isQuotedLiteralArg(fieldArg)) continue;
490
+ const assignment = nearestPrecedingAssignment(stripped, i - 1, contentArg);
491
+ const isStripped = assignment !== null && /^stripFrontmatter\s*\(/.test(assignment);
492
+ if (isStripped) continue;
493
+ // `file`/`source` sanitized for the same fork-PR reason as every other
494
+ // finding in this guard; `contentArg` is captured out of repo source
495
+ // (an identifier name), attacker-controlled on the same basis.
496
+ out.push({
497
+ reason: REASON.UNSTRIPPED_CONTENT_WRITE,
498
+ axis: 'frontmatter-write',
499
+ file: sanitizeForReport(rel),
500
+ line: i + 1,
501
+ field: sanitizeForReport(contentArg),
502
+ source: sanitizeForReport(rawLines[i].trim()),
503
+ });
504
+ }
505
+ }
506
+ return out;
507
+ }
508
+
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/;
523
+
524
+ /**
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.
532
+ */
533
+ function findSeamBypasses(rel, text) {
534
+ const rawLines = text.split('\n');
535
+ const stripped = stripComments(text);
536
+ const out = [];
537
+ for (let i = 0; i < stripped.length; i++) {
538
+ const line = stripped[i];
539
+ if (!line.trim()) continue;
540
+ if (SEAM_DEF_LINE_RE.test(line)) continue;
541
+ SEAM_CALL_RE.lastIndex = 0;
542
+ let m;
543
+ while ((m = SEAM_CALL_RE.exec(line)) !== null) {
544
+ if (rel === SEAM_OWNER_FILE) {
545
+ const fn = enclosingFunction(stripped, i);
546
+ if (fn && SEAM_OWNER_EXEMPT_FUNCTIONS.includes(fn)) continue;
547
+ }
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.
553
+ out.push({
554
+ axis: 'write-seam',
555
+ file: sanitizeForReport(rel),
556
+ line: i + 1,
557
+ symbol: m[1],
558
+ source: sanitizeForReport(rawLines[i].trim()),
559
+ });
560
+ }
561
+ }
562
+ return out;
563
+ }
564
+
565
+ // Prose in the prompt layer shelling out to a write-side `gsd-tools`
566
+ // subcommand — the SAME write seam, expressed as markdown instructing an
567
+ // agent to run a command, rather than TypeScript calling a function
568
+ // directly (Decision 4(d)'s "the scan surface is declared, and is not just
569
+ // `src/`"). Matched on RAW lines — no comment stripping — because markdown
570
+ // carries no comment syntax this guard should be stripping in the first
571
+ // place. `g`-flagged so multiple candidate occurrences on one line are all
572
+ // checked against backtick spans below, rather than only the first.
573
+ const PROMPT_SEAM_RE = /gsd[-_]?tools[^\n]*\b(state\.patch|state\.planned-phase|state\.sync|phase\.complete)\b/g;
574
+
575
+ /**
576
+ * Every `` `...` `` inline-code span on `line`, as `[start, end)` character
577
+ * ranges (end exclusive). Handles multiple spans on one line correctly by
578
+ * repeated `exec` over a global, non-overlapping backtick-pair pattern —
579
+ * unlike a naive "count backticks before the match" parity check, this does
580
+ * not get confused by a line that mixes code spans with unrelated literal
581
+ * backticks (e.g. an unmatched one in prose).
582
+ */
583
+ const CODE_SPAN_RE = /`[^`\n]+`/g;
584
+ function codeSpanRanges(line) {
585
+ const ranges = [];
586
+ CODE_SPAN_RE.lastIndex = 0;
587
+ let m;
588
+ while ((m = CODE_SPAN_RE.exec(line)) !== null) {
589
+ ranges.push([m.index, m.index + m[0].length]);
590
+ }
591
+ return ranges;
592
+ }
593
+
594
+ /**
595
+ * True when character offset `index` of `line` falls inside one of `line`'s
596
+ * inline-code spans.
597
+ */
598
+ function isInsideCodeSpan(line, index) {
599
+ return codeSpanRanges(line).some(([start, end]) => index >= start && index < end);
600
+ }
601
+
602
+ /**
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.
607
+ *
608
+ * A candidate occurrence enclosed in backticks is a MENTION, not an
609
+ * invocation, and is deliberately not reported — CONTRIBUTING.md's "Every
610
+ * `commit` invocation in shipped content must declare `--files`" section
611
+ * states the repo's settled convention verbatim: "Write the command
612
+ * reference in backticks — the repo's own convention — and it is correctly
613
+ * read as a mention." ADR-3180 Amendment 3 records the cost of getting this
614
+ * wrong: the first `lint-phase-enumeration-drift.cjs` flagged JSDoc that
615
+ * merely documented the canonical owner as drift, which trains readers to
616
+ * 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.
619
+ */
620
+ function findPromptSeamUses(rel, text) {
621
+ const lines = text.split('\n');
622
+ const out = [];
623
+ for (let i = 0; i < lines.length; i++) {
624
+ const line = lines[i];
625
+ PROMPT_SEAM_RE.lastIndex = 0;
626
+ let m;
627
+ while ((m = PROMPT_SEAM_RE.exec(line)) !== null) {
628
+ 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`.
632
+ out.push({
633
+ axis: 'write-seam',
634
+ file: sanitizeForReport(rel),
635
+ line: i + 1,
636
+ symbol: 'prompt-layer-state-write',
637
+ source: sanitizeForReport(line.trim()),
638
+ });
639
+ }
640
+ }
641
+ return out;
642
+ }
643
+
644
+ /**
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.
664
+ *
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.
673
+ */
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() {
786
+ const srcFindings = scanTree({
787
+ root: REPO_ROOT,
788
+ scanDirs: SRC_DIRS,
789
+ scanExt: SRC_EXT,
790
+ onFile(rel, text) {
791
+ const relPosix = toPosixRel(rel);
792
+ const found = [];
793
+ if (relPosix === EXECUTOR_FILE) {
794
+ found.push(...findPolicyDispatchDrift(relPosix, text));
795
+ found.push(...findUnimplementedPolicies(text, relPosix));
796
+ found.push(...findUnstrippedContentWrites(relPosix, text));
797
+ }
798
+ found.push(...findSeamBypasses(relPosix, text));
799
+ return found;
800
+ },
801
+ });
802
+
803
+ const promptFindings = scanTree({
804
+ root: REPO_ROOT,
805
+ scanDirs: PROMPT_DIRS,
806
+ scanExt: PROMPT_EXT,
807
+ onFile(rel, text) {
808
+ return findPromptSeamUses(toPosixRel(rel), text);
809
+ },
810
+ });
811
+
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;
898
+ }
899
+
900
+ const GOODHART_NOTE =
901
+ 'Goodhart note (ADR-3408 Decision 5): this "0 write-path bypasses" is a LAGGING metric — report ' +
902
+ 'it only alongside the behavioral identity test\'s result (asserted at the consumer\'s output), ' +
903
+ 'never alone.';
904
+
905
+ function printFindings(findings) {
906
+ for (const f of findings) {
907
+ process.stderr.write(`[${f.reason}] ${sanitizeForReport(f.file)}:${f.line}\n`);
908
+ process.stderr.write(` ${sanitizeForReport(f.source)}\n`);
909
+ }
910
+ }
911
+
912
+ /**
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.
918
+ */
919
+ function main(argv) {
920
+ const args = argv || [];
921
+ const recognized = new Set(['--baseline', '--json']);
922
+ const unknown = args.filter((a) => !recognized.has(a));
923
+ if (unknown.length > 0) {
924
+ process.stderr.write(
925
+ `lint-state-write-path-drift: unrecognized argument(s): ${unknown.map((a) => sanitizeForReport(a)).join(', ')} ` +
926
+ '(expected --baseline and/or --json)\n',
927
+ );
928
+ process.exitCode = 2;
929
+ return;
930
+ }
931
+
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
+ );
983
+ const summary = {
984
+ policyDispatchViolations: policyFindings.length,
985
+ seamBypassesObserved: seamFindings.length,
986
+ seamBypassesAcknowledged: acknowledgedTotal,
987
+ ratchetViolations: ratchetFindings.length,
988
+ };
989
+
990
+ if (wantJson) {
991
+ process.stdout.write(`${JSON.stringify({ ok: findings.length === 0, findings, summary }, null, 2)}\n`);
992
+ process.exitCode = findings.length === 0 ? 0 : 1;
993
+ return;
994
+ }
995
+
996
+ if (findings.length === 0) {
997
+ 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',
1000
+ );
1001
+ process.stdout.write(`${GOODHART_NOTE}\n`);
1002
+ process.exitCode = 0;
1003
+ return;
1004
+ }
1005
+
1006
+ 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',
1009
+ );
1010
+ printFindings(findings);
1011
+ process.exitCode = 1;
1012
+ }
1013
+
1014
+ if (require.main === module) main(process.argv.slice(2));
1015
+
1016
+ module.exports = {
1017
+ REASON,
1018
+ REPO_ROOT,
1019
+ BASELINE_PATH,
1020
+ SRC_DIRS,
1021
+ SRC_EXT,
1022
+ PROMPT_DIRS,
1023
+ PROMPT_EXT,
1024
+ EXECUTOR_FILE,
1025
+ SEAM_OWNER_FILE,
1026
+ SEAM_OWNER_EXEMPT_FUNCTIONS,
1027
+ toPosixRel,
1028
+ stripComments,
1029
+ enclosingFunction,
1030
+ readPolicyUnion,
1031
+ findPolicyDispatchDrift,
1032
+ findUnimplementedPolicies,
1033
+ findUnstrippedContentWrites,
1034
+ isQuotedLiteralArg,
1035
+ nearestPrecedingAssignment,
1036
+ findSeamBypasses,
1037
+ findPromptSeamUses,
1038
+ isInsideCodeSpan,
1039
+ ratchetKey,
1040
+ loadBaseline,
1041
+ applyRatchet,
1042
+ collect,
1043
+ buildBaselineEntries,
1044
+ main,
1045
+ };