heyspex 0.1.0 → 0.2.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 (322) hide show
  1. package/README.md +21 -0
  2. package/bin/hs.mjs +931 -98
  3. package/bin/spexcode.mjs +262 -0
  4. package/dev-factory/engine/worktree.mjs +740 -0
  5. package/dev-factory/lib/code-repo-root.mjs +67 -0
  6. package/dev-factory/lib/context-budget.mjs +84 -0
  7. package/dev-factory/lib/epic-detect.mjs +190 -0
  8. package/dev-factory/lib/fleet-queue-file.mjs +846 -0
  9. package/dev-factory/lib/fleet-queue.mjs +426 -0
  10. package/dev-factory/lib/fleet-state.mjs +14 -18
  11. package/dev-factory/lib/model-catalog.mjs +338 -0
  12. package/dev-factory/lib/seat-context.mjs +199 -0
  13. package/dev-factory/lib/spec-io.mjs +93 -0
  14. package/dev-factory/lib/sufficiency-score.mjs +8 -2
  15. package/dev-factory/lib/worktree-allocator.mjs +744 -0
  16. package/dev-factory/model-catalog.json +222 -0
  17. package/dist/cli/hs-check.mjs +1085 -103
  18. package/dist/cli/hs-claim.mjs +936 -63
  19. package/dist/cli/hs-connect.mjs +9786 -1910
  20. package/dist/cli/hs-defer.mjs +5267 -3053
  21. package/dist/cli/hs-dev.mjs +564 -0
  22. package/dist/cli/hs-env.mjs +385 -299
  23. package/dist/cli/hs-index-rebuild.mjs +7129 -0
  24. package/dist/cli/hs-index-reconcile.mjs +7852 -0
  25. package/dist/cli/hs-init.mjs +777 -138
  26. package/dist/cli/hs-install.mjs +6583 -485
  27. package/dist/cli/hs-join.mjs +10137 -2368
  28. package/dist/cli/hs-vault-pull.mjs +4659 -5882
  29. package/dist/cli/hs-vault.mjs +3793 -1537
  30. package/dist/cli/hs-workspace.mjs +2550 -524
  31. package/dist/cli-templates/customer-vault/specs/entity-model.md +594 -601
  32. package/dist/cli-templates/customer-vault/specs/protocols/canonical-frontmatter-templates.md +110 -177
  33. package/dist/cli-templates/customer-vault/specs/protocols/destructive-action-checklist.md +122 -0
  34. package/dist/cli-templates/customer-vault/specs/protocols/memory-protocol.md +13 -15
  35. package/dist/cli-templates/customer-vault/specs/protocols/session-close-protocol.md +91 -0
  36. package/dist/cli-templates/customer-vault/specs/protocols/spec-hierarchy.md +150 -168
  37. package/dist/cli-templates/customer-vault/specs/protocols/spec-summary-authoring.md +83 -0
  38. package/dist/cli-templates/harness-commands/claude/hs-auto.md +36 -0
  39. package/dist/cli-templates/harness-commands/claude/hs-backfill.md +28 -0
  40. package/dist/cli-templates/harness-commands/claude/hs-capture.md +62 -0
  41. package/dist/cli-templates/harness-commands/claude/hs-defer.md +29 -0
  42. package/dist/cli-templates/harness-commands/claude/hs-dev.md +20 -0
  43. package/dist/cli-templates/harness-commands/claude/hs-doctor.md +32 -0
  44. package/dist/cli-templates/harness-commands/claude/hs-ground.md +28 -0
  45. package/dist/cli-templates/harness-commands/claude/hs-handoff.md +33 -0
  46. package/dist/cli-templates/harness-commands/claude/hs-log.md +27 -0
  47. package/dist/cli-templates/harness-commands/claude/hs-next.md +29 -0
  48. package/dist/cli-templates/harness-commands/claude/hs-promote.md +70 -0
  49. package/dist/cli-templates/harness-commands/claude/hs-push.md +35 -0
  50. package/dist/cli-templates/harness-commands/claude/hs-qc.md +27 -0
  51. package/dist/cli-templates/harness-commands/claude/hs-res.md +63 -0
  52. package/dist/cli-templates/harness-commands/claude/hs-resolve.md +23 -0
  53. package/dist/cli-templates/harness-commands/claude/hs-review.md +23 -0
  54. package/dist/cli-templates/harness-commands/claude/hs-view.md +26 -0
  55. package/dist/cli-templates/harness-commands/claude/kill-ports.md +32 -0
  56. package/dist/cli-templates/harness-commands/claude/prototype.md +91 -0
  57. package/dist/cli-templates/harness-commands/cursor/hs-auto.md +40 -0
  58. package/dist/cli-templates/harness-commands/cursor/hs-backfill.md +31 -0
  59. package/dist/cli-templates/harness-commands/cursor/hs-capture.md +65 -0
  60. package/dist/cli-templates/harness-commands/cursor/hs-defer.md +32 -0
  61. package/dist/cli-templates/harness-commands/cursor/hs-dev.md +24 -0
  62. package/dist/cli-templates/harness-commands/cursor/hs-doctor.md +36 -0
  63. package/dist/cli-templates/harness-commands/cursor/hs-ground.md +31 -0
  64. package/dist/cli-templates/harness-commands/cursor/hs-handoff.md +37 -0
  65. package/dist/cli-templates/harness-commands/cursor/hs-log.md +31 -0
  66. package/dist/cli-templates/harness-commands/cursor/hs-next.md +32 -0
  67. package/dist/cli-templates/harness-commands/cursor/hs-promote.md +73 -0
  68. package/dist/cli-templates/harness-commands/cursor/hs-push.md +38 -0
  69. package/dist/cli-templates/harness-commands/cursor/hs-qc.md +30 -0
  70. package/dist/cli-templates/harness-commands/cursor/hs-res.md +67 -0
  71. package/dist/cli-templates/harness-commands/cursor/hs-resolve.md +26 -0
  72. package/dist/cli-templates/harness-commands/cursor/hs-review.md +26 -0
  73. package/dist/cli-templates/harness-commands/cursor/hs-view.md +30 -0
  74. package/dist/cli-templates/harness-commands/cursor/kill-ports.md +35 -0
  75. package/dist/cli-templates/harness-commands/cursor/prototype.md +95 -0
  76. package/docs/mcp-client-config.md +259 -6
  77. package/package.json +231 -124
  78. package/plugins/heyspex/.claude-plugin/plugin.json +12 -0
  79. package/plugins/heyspex/.cursor-plugin/plugin.json +15 -0
  80. package/plugins/heyspex/.mcp.json +12 -0
  81. package/plugins/heyspex/bin/launch-mcp.mjs +95 -0
  82. package/plugins/heyspex/bin/run-hook.mjs +62 -0
  83. package/plugins/heyspex/hooks/cursor-hooks.json +227 -0
  84. package/plugins/heyspex/hooks/hooks.json +361 -0
  85. package/plugins/heyspex/mcp.json +13 -0
  86. package/scripts/check-mcp-freshness.mjs +17 -9
  87. package/scripts/claude-hooks/_env-vault-events.mjs +68 -0
  88. package/scripts/claude-hooks/_guardrail-events.mjs +96 -0
  89. package/scripts/claude-hooks/_guardrail-loader.mjs +494 -0
  90. package/scripts/claude-hooks/_intent-classifier-router.mjs +410 -0
  91. package/scripts/claude-hooks/_intent-classifier-semantic.mjs +351 -0
  92. package/scripts/claude-hooks/_run.mjs +120 -0
  93. package/scripts/claude-hooks/_transcript-text.mjs +60 -0
  94. package/scripts/claude-hooks/_vault-retrieval.mjs +322 -0
  95. package/scripts/claude-hooks/agent-spawn-delegation-gate.mjs +133 -0
  96. package/scripts/claude-hooks/capture-workflow-gate.mjs +11 -0
  97. package/scripts/claude-hooks/check-auto-memory-budget.mjs +396 -0
  98. package/scripts/claude-hooks/check-claude-md-budget.mjs +327 -0
  99. package/scripts/claude-hooks/check-mcp-freshness-hook.mjs +14 -0
  100. package/scripts/claude-hooks/claim-before-code-gate.mjs +11 -0
  101. package/scripts/claude-hooks/claim-liveness-shared.mjs +575 -0
  102. package/scripts/claude-hooks/close-terminal-on-ship.mjs +64 -0
  103. package/scripts/claude-hooks/coo-denylist-gate.mjs +11 -0
  104. package/scripts/claude-hooks/coo-guard-check.mjs +11 -0
  105. package/scripts/claude-hooks/deep-research-cost-gate.mjs +11 -0
  106. package/scripts/claude-hooks/dev-server-task-reminder.mjs +15 -0
  107. package/scripts/claude-hooks/emit-claim-status.mjs +41 -0
  108. package/scripts/claude-hooks/enforce-session-sequence.mjs +11 -0
  109. package/scripts/claude-hooks/enforce-vault-naming.mjs +11 -0
  110. package/scripts/claude-hooks/env-vault-guard.mjs +11 -0
  111. package/scripts/claude-hooks/epic-milestone-phrasing-gate.mjs +11 -0
  112. package/scripts/claude-hooks/git-identity-guard.mjs +11 -0
  113. package/scripts/claude-hooks/git-reflog-audit.mjs +15 -0
  114. package/scripts/claude-hooks/git-stash-gate.mjs +11 -0
  115. package/scripts/claude-hooks/guard-auto-memory-writes.mjs +137 -0
  116. package/scripts/claude-hooks/id-display-format-gate.mjs +11 -0
  117. package/scripts/claude-hooks/inject-communication-style.mjs +66 -0
  118. package/scripts/claude-hooks/inject-coo-seat.mjs +78 -0
  119. package/scripts/claude-hooks/inject-dev-server-awareness.mjs +11 -0
  120. package/scripts/claude-hooks/inject-doctrine.mjs +13 -0
  121. package/scripts/claude-hooks/inject-vault-context.mjs +14 -0
  122. package/scripts/claude-hooks/intent-classifier-trigger.mjs +247 -0
  123. package/scripts/claude-hooks/log-session-activity.mjs +341 -0
  124. package/scripts/claude-hooks/naughty-list-gate.mjs +11 -0
  125. package/scripts/claude-hooks/notify-status-flip.mjs +72 -0
  126. package/scripts/claude-hooks/plan-exit-ingest-gate.mjs +170 -0
  127. package/scripts/claude-hooks/plan-ingest-close-reminder.mjs +66 -0
  128. package/scripts/claude-hooks/plan-review-gate.mjs +11 -0
  129. package/scripts/claude-hooks/preflight-reminder.mjs +262 -0
  130. package/scripts/claude-hooks/protected-action-intent.mjs +221 -0
  131. package/scripts/claude-hooks/research-method-guard.mjs +398 -0
  132. package/scripts/claude-hooks/research-protocol-gate.mjs +11 -0
  133. package/scripts/claude-hooks/session-end.mjs +449 -0
  134. package/scripts/claude-hooks/settings-write-gate.mjs +11 -0
  135. package/scripts/claude-hooks/settlement-check-gate.mjs +39 -0
  136. package/scripts/claude-hooks/status-flip-detect.mjs +17 -0
  137. package/scripts/claude-hooks/terminal-close-shared.mjs +426 -0
  138. package/scripts/claude-hooks/terminal-close-watcher.mjs +122 -0
  139. package/scripts/claude-hooks/wip-checkpoint.mjs +298 -0
  140. package/scripts/cursor-hooks/manifest.mjs +235 -0
  141. package/scripts/dev-server-status.mjs +34 -13
  142. package/scripts/dev-server-stop.mjs +313 -0
  143. package/scripts/heyspex-mode.mjs +268 -0
  144. package/scripts/hs-auto-resolve.mjs +240 -0
  145. package/scripts/hs-auto-source.mjs +398 -0
  146. package/scripts/hs-auto.mjs +134 -0
  147. package/scripts/hs-capture-cli.mjs +326 -0
  148. package/scripts/hs-classifier.mjs +136 -0
  149. package/scripts/hs-companion.mjs +88 -0
  150. package/scripts/hs-gateway.mjs +115 -0
  151. package/scripts/hs-mode.mjs +125 -0
  152. package/scripts/hs-next.mjs +441 -0
  153. package/scripts/hs-settings.mjs +338 -0
  154. package/scripts/hs-view.mjs +333 -0
  155. package/scripts/hs-worktree.mjs +201 -0
  156. package/scripts/install-claude-code-hooks.mjs +270 -0
  157. package/scripts/install-cursor-hooks.mjs +471 -0
  158. package/scripts/lib/agent-env-markers.mjs +38 -0
  159. package/scripts/lib/console-url-resolver.mjs +197 -60
  160. package/scripts/lib/customer-kit.mjs +886 -0
  161. package/scripts/lib/dev-mode.mjs +244 -28
  162. package/scripts/lib/heyspex-home.mjs +72 -0
  163. package/scripts/lib/hs-command.mjs +277 -0
  164. package/scripts/lib/hs-companion-settings.mjs +472 -0
  165. package/scripts/lib/hs-source-commit.mjs +59 -5
  166. package/scripts/lib/hs-source-identity.mjs +526 -0
  167. package/scripts/lib/hs-source-log.mjs +11 -21
  168. package/scripts/lib/hs-source-push.mjs +107 -58
  169. package/scripts/lib/hs-worktree-args.mjs +115 -0
  170. package/scripts/lib/shared-path-helpers.mjs +134 -0
  171. package/scripts/lib/standalone-doctor.mjs +540 -3
  172. package/scripts/lib/subagent-git-lock.mjs +109 -0
  173. package/scripts/lib/template-pin.mjs +34 -0
  174. package/scripts/provenance-lib.mjs +91 -0
  175. package/scripts/stamp-provenance-on-edit.mjs +222 -0
  176. package/scripts/wip-ledger-health.mjs +653 -0
  177. package/spexharness/doctrine/baseline/SPEXCODE-DOCTRINE.baseline.json +29 -0
  178. package/spexharness/src/adapters/audit.mjs +197 -0
  179. package/spexharness/src/adapters/claude-code.mjs +441 -0
  180. package/spexharness/src/chat/tui/ansi.mjs +448 -0
  181. package/spexharness/src/chat/tui/palette.mjs +22 -0
  182. package/spexharness/src/chat/tui/select.mjs +208 -0
  183. package/spexharness/src/config-root.mjs +54 -0
  184. package/spexharness/src/context-budget.mjs +141 -0
  185. package/spexharness/src/dev-server-control.mjs +389 -0
  186. package/spexharness/src/doctrine/behaviour.mjs +371 -0
  187. package/spexharness/src/doctrine/corpus.mjs +733 -0
  188. package/spexharness/src/doctrine/inject.mjs +338 -0
  189. package/spexharness/src/doctrine/merge.mjs +184 -0
  190. package/spexharness/src/doctrine/sources.mjs +252 -0
  191. package/spexharness/src/doctrine/synthesize.mjs +401 -0
  192. package/spexharness/src/doctrine/vault-source.mjs +574 -0
  193. package/spexharness/src/doctrine/writer.mjs +176 -0
  194. package/spexharness/src/events.mjs +84 -0
  195. package/spexharness/src/gates/capture-workflow-gate.mjs +202 -0
  196. package/spexharness/src/gates/claim-before-code-gate.decide.mjs +33 -0
  197. package/spexharness/src/gates/claim-before-code-gate.mjs +129 -0
  198. package/spexharness/src/gates/deep-research-cost-gate.mjs +274 -0
  199. package/spexharness/src/gates/enforce-session-sequence.mjs +895 -0
  200. package/spexharness/src/gates/enforce-vault-naming.mjs +215 -0
  201. package/spexharness/src/gates/epic-milestone-phrasing-gate.mjs +233 -0
  202. package/spexharness/src/gates/git-identity-guard.decide.mjs +436 -0
  203. package/spexharness/src/gates/git-identity-guard.mjs +61 -0
  204. package/spexharness/src/gates/git-reflog-audit.mjs +151 -0
  205. package/spexharness/src/gates/git-stash-gate.decide.mjs +361 -0
  206. package/spexharness/src/gates/git-stash-gate.mjs +159 -0
  207. package/spexharness/src/gates/id-display-format-gate.mjs +190 -0
  208. package/spexharness/src/gates/inject-doctrine-gate.mjs +89 -0
  209. package/spexharness/src/gates/inject-vault-context.mjs +239 -0
  210. package/spexharness/src/gates/naughty-list-gate.mjs +213 -0
  211. package/spexharness/src/gates/plan-review-gate.mjs +233 -0
  212. package/spexharness/src/gates/preflight-reminder-gate.mjs +265 -0
  213. package/spexharness/src/gates/research-protocol-gate.mjs +175 -0
  214. package/spexharness/src/gates/settings-write-gate.decide.mjs +96 -0
  215. package/spexharness/src/gates/settings-write-gate.mjs +104 -0
  216. package/spexharness/src/git-reflog-audit.mjs +294 -0
  217. package/spexharness/src/intent-classifier.mjs +194 -0
  218. package/spexharness/src/intent-directive.mjs +541 -0
  219. package/spexharness/src/profile/learn.mjs +551 -0
  220. package/spexharness/src/profile/proposals.mjs +357 -0
  221. package/spexharness/src/profile/vault-inclusion.mjs +386 -0
  222. package/spexharness/src/redact.mjs +151 -0
  223. package/spexharness/src/rule-table.mjs +819 -0
  224. package/spexharness/src/seat-context.mjs +122 -0
  225. package/spexharness/src/session-bootstrap.mjs +512 -0
  226. package/spexharness/src/session-record.mjs +815 -0
  227. package/spexharness/src/token-inventory.mjs +207 -0
  228. package/vendor/heyspex-mcp/account-switch.mjs +95 -0
  229. package/vendor/heyspex-mcp/alignment-gate.mjs +315 -1
  230. package/vendor/heyspex-mcp/blocker-resolver.mjs +37 -2
  231. package/vendor/heyspex-mcp/breakdown-ladder.mjs +119 -4
  232. package/vendor/heyspex-mcp/breakdown-source.mjs +271 -0
  233. package/vendor/heyspex-mcp/breakdown-writer.mjs +23 -3
  234. package/vendor/heyspex-mcp/capture-buffer-patches.mjs +46 -5
  235. package/vendor/heyspex-mcp/capture-channel.mjs +55 -5
  236. package/vendor/heyspex-mcp/capture-console-client.mjs +80 -1
  237. package/vendor/heyspex-mcp/capture-focus.mjs +1035 -29
  238. package/vendor/heyspex-mcp/capture-outbox.mjs +5 -1
  239. package/vendor/heyspex-mcp/capture-tools.mjs +1219 -0
  240. package/vendor/heyspex-mcp/claim-tools.mjs +1124 -0
  241. package/vendor/heyspex-mcp/cli-verbs.mjs +2076 -0
  242. package/vendor/heyspex-mcp/cli.mjs +201 -15524
  243. package/vendor/heyspex-mcp/cloud-tool-surface.mjs +128 -0
  244. package/vendor/heyspex-mcp/cloud-vault-writes.mjs +412 -0
  245. package/vendor/heyspex-mcp/config-seed.yaml +15 -0
  246. package/vendor/heyspex-mcp/config-settings.mjs +290 -7
  247. package/vendor/heyspex-mcp/console-fetch.mjs +657 -16
  248. package/vendor/heyspex-mcp/console-transport.mjs +166 -0
  249. package/vendor/heyspex-mcp/credentials.mjs +584 -0
  250. package/vendor/heyspex-mcp/defer-spec.mjs +44 -4
  251. package/vendor/heyspex-mcp/deferred-reclaim.mjs +29 -3
  252. package/vendor/heyspex-mcp/design-handlers.mjs +31 -0
  253. package/vendor/heyspex-mcp/design-tools.mjs +4 -1
  254. package/vendor/heyspex-mcp/dev-factory-bridge.mjs +4 -4
  255. package/vendor/heyspex-mcp/dev-mode-core.mjs +45 -0
  256. package/vendor/heyspex-mcp/dev-tools.mjs +124 -0
  257. package/vendor/heyspex-mcp/diagnose-handlers.mjs +54 -0
  258. package/vendor/heyspex-mcp/events-tools.mjs +207 -0
  259. package/vendor/heyspex-mcp/filing-tools.mjs +688 -0
  260. package/vendor/heyspex-mcp/fleet-handlers.mjs +37 -0
  261. package/vendor/heyspex-mcp/fleet-queue-tools.mjs +69 -14
  262. package/vendor/heyspex-mcp/focus-tools.mjs +190 -0
  263. package/vendor/heyspex-mcp/folder-trust-store.mjs +59 -0
  264. package/vendor/heyspex-mcp/http-transport.mjs +300 -0
  265. package/vendor/heyspex-mcp/list-tools.mjs +257 -0
  266. package/vendor/heyspex-mcp/login-handoff.mjs +15 -1
  267. package/vendor/heyspex-mcp/mcp-config.mjs +94 -0
  268. package/vendor/heyspex-mcp/memory-tools.mjs +523 -0
  269. package/vendor/heyspex-mcp/mvp-mode.mjs +53 -6
  270. package/vendor/heyspex-mcp/occupied-keys.mjs +141 -0
  271. package/vendor/heyspex-mcp/on-deck-brief.mjs +3360 -0
  272. package/vendor/heyspex-mcp/on-deck-scheduler.mjs +1708 -0
  273. package/vendor/heyspex-mcp/package-root.mjs +32 -0
  274. package/vendor/heyspex-mcp/peer-presence.mjs +8 -1
  275. package/vendor/heyspex-mcp/plan-ground.mjs +621 -652
  276. package/vendor/heyspex-mcp/plan-push.mjs +906 -21
  277. package/vendor/heyspex-mcp/plan-review.mjs +124 -30
  278. package/vendor/heyspex-mcp/preflight-gate.mjs +0 -0
  279. package/vendor/heyspex-mcp/procedure-handlers.mjs +325 -0
  280. package/vendor/heyspex-mcp/procedure-tools.mjs +540 -351
  281. package/vendor/heyspex-mcp/promote-capture.mjs +386 -69
  282. package/vendor/heyspex-mcp/promote-diagnose.mjs +74 -0
  283. package/vendor/heyspex-mcp/promote-research-to-diagnose.mjs +9 -2
  284. package/vendor/heyspex-mcp/promote-research.mjs +660 -53
  285. package/vendor/heyspex-mcp/promotion-helpers.mjs +251 -0
  286. package/vendor/heyspex-mcp/prompts.mjs +1460 -0
  287. package/vendor/heyspex-mcp/read-path.mjs +29 -9
  288. package/vendor/heyspex-mcp/receipt-shape.mjs +146 -0
  289. package/vendor/heyspex-mcp/recipe-handlers.mjs +117 -0
  290. package/vendor/heyspex-mcp/regen-tasks.mjs +210 -3
  291. package/vendor/heyspex-mcp/register-spec.mjs +266 -54
  292. package/vendor/heyspex-mcp/request-context.mjs +59 -0
  293. package/vendor/heyspex-mcp/research-focus.mjs +207 -4
  294. package/vendor/heyspex-mcp/research-handlers.mjs +623 -0
  295. package/vendor/heyspex-mcp/research-tools.mjs +4 -4
  296. package/vendor/heyspex-mcp/response-budget.mjs +348 -0
  297. package/vendor/heyspex-mcp/review-focus.mjs +25 -1
  298. package/vendor/heyspex-mcp/search-specs.mjs +280 -0
  299. package/vendor/heyspex-mcp/seat-block-ledger.mjs +113 -0
  300. package/vendor/heyspex-mcp/seat-identity.mjs +2 -6
  301. package/vendor/heyspex-mcp/session-owner.mjs +108 -0
  302. package/vendor/heyspex-mcp/session-tools.mjs +152 -0
  303. package/vendor/heyspex-mcp/settings-registry.mjs +232 -0
  304. package/vendor/heyspex-mcp/settings-tools.mjs +85 -12
  305. package/vendor/heyspex-mcp/spec-edit-tools.mjs +446 -0
  306. package/vendor/heyspex-mcp/spec-id-lib.mjs +93 -17
  307. package/vendor/heyspex-mcp/split-research.mjs +301 -56
  308. package/vendor/heyspex-mcp/stdio-rpc.mjs +22 -0
  309. package/vendor/heyspex-mcp/submit-for-review.mjs +132 -6
  310. package/vendor/heyspex-mcp/summary-contract.mjs +105 -0
  311. package/vendor/heyspex-mcp/summary-write-tools.mjs +224 -0
  312. package/vendor/heyspex-mcp/symptom-query.mjs +146 -0
  313. package/vendor/heyspex-mcp/test-helpers.mjs +108 -1
  314. package/vendor/heyspex-mcp/tool-catalog.mjs +2813 -0
  315. package/vendor/heyspex-mcp/tool-dispatch.mjs +639 -0
  316. package/vendor/heyspex-mcp/tool-helpers.mjs +325 -0
  317. package/vendor/heyspex-mcp/validate-linkage.mjs +414 -27
  318. package/vendor/heyspex-mcp/vault-io.mjs +724 -0
  319. package/vendor/heyspex-mcp/vault-less-endpoints.mjs +52 -0
  320. package/vendor/heyspex-mcp/vault-resolve.mjs +366 -68
  321. package/vendor/heyspex-mcp/verify-reproduction.mjs +270 -30
  322. package/vendor/heyspex-mcp/whoami.mjs +91 -0
@@ -0,0 +1,197 @@
1
+ // spexharness/src/adapters/audit.mjs
2
+ //
3
+ // Restores the `.heyspex/guardrail-history.jsonl` audit-log side effect the
4
+ // ported gates under spexharness/src/gates/ deliberately left out — each
5
+ // one's header comment says "Not ported HERE" and points here. A gate stays
6
+ // a pure decision function (no fs writes); THIS module is the front-side
7
+ // observer that turns a gate's decision back into the exact row(s) its
8
+ // pre-cutover Claude Code hook wrote for the same firing, so
9
+ // scripts/claude-hooks/<gate>.smoke.mjs — the spec of that original
10
+ // behavior — keeps passing now that the shells route through the ported
11
+ // gates instead of the original scripts.
12
+ //
13
+ // Row shape is byte-compatible with the original writer
14
+ // (scripts/claude-hooks/_guardrail-events.mjs's appendGuardrailEvent):
15
+ // `{ timestamp, event_kind: "guardrail.fired", actor, spec_id, metadata }`.
16
+ // This module calls that function directly rather than reimplementing it —
17
+ // same "import the shared, already-pure library" precedent this porting
18
+ // effort already set for _guardrail-loader.mjs / _transcript-text.mjs (see
19
+ // e.g. enforce-session-sequence.mjs's own header) — so the JSONL rows the
20
+ // console's guardrail-history reader sees are identical whichever writer
21
+ // produced them.
22
+ //
23
+ // Per-gate mapping: only the three gates whose pre-cutover hooks called
24
+ // appendGuardrailEvent AND whose smoke tests assert on the resulting log
25
+ // (capture-workflow-gate, enforce-session-sequence, naughty-list-gate) are
26
+ // mapped in {@link auditRowsFor} today. Each case reads its exact original
27
+ // call shape straight from `git show HEAD:scripts/claude-hooks/<gate>.mjs`
28
+ // (pre-cutover) and reconstructs it from the fields the ported gate's
29
+ // decision object carries forward for exactly this purpose (see each gate's
30
+ // own header comment for which fields and why). A gate/decision this module
31
+ // has no case for returns no rows — this NEVER guesses a shape for a gate it
32
+ // doesn't recognize.
33
+ //
34
+ // FAIL-SAFE (same contract as every hook and as _guardrail-events.mjs
35
+ // itself): a write failure here must never break the caller. Every row
36
+ // write is wrapped; recordGuardrailAudit() never throws.
37
+
38
+ import { appendGuardrailEvent as defaultAppendGuardrailEvent } from '../../../scripts/claude-hooks/_guardrail-events.mjs';
39
+ import { redactRecord } from '../redact.mjs';
40
+
41
+ /**
42
+ * Build the zero-or-more guardrail-history rows implied by one gate's
43
+ * decision, matching what its pre-cutover Claude Code hook wrote for the
44
+ * same firing. Pure — no I/O. Returns `[]` for a plain `allow`, for any gate
45
+ * this module has no mapping for, or for a decision shape the mapping
46
+ * doesn't recognize (fail-safe: never guess).
47
+ *
48
+ * Each returned row is an `appendGuardrailEvent()`-shaped options object
49
+ * (`{ rule, surface, severity, decision, specId?, matchContext? }`) — the
50
+ * caller still supplies `repoRoot`.
51
+ *
52
+ * @param {{ id?: string }} gate
53
+ * @param {{ decision: string, reason?: string, rewrite?: Record<string, unknown>, rule?: string, blockHits?: Array<{ word: string, context?: string }> }} decision
54
+ * @returns {Array<Record<string, unknown>>}
55
+ */
56
+ export function auditRowsFor(gate, decision) {
57
+ const kind = decision?.decision ?? 'allow';
58
+ if (kind === 'allow' || !decision) return [];
59
+
60
+ switch (gate?.id) {
61
+ // Original (scripts/claude-hooks/capture-workflow-gate.mjs, pre-cutover):
62
+ // appendGuardrailEvent({ rule: `capture-workflow-gate:${type}`,
63
+ // surface: 'pretooluse', severity: 'warn', decision: 'warn',
64
+ // specId: resolved.spec_id }, root);
65
+ // Fired on every match — this gate NEVER denies (see its header), so
66
+ // only the `rewrite.warn` shape is mapped.
67
+ case 'capture-workflow-gate': {
68
+ if (kind !== 'rewrite' || !decision.rewrite?.warn) return [];
69
+ const captureId = decision.rewrite?.captureId;
70
+ if (!captureId) return [];
71
+ const type = decision.rewrite?.type ?? '';
72
+ return [
73
+ {
74
+ rule: `capture-workflow-gate:${type}`,
75
+ surface: 'pretooluse',
76
+ severity: 'warn',
77
+ decision: 'warn',
78
+ specId: captureId,
79
+ },
80
+ ];
81
+ }
82
+
83
+ // Original (scripts/claude-hooks/enforce-session-sequence.mjs, pre-cutover)
84
+ // fireRule():
85
+ // appendGuardrailEvent({ rule: ruleId, surface: 'stop',
86
+ // severity: forReal ? (rule.severity ?? 'block') : 'warn',
87
+ // decision: forReal ? 'block' : 'warn' }, root);
88
+ // Fired exactly once per Stop — either the wip rule or the progress
89
+ // rule, whichever the gate reported (`decision.rule`, carried on both
90
+ // the `deny` and `rewrite` results — see the gate's own header). The
91
+ // registry's `session-wip-before-stop` / `session-progress-before-review`
92
+ // rules are both severity "block" today, so `rule.severity ?? 'block'`
93
+ // and the `'block'` literal here agree; this reads the literal (matching
94
+ // the fallback) rather than re-loading the registry, keeping this module
95
+ // free of any guardrail-loader dependency of its own.
96
+ case 'enforce-session-sequence': {
97
+ const ruleId = decision.rule;
98
+ if (!ruleId) return []; // no rule id carried → nothing to log (fail-safe)
99
+ if (kind === 'deny') {
100
+ return [{ rule: ruleId, surface: 'stop', severity: 'block', decision: 'block' }];
101
+ }
102
+ if (kind === 'rewrite' && decision.rewrite?.warn) {
103
+ return [{ rule: ruleId, surface: 'stop', severity: 'warn', decision: 'warn' }];
104
+ }
105
+ return [];
106
+ }
107
+
108
+ // Original (scripts/claude-hooks/naughty-list-gate.mjs, pre-cutover):
109
+ // for (const hit of blockHits) {
110
+ // appendGuardrailEvent({ rule: `naughty-list:${hit.word}`,
111
+ // surface: 'stop', severity: 'block',
112
+ // decision: warnOnly ? 'warn' : 'block', matchContext: hit.context });
113
+ // }
114
+ // One row PER block-severity hit (not deduped by word — a repeated hit
115
+ // logs a repeated row, same as the original's own loop), fired on both
116
+ // rungs; warn-severity-only hits are never logged (the original's loop
117
+ // only ever iterates `blockHits`). `decision.blockHits` carries exactly
118
+ // that array on both `deny` and `rewrite` — see the gate's own header.
119
+ case 'naughty-list-gate': {
120
+ const hits = Array.isArray(decision.blockHits) ? decision.blockHits : [];
121
+ if (!hits.length) return [];
122
+ const rowDecision = kind === 'deny' ? 'block' : 'warn';
123
+ return hits.map((hit) => ({
124
+ rule: `naughty-list:${hit?.word ?? ''}`,
125
+ surface: 'stop',
126
+ severity: 'block',
127
+ decision: rowDecision,
128
+ matchContext: hit?.context,
129
+ }));
130
+ }
131
+
132
+ // Original (scripts/claude-hooks/epic-milestone-phrasing-gate.mjs,
133
+ // pre-cutover):
134
+ // appendGuardrailEvent({ rule: RULE_ID, surface: 'stop',
135
+ // severity: 'warn', decision: warnOnly ? 'warn' : 'block',
136
+ // matchContext: hits[0].excerpt });
137
+ // Exactly ONE row per firing (the original logs the FIRST hit's excerpt,
138
+ // not one row per hit — unlike naughty-list-gate above), at the literal
139
+ // severity 'warn' on BOTH rungs, because this gate ships warn-first by
140
+ // design and the registry row's own severity is 'warn' too. `decision.hits`
141
+ // carries the excerpts on both the `deny` and `rewrite` results — see the
142
+ // gate's own header.
143
+ case 'epic-milestone-phrasing-gate': {
144
+ const hits = Array.isArray(decision.hits) ? decision.hits : [];
145
+ if (!hits.length) return [];
146
+ if (kind !== 'deny' && !(kind === 'rewrite' && decision.rewrite?.warn)) return [];
147
+ return [
148
+ {
149
+ rule: 'epic-milestone-phrasing-gate',
150
+ surface: 'stop',
151
+ severity: 'warn',
152
+ decision: kind === 'deny' ? 'block' : 'warn',
153
+ matchContext: hits[0]?.excerpt,
154
+ },
155
+ ];
156
+ }
157
+
158
+ default:
159
+ return [];
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Write every audit row {@link auditRowsFor} derives from one gate's
165
+ * decision. Injectable: pass `deps.append` to swap out the real
166
+ * `_guardrail-events.mjs` writer (e.g. so a unit test can assert the call
167
+ * without touching the real file, or so an in-process caller that wants no
168
+ * file writes passes a no-op). Never throws — a row write failure is
169
+ * swallowed per row, matching `appendGuardrailEvent`'s own fail-safe
170
+ * contract (and this module's own header).
171
+ *
172
+ * @param {{ id?: string }} gate
173
+ * @param {{ decision: string }} decision
174
+ * @param {string} repoRoot
175
+ * @param {{ append?: (row: Record<string, unknown>, repoRoot: string) => unknown }} [deps]
176
+ * @returns {number} how many rows were written (best-effort; a swallowed
177
+ * per-row failure still counts as attempted, not written — callers that
178
+ * need the true count should inspect their own `deps.append`'s return).
179
+ */
180
+ export function recordGuardrailAudit(gate, decision, repoRoot, deps = {}) {
181
+ const append = deps.append ?? defaultAppendGuardrailEvent;
182
+ const rows = auditRowsFor(gate, decision);
183
+ let written = 0;
184
+ for (const row of rows) {
185
+ try {
186
+ // Redacted before it ever reaches `.heyspex/guardrail-history.jsonl`
187
+ // (HS-Y424) — `matchContext` (naughty-list-gate) is literally a
188
+ // snippet of whatever text tripped a rule, the field most likely to
189
+ // carry ingested prose or a leaked key verbatim.
190
+ append(redactRecord(row), repoRoot);
191
+ written += 1;
192
+ } catch {
193
+ // fail-safe: observability must never break the caller.
194
+ }
195
+ }
196
+ return written;
197
+ }
@@ -0,0 +1,441 @@
1
+ // spexharness/src/adapters/claude-code.mjs
2
+ //
3
+ // The Claude Code front's translation layer (HS-IQBF, wave-5 of
4
+ // [[HS-AZ61-NYPR]]). Every one of the twelve ported gates under
5
+ // spexharness/src/gates/ is now driven through THIS module by a generated
6
+ // shell (scripts/generate-hook-shells.mjs) rather than by its own
7
+ // stdin-reading, stdout-writing main() — this file is the only place that
8
+ // speaks the Claude Code hook stdin/stdout contract.
9
+ //
10
+ // Two directions, both pure except the tiny stdin/stdout glue at the bottom:
11
+ // 1. toEngineEvent(payload) — Claude Code hook stdin JSON → an
12
+ // spexharness/src/events.mjs event.
13
+ // 2. toHookResponse(point, id, decision) — a gate's decide.mjs-shaped
14
+ // decision → { exitCode, stdout, stderr }
15
+ // in the exact shape Claude Code expects
16
+ // back, one of four answer forms:
17
+ // a. bare exit code (0 allow / 2 deny)
18
+ // plus a stderr banner — the
19
+ // LEGACY style two of the twelve
20
+ // originals used (env-vault-guard,
21
+ // enforce-vault-naming).
22
+ // b. hookSpecificOutput.permissionDecision
23
+ // allow|deny|ask (+ reason) on
24
+ // stdout, exit 0 — the MODERN style
25
+ // the other before_tool gates use.
26
+ // c. hookSpecificOutput.additionalContext
27
+ // on stdout, exit 0 — an `inject`
28
+ // decision (no built-in gate uses
29
+ // this yet; it's here so the
30
+ // adapter's own contract is complete
31
+ // per this task's AC1).
32
+ // d. { decision: "block", reason } on
33
+ // stdout, exit 0 — the Stop hook
34
+ // contract (enforce-session-sequence,
35
+ // naughty-list-gate,
36
+ // id-display-format-gate).
37
+ //
38
+ // `runGateAsHook(gate)` at the bottom is the one impure entry point: it reads
39
+ // stdin, calls the two pure functions above, restores the guardrail-history
40
+ // audit-log write each ported gate deliberately left out of its own pure
41
+ // decide() (see ./audit.mjs and each gate's "Not ported HERE" header), writes
42
+ // stdout/stderr, and sets process.exitCode. This is what every generated
43
+ // shell calls — see scripts/generate-hook-shells.mjs and PORTING.md.
44
+ //
45
+ // FAIL-SAFE (house rule every original hook already followed): any internal
46
+ // error here — bad JSON on stdin, an unknown hook_event_name, a gate that
47
+ // throws — must never deny. It falls through to allow (exit 0, no output),
48
+ // same as every original script's own top-level `.catch(() => process.exit(0))`.
49
+
50
+ import path from 'node:path';
51
+ import { fileURLToPath } from 'node:url';
52
+
53
+ import { createEvent } from '../events.mjs';
54
+ import { recordGuardrailAudit } from './audit.mjs';
55
+
56
+ /** Claude Code `hook_event_name` → spexharness/src/events.mjs event point.
57
+ * Deliberately does NOT map `SubagentStop` to `stop` — SubagentStop is a
58
+ * distinct Claude Code hook event none of the twelve gates in this task are
59
+ * registered for (see claim-liveness-shared.mjs for where it IS used); an
60
+ * explicit `hook_event_name: "SubagentStop"` on stdin must read as "not this
61
+ * gate's event" (→ allow), never silently fold into `stop`. */
62
+ const HOOK_EVENT_TO_POINT = {
63
+ SessionStart: 'session_start',
64
+ UserPromptSubmit: 'prompt_submit',
65
+ PreToolUse: 'before_tool',
66
+ PostToolUse: 'after_tool',
67
+ Stop: 'stop',
68
+ SessionEnd: 'session_end',
69
+ };
70
+
71
+ /** The inverse of {@link HOOK_EVENT_TO_POINT}, for building output JSON's
72
+ * `hookEventName` field from an engine event's `point`. */
73
+ const POINT_TO_HOOK_EVENT = {
74
+ session_start: 'SessionStart',
75
+ prompt_submit: 'UserPromptSubmit',
76
+ before_tool: 'PreToolUse',
77
+ after_tool: 'PostToolUse',
78
+ stop: 'Stop',
79
+ session_end: 'SessionEnd',
80
+ };
81
+
82
+ /**
83
+ * @param {string} hookEventName - Claude Code's `hook_event_name` stdin field.
84
+ * @returns {string | null} the matching spexharness event point, or null for
85
+ * an event this adapter has no mapping for (a front-end concern, not a
86
+ * gate concern — the caller should fail open, never guess).
87
+ */
88
+ export function hookEventNameToPoint(hookEventName) {
89
+ return HOOK_EVENT_TO_POINT[hookEventName] ?? null;
90
+ }
91
+
92
+ /**
93
+ * Field mapping (Claude Code stdin → engine event fields), shared by
94
+ * {@link toEngineEvent} and the lenient per-gate resolution
95
+ * {@link runGateAsHook} uses:
96
+ * session_id → sessionId (every point)
97
+ * tool_name → toolName (before_tool / after_tool)
98
+ * tool_input → toolInput (before_tool / after_tool)
99
+ * tool_response → toolResult (after_tool)
100
+ * prompt → prompt (prompt_submit)
101
+ * cwd → cwd (session_start)
102
+ * source → agent (session_start — Claude Code's own field
103
+ * name for "how the session started")
104
+ * transcript_path → transcriptPath (stop)
105
+ * stop_hook_active → stopHookActive (stop)
106
+ * reason → reason (session_end)
107
+ *
108
+ * @param {string} point
109
+ * @param {Record<string, unknown>} payload
110
+ * @returns {Record<string, unknown>}
111
+ */
112
+ function fieldsForPoint(point, payload) {
113
+ const fields = { sessionId: payload?.session_id };
114
+ switch (point) {
115
+ case 'before_tool':
116
+ fields.toolName = payload?.tool_name;
117
+ fields.toolInput = payload?.tool_input ?? {};
118
+ break;
119
+ case 'after_tool':
120
+ fields.toolName = payload?.tool_name;
121
+ fields.toolInput = payload?.tool_input ?? {};
122
+ fields.toolResult = payload?.tool_response;
123
+ break;
124
+ case 'prompt_submit':
125
+ fields.prompt = payload?.prompt;
126
+ break;
127
+ case 'session_start':
128
+ fields.cwd = payload?.cwd;
129
+ fields.agent = payload?.source;
130
+ break;
131
+ case 'stop':
132
+ fields.transcriptPath = payload?.transcript_path;
133
+ fields.stopHookActive = payload?.stop_hook_active;
134
+ break;
135
+ case 'session_end':
136
+ fields.reason = payload?.reason;
137
+ break;
138
+ default:
139
+ break;
140
+ }
141
+ return fields;
142
+ }
143
+
144
+ /**
145
+ * Map a Claude Code hook stdin payload (already JSON.parse'd) to a
146
+ * spexharness engine event. Returns null when `hook_event_name` is missing
147
+ * or unrecognized — the caller's job to fail open on that, not this
148
+ * function's (this module has no I/O of its own).
149
+ *
150
+ * @param {Record<string, unknown>} payload
151
+ * @returns {ReturnType<typeof createEvent> | null}
152
+ */
153
+ export function toEngineEvent(payload) {
154
+ const point = hookEventNameToPoint(payload?.hook_event_name);
155
+ if (!point) return null;
156
+ return createEvent(point, fieldsForPoint(point, payload));
157
+ }
158
+
159
+ // Two of the twelve original hooks used the LEGACY "bare exit code + stderr
160
+ // banner" style (exit 2, empty stdout, a `\n[<banner>]\n<reason>\n` stderr
161
+ // message) instead of the modern permissionDecision JSON every other
162
+ // before_tool gate here uses. Keyed by gate id so a future gate defaults to
163
+ // the modern style without needing to opt out of anything.
164
+ const LEGACY_EXIT2_BANNER = {
165
+ 'env-vault-guard': 'HeySpex env-vault guard',
166
+ 'enforce-vault-naming': 'HeySpex vault-naming hook',
167
+ };
168
+
169
+ function jsonStdout(obj) {
170
+ return JSON.stringify(obj);
171
+ }
172
+
173
+ /**
174
+ * Map one gate's decide.mjs-shaped result — `{ decision: 'allow'|'deny'|
175
+ * 'rewrite'|'inject', reason?, rewrite?, inject? }` — onto the Claude Code
176
+ * hook contract for the event it ran against.
177
+ *
178
+ * @param {{ id: string, event: string }} gate - the gate's own `id`/`event`
179
+ * (used for the legacy-banner lookup and as a fallback hookEventName).
180
+ * @param {{ point: string }} engineEvent - the event the gate ran against
181
+ * (its `point` decides which of the four answer shapes applies).
182
+ * @param {{ decision: string, reason?: string, rewrite?: object, inject?: object|string }} decision
183
+ * @returns {{ exitCode: number, stdout: string, stderr: string }}
184
+ */
185
+ export function toHookResponse(gate, engineEvent, decision) {
186
+ const point = engineEvent?.point ?? gate?.event;
187
+ const hookEventName = POINT_TO_HOOK_EVENT[point] ?? 'PreToolUse';
188
+ const kind = decision?.decision ?? 'allow';
189
+
190
+ // ── Stop hook contract: { decision: "block", reason } or nothing. ────────
191
+ if (point === 'stop') {
192
+ if (kind === 'deny') {
193
+ return { exitCode: 0, stdout: jsonStdout({ decision: 'block', reason: decision.reason }), stderr: '' };
194
+ }
195
+ if (kind === 'rewrite' && decision.rewrite?.warn) {
196
+ const text = decision.rewrite.stderrText ?? warnStderrText(decision.reason ?? decision.rewrite.reason);
197
+ return { exitCode: 0, stdout: '', stderr: text };
198
+ }
199
+ return { exitCode: 0, stdout: '', stderr: '' };
200
+ }
201
+
202
+ // ── before_tool / after_tool: permissionDecision, or the legacy exit-2 style. ──
203
+ if (kind === 'deny') {
204
+ const banner = LEGACY_EXIT2_BANNER[gate?.id];
205
+ if (banner) {
206
+ return { exitCode: 2, stdout: '', stderr: `\n[${banner}]\n${decision.reason}\n` };
207
+ }
208
+ return {
209
+ exitCode: 0,
210
+ stdout: jsonStdout({
211
+ hookSpecificOutput: { hookEventName, permissionDecision: 'deny', permissionDecisionReason: decision.reason },
212
+ }),
213
+ stderr: '',
214
+ };
215
+ }
216
+
217
+ if (kind === 'rewrite') {
218
+ if (decision.rewrite?.ask) {
219
+ return {
220
+ exitCode: 0,
221
+ stdout: jsonStdout({
222
+ hookSpecificOutput: { hookEventName, permissionDecision: 'ask', permissionDecisionReason: decision.reason },
223
+ }),
224
+ stderr: '',
225
+ };
226
+ }
227
+ if (decision.rewrite?.warn) {
228
+ const text = decision.rewrite.stderrText ?? warnStderrText(decision.reason ?? decision.rewrite.reason);
229
+ return { exitCode: 0, stdout: '', stderr: text };
230
+ }
231
+ // A rewrite with neither ask nor warn (not produced by any of the twelve
232
+ // gates today) — fail open rather than guess at a shape.
233
+ return { exitCode: 0, stdout: '', stderr: '' };
234
+ }
235
+
236
+ if (kind === 'inject') {
237
+ const additionalContext =
238
+ typeof decision.inject === 'string' ? decision.inject : decision.inject?.additionalContext;
239
+ if (!additionalContext) return { exitCode: 0, stdout: '', stderr: '' };
240
+ return {
241
+ exitCode: 0,
242
+ stdout: jsonStdout({ hookSpecificOutput: { hookEventName, additionalContext } }),
243
+ stderr: '',
244
+ };
245
+ }
246
+
247
+ // 'allow' (and any unrecognized decision — fail open, never guess a deny).
248
+ return { exitCode: 0, stdout: '', stderr: '' };
249
+ }
250
+
251
+ /** Default stderr formatter for a `rewrite.warn` decision that carries no
252
+ * explicit `rewrite.stderrText` override: append exactly one trailing
253
+ * newline, unless `reason` already ends with one (several gates' own warn
254
+ * text already carries its own trailing newline — see naughty-list-gate.mjs
255
+ * / id-display-format-gate.mjs's `warnReason()`). */
256
+ function warnStderrText(reason) {
257
+ const text = String(reason ?? '');
258
+ return text.endsWith('\n') ? text : `${text}\n`;
259
+ }
260
+
261
+ // ── stdin / process glue — the only impure code in this module ─────────────
262
+
263
+ /** Read and JSON.parse Claude Code's hook stdin payload. Mirrors every
264
+ * original hook's own `readStdinJson()`: a TTY (no piped stdin) or
265
+ * unparseable input both resolve to null, never throw. */
266
+ async function readStdinJson() {
267
+ if (process.stdin.isTTY) return null;
268
+ let buf = '';
269
+ process.stdin.setEncoding('utf8');
270
+ for await (const chunk of process.stdin) buf += chunk;
271
+ if (!buf.trim()) return null;
272
+ try {
273
+ return JSON.parse(buf);
274
+ } catch {
275
+ return null;
276
+ }
277
+ }
278
+
279
+ /**
280
+ * Resolve the engine event a gate should run against, for one gate's shell
281
+ * specifically — LENIENT where {@link toEngineEvent} is strict: when
282
+ * `hook_event_name` is present, it must map to this gate's OWN `event`
283
+ * (anything else, including a recognized-but-different event like
284
+ * `SubagentStop` or `PostToolUse`, is "not this gate's turn" → null, allow).
285
+ * When `hook_event_name` is absent altogether (no key on the payload at
286
+ * all — every real Claude Code hook call always includes it; only a
287
+ * hand-built test payload omits it, e.g. `{ tool_name, tool_input }` with no
288
+ * envelope), this assumes the payload is already shaped for THIS gate's own
289
+ * registered event and maps its fields accordingly — a shell is always
290
+ * wired to exactly one event, so there is nothing to guess here.
291
+ *
292
+ * @param {Record<string, unknown>} payload
293
+ * @param {{ event: string }} gate
294
+ * @returns {ReturnType<typeof createEvent> | null}
295
+ */
296
+ function resolveEventForGate(payload, gate) {
297
+ const hasHookEventName = payload?.hook_event_name !== undefined;
298
+ // A hook script registered on SEVERAL Claude Code events (coo-guard-check is
299
+ // registered on both SessionStart and PreToolUse in .claude/settings.json)
300
+ // hands this function a gate whose `event` is the ARRAY of points it answers
301
+ // for — see spexharness/src/gates/coo-guard-gate.mjs's `hookGate`. Only this
302
+ // adapter understands that form; registry.mjs stays one-event-per-gate by
303
+ // construction, so such a hook registers its rungs there as separate gates.
304
+ const multi = Array.isArray(gate?.event);
305
+ // With no `hook_event_name` at all (a hand-built test payload), a single-event
306
+ // gate's own event is the only sensible reading; a multi-event gate has
307
+ // nothing to guess from, so it declines rather than picking one.
308
+ const point = hasHookEventName ? hookEventNameToPoint(payload.hook_event_name) : multi ? null : gate.event;
309
+ if (!point) return null;
310
+ if (multi ? !gate.event.includes(point) : point !== gate.event) return null;
311
+ return createEvent(point, fieldsForPoint(point, payload));
312
+ }
313
+
314
+ /** This adapter's own repo root (spexharness/src/adapters/claude-code.mjs is
315
+ * three levels below it) — the same depth every ported gate's own REPO_ROOT
316
+ * constant resolves from, so {@link auditRepoRoot}'s env-first fallback
317
+ * agrees with whatever root a gate itself used to resolve its decision. */
318
+ const ADAPTER_REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..');
319
+
320
+ /** Same env-first resolution every pre-cutover hook's own `repoRoot()`
321
+ * helper used (CLAUDE_PROJECT_DIR, trailing slash stripped, else the
322
+ * repo-relative fallback) — kept here rather than imported so this module
323
+ * has no dependency on any one gate's own copy of the same three lines. */
324
+ function auditRepoRoot() {
325
+ const fromEnv = process.env.CLAUDE_PROJECT_DIR?.trim();
326
+ if (fromEnv) return fromEnv.replace(/[\\/]+$/, '');
327
+ return ADAPTER_REPO_ROOT;
328
+ }
329
+
330
+ /**
331
+ * Run ONE gate as a Claude Code hook process: read stdin, map it to an
332
+ * engine event, run the gate, restore the guardrail-history audit-log side
333
+ * effect the gate itself deliberately left out (see audit.mjs and each
334
+ * ported gate's own "Not ported HERE" header), map the decision back to the
335
+ * hook contract, write stdout/stderr, and set `process.exitCode`. This is
336
+ * the whole body of every generated shell under scripts/claude-hooks/ — see
337
+ * scripts/generate-hook-shells.mjs.
338
+ *
339
+ * Fail-safe throughout: any error anywhere in this pipeline is swallowed and
340
+ * treated as allow (exit 0, no output) — matching every original hook's own
341
+ * `.catch(() => process.exit(0))`. The audit write is additionally never
342
+ * allowed to affect the hook response either way — see
343
+ * `recordGuardrailAudit`'s own fail-safe contract.
344
+ *
345
+ * @param {{ id: string, event: string, run: (event: object, ctx: object) => unknown }} gate
346
+ * @param {{ audit?: false | ((gate: object, decision: object, repoRoot: string) => unknown),
347
+ * append?: (row: Record<string, unknown>, repoRoot: string) => unknown,
348
+ * payload?: Record<string, unknown> }} [deps]
349
+ * `deps.payload` — an ALREADY-READ stdin payload. Only {@link runGatesAsHook}
350
+ * passes it: stdin can be consumed exactly once, so a shell wired to several
351
+ * gates reads it, picks the gate whose event matches, and hands the payload
352
+ * on rather than letting this function read an empty stream.
353
+ * `deps.audit` — full override of the audit step; pass `false` to disable
354
+ * it entirely (an in-process caller that wants no `.heyspex/guardrail-
355
+ * history.jsonl` writes at all). `deps.append` — override just the
356
+ * low-level row writer (recordGuardrailAudit's own `deps.append`), while
357
+ * keeping this module's row-shape mapping — what the adapter's own unit
358
+ * test uses to assert the call without touching the real file. Neither
359
+ * set → the real audit trail is written, restoring the pre-cutover
360
+ * behavior unconditionally (every generated shell calls `runGateAsHook(gate)`
361
+ * with no `deps`, so this is what a live Claude Code session gets).
362
+ * @returns {Promise<void>}
363
+ */
364
+ /**
365
+ * Run the ONE gate out of `gates` that is registered for the incoming hook
366
+ * event, for a Claude Code hook script wired to MORE THAN ONE event.
367
+ *
368
+ * Why it exists (wave 6 of [[HS-AZ61-NYPR]]): `.claude/settings.json` registers
369
+ * `git-reflog-audit` on BOTH SessionStart and Stop, and the original script
370
+ * branched on `hook_event_name` inside its own `main()`. The engine registry
371
+ * keys a gate on exactly one event point (registry.mjs's `assertValidGate`), so
372
+ * that rule ships as a PAIR of gates and its shell needs to pick between them.
373
+ * `runGateAsHook` cannot simply be called twice: stdin can be consumed once, so
374
+ * the second call would read an empty stream. This reads it once and hands it
375
+ * on via `deps.payload`.
376
+ *
377
+ * Fail-safe throughout, exactly like {@link runGateAsHook}: a payload that maps
378
+ * to none of these gates' events writes nothing and exits 0.
379
+ *
380
+ * @param {Array<{ id: string, event: string, run: Function }>|{ id: string, event: string, run: Function }} gates
381
+ * @param {object} [deps] - forwarded to {@link runGateAsHook}.
382
+ * @returns {Promise<void>}
383
+ */
384
+ export async function runGatesAsHook(gates, deps = {}) {
385
+ let payload = null;
386
+ try {
387
+ payload = await readStdinJson();
388
+ } catch {
389
+ return; // fail-open, same as runGateAsHook's own catch
390
+ }
391
+ if (!payload) return;
392
+
393
+ const list = Array.isArray(gates) ? gates : [gates];
394
+ const match = list.find((gate) => {
395
+ try {
396
+ return resolveEventForGate(payload, gate) !== null;
397
+ } catch {
398
+ return false;
399
+ }
400
+ });
401
+ if (!match) return; // none of these gates runs on this event — allow
402
+
403
+ await runGateAsHook(match, { ...deps, payload });
404
+ }
405
+
406
+ export async function runGateAsHook(gate, deps = {}) {
407
+ try {
408
+ const payload = deps.payload ?? (await readStdinJson());
409
+ if (!payload) return;
410
+
411
+ const engineEvent = resolveEventForGate(payload, gate);
412
+ if (!engineEvent) return; // not this gate's event — allow
413
+
414
+ const raw = await gate.run(engineEvent, {});
415
+ const decision = typeof raw === 'string' ? { decision: raw } : raw ?? { decision: 'allow' };
416
+
417
+ if (deps.audit === false) {
418
+ // in-process caller opt-out — no audit write at all.
419
+ } else if (typeof deps.audit === 'function') {
420
+ try {
421
+ await deps.audit(gate, decision, auditRepoRoot());
422
+ } catch {
423
+ /* fail-safe: the audit step must never affect the hook response */
424
+ }
425
+ } else {
426
+ try {
427
+ recordGuardrailAudit(gate, decision, auditRepoRoot(), deps.append ? { append: deps.append } : {});
428
+ } catch {
429
+ /* fail-safe: the audit step must never affect the hook response */
430
+ }
431
+ }
432
+
433
+ const { exitCode, stdout, stderr } = toHookResponse(gate, engineEvent, decision);
434
+ if (stdout) process.stdout.write(stdout);
435
+ if (stderr) process.stderr.write(stderr);
436
+ process.exitCode = exitCode;
437
+ } catch {
438
+ // Fail-open: never strand the agent on an adapter/gate crash.
439
+ process.exitCode = 0;
440
+ }
441
+ }