heyspex 0.1.0 → 0.2.1

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 (352) hide show
  1. package/.claude-plugin/marketplace.json +14 -0
  2. package/README.md +21 -0
  3. package/bin/hs.mjs +1089 -104
  4. package/bin/spexcode.mjs +262 -0
  5. package/dev-factory/engine/worktree.mjs +740 -0
  6. package/dev-factory/lib/code-repo-root.mjs +67 -0
  7. package/dev-factory/lib/context-budget.mjs +84 -0
  8. package/dev-factory/lib/epic-detect.mjs +190 -0
  9. package/dev-factory/lib/fleet-queue-file.mjs +846 -0
  10. package/dev-factory/lib/fleet-queue.mjs +426 -0
  11. package/dev-factory/lib/fleet-state.mjs +14 -18
  12. package/dev-factory/lib/model-catalog.mjs +338 -0
  13. package/dev-factory/lib/seat-context.mjs +199 -0
  14. package/dev-factory/lib/spec-io.mjs +93 -0
  15. package/dev-factory/lib/sufficiency-score.mjs +8 -2
  16. package/dev-factory/lib/worktree-allocator.mjs +744 -0
  17. package/dev-factory/model-catalog.json +222 -0
  18. package/dist/cli/hs-check.mjs +1127 -103
  19. package/dist/cli/hs-claim.mjs +981 -63
  20. package/dist/cli/hs-connect.mjs +10059 -1729
  21. package/dist/cli/hs-defer.mjs +5507 -3072
  22. package/dist/cli/hs-dev.mjs +576 -0
  23. package/dist/cli/hs-env.mjs +416 -302
  24. package/dist/cli/hs-index-rebuild.mjs +7334 -0
  25. package/dist/cli/hs-index-reconcile.mjs +8138 -0
  26. package/dist/cli/hs-init.mjs +2840 -149
  27. package/dist/cli/hs-install.mjs +7214 -594
  28. package/dist/cli/hs-join.mjs +10640 -2421
  29. package/dist/cli/hs-vault-pull.mjs +8157 -6370
  30. package/dist/cli/hs-vault-push.mjs +6433 -0
  31. package/dist/cli/hs-vault.mjs +4029 -1555
  32. package/dist/cli/hs-workspace.mjs +2702 -538
  33. package/dist/cli-templates/customer-vault/specs/entity-model.md +594 -601
  34. package/dist/cli-templates/customer-vault/specs/protocols/canonical-frontmatter-templates.md +110 -177
  35. package/dist/cli-templates/customer-vault/specs/protocols/destructive-action-checklist.md +122 -0
  36. package/dist/cli-templates/customer-vault/specs/protocols/memory-protocol.md +13 -15
  37. package/dist/cli-templates/customer-vault/specs/protocols/session-close-protocol.md +91 -0
  38. package/dist/cli-templates/customer-vault/specs/protocols/spec-hierarchy.md +150 -168
  39. package/dist/cli-templates/customer-vault/specs/protocols/spec-summary-authoring.md +83 -0
  40. package/dist/cli-templates/harness-commands/claude/hs-auto.md +36 -0
  41. package/dist/cli-templates/harness-commands/claude/hs-backfill.md +28 -0
  42. package/dist/cli-templates/harness-commands/claude/hs-capture.md +62 -0
  43. package/dist/cli-templates/harness-commands/claude/hs-defer.md +29 -0
  44. package/dist/cli-templates/harness-commands/claude/hs-dev.md +20 -0
  45. package/dist/cli-templates/harness-commands/claude/hs-doctor.md +32 -0
  46. package/dist/cli-templates/harness-commands/claude/hs-ground.md +30 -0
  47. package/dist/cli-templates/harness-commands/claude/hs-handoff.md +33 -0
  48. package/dist/cli-templates/harness-commands/claude/hs-log.md +27 -0
  49. package/dist/cli-templates/harness-commands/claude/hs-next.md +29 -0
  50. package/dist/cli-templates/harness-commands/claude/hs-promote.md +70 -0
  51. package/dist/cli-templates/harness-commands/claude/hs-push.md +35 -0
  52. package/dist/cli-templates/harness-commands/claude/hs-qc.md +27 -0
  53. package/dist/cli-templates/harness-commands/claude/hs-res.md +63 -0
  54. package/dist/cli-templates/harness-commands/claude/hs-resolve.md +23 -0
  55. package/dist/cli-templates/harness-commands/claude/hs-review.md +23 -0
  56. package/dist/cli-templates/harness-commands/claude/hs-view.md +28 -0
  57. package/dist/cli-templates/harness-commands/claude/kill-ports.md +32 -0
  58. package/dist/cli-templates/harness-commands/claude/prototype.md +91 -0
  59. package/dist/cli-templates/harness-commands/cursor/hs-auto.md +40 -0
  60. package/dist/cli-templates/harness-commands/cursor/hs-backfill.md +31 -0
  61. package/dist/cli-templates/harness-commands/cursor/hs-capture.md +65 -0
  62. package/dist/cli-templates/harness-commands/cursor/hs-defer.md +32 -0
  63. package/dist/cli-templates/harness-commands/cursor/hs-dev.md +24 -0
  64. package/dist/cli-templates/harness-commands/cursor/hs-doctor.md +36 -0
  65. package/dist/cli-templates/harness-commands/cursor/hs-ground.md +33 -0
  66. package/dist/cli-templates/harness-commands/cursor/hs-handoff.md +37 -0
  67. package/dist/cli-templates/harness-commands/cursor/hs-log.md +31 -0
  68. package/dist/cli-templates/harness-commands/cursor/hs-next.md +32 -0
  69. package/dist/cli-templates/harness-commands/cursor/hs-promote.md +73 -0
  70. package/dist/cli-templates/harness-commands/cursor/hs-push.md +38 -0
  71. package/dist/cli-templates/harness-commands/cursor/hs-qc.md +30 -0
  72. package/dist/cli-templates/harness-commands/cursor/hs-res.md +67 -0
  73. package/dist/cli-templates/harness-commands/cursor/hs-resolve.md +26 -0
  74. package/dist/cli-templates/harness-commands/cursor/hs-review.md +26 -0
  75. package/dist/cli-templates/harness-commands/cursor/hs-view.md +32 -0
  76. package/dist/cli-templates/harness-commands/cursor/kill-ports.md +35 -0
  77. package/dist/cli-templates/harness-commands/cursor/prototype.md +95 -0
  78. package/docs/copilot-plugin-install.md +162 -0
  79. package/docs/driving-heyspex-from-copilot.md +165 -0
  80. package/docs/mcp-client-config.md +259 -6
  81. package/package.json +249 -124
  82. package/plugins/heyspex/.claude-plugin/plugin.json +12 -0
  83. package/plugins/heyspex/.cursor-plugin/plugin.json +15 -0
  84. package/plugins/heyspex/.mcp.json +12 -0
  85. package/plugins/heyspex/README.md +309 -0
  86. package/plugins/heyspex/bin/launch-mcp.mjs +95 -0
  87. package/plugins/heyspex/bin/run-hook.mjs +80 -0
  88. package/plugins/heyspex/hooks/cursor-hooks.json +308 -0
  89. package/plugins/heyspex/hooks/hooks.json +418 -0
  90. package/plugins/heyspex/mcp.json +13 -0
  91. package/scripts/check-mcp-freshness.mjs +17 -9
  92. package/scripts/claude-hooks/_env-vault-events.mjs +68 -0
  93. package/scripts/claude-hooks/_guardrail-events.mjs +96 -0
  94. package/scripts/claude-hooks/_guardrail-loader.mjs +494 -0
  95. package/scripts/claude-hooks/_intent-classifier-router.mjs +456 -0
  96. package/scripts/claude-hooks/_intent-classifier-semantic.mjs +356 -0
  97. package/scripts/claude-hooks/_run.mjs +413 -0
  98. package/scripts/claude-hooks/_transcript-text.mjs +60 -0
  99. package/scripts/claude-hooks/_vault-retrieval.mjs +322 -0
  100. package/scripts/claude-hooks/agent-spawn-delegation-gate.mjs +133 -0
  101. package/scripts/claude-hooks/capture-execution-gate.mjs +51 -0
  102. package/scripts/claude-hooks/capture-workflow-gate.mjs +11 -0
  103. package/scripts/claude-hooks/check-auto-memory-budget.mjs +396 -0
  104. package/scripts/claude-hooks/check-claude-md-budget.mjs +327 -0
  105. package/scripts/claude-hooks/check-mcp-freshness-hook.mjs +14 -0
  106. package/scripts/claude-hooks/claim-before-code-gate.mjs +11 -0
  107. package/scripts/claude-hooks/claim-liveness-shared.mjs +577 -0
  108. package/scripts/claude-hooks/close-terminal-on-ship.mjs +64 -0
  109. package/scripts/claude-hooks/coo-denylist-gate.mjs +11 -0
  110. package/scripts/claude-hooks/coo-guard-check.mjs +11 -0
  111. package/scripts/claude-hooks/deep-research-cost-gate.mjs +11 -0
  112. package/scripts/claude-hooks/dev-server-task-reminder.mjs +15 -0
  113. package/scripts/claude-hooks/diagnose-capture-reminder.mjs +11 -0
  114. package/scripts/claude-hooks/directive-obligation-gate.mjs +11 -0
  115. package/scripts/claude-hooks/emit-claim-status.mjs +41 -0
  116. package/scripts/claude-hooks/enforce-session-sequence.mjs +11 -0
  117. package/scripts/claude-hooks/enforce-vault-naming.mjs +11 -0
  118. package/scripts/claude-hooks/env-vault-guard.mjs +11 -0
  119. package/scripts/claude-hooks/epic-milestone-phrasing-gate.mjs +11 -0
  120. package/scripts/claude-hooks/git-identity-guard.mjs +11 -0
  121. package/scripts/claude-hooks/git-reflog-audit.mjs +15 -0
  122. package/scripts/claude-hooks/git-stash-gate.mjs +11 -0
  123. package/scripts/claude-hooks/guard-auto-memory-writes.mjs +137 -0
  124. package/scripts/claude-hooks/id-display-format-gate.mjs +11 -0
  125. package/scripts/claude-hooks/inject-communication-style.mjs +86 -0
  126. package/scripts/claude-hooks/inject-coo-seat.mjs +78 -0
  127. package/scripts/claude-hooks/inject-dev-server-awareness.mjs +11 -0
  128. package/scripts/claude-hooks/inject-doctrine.mjs +13 -0
  129. package/scripts/claude-hooks/inject-vault-context.mjs +14 -0
  130. package/scripts/claude-hooks/intent-classifier-trigger.mjs +279 -0
  131. package/scripts/claude-hooks/log-session-activity.mjs +341 -0
  132. package/scripts/claude-hooks/naughty-list-gate.mjs +11 -0
  133. package/scripts/claude-hooks/notify-status-flip.mjs +72 -0
  134. package/scripts/claude-hooks/plan-exit-ingest-gate.mjs +170 -0
  135. package/scripts/claude-hooks/plan-ingest-close-reminder.mjs +66 -0
  136. package/scripts/claude-hooks/plan-review-gate.mjs +11 -0
  137. package/scripts/claude-hooks/preflight-reminder.mjs +262 -0
  138. package/scripts/claude-hooks/protected-action-intent.mjs +221 -0
  139. package/scripts/claude-hooks/research-method-guard.mjs +398 -0
  140. package/scripts/claude-hooks/research-protocol-gate.mjs +11 -0
  141. package/scripts/claude-hooks/session-end.mjs +451 -0
  142. package/scripts/claude-hooks/settings-write-gate.mjs +11 -0
  143. package/scripts/claude-hooks/settlement-check-gate.mjs +39 -0
  144. package/scripts/claude-hooks/status-flip-detect.mjs +17 -0
  145. package/scripts/claude-hooks/terminal-close-shared.mjs +426 -0
  146. package/scripts/claude-hooks/terminal-close-watcher.mjs +122 -0
  147. package/scripts/claude-hooks/vault-mirror-catchup.mjs +11 -0
  148. package/scripts/claude-hooks/vault-write-tier-guard.mjs +11 -0
  149. package/scripts/claude-hooks/wip-checkpoint.mjs +302 -0
  150. package/scripts/cursor-hooks/manifest.mjs +281 -0
  151. package/scripts/dev-server-status.mjs +34 -13
  152. package/scripts/dev-server-stop.mjs +313 -0
  153. package/scripts/heyspex-mode.mjs +288 -0
  154. package/scripts/hs-auto-resolve.mjs +240 -0
  155. package/scripts/hs-auto-source.mjs +398 -0
  156. package/scripts/hs-auto.mjs +134 -0
  157. package/scripts/hs-capture-cli.mjs +326 -0
  158. package/scripts/hs-classifier.mjs +136 -0
  159. package/scripts/hs-companion.mjs +88 -0
  160. package/scripts/hs-gateway.mjs +115 -0
  161. package/scripts/hs-mode.mjs +125 -0
  162. package/scripts/hs-next.mjs +441 -0
  163. package/scripts/hs-settings.mjs +338 -0
  164. package/scripts/hs-view.mjs +496 -0
  165. package/scripts/hs-worktree.mjs +201 -0
  166. package/scripts/install-claude-code-hooks.mjs +272 -0
  167. package/scripts/install-cursor-hooks.mjs +471 -0
  168. package/scripts/lib/agent-env-markers.mjs +38 -0
  169. package/scripts/lib/console-url-resolver.mjs +199 -61
  170. package/scripts/lib/customer-kit.mjs +1203 -0
  171. package/scripts/lib/dev-mode.mjs +249 -32
  172. package/scripts/lib/directive-obligations.mjs +349 -0
  173. package/scripts/lib/heyspex-home.mjs +72 -0
  174. package/scripts/lib/hook-dispositions.mjs +210 -0
  175. package/scripts/lib/hook-vault-header.mjs +30 -0
  176. package/scripts/lib/hs-command.mjs +277 -0
  177. package/scripts/lib/hs-companion-settings.mjs +472 -0
  178. package/scripts/lib/hs-source-branch.mjs +196 -0
  179. package/scripts/lib/hs-source-commit.mjs +59 -5
  180. package/scripts/lib/hs-source-identity.mjs +526 -0
  181. package/scripts/lib/hs-source-log.mjs +13 -22
  182. package/scripts/lib/hs-source-preflight.mjs +18 -4
  183. package/scripts/lib/hs-source-push.mjs +386 -307
  184. package/scripts/lib/hs-worktree-args.mjs +115 -0
  185. package/scripts/lib/shared-path-helpers.mjs +134 -0
  186. package/scripts/lib/standalone-doctor.mjs +540 -3
  187. package/scripts/lib/subagent-git-lock.mjs +109 -0
  188. package/scripts/lib/template-pin.mjs +34 -0
  189. package/scripts/lib/window-placement.mjs +109 -0
  190. package/scripts/provenance-lib.mjs +91 -0
  191. package/scripts/stamp-provenance-on-edit.mjs +222 -0
  192. package/scripts/wip-ledger-health.mjs +653 -0
  193. package/spexharness/doctrine/baseline/SPEXCODE-DOCTRINE.baseline.json +29 -0
  194. package/spexharness/src/adapters/audit.mjs +197 -0
  195. package/spexharness/src/adapters/claude-code.mjs +449 -0
  196. package/spexharness/src/adapters/copilot.mjs +236 -0
  197. package/spexharness/src/adapters/detect-harness.mjs +34 -0
  198. package/spexharness/src/chat/tui/ansi.mjs +448 -0
  199. package/spexharness/src/chat/tui/palette.mjs +22 -0
  200. package/spexharness/src/chat/tui/select.mjs +208 -0
  201. package/spexharness/src/config-root.mjs +54 -0
  202. package/spexharness/src/context-budget.mjs +141 -0
  203. package/spexharness/src/dev-server-control.mjs +389 -0
  204. package/spexharness/src/doctrine/behaviour.mjs +371 -0
  205. package/spexharness/src/doctrine/corpus.mjs +733 -0
  206. package/spexharness/src/doctrine/inject.mjs +338 -0
  207. package/spexharness/src/doctrine/merge.mjs +184 -0
  208. package/spexharness/src/doctrine/sources.mjs +252 -0
  209. package/spexharness/src/doctrine/synthesize.mjs +401 -0
  210. package/spexharness/src/doctrine/vault-source.mjs +574 -0
  211. package/spexharness/src/doctrine/writer.mjs +176 -0
  212. package/spexharness/src/events.mjs +84 -0
  213. package/spexharness/src/gates/_shell-write-targets.mjs +77 -0
  214. package/spexharness/src/gates/capture-workflow-gate.mjs +202 -0
  215. package/spexharness/src/gates/claim-before-code-gate.decide.mjs +33 -0
  216. package/spexharness/src/gates/claim-before-code-gate.mjs +129 -0
  217. package/spexharness/src/gates/deep-research-cost-gate.mjs +275 -0
  218. package/spexharness/src/gates/diagnose-capture-reminder.mjs +112 -0
  219. package/spexharness/src/gates/directive-obligation-gate.decide.mjs +132 -0
  220. package/spexharness/src/gates/directive-obligation-gate.mjs +183 -0
  221. package/spexharness/src/gates/enforce-session-sequence.mjs +895 -0
  222. package/spexharness/src/gates/enforce-vault-naming.mjs +215 -0
  223. package/spexharness/src/gates/epic-milestone-phrasing-gate.mjs +233 -0
  224. package/spexharness/src/gates/git-identity-guard.decide.mjs +436 -0
  225. package/spexharness/src/gates/git-identity-guard.mjs +61 -0
  226. package/spexharness/src/gates/git-reflog-audit.mjs +151 -0
  227. package/spexharness/src/gates/git-stash-gate.decide.mjs +361 -0
  228. package/spexharness/src/gates/git-stash-gate.mjs +159 -0
  229. package/spexharness/src/gates/id-display-format-gate.mjs +190 -0
  230. package/spexharness/src/gates/inject-doctrine-gate.mjs +89 -0
  231. package/spexharness/src/gates/inject-vault-context.mjs +239 -0
  232. package/spexharness/src/gates/naughty-list-gate.mjs +213 -0
  233. package/spexharness/src/gates/plan-review-gate.mjs +233 -0
  234. package/spexharness/src/gates/preflight-reminder-gate.mjs +265 -0
  235. package/spexharness/src/gates/research-protocol-gate.mjs +175 -0
  236. package/spexharness/src/gates/settings-write-gate.decide.mjs +96 -0
  237. package/spexharness/src/gates/settings-write-gate.mjs +104 -0
  238. package/spexharness/src/gates/vault-mirror-catchup.mjs +124 -0
  239. package/spexharness/src/gates/vault-write-tier-guard.decide.mjs +125 -0
  240. package/spexharness/src/gates/vault-write-tier-guard.mjs +42 -0
  241. package/spexharness/src/git-reflog-audit.mjs +294 -0
  242. package/spexharness/src/intent-classifier.mjs +196 -0
  243. package/spexharness/src/intent-directive.mjs +626 -0
  244. package/spexharness/src/profile/learn.mjs +551 -0
  245. package/spexharness/src/profile/proposals.mjs +357 -0
  246. package/spexharness/src/profile/vault-inclusion.mjs +386 -0
  247. package/spexharness/src/redact.mjs +151 -0
  248. package/spexharness/src/rule-table.mjs +849 -0
  249. package/spexharness/src/seat-context.mjs +122 -0
  250. package/spexharness/src/session-bootstrap.mjs +512 -0
  251. package/spexharness/src/session-record.mjs +815 -0
  252. package/spexharness/src/token-inventory.mjs +207 -0
  253. package/vendor/heyspex-mcp/account-switch.mjs +96 -0
  254. package/vendor/heyspex-mcp/alignment-gate.mjs +315 -1
  255. package/vendor/heyspex-mcp/blocker-resolver.mjs +37 -2
  256. package/vendor/heyspex-mcp/breakdown-ladder.mjs +119 -4
  257. package/vendor/heyspex-mcp/breakdown-source.mjs +271 -0
  258. package/vendor/heyspex-mcp/breakdown-writer.mjs +23 -3
  259. package/vendor/heyspex-mcp/capture-buffer-patches.mjs +77 -8
  260. package/vendor/heyspex-mcp/capture-channel.mjs +55 -5
  261. package/vendor/heyspex-mcp/capture-console-client.mjs +117 -3
  262. package/vendor/heyspex-mcp/capture-focus.mjs +1061 -31
  263. package/vendor/heyspex-mcp/capture-outbox.mjs +15 -5
  264. package/vendor/heyspex-mcp/capture-sources.mjs +195 -0
  265. package/vendor/heyspex-mcp/capture-tools.mjs +1264 -0
  266. package/vendor/heyspex-mcp/claim-tools.mjs +1147 -0
  267. package/vendor/heyspex-mcp/cli-verbs.mjs +2074 -0
  268. package/vendor/heyspex-mcp/cli.mjs +207 -15524
  269. package/vendor/heyspex-mcp/close-branch-report.mjs +102 -0
  270. package/vendor/heyspex-mcp/cloud-tool-surface.mjs +128 -0
  271. package/vendor/heyspex-mcp/cloud-vault-writes.mjs +602 -0
  272. package/vendor/heyspex-mcp/config-seed.yaml +15 -0
  273. package/vendor/heyspex-mcp/config-settings.mjs +290 -7
  274. package/vendor/heyspex-mcp/console-fetch.mjs +822 -17
  275. package/vendor/heyspex-mcp/console-transport.mjs +172 -0
  276. package/vendor/heyspex-mcp/credentials.mjs +585 -0
  277. package/vendor/heyspex-mcp/defer-spec.mjs +44 -4
  278. package/vendor/heyspex-mcp/deferred-reclaim.mjs +29 -3
  279. package/vendor/heyspex-mcp/design-handlers.mjs +31 -0
  280. package/vendor/heyspex-mcp/design-tools.mjs +4 -1
  281. package/vendor/heyspex-mcp/dev-factory-bridge.mjs +4 -4
  282. package/vendor/heyspex-mcp/dev-mode-core.mjs +45 -0
  283. package/vendor/heyspex-mcp/dev-tools.mjs +131 -0
  284. package/vendor/heyspex-mcp/diagnose-handlers.mjs +54 -0
  285. package/vendor/heyspex-mcp/events-tools.mjs +207 -0
  286. package/vendor/heyspex-mcp/filing-tools.mjs +723 -0
  287. package/vendor/heyspex-mcp/fleet-handlers.mjs +37 -0
  288. package/vendor/heyspex-mcp/fleet-queue-tools.mjs +71 -15
  289. package/vendor/heyspex-mcp/focus-tools.mjs +190 -0
  290. package/vendor/heyspex-mcp/folder-trust-store.mjs +60 -0
  291. package/vendor/heyspex-mcp/http-transport.mjs +302 -0
  292. package/vendor/heyspex-mcp/list-tools.mjs +257 -0
  293. package/vendor/heyspex-mcp/login-handoff.mjs +15 -1
  294. package/vendor/heyspex-mcp/mcp-config.mjs +107 -0
  295. package/vendor/heyspex-mcp/memory-tools.mjs +523 -0
  296. package/vendor/heyspex-mcp/mvp-mode.mjs +53 -6
  297. package/vendor/heyspex-mcp/obligation-backstop.mjs +103 -0
  298. package/vendor/heyspex-mcp/occupied-keys.mjs +142 -0
  299. package/vendor/heyspex-mcp/on-deck-brief.mjs +3377 -0
  300. package/vendor/heyspex-mcp/on-deck-scheduler.mjs +1708 -0
  301. package/vendor/heyspex-mcp/package-root.mjs +32 -0
  302. package/vendor/heyspex-mcp/peer-presence.mjs +8 -1
  303. package/vendor/heyspex-mcp/plan-ground.mjs +621 -652
  304. package/vendor/heyspex-mcp/plan-push.mjs +908 -22
  305. package/vendor/heyspex-mcp/plan-review.mjs +124 -30
  306. package/vendor/heyspex-mcp/preflight-gate.mjs +0 -0
  307. package/vendor/heyspex-mcp/procedure-handlers.mjs +327 -0
  308. package/vendor/heyspex-mcp/procedure-tools.mjs +1181 -970
  309. package/vendor/heyspex-mcp/promote-capture.mjs +388 -69
  310. package/vendor/heyspex-mcp/promote-diagnose.mjs +74 -0
  311. package/vendor/heyspex-mcp/promote-research-to-diagnose.mjs +9 -2
  312. package/vendor/heyspex-mcp/promote-research.mjs +666 -53
  313. package/vendor/heyspex-mcp/promotion-helpers.mjs +251 -0
  314. package/vendor/heyspex-mcp/prompts.mjs +1460 -0
  315. package/vendor/heyspex-mcp/read-path.mjs +191 -15
  316. package/vendor/heyspex-mcp/receipt-shape.mjs +146 -0
  317. package/vendor/heyspex-mcp/recipe-handlers.mjs +117 -0
  318. package/vendor/heyspex-mcp/regen-tasks.mjs +210 -3
  319. package/vendor/heyspex-mcp/register-spec.mjs +266 -54
  320. package/vendor/heyspex-mcp/request-context.mjs +83 -0
  321. package/vendor/heyspex-mcp/research-focus.mjs +207 -4
  322. package/vendor/heyspex-mcp/research-handlers.mjs +625 -0
  323. package/vendor/heyspex-mcp/research-tools.mjs +4 -4
  324. package/vendor/heyspex-mcp/response-budget.mjs +348 -0
  325. package/vendor/heyspex-mcp/review-focus.mjs +25 -1
  326. package/vendor/heyspex-mcp/roadmap-default.mjs +70 -0
  327. package/vendor/heyspex-mcp/search-specs.mjs +280 -0
  328. package/vendor/heyspex-mcp/seat-block-ledger.mjs +113 -0
  329. package/vendor/heyspex-mcp/seat-identity.mjs +2 -6
  330. package/vendor/heyspex-mcp/session-owner.mjs +109 -0
  331. package/vendor/heyspex-mcp/session-tools.mjs +162 -0
  332. package/vendor/heyspex-mcp/settings-registry.mjs +232 -0
  333. package/vendor/heyspex-mcp/settings-tools.mjs +85 -12
  334. package/vendor/heyspex-mcp/spec-edit-tools.mjs +453 -0
  335. package/vendor/heyspex-mcp/spec-id-lib.mjs +93 -17
  336. package/vendor/heyspex-mcp/split-research.mjs +304 -56
  337. package/vendor/heyspex-mcp/stdio-rpc.mjs +22 -0
  338. package/vendor/heyspex-mcp/submit-for-review.mjs +150 -7
  339. package/vendor/heyspex-mcp/summary-contract.mjs +105 -0
  340. package/vendor/heyspex-mcp/summary-write-tools.mjs +228 -0
  341. package/vendor/heyspex-mcp/symptom-query.mjs +146 -0
  342. package/vendor/heyspex-mcp/test-helpers.mjs +123 -1
  343. package/vendor/heyspex-mcp/tool-catalog.mjs +2819 -0
  344. package/vendor/heyspex-mcp/tool-dispatch.mjs +657 -0
  345. package/vendor/heyspex-mcp/tool-helpers.mjs +325 -0
  346. package/vendor/heyspex-mcp/validate-linkage.mjs +414 -27
  347. package/vendor/heyspex-mcp/vault-io.mjs +720 -0
  348. package/vendor/heyspex-mcp/vault-less-endpoints.mjs +52 -0
  349. package/vendor/heyspex-mcp/vault-resolve.mjs +554 -68
  350. package/vendor/heyspex-mcp/verify-reproduction.mjs +270 -30
  351. package/vendor/heyspex-mcp/whoami.mjs +92 -0
  352. package/vendor/heyspex-mcp/write-outbox.mjs +512 -0
@@ -0,0 +1,895 @@
1
+ // spexharness/src/gates/enforce-session-sequence.mjs
2
+ //
3
+ // Engine port of scripts/claude-hooks/enforce-session-sequence.mjs — the
4
+ // Stop-hook L3 SEQUENCING gate ([[HS-EPIC-ZGWR]] AC#4): once a session has
5
+ // flipped a spec to `status: in_review`, the structured handoff
6
+ // (heyspex_record_wip) and a progress checkpoint (heyspex_report_progress)
7
+ // are hard close-out steps. See ./PORTING.md for the recipe this follows and
8
+ // ./env-vault-guard.mjs for the reference port.
9
+ //
10
+ // The original script does NOT export a pure decision function — its logic
11
+ // (`scanTranscript`, `toolUseIsFlip`, `isUnderSpecsTaskOrEpic`,
12
+ // `shouldBlockForReal`, `fireRule`) lives as un-exported helpers inside the
13
+ // file, tangled with `main()`'s stdin read. Per PORTING.md step 1's
14
+ // "otherwise" branch that logic would normally be extracted into the
15
+ // original script itself, but this task's Do-nots forbid touching anything
16
+ // under scripts/claude-hooks/. This port therefore re-derives those helpers
17
+ // (transcript scanning, the in_review flip detector, the interactive-vs-
18
+ // unattended rung check) here, verbatim in behavior, while importing the
19
+ // REAL shared/canonical dependencies unchanged rather than re-deriving them:
20
+ // - scripts/claude-hooks/_guardrail-loader.mjs — loadGuardrails,
21
+ // rulesForSurface, isRuleEnabled. This is a dependency-free shared
22
+ // loader (not one of the twelve hook scripts themselves; no stdin read,
23
+ // no unconditional main()), so importing it carries no import hazard and
24
+ // keeps the toggle/reason-text source of truth identical to the
25
+ // original (the compiled `.heyspex/guardrails.json`, authored from
26
+ // session-close-protocol.md's `enforcement:` block).
27
+ // - vendor/heyspex-mcp/vault-resolve.mjs — getActiveConfig(), for the
28
+ // same optional second vault-root base the original's `vaultRoot()`
29
+ // helper resolves (specs living in a vault split out from the code
30
+ // repo). Also dependency-free / no import hazard.
31
+ // Nothing here re-derives the wip/progress reason TEXT or the enable/disable
32
+ // toggle logic — both come from the same compiled registry the original
33
+ // reads, so the two cannot drift on THAT axis; what's re-derived is the
34
+ // transcript-scanning glue, which carries no rule content of its own.
35
+ //
36
+ // IMPORT HAZARD NOTE (why the original script itself is never imported,
37
+ // unlike settings-write-gate.mjs which imports its original lazily):
38
+ // enforce-session-sequence.mjs's `main()` is also unguarded (no
39
+ // `isDirectExecution` check) and also reads stdin unconditionally — but it
40
+ // exports no pure function worth reaching for in the first place (see
41
+ // above), so there is nothing to gain by importing it at all, hazardous or
42
+ // otherwise. It is simply never imported here.
43
+ //
44
+ // Event/field mapping: a Stop hook → the engine's `stop` event point.
45
+ // stdin.transcript_path → evt.transcriptPath
46
+ // stdin.stop_hook_active → evt.stopHookActive
47
+ // (`events.mjs`'s EVENT_FIELDS documents `stop` as carrying no fields beyond
48
+ // the common ones; createEvent does not validate field names, so a front
49
+ // passes these two straight through.)
50
+ //
51
+ // ---------------------------------------------------------------------------
52
+ // FRONT-AGNOSTIC (wave 6 of [[HS-AZ61-NYPR]]): the session-record path
53
+ // ---------------------------------------------------------------------------
54
+ // The port above reads a CLAUDE CODE TRANSCRIPT, which is the only place a
55
+ // Claude Code session records what it did. Every other front has no such file —
56
+ // so on `hs chat`, an overnight headless seat, a DeepSeek seat or a local-model
57
+ // seat this gate used to short-circuit on `if (!transcriptPath) return allow`,
58
+ // i.e. it registered and then allowed, unconditionally. The rule reached
59
+ // exactly one front.
60
+ //
61
+ // It no longer does. Those fronts DO have a record of what the session did —
62
+ // spexharness/src/session-record.mjs, which spexharness/src/session-lifecycle.mjs
63
+ // hands to every `stop` event as BOTH `evt.sessionRecord` and `ctx.sessionRecord`.
64
+ // Its `toolCalls` array carries `{ name, input, output }` per call in order,
65
+ // which is precisely the information `scanTranscript` digs out of a transcript's
66
+ // assistant `tool_use` blocks: heyspex_submit_for_review (or an Edit/Write/
67
+ // MultiEdit that introduces `status: in_review` under specs/tasks|epics) is the
68
+ // FLIP, heyspex_record_wip is the handoff, heyspex_report_progress is the
69
+ // checkpoint. `scanSessionRecord` below reads exactly those three facts off the
70
+ // record, reusing the SAME `toolUseIsFlip` helper the transcript scan uses — one
71
+ // flip detector, two carriers.
72
+ //
73
+ // Precedence is deliberate and preserves every existing golden: a transcript,
74
+ // when present, still wins. The record path is what runs when there is no
75
+ // transcript, which is every non-Claude-Code front. Both paths feed the identical
76
+ // rule evaluation below (the same two registry rows, the same two rungs, the same
77
+ // reason text), so the two fronts cannot enforce different rules.
78
+ //
79
+ // Decision mapping — the builder brief's own instruction: "map its current
80
+ // `{ decision: 'block', reason }` stdout shape onto the engine's deny
81
+ // decision". The original is actually a TWO-RUNG gate, not a single
82
+ // block/allow switch — HEYSPEX_SESSION_SEQUENCE_INTERACTIVE and
83
+ // CLAUDE_AGENT decide whether an unmet check is a real BLOCK (stdout JSON,
84
+ // `{decision:"block", reason}`) or an interactive WARN-ONLY (empty stdout —
85
+ // the stop is still allowed — with the same reason text only on stderr).
86
+ // The warn-only rung is a third, richer-than-binary outcome the engine has
87
+ // no native slot for either (same shape as `env-vault-guard.mjs`'s "ask" →
88
+ // rewrite mapping): mapped here to `rewrite` with `{ warn: true, reason,
89
+ // rule }` rather than silently downgrading it to a plain `allow` (which
90
+ // would drop the reason text a caller might still want to surface) or
91
+ // upgrading it to `deny` (a real behavior change — the original explicitly
92
+ // does NOT block the stop in this rung). This also gives this one gate
93
+ // natural coverage of all three engine decisions by itself (AC3: allow when
94
+ // nothing is missing or the check doesn't apply, deny on a real block, and
95
+ // rewrite on the warn-only rung) — see ./enforce-session-sequence.test.mjs.
96
+ //
97
+ // AC5 (the blocking case is preserved exactly): a real block's `reason` is
98
+ // `wipRule.inject.trim()` / `progressRule.inject.trim()` — the SAME compiled
99
+ // registry text the original emits, verbatim, not a re-authored string.
100
+ //
101
+ // Env hatches (unchanged):
102
+ // HEYSPEX_SESSION_SEQUENCE_OFF=1 Hard kill switch (both rungs).
103
+ // HEYSPEX_SESSION_SEQUENCE_INTERACTIVE=block Raises the interactive rung to a real block.
104
+ // CLAUDE_AGENT=true Unattended session → real block (unchanged).
105
+ // The rule also honors its registry `configKey`
106
+ // (agent_behaviors.session_wip_before_stop.enabled /
107
+ // agent_behaviors.session_progress_before_stop.enabled) via isRuleEnabled.
108
+ //
109
+ // Not ported HERE: the original's `appendGuardrailEvent(...)` audit-log
110
+ // write on fire, and the loop-guard/no-op fail-safe paths' silence — a gate
111
+ // module stays a pure decision function (AC1: no stdout writes; same
112
+ // spirit, no other observability side effects), matching env-vault-guard.mjs's
113
+ // port. The `rule` field this run() carries on both its `deny` and `rewrite`
114
+ // results exists so a FRONT can restore that same audit trail without
115
+ // re-deriving which rule fired from `reason` text — see
116
+ // spexharness/src/adapters/audit.mjs, called from
117
+ // spexharness/src/adapters/claude-code.mjs's runGateAsHook after this
118
+ // gate's decision comes back.
119
+ //
120
+ // `ctx` contract (all optional; every field defaults to a real fs/env read):
121
+ // ctx.cwd - repo root to resolve the guardrail registry + flip-path
122
+ // checks from. Defaults to CLAUDE_PROJECT_DIR, else this
123
+ // repo's root. When given it is a self-contained fixture
124
+ // root and both rules' config toggles are read from it too.
125
+ // ctx.configRoot - the directory holding `.heyspex/config.yaml` (both rules'
126
+ // configKey toggles); wins over ctx.cwd for that read. With
127
+ // neither, the VAULT root (../config-root.mjs
128
+ // `defaultConfigRoot()`) — never CLAUDE_PROJECT_DIR, which
129
+ // under Claude Code is the app checkout, not where the
130
+ // settings file lives
131
+ // ([[HS-4C76-9W86-DIAG-heyspex-config-yaml-has-no-single-home]]).
132
+ // Distinct from ctx.vaultRoot below, which is only the
133
+ // flip-path second base and may be a function.
134
+ // ctx.readFile(path) - reads the transcript file's content, or null if
135
+ // unreadable. Defaults to a real fs.readFileSync. Tests
136
+ // inject the fixture's transcript text directly instead
137
+ // of writing a real file.
138
+ // ctx.vaultRoot - string, or a zero-arg function returning one (or
139
+ // throwing) — the second base `isUnderSpecsTaskOrEpic`
140
+ // checks a flip path against, mirroring the original's
141
+ // `vaultRoot()` (getActiveConfig().repoRootAbs, cached
142
+ // process-lifetime by vault-resolve.mjs). Defaults to a
143
+ // fail-safe-wrapped call to the real getActiveConfig().
144
+ // None of this port's own fixtures need it (every fixture
145
+ // flip path already resolves under ctx.cwd itself), but
146
+ // it is available for a caller/tester that does.
147
+ // ctx.env - env var overrides (tests use this instead of mutating
148
+ // process.env).
149
+
150
+ import fs from 'node:fs';
151
+ import path from 'node:path';
152
+ import { fileURLToPath } from 'node:url';
153
+
154
+ import {
155
+ loadGuardrails,
156
+ rulesForSurface,
157
+ isRuleEnabled,
158
+ vocabularyViolations,
159
+ compilePatterns,
160
+ proseOnly,
161
+ matchContextAround,
162
+ } from '../../../scripts/claude-hooks/_guardrail-loader.mjs';
163
+ import { lastAssistantText } from '../../../scripts/claude-hooks/_transcript-text.mjs';
164
+ import { getActiveConfig } from '../../../vendor/heyspex-mcp/vault-resolve.mjs';
165
+ import { defaultConfigRoot } from '../config-root.mjs';
166
+
167
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
168
+ const REPO_ROOT = path.resolve(HERE, '..', '..', '..');
169
+
170
+ const RULE_ID = 'session-wip-before-stop';
171
+ const PROGRESS_RULE_ID = 'session-progress-before-review';
172
+ const STATUS_REVIEW_RE = /^\s*status:\s*in_review\s*$/m;
173
+ const SUBMIT_FOR_REVIEW_RE = /heyspex_submit_for_review/;
174
+ const RECORD_WIP_RE = /heyspex_record_wip/;
175
+ const REPORT_PROGRESS_RE = /heyspex_report_progress/;
176
+ const OFF_ENV = 'HEYSPEX_SESSION_SEQUENCE_OFF';
177
+ const INTERACTIVE_ENV = 'HEYSPEX_SESSION_SEQUENCE_INTERACTIVE';
178
+
179
+ function defaultReadFile(p) {
180
+ try {
181
+ return fs.readFileSync(p, 'utf8');
182
+ } catch {
183
+ return null;
184
+ }
185
+ }
186
+
187
+ function resolveVaultRootAbs(ctx) {
188
+ if (typeof ctx.vaultRoot === 'string') return ctx.vaultRoot;
189
+ if (typeof ctx.vaultRoot === 'function') {
190
+ try {
191
+ return ctx.vaultRoot();
192
+ } catch {
193
+ return null;
194
+ }
195
+ }
196
+ try {
197
+ return getActiveConfig()?.repoRootAbs ?? null;
198
+ } catch {
199
+ return null; // fail-safe: the code-repo base still applies
200
+ }
201
+ }
202
+
203
+ // True when a file path resolves under specs/tasks or specs/epics (the spec
204
+ // kinds that carry a reviewable lifecycle). Mirrors the original hook's
205
+ // local helper of the same name (itself mirroring notify-status-flip.mjs).
206
+ function isUnderSpecsTaskOrEpic(filePath, root, vaultRootAbs) {
207
+ if (typeof filePath !== 'string' || filePath.length === 0) return false;
208
+ const abs = path.isAbsolute(filePath) ? filePath : path.resolve(root, filePath);
209
+ const bases = [root];
210
+ if (vaultRootAbs && vaultRootAbs !== root) bases.push(vaultRootAbs);
211
+ for (const base of bases) {
212
+ for (const sub of ['specs/tasks', 'specs/epics']) {
213
+ const rel = path.relative(path.join(base, sub), abs);
214
+ if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) return true;
215
+ }
216
+ }
217
+ return false;
218
+ }
219
+
220
+ // Did THIS Edit/Write/MultiEdit tool_use introduce `status: in_review`?
221
+ // Mirrors the original hook's local helper of the same name.
222
+ function toolUseIsFlip(name, input, root, vaultRootAbs) {
223
+ if (!input || typeof input !== 'object') return false;
224
+ if (!isUnderSpecsTaskOrEpic(input.file_path, root, vaultRootAbs)) return false;
225
+ if (name === 'Edit') {
226
+ const next = typeof input.new_string === 'string' ? input.new_string : '';
227
+ const prev = typeof input.old_string === 'string' ? input.old_string : '';
228
+ return STATUS_REVIEW_RE.test(next) && !STATUS_REVIEW_RE.test(prev);
229
+ }
230
+ if (name === 'MultiEdit') {
231
+ const edits = Array.isArray(input.edits) ? input.edits : [];
232
+ return edits.some((e) => {
233
+ const next = typeof e?.new_string === 'string' ? e.new_string : '';
234
+ const prev = typeof e?.old_string === 'string' ? e.old_string : '';
235
+ return STATUS_REVIEW_RE.test(next) && !STATUS_REVIEW_RE.test(prev);
236
+ });
237
+ }
238
+ if (name === 'Write') {
239
+ const content = typeof input.content === 'string' ? input.content : '';
240
+ return STATUS_REVIEW_RE.test(content);
241
+ }
242
+ return false;
243
+ }
244
+
245
+ // Walk the whole transcript JSONL once, returning whether the session (a)
246
+ // flipped a spec to in_review, (b) called heyspex_record_wip, and (c) called
247
+ // heyspex_report_progress at least once — by scanning every assistant
248
+ // tool_use block. Mirrors the original hook's local helper of the same name,
249
+ // except it takes the transcript's TEXT directly (via ctx.readFile) rather
250
+ // than a path + its own fs.readFileSync call.
251
+ function scanTranscript(raw, root, vaultRootAbs) {
252
+ const result = { flip: false, wip: false, progress: false };
253
+ if (typeof raw !== 'string') return result;
254
+ for (const line of raw.split(/\r?\n/)) {
255
+ if (!line) continue;
256
+ let evt;
257
+ try {
258
+ evt = JSON.parse(line);
259
+ } catch {
260
+ continue;
261
+ }
262
+ const content = evt?.message?.content;
263
+ if (!Array.isArray(content)) continue;
264
+ for (const block of content) {
265
+ if (!block || block.type !== 'tool_use' || typeof block.name !== 'string') continue;
266
+ if (RECORD_WIP_RE.test(block.name)) result.wip = true;
267
+ if (REPORT_PROGRESS_RE.test(block.name)) result.progress = true;
268
+ if (SUBMIT_FOR_REVIEW_RE.test(block.name)) result.flip = true;
269
+ if (toolUseIsFlip(block.name, block.input, root, vaultRootAbs)) result.flip = true;
270
+ }
271
+ if (result.flip && result.wip && result.progress) break; // nothing left to decide
272
+ }
273
+ return result;
274
+ }
275
+
276
+ /**
277
+ * The same three facts {@link scanTranscript} extracts, read off a
278
+ * spexharness/src/session-record.mjs record instead of a Claude Code
279
+ * transcript — the front-agnostic carrier (see this module's header).
280
+ *
281
+ * Only `record.toolCalls` is read: `record.events` carries an event's `point`
282
+ * and its decision but NOT the tool name (see session-record.mjs's
283
+ * `recordEvent`), so it cannot answer "was heyspex_record_wip called". The flip
284
+ * detector is the SAME `toolUseIsFlip` the transcript scan uses, so a
285
+ * status-flipping Write/Edit/MultiEdit is recognized identically on both paths.
286
+ *
287
+ * @param {{ toolCalls?: Array<{ name?: string|null, input?: unknown }> }|null|undefined} record
288
+ * @param {string} root
289
+ * @param {string|null} vaultRootAbs
290
+ * @returns {{ flip: boolean, wip: boolean, progress: boolean }}
291
+ */
292
+ export function scanSessionRecord(record, root, vaultRootAbs) {
293
+ const result = { flip: false, wip: false, progress: false };
294
+ const calls = Array.isArray(record?.toolCalls) ? record.toolCalls : [];
295
+ for (const call of calls) {
296
+ const name = typeof call?.name === 'string' ? call.name : '';
297
+ if (!name) continue;
298
+ if (RECORD_WIP_RE.test(name)) result.wip = true;
299
+ if (REPORT_PROGRESS_RE.test(name)) result.progress = true;
300
+ if (SUBMIT_FOR_REVIEW_RE.test(name)) result.flip = true;
301
+ if (toolUseIsFlip(name, call?.input, root, vaultRootAbs)) result.flip = true;
302
+ if (result.flip && result.wip && result.progress) break; // nothing left to decide
303
+ }
304
+ return result;
305
+ }
306
+
307
+ // Real block (unattended, or an interactive session explicitly raised the
308
+ // rung) vs. warn-only (interactive default). Mirrors the original hook's
309
+ // local helper of the same name.
310
+ function shouldBlockForReal(env) {
311
+ return env.CLAUDE_AGENT === 'true' || env[INTERACTIVE_ENV] === 'block';
312
+ }
313
+
314
+ // Byte-for-byte the original hook's own interactive-warn stderr banner (its
315
+ // unexported fireRule()'s `else` branch) — kept here rather than left to the
316
+ // adapter's generic warn formatter (spexharness/src/adapters/claude-code.mjs's
317
+ // `warnStderrText`) because the second line names THIS gate's own env var,
318
+ // same convention as claim-before-code-gate.mjs's warnReason.
319
+ function warnStderrText(reason) {
320
+ return (
321
+ `[enforce-session-sequence] warning — ${reason}\n` +
322
+ '(Interactive session: this only warns. Set HEYSPEX_SESSION_SEQUENCE_INTERACTIVE=block to enforce it here too.)\n'
323
+ );
324
+ }
325
+
326
+ /** The session-sequence gate's id — unique across the built-in registry. */
327
+ export const id = 'enforce-session-sequence';
328
+ /** Runs on the Stop event, same as its Stop Claude Code hook registration. */
329
+ export const event = 'stop';
330
+ /** Caller-defined severity label; this gate can deny (the unattended/raised rung). */
331
+ export const severity = 'block';
332
+
333
+ /**
334
+ * @param {{ point: string, transcriptPath?: string, stopHookActive?: boolean,
335
+ * sessionRecord?: object }} evt
336
+ * @param {{ cwd?: string, readFile?: (p: string) => string | null,
337
+ * sessionRecord?: object,
338
+ * vaultRoot?: string | (() => string | null),
339
+ * env?: Record<string, string | undefined> }} [ctx]
340
+ * `sessionRecord` (on either the event or the ctx — session-lifecycle.mjs
341
+ * supplies both) is the front-agnostic carrier read when no transcript exists.
342
+ * @returns {{ decision: 'allow'|'deny'|'rewrite', reason?: string, rewrite?: object }}
343
+ */
344
+ export function run(evt, ctx = {}) {
345
+ const env = ctx.env ?? process.env;
346
+ if (String(env[OFF_ENV] ?? '') === '1') return { decision: 'allow' };
347
+
348
+ // Already inside a stop-hook continuation — one reminder is enough; never loop.
349
+ if (evt?.stopHookActive === true) return { decision: 'allow' };
350
+
351
+ // A transcript (Claude Code) or a session record (every other front). With
352
+ // neither there is nothing to judge — that is the only remaining allow-by-
353
+ // absence, and it is now a genuinely empty session rather than "this front
354
+ // isn't Claude Code".
355
+ const transcriptPath = evt?.transcriptPath;
356
+ const sessionRecord = evt?.sessionRecord ?? ctx.sessionRecord ?? null;
357
+ if (!transcriptPath && !sessionRecord) return { decision: 'allow' };
358
+
359
+ const root = ctx.cwd ?? process.env.CLAUDE_PROJECT_DIR ?? REPO_ROOT;
360
+ const configRoot = ctx.configRoot ?? (ctx.cwd ? ctx.cwd : defaultConfigRoot());
361
+ const readFile = ctx.readFile ?? defaultReadFile;
362
+
363
+ const guardrails = loadGuardrails(root);
364
+ const stopRules = rulesForSurface(guardrails, 'stop');
365
+
366
+ // Load both rules independently — toggle + reason text each. Missing/
367
+ // disabled rule → that check is skipped (fail-open per-rule, matching
368
+ // every other registry-driven gate in this repo), not a reason to skip the
369
+ // other check.
370
+ const wipRule = stopRules.find((r) => r && r.rule === RULE_ID);
371
+ const progressRule = stopRules.find((r) => r && r.rule === PROGRESS_RULE_ID);
372
+ if ((!wipRule || !wipRule.inject) && (!progressRule || !progressRule.inject)) {
373
+ return { decision: 'allow' };
374
+ }
375
+
376
+ const vaultRootAbs = resolveVaultRootAbs(ctx);
377
+ // Transcript first when there is one (every existing golden is recorded on
378
+ // this path and must keep matching byte for byte); the session record is the
379
+ // carrier every other front actually has.
380
+ const { flip, wip, progress } = transcriptPath
381
+ ? scanTranscript(readFile(transcriptPath) ?? '', root, vaultRootAbs)
382
+ : scanSessionRecord(sessionRecord, root, vaultRootAbs);
383
+ if (!flip) return { decision: 'allow' };
384
+
385
+ const forReal = shouldBlockForReal(env);
386
+
387
+ // Checked in this order so a turn missing BOTH reports the wip gate first
388
+ // (the more foundational of the two — no handoff at all vs. an incomplete
389
+ // checklist) — same ordering as the original.
390
+ if (wipRule && wipRule.inject && !wip && isRuleEnabled(wipRule, configRoot)) {
391
+ const reason = wipRule.inject.trim();
392
+ // `rule` rides at the top level (not just inside `rewrite`) so a caller
393
+ // observing only the deny branch — e.g. the audit-log writer in
394
+ // spexharness/src/adapters/audit.mjs — can still name which of the two
395
+ // rules fired without re-deriving it from `reason` text.
396
+ return forReal
397
+ ? { decision: 'deny', reason, rule: RULE_ID }
398
+ : { decision: 'rewrite', reason, rule: RULE_ID, rewrite: { warn: true, reason, rule: RULE_ID, stderrText: warnStderrText(reason) } };
399
+ }
400
+
401
+ if (progressRule && progressRule.inject && !progress && isRuleEnabled(progressRule, configRoot)) {
402
+ const reason = progressRule.inject.trim();
403
+ return forReal
404
+ ? { decision: 'deny', reason, rule: PROGRESS_RULE_ID }
405
+ : { decision: 'rewrite', reason, rule: PROGRESS_RULE_ID, rewrite: { warn: true, reason, rule: PROGRESS_RULE_ID, stderrText: warnStderrText(reason) } };
406
+ }
407
+
408
+ return { decision: 'allow' };
409
+ }
410
+
411
+ export default { id, event, severity, run };
412
+
413
+ // ---------------------------------------------------------------------------
414
+ // HS-RCIT — the stop-time correction gate (bounded, one-shot).
415
+ //
416
+ // [[HS-Y646-NYPR-EPIC-session-bootstrap-and-stop-for-every]] acceptance
417
+ // criterion 3: "A stop-time violation is rewritten once with the specific
418
+ // correction and the second reply is shown with a flag if it still violates."
419
+ // The epic's "What you will see" says the same in user words: "A reply that
420
+ // breaks a stop-time rule is rewritten once and shown with a one-line note,
421
+ // never silently passed and never looped."
422
+ //
423
+ // This section adds that bounded correction ON TOP of the session-sequence
424
+ // port above — nothing above this line changed shape, so the registry entry
425
+ // (./index.mjs), the golden fixtures and every existing caller of `run()`
426
+ // keep behaving exactly as recorded.
427
+ //
428
+ // The three stop-time rules checked, all read from the SAME compiled
429
+ // registry (`.heyspex/guardrails.json`) the sibling Stop gates read — never a
430
+ // second copy of a word list or a pattern:
431
+ // 1. naughty list → vocabularyViolations (naughty-list-gate.mjs's source)
432
+ // 2. id display format → compilePatterns + proseOnly (id-display-format-gate.mjs's source)
433
+ // 3. session sequence → this module's own run(), given the stop event
434
+ //
435
+ // The bound (never loops): a reply is corrected AT MOST ONCE
436
+ // (STOP_TIME_MAX_REWRITES = 1) and re-checked AT MOST ONCE. If the corrected
437
+ // reply still violates, it is returned UNCHANGED carrying `flagged: true`
438
+ // plus a one-line note — never corrected a second time, never re-checked a
439
+ // third time, and never downgraded to a plain `allow` (which is what
440
+ // "silently passes a violation" would mean). Two loop bounds exist so a
441
+ // misbehaving caller cannot defeat either:
442
+ // - in-process: enforceStopTimeRules() does the single correction pass
443
+ // itself and terminates on its own;
444
+ // - out-of-process (the model does the rewrite between calls): the caller
445
+ // passes `attempt`, and createStopTimeCorrector() holds that counter for
446
+ // callers that would otherwise keep passing 0.
447
+ //
448
+ // Decision mapping onto decide.mjs: a clean reply is `allow`; a corrected
449
+ // reply and a flagged reply are BOTH `rewrite` (the caller must show the
450
+ // payload's `reply`, not the one it handed in) — never `deny`, because a
451
+ // stop-time correction shows the answer with its note rather than swallowing
452
+ // it. `deny` stays what the session-sequence rung above uses.
453
+ // ---------------------------------------------------------------------------
454
+
455
+ /** Break-glass for the whole stop-time correction path (per-rule hatches still apply). */
456
+ export const STOP_TIME_OFF_ENV = 'HEYSPEX_STOP_TIME_OFF';
457
+ /** The bound: a violating reply is corrected at most this many times. Never raise it. */
458
+ export const STOP_TIME_MAX_REWRITES = 1;
459
+
460
+ const NAUGHTY_RULE_ID = 'naughty-list';
461
+ const ID_DISPLAY_RULE_ID = 'id-display-shape-gate';
462
+ /** The stop-time rule families this gate checks a reply against (acceptance criterion 1). */
463
+ export const STOP_TIME_RULES = Object.freeze([NAUGHTY_RULE_ID, ID_DISPLAY_RULE_ID, 'session-sequence']);
464
+
465
+ // The same non-prose spans proseOnly() blanks out (code spans, fenced blocks,
466
+ // [[wikilinks]], URLs, blockquotes) — kept as an ordered split rather than a
467
+ // blanking replace so a mechanical correction can rewrite the prose parts and
468
+ // put the protected parts back byte-for-byte.
469
+ const NON_PROSE_RE = /```[\s\S]*?```|`[^`]*`|\[\[[^\]]*\]\]|https?:\/\/\S+|^[ \t]{0,3}>.*$/gm;
470
+
471
+ function proseSegments(text) {
472
+ const segments = [];
473
+ const re = new RegExp(NON_PROSE_RE.source, 'gm');
474
+ let last = 0;
475
+ let m;
476
+ while ((m = re.exec(text)) !== null) {
477
+ if (m.index > last) segments.push({ text: text.slice(last, m.index), prose: true });
478
+ segments.push({ text: m[0], prose: false });
479
+ last = m.index + m[0].length;
480
+ if (m[0].length === 0) re.lastIndex += 1; // guard against a zero-width match spinning
481
+ }
482
+ if (last < text.length) segments.push({ text: text.slice(last), prose: true });
483
+ return segments;
484
+ }
485
+
486
+ function escapeRe(s) {
487
+ return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
488
+ }
489
+
490
+ /**
491
+ * The literal drop-in a naughty-list term's `fix` offers, when it offers one.
492
+ * The registry writes fixes as prose, some of which lead with a quoted
493
+ * replacement ("knob" → `"setting," "parameter," "control"`); those can be
494
+ * applied mechanically. A fix with no quoted candidate ("seam" → `name what
495
+ * the boundary or integration actually is`) has no mechanical form — the
496
+ * correction still names it, but only a real rewriter can clear it, which is
497
+ * exactly the case that exercises the flagged second-violation path.
498
+ *
499
+ * @param {string} fix
500
+ * @returns {string|null}
501
+ */
502
+ export function suggestedReplacement(fix) {
503
+ if (typeof fix !== 'string') return null;
504
+ const m = /["“]([^"“”]{1,60})["”]/.exec(fix);
505
+ if (!m) return null;
506
+ const candidate = m[1].trim().replace(/[,.;:]+$/, '').trim();
507
+ return candidate.length > 0 ? candidate : null;
508
+ }
509
+
510
+ function stopTimeRoot(options) {
511
+ return options.cwd ?? process.env.CLAUDE_PROJECT_DIR ?? REPO_ROOT;
512
+ }
513
+
514
+ /** Same code-root vs config-root split as run() — see the ctx contract in the header. */
515
+ function stopTimeConfigRoot(options) {
516
+ return options.configRoot ?? (options.cwd ? options.cwd : defaultConfigRoot());
517
+ }
518
+
519
+ function naughtyListViolations(reply, root, env, configRoot = root) {
520
+ if (String(env.HEYSPEX_NAUGHTY_LIST_OFF ?? '') === '1') return [];
521
+ const rules = rulesForSurface(loadGuardrails(root), 'stop').filter((r) => r && r.kind === 'vocabulary');
522
+ const found = [];
523
+ for (const rule of rules) {
524
+ if (!isRuleEnabled(rule, configRoot)) continue;
525
+ for (const hit of vocabularyViolations(rule, reply, root)) {
526
+ found.push({
527
+ rule: rule.rule ?? NAUGHTY_RULE_ID,
528
+ kind: 'vocabulary',
529
+ severity: rule.severity ?? 'block',
530
+ word: hit.word,
531
+ fix: hit.fix,
532
+ context: hit.context,
533
+ replacement: suggestedReplacement(hit.fix),
534
+ correction: `"${hit.word}" → ${hit.fix}`,
535
+ });
536
+ }
537
+ }
538
+ return found;
539
+ }
540
+
541
+ function idDisplayViolations(reply, root, env, configRoot = root) {
542
+ if (String(env.HEYSPEX_ID_DISPLAY_GATE_OFF ?? '') === '1') return [];
543
+ const rule = rulesForSurface(loadGuardrails(root), 'stop').find((r) => r && r.rule === ID_DISPLAY_RULE_ID);
544
+ if (!rule || !isRuleEnabled(rule, configRoot)) return [];
545
+ const patterns = compilePatterns(rule);
546
+ if (!patterns.length) return [];
547
+ const prose = proseOnly(reply);
548
+ const found = [];
549
+ for (const re of patterns) {
550
+ const m = re.exec(prose);
551
+ if (!m) continue;
552
+ const excerpt = matchContextAround(prose, m.index, m[0].length);
553
+ found.push({
554
+ rule: ID_DISPLAY_RULE_ID,
555
+ kind: 'pattern',
556
+ severity: rule.severity ?? 'warn',
557
+ excerpt,
558
+ replacement: null, // no mechanical form: the type must be read off the spec, never guessed
559
+ correction:
560
+ `"${excerpt}" → write the id suffixed (HS-OFUB-CVOB-EPIC) or prose-prefixed (Epic: HS-OFUB-CVOB), ` +
561
+ `never annotated in parentheses`,
562
+ });
563
+ }
564
+ return found;
565
+ }
566
+
567
+ function sequenceViolations(evt, options) {
568
+ if (!evt) return [];
569
+ const result = run(evt, options);
570
+ if (!result || result.decision === 'allow') return [];
571
+ const reason = typeof result.reason === 'string' ? result.reason : '';
572
+ return [
573
+ {
574
+ rule: result.rule ?? 'session-sequence',
575
+ kind: 'sequence',
576
+ severity: result.decision === 'deny' ? 'block' : 'warn',
577
+ // The registry's own inject text, verbatim — same words the deny rung
578
+ // above emits, not a re-authored string.
579
+ correction: reason,
580
+ reason,
581
+ },
582
+ ];
583
+ }
584
+
585
+ /**
586
+ * Check one reply against all three stop-time rule families (acceptance
587
+ * criterion 1). Pure with respect to the reply: it never modifies it.
588
+ *
589
+ * @param {string} reply - the assistant reply about to be shown.
590
+ * @param {{ cwd?: string, configRoot?: string, env?: Record<string, string|undefined>, event?: object,
591
+ * readFile?: (p: string) => string|null, vaultRoot?: string|(() => string|null) }} [options]
592
+ * @returns {{ violations: Array<object>, checked: string[], off: boolean }}
593
+ */
594
+ export function checkStopTimeRules(reply, options = {}) {
595
+ const env = options.env ?? process.env;
596
+ if (String(env[STOP_TIME_OFF_ENV] ?? '') === '1') {
597
+ return { violations: [], checked: [], off: true };
598
+ }
599
+ const root = stopTimeRoot(options);
600
+ const configRoot = stopTimeConfigRoot(options);
601
+ const text = typeof reply === 'string' ? reply : '';
602
+ const checked = [NAUGHTY_RULE_ID, ID_DISPLAY_RULE_ID];
603
+ const violations = [
604
+ ...naughtyListViolations(text, root, env, configRoot),
605
+ ...idDisplayViolations(text, root, env, configRoot),
606
+ ];
607
+ // The sequence rule is transcript state, not reply text — it is only
608
+ // checkable when the caller hands over the stop event it belongs to.
609
+ if (options.event) {
610
+ checked.push('session-sequence');
611
+ violations.push(...sequenceViolations(options.event, options));
612
+ }
613
+ return { violations, checked, off: false };
614
+ }
615
+
616
+ /**
617
+ * The SPECIFIC correction for a set of violations — the per-rule fix text,
618
+ * never a generic "you broke a rule" line.
619
+ *
620
+ * @param {Array<object>} violations
621
+ * @returns {string}
622
+ */
623
+ export function correctionFor(violations) {
624
+ const lines = [];
625
+ const seen = new Set();
626
+ for (const v of violations ?? []) {
627
+ const line = ` • ${v.rule}: ${v.correction}`;
628
+ if (seen.has(line)) continue;
629
+ seen.add(line);
630
+ lines.push(line);
631
+ }
632
+ if (!lines.length) return '';
633
+ return `Stop-time correction — rewrite the reply and resend:\n${lines.join('\n')}`;
634
+ }
635
+
636
+ /**
637
+ * The one-line note a flagged (still-violating) reply is shown with.
638
+ *
639
+ * @param {Array<object>} violations
640
+ * @returns {string}
641
+ */
642
+ export function stopTimeFlagNote(violations) {
643
+ const parts = [];
644
+ for (const v of violations ?? []) {
645
+ const label = v.word ? `${v.rule} ("${v.word}")` : v.rule;
646
+ if (!parts.includes(label)) parts.push(label);
647
+ }
648
+ return `[stop-time] shown with a flag — still violates after one correction: ${parts.join(', ')}`;
649
+ }
650
+
651
+ /**
652
+ * The default, mechanical correction pass: swap each banned term that carries
653
+ * a literal replacement candidate, in the PROSE parts of the reply only (a
654
+ * banned word inside backticks, a wikilink, a URL or a blockquote is exempt,
655
+ * same as every other stop gate). Violations with no mechanical form are left
656
+ * alone — deliberately, so they surface on the re-check as a flagged reply
657
+ * instead of being silently dropped.
658
+ *
659
+ * A caller with a real rewriter (a model) passes it as `options.rewrite`
660
+ * instead; this is the fallback so the gate corrects something on its own.
661
+ *
662
+ * Takes the violations as either the second or the third argument, so it can
663
+ * be handed straight to `options.rewrite` (whose rewriter signature is
664
+ * `(reply, correction, violations)`) as well as called directly.
665
+ *
666
+ * @param {string} reply
667
+ * @param {Array<object>|string} violations - the violations, or the correction
668
+ * text when called in rewriter position (then `alsoViolations` carries them).
669
+ * @param {Array<object>} [alsoViolations]
670
+ * @returns {string} the corrected reply (=== the input when nothing was mechanically fixable)
671
+ */
672
+ export function applyCorrection(reply, violations, alsoViolations) {
673
+ if (typeof reply !== 'string' || !reply) return typeof reply === 'string' ? reply : '';
674
+ const list = Array.isArray(violations) ? violations : Array.isArray(alsoViolations) ? alsoViolations : [];
675
+ const swaps = list.filter((v) => v && v.kind === 'vocabulary' && v.word && v.replacement);
676
+ if (!swaps.length) return reply;
677
+ return proseSegments(reply)
678
+ .map((seg) => {
679
+ if (!seg.prose) return seg.text;
680
+ let out = seg.text;
681
+ for (const swap of swaps) {
682
+ const re = new RegExp(`(?<![\\w-])${escapeRe(swap.word)}(?![\\w-])`, 'gi');
683
+ out = out.replace(re, (match) =>
684
+ /^[A-Z]/.test(match) ? swap.replacement.charAt(0).toUpperCase() + swap.replacement.slice(1) : swap.replacement,
685
+ );
686
+ }
687
+ return out;
688
+ })
689
+ .join('');
690
+ }
691
+
692
+ /**
693
+ * The stop-time gate itself (acceptance criteria 2, 3 and 4).
694
+ *
695
+ * @param {string} reply - the reply about to be shown.
696
+ * @param {{
697
+ * cwd?: string,
698
+ * env?: Record<string, string|undefined>,
699
+ * event?: object,
700
+ * readFile?: (p: string) => string|null,
701
+ * vaultRoot?: string|(() => string|null),
702
+ * attempt?: number,
703
+ * rewrite?: false | ((reply: string, correction: string, violations: Array<object>) => string),
704
+ * }} [options]
705
+ * `attempt` — how many corrections this reply already went through
706
+ * upstream; `>= STOP_TIME_MAX_REWRITES` means this IS the second reply, so
707
+ * it is flagged rather than corrected again. `rewrite` — a rewriter to run
708
+ * the single correction pass with (defaults to {@link applyCorrection});
709
+ * pass `false` for verdict-only mode, where the caller performs the rewrite
710
+ * between calls and comes back with `attempt: 1`.
711
+ * @returns {{
712
+ * decision: 'allow'|'rewrite',
713
+ * reply: string, originalReply: string,
714
+ * violations: Array<object>, initialViolations: Array<object>,
715
+ * correction: string|null, reason?: string,
716
+ * rewritten: boolean, flagged: boolean, needsRewrite: boolean,
717
+ * attempts: number, checked: string[],
718
+ * flag?: { note: string, rules: string[], violations: Array<object> },
719
+ * rewrite?: object,
720
+ * }}
721
+ */
722
+ export function enforceStopTimeRules(reply, options = {}) {
723
+ const original = typeof reply === 'string' ? reply : '';
724
+ const attemptIn = Number.isInteger(options.attempt) && options.attempt > 0 ? options.attempt : 0;
725
+ const first = checkStopTimeRules(original, options);
726
+
727
+ const clean = (attempts) => ({
728
+ decision: 'allow',
729
+ reply: original,
730
+ originalReply: original,
731
+ violations: [],
732
+ initialViolations: first.violations,
733
+ correction: null,
734
+ rewritten: false,
735
+ flagged: false,
736
+ needsRewrite: false,
737
+ attempts,
738
+ checked: first.checked,
739
+ });
740
+
741
+ if (!first.violations.length) return clean(attemptIn);
742
+
743
+ const correction = correctionFor(first.violations);
744
+
745
+ // `attempts` is the correction budget SPENT overall (never above the
746
+ // bound); `rewritten` says whether THIS call did the rewriting, so a caller
747
+ // re-asking about an already-corrected reply never double-counts it.
748
+ const flaggedVerdict = (shownReply, violations, attempts, rewritten) => {
749
+ const note = stopTimeFlagNote(violations);
750
+ const flag = { note, rules: [...new Set(violations.map((v) => v.rule))], violations };
751
+ return {
752
+ // Still a rewrite decision, never `allow` — a flagged reply is shown
753
+ // WITH its note, which is not the same thing as silently passing it.
754
+ decision: 'rewrite',
755
+ reply: shownReply,
756
+ originalReply: original,
757
+ violations,
758
+ initialViolations: first.violations,
759
+ correction,
760
+ reason: note,
761
+ rewritten,
762
+ flagged: true,
763
+ needsRewrite: false, // the single correction is spent; never ask for another
764
+ attempts,
765
+ checked: first.checked,
766
+ flag,
767
+ rewrite: { reply: shownReply, correction, violations, flagged: true, note, attempts },
768
+ };
769
+ };
770
+
771
+ // The reply already went through its one correction upstream: show it
772
+ // UNCHANGED with the flag. No second correction, no third check.
773
+ if (attemptIn >= STOP_TIME_MAX_REWRITES) {
774
+ return flaggedVerdict(original, first.violations, STOP_TIME_MAX_REWRITES, false);
775
+ }
776
+
777
+ // Verdict-only mode: hand the specific correction back and let the caller
778
+ // do the single rewrite. It must return with `attempt: 1`, which lands on
779
+ // the flagged branch above if the new reply still violates.
780
+ if (options.rewrite === false) {
781
+ return {
782
+ decision: 'rewrite',
783
+ reply: original,
784
+ originalReply: original,
785
+ violations: first.violations,
786
+ initialViolations: first.violations,
787
+ correction,
788
+ reason: correction,
789
+ rewritten: false,
790
+ flagged: false,
791
+ needsRewrite: true,
792
+ attempts: 0,
793
+ checked: first.checked,
794
+ rewrite: { reply: original, correction, violations: first.violations, flagged: false, needsRewrite: true, attempts: 0 },
795
+ };
796
+ }
797
+
798
+ const rewriter = typeof options.rewrite === 'function' ? options.rewrite : applyCorrection;
799
+ let corrected;
800
+ try {
801
+ corrected = rewriter(original, correction, first.violations);
802
+ } catch {
803
+ corrected = original; // a broken rewriter must not lose the reply
804
+ }
805
+ if (typeof corrected !== 'string') corrected = original;
806
+
807
+ // Exactly one re-check. Whatever it says, this call terminates here.
808
+ const second = checkStopTimeRules(corrected, options);
809
+ if (second.violations.length) {
810
+ return flaggedVerdict(corrected, second.violations, STOP_TIME_MAX_REWRITES, true);
811
+ }
812
+
813
+ return {
814
+ decision: 'rewrite',
815
+ reply: corrected,
816
+ originalReply: original,
817
+ violations: [],
818
+ initialViolations: first.violations,
819
+ correction,
820
+ reason: correction,
821
+ rewritten: true,
822
+ flagged: false,
823
+ needsRewrite: false,
824
+ attempts: STOP_TIME_MAX_REWRITES,
825
+ checked: first.checked,
826
+ rewrite: { reply: corrected, correction, violations: [], flagged: false, note: null, attempts: STOP_TIME_MAX_REWRITES },
827
+ };
828
+ }
829
+
830
+ /**
831
+ * A session-scoped holder of the one-correction budget, for the
832
+ * out-of-process shape where the caller (not this module) does the rewriting
833
+ * and could otherwise keep asking forever. It carries the attempt counter so
834
+ * a caller that always passes `attempt: 0` still gets exactly one correction
835
+ * and a flag after that.
836
+ *
837
+ * @param {object} [baseOptions] - the options every review() call inherits.
838
+ * @returns {{ attempts: number, done: boolean, review: (reply: string, extra?: object) => object }}
839
+ */
840
+ export function createStopTimeCorrector(baseOptions = {}) {
841
+ let attempts = 0;
842
+ let done = false;
843
+
844
+ return {
845
+ get attempts() {
846
+ return attempts;
847
+ },
848
+ get done() {
849
+ return done;
850
+ },
851
+ review(reply, extra = {}) {
852
+ const verdict = enforceStopTimeRules(reply, { ...baseOptions, ...extra, attempt: attempts });
853
+ if (verdict.rewritten || verdict.needsRewrite) {
854
+ attempts = Math.min(STOP_TIME_MAX_REWRITES, attempts + 1);
855
+ }
856
+ if (verdict.decision === 'allow' || verdict.flagged) done = true;
857
+ return verdict;
858
+ },
859
+ };
860
+ }
861
+
862
+ /**
863
+ * decide.mjs-shaped adapter, so a front can register the stop-time gate
864
+ * alongside the other stop gates. The reply comes from `ctx.reply` when the
865
+ * front already holds it, else from the transcript's last assistant text (the
866
+ * same reader naughty-list-gate.mjs and id-display-format-gate.mjs use).
867
+ *
868
+ * @param {{ point: string, transcriptPath?: string, stopHookActive?: boolean }} evt
869
+ * @param {object} [ctx] - the same option bag {@link enforceStopTimeRules} takes.
870
+ * @returns {{ decision: 'allow'|'rewrite', reason?: string, rewrite?: object, verdict: object }}
871
+ */
872
+ export function runStopTime(evt, ctx = {}) {
873
+ const reply =
874
+ typeof ctx.reply === 'string'
875
+ ? ctx.reply
876
+ : evt?.transcriptPath
877
+ ? (lastAssistantText(evt.transcriptPath) ?? '')
878
+ : '';
879
+ const verdict = enforceStopTimeRules(reply, { ...ctx, event: evt });
880
+ if (verdict.decision === 'allow') return { decision: 'allow', verdict };
881
+ return { decision: 'rewrite', reason: verdict.reason, rewrite: verdict.rewrite, verdict };
882
+ }
883
+
884
+ /**
885
+ * The stop-time correction gate as a registry.mjs gate object. Kept as a
886
+ * named export rather than registered in ./index.mjs — that file's twelve-gate
887
+ * set is asserted by fixtures/goldens.test.mjs and index.test.mjs, so a front
888
+ * that wants this gate registers it itself.
889
+ */
890
+ export const stopTimeGate = {
891
+ id: 'stop-time-correction',
892
+ event: 'stop',
893
+ severity: 'block',
894
+ run: runStopTime,
895
+ };