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,221 @@
1
+ // scripts/claude-hooks/protected-action-intent.mjs
2
+ //
3
+ // HS-H8KI-P3TJ (epic HS-GG01-P3TJ): before ANY destructive git command can
4
+ // even get an ASK from git-stash-gate.mjs's runGitDestructiveGate, a written
5
+ // protected-action intent must exist naming the command, what it could break,
6
+ // and which other agents currently hold claims — measured from the claims
7
+ // system (the console's live peer_agents shape), never from agent memory.
8
+ //
9
+ // This module is pure read/validate — no stdout, no process.exit — so it can
10
+ // be unit-tested by import (protected-action-intent.test.mjs) with fixtures
11
+ // written to temp dirs, never the real .heyspex/. git-stash-gate.mjs wires
12
+ // these functions into runGitDestructiveGate.
13
+ //
14
+ // Schema (.heyspex/protected-action-intent.json):
15
+ // { command, expected_breakage, peers, claim_spec_id, written_at }
16
+ // - valid for INTENT_TTL_MS (10 minutes) from written_at
17
+ // - single-use: consumeIntent() renames it after a decision is reached
18
+ // (ask OR deny both count as "a decision" — a denied attempt must not let a
19
+ // later, different command ride the same stale-but-still-fresh intent file)
20
+ // - `peers` MUST be shaped like the claims API's live peer_agents response
21
+ // (vendor/heyspex-mcp/peer-presence.mjs buildPeerAgentsBlock): an array of
22
+ // objects, each at least { agent: string, session: string, spec_ids: array }
23
+ // (extra fields — stage, last_active, files_touched, dirs, dirs_more — are
24
+ // allowed and passed through untouched, never required).
25
+ // - `command` must match, verbatim (trimmed), the command actually being
26
+ // gated — an intent written for one command is not license for a different
27
+ // one, even a same-verb one.
28
+
29
+ import fs from 'node:fs';
30
+ import path from 'node:path';
31
+
32
+ export const INTENT_FILE_NAME = 'protected-action-intent.json';
33
+ export const CONSUMED_FILE_NAME = 'protected-action-intent.consumed.json';
34
+ export const INTENT_TTL_MS = 10 * 60 * 1000; // 10 minutes
35
+ export const REQUIRED_FIELDS = ['command', 'expected_breakage', 'peers', 'claim_spec_id', 'written_at'];
36
+ // A written_at more than this far in the future is not a legitimate intent
37
+ // (clock skew tolerance, not a loophole) — it can never itself be "fresh"
38
+ // under the TTL check since nowMs - writtenAtMs would be negative.
39
+ const FUTURE_SKEW_TOLERANCE_MS = 60_000;
40
+
41
+ export function intentPath(root) {
42
+ return path.join(root, '.heyspex', INTENT_FILE_NAME);
43
+ }
44
+
45
+ export function consumedIntentPath(root) {
46
+ return path.join(root, '.heyspex', CONSUMED_FILE_NAME);
47
+ }
48
+
49
+ /**
50
+ * True when `peers` is shaped like the console claims API's live peer_agents
51
+ * response: an array (possibly empty — "no other live sessions" is a
52
+ * legitimate, checked result) of objects each carrying at least
53
+ * `agent` (non-empty string), `session` (non-empty string), and `spec_ids`
54
+ * (array). Exported for tests.
55
+ */
56
+ export function isValidPeersShape(peers) {
57
+ if (!Array.isArray(peers)) return false;
58
+ return peers.every(
59
+ (p) =>
60
+ p !== null &&
61
+ typeof p === 'object' &&
62
+ !Array.isArray(p) &&
63
+ typeof p.agent === 'string' &&
64
+ p.agent.trim().length > 0 &&
65
+ typeof p.session === 'string' &&
66
+ p.session.trim().length > 0 &&
67
+ Array.isArray(p.spec_ids),
68
+ );
69
+ }
70
+
71
+ /**
72
+ * Validate one parsed intent object's shape + freshness (does NOT check it
73
+ * against a specific command being run — see readAndValidateIntent for that).
74
+ * Returns `{ ok: true, intent }` or `{ ok: false, reason }`. Exported for tests.
75
+ */
76
+ export function validateIntent(intent, { nowMs = Date.now() } = {}) {
77
+ if (!intent || typeof intent !== 'object' || Array.isArray(intent)) {
78
+ return { ok: false, reason: 'intent is not an object' };
79
+ }
80
+
81
+ const missing = REQUIRED_FIELDS.filter((f) => !(f in intent));
82
+ if (missing.length > 0) {
83
+ return { ok: false, reason: `missing required field(s): ${missing.join(', ')}` };
84
+ }
85
+
86
+ if (typeof intent.command !== 'string' || !intent.command.trim()) {
87
+ return { ok: false, reason: '"command" must be a non-empty string' };
88
+ }
89
+ if (typeof intent.expected_breakage !== 'string' || !intent.expected_breakage.trim()) {
90
+ return { ok: false, reason: '"expected_breakage" must be a non-empty string' };
91
+ }
92
+ if (typeof intent.claim_spec_id !== 'string' || !intent.claim_spec_id.trim()) {
93
+ return { ok: false, reason: '"claim_spec_id" must be a non-empty string' };
94
+ }
95
+ if (typeof intent.written_at !== 'string' || !intent.written_at.trim()) {
96
+ return { ok: false, reason: '"written_at" must be an ISO timestamp string' };
97
+ }
98
+
99
+ const writtenAtMs = Date.parse(intent.written_at);
100
+ if (Number.isNaN(writtenAtMs)) {
101
+ return { ok: false, reason: '"written_at" is not a parseable timestamp' };
102
+ }
103
+ if (writtenAtMs > nowMs + FUTURE_SKEW_TOLERANCE_MS) {
104
+ return { ok: false, reason: '"written_at" is in the future' };
105
+ }
106
+ const ageMs = nowMs - writtenAtMs;
107
+ if (ageMs > INTENT_TTL_MS) {
108
+ return {
109
+ ok: false,
110
+ reason: `intent is stale — written ${Math.round(ageMs / 1000)}s ago, valid for ${INTENT_TTL_MS / 60_000} minutes`,
111
+ };
112
+ }
113
+
114
+ if (!isValidPeersShape(intent.peers)) {
115
+ return {
116
+ ok: false,
117
+ reason:
118
+ '"peers" must be an array shaped like the claims API\'s live peer_agents response ' +
119
+ '(each item: { agent: string, session: string, spec_ids: array, ... }) — absent, ' +
120
+ 'not-an-array, or missing/mis-typed fields on any item are all rejected',
121
+ };
122
+ }
123
+
124
+ return { ok: true, intent };
125
+ }
126
+
127
+ /**
128
+ * Exact-match (trimmed) comparison between the intent's recorded command and
129
+ * the command actually being gated. Deliberately strict — an intent written
130
+ * for one command is not license for a different one. Exported for tests.
131
+ */
132
+ export function commandMatches(intentCommand, actualCommand) {
133
+ if (typeof intentCommand !== 'string' || typeof actualCommand !== 'string') return false;
134
+ return intentCommand.trim() === actualCommand.trim() && intentCommand.trim().length > 0;
135
+ }
136
+
137
+ /**
138
+ * Read + fully validate the on-disk intent file against the command actually
139
+ * being gated. Returns `{ ok: true, intent }` or `{ ok: false, reason }`.
140
+ * Never throws — a missing/corrupt/unreadable file is just "no intent", with
141
+ * `reason` saying why.
142
+ */
143
+ export function readAndValidateIntent({ root, command, nowMs = Date.now() }) {
144
+ let raw;
145
+ try {
146
+ raw = fs.readFileSync(intentPath(root), 'utf8');
147
+ } catch {
148
+ return { ok: false, reason: 'no intent file found' };
149
+ }
150
+
151
+ let parsed;
152
+ try {
153
+ parsed = JSON.parse(raw);
154
+ } catch {
155
+ return { ok: false, reason: 'intent file is not valid JSON' };
156
+ }
157
+
158
+ const validated = validateIntent(parsed, { nowMs });
159
+ if (!validated.ok) return validated;
160
+
161
+ if (!commandMatches(validated.intent.command, command)) {
162
+ return {
163
+ ok: false,
164
+ reason: `intent's "command" does not match the command being run (intent names: "${validated.intent.command}")`,
165
+ };
166
+ }
167
+
168
+ return { ok: true, intent: validated.intent };
169
+ }
170
+
171
+ /**
172
+ * Single-use: rename the intent file after a decision is reached (ask OR
173
+ * deny) so it can never be read again for a subsequent gated command.
174
+ * Best-effort — a rename failure never blocks the gate's own decision (the
175
+ * decision has already been made by the time this runs); returns a boolean
176
+ * so callers/tests can observe it without it ever throwing.
177
+ */
178
+ export function consumeIntent(root) {
179
+ try {
180
+ fs.renameSync(intentPath(root), consumedIntentPath(root));
181
+ return true;
182
+ } catch {
183
+ return false;
184
+ }
185
+ }
186
+
187
+ /** Denial message for AC1 — names the intent file path and every required field. */
188
+ export function buildNoIntentDenyMessage({ root, reason }) {
189
+ return (
190
+ `DENIED — no valid protected-action intent (${reason}). This git command is a PROTECTED action ` +
191
+ `(protected-shared-actions-gate) and, per HS-H8KI, cannot even be ASKED about without a fresh, ` +
192
+ `written breakage assessment. Write ${intentPath(root)} first, with these required fields: ` +
193
+ `${REQUIRED_FIELDS.join(', ')}. "peers" must be the LIVE peer_agents result from the claims API ` +
194
+ `(the same shape the MCP tools print as PEER AGENTS — array of { agent, session, spec_ids, ... }), ` +
195
+ `never recalled from memory. "written_at" is an ISO timestamp; the intent is valid for ` +
196
+ `${INTENT_TTL_MS / 60_000} minutes and single-use. "command" must exactly match the command you ` +
197
+ `are about to run.`
198
+ );
199
+ }
200
+
201
+ /** Ask message for AC2 — surfaces the expected_breakage text and the peer list from the intent. */
202
+ export function buildIntentAskMessage({ intent, baseReason }) {
203
+ return (
204
+ `${baseReason} A written protected-action intent is on file (HS-H8KI): expected breakage — ` +
205
+ `"${intent.expected_breakage}". Peers at claim time (from the claims API, claim_spec_id ` +
206
+ `${intent.claim_spec_id}): ${JSON.stringify(intent.peers)}. Approving IS the permission.`
207
+ );
208
+ }
209
+
210
+ /** Deny message for AC3 — cites the orchestrator's sub-agent lock. */
211
+ export function buildLockDenyMessage(lock) {
212
+ const who = lock && typeof lock.setBy === 'string' && lock.setBy.trim() ? ` by ${lock.setBy}` : '';
213
+ const reason = lock && typeof lock.reason === 'string' && lock.reason.trim() ? ` — ${lock.reason}` : '';
214
+ return (
215
+ `DENIED — a sub-agent hard lock is set on this session${who}${reason} ` +
216
+ `(scripts/lib/subagent-git-lock.mjs, orchestrator rule: destructive git is barred for the ` +
217
+ `duration of delegated sub-agent work). No intent file can override an active lock — deny is ` +
218
+ `stronger than ask. The orchestrator clears the lock (clearSubagentLock()) when its delegated ` +
219
+ `work window ends.`
220
+ );
221
+ }
@@ -0,0 +1,398 @@
1
+ #!/usr/bin/env node
2
+ // research-method-guard.mjs — PreToolUse hook that structurally blocks the
3
+ // built-in Claude Code `deep-research-quick|medium|thorough` skills/workflows
4
+ // from firing in this repo. HeySpex's own research method is `/hs-res`
5
+ // (dev-factory/spexcrawl/{spexcrawl-cli,firecrawl-cli,synthesize-cli}.mjs
6
+ // — SpexCrawl via the Companion + Firecrawl-as-permission-only-fallback for
7
+ // gather, DeepSeek for synthesis). Sonnet is a fallback that requires the
8
+ // user's explicit per-run permission granted directly in the current chat,
9
+ // never a silent default ([[research-routing-hard-rule-sonnet]]).
10
+ //
11
+ // The rule already existed TWICE in prose — `.commandcode/skills/hs-res/
12
+ // SKILL.md` "## Hard prohibitions" and the research-routing-hard-rule-sonnet
13
+ // memory decision — and a Claude Code session still called
14
+ // `Skill({ skill: "deep-research-medium" })` → `Workflow(...)` under time
15
+ // pressure. Prose alone does not stop a wrong tool call; this hook does.
16
+ //
17
+ // What is blocked:
18
+ // - `Skill` calls whose `skill` input is EXACTLY one of deep-research-quick /
19
+ // deep-research-medium / deep-research-thorough.
20
+ // - `Workflow` calls whose `name` input is one of those three strings, OR
21
+ // whose `scriptPath` input CONTAINS one of those three strings as a
22
+ // substring (catches `.claude/workflows/deep-research-thorough.js` etc,
23
+ // any launch directory prefix).
24
+ // What is allowed: everything else — `Skill(hs-res)`, any other skill name,
25
+ // any Workflow launch that doesn't name one of the three forbidden
26
+ // profiles, and every non-Skill/Workflow tool.
27
+ //
28
+ // This guard is DELIBERATELY narrower and stricter than the pre-existing
29
+ // deep-research-cost-gate.mjs (same `Skill|Workflow` matcher): that gate
30
+ // treats a deep-research launch as costly-but-permittable and only ASKs for
31
+ // approval (plus a grounding check) — it never actually stops the built-in
32
+ // profiles from running once approved. The rule in hs-res/SKILL.md and
33
+ // research-routing-hard-rule-sonnet is "never fall back to these", not "ask
34
+ // first" — so this hook denies outright, no ask, no cost table.
35
+ //
36
+ // Env: HEYSPEX_RESEARCH_GUARD_OFF=1 kill switch — never blocks. An ENV VAR,
37
+ // not a file, mirroring env-vault-guard's HEYSPEX_ENV_VAULT_GUARD_OFF — a
38
+ // file an agent can write is a disarm chain.
39
+ // Stdin: Claude Code hook JSON ({tool_name, tool_input, ...}).
40
+ // Exit 0 = allow. Exit 2 = block (stderr surfaces back to the model) — same
41
+ // mechanism as env-vault-guard.mjs.
42
+ // FAIL-SAFE: any internal error → exit 0 (house style: never strand the agent).
43
+ //
44
+ // REGISTERED: .claude/settings.json PreToolUse, matcher `Skill|Workflow`,
45
+ // routed through _run.mjs, and added to _run.mjs's ALWAYS_ON set (mirroring
46
+ // env-vault-guard) — a research-routing rule that can be silenced by an
47
+ // agent-writable `.heyspex/mode.json` flag would reopen exactly the bypass
48
+ // this hook exists to close. Registered LIVE (not owner-gated/inert): the
49
+ // user explicitly asked for this fix to prevent a recurrence of the incident
50
+ // above, so shipping it inert would not satisfy the request.
51
+ //
52
+ // Self-test: `node scripts/claude-hooks/research-method-guard.mjs --self-test`
53
+
54
+ import { pathToFileURL } from "node:url";
55
+
56
+ // Order matters for the scriptPath substring scan below (first match wins) —
57
+ // the three specific profiles are listed before the bare "deep-research" so a
58
+ // path naming one of them is reported by its specific name, not the generic one.
59
+ const FORBIDDEN_PROFILES = [
60
+ "deep-research-quick",
61
+ "deep-research-medium",
62
+ "deep-research-thorough",
63
+ "deep-research",
64
+ ];
65
+
66
+ /** Strip a `plugin:` prefix (`some-plugin:deep-research-medium` → `deep-research-medium`) so a
67
+ * plugin-namespaced skill name is matched the same as a bare one. */
68
+ function stripPluginPrefix(name) {
69
+ const idx = name.lastIndexOf(":");
70
+ return idx === -1 ? name : name.slice(idx + 1);
71
+ }
72
+
73
+ const REDIRECT_MESSAGE =
74
+ "This repo's research method is /hs-res (SpexCrawl via the Companion — or self-hosted " +
75
+ "Firecrawl as a user-permission-only fallback — for gather, DeepSeek for synthesis). " +
76
+ "Sonnet is a fallback here too, and it requires the user's explicit permission for THIS " +
77
+ "specific run, granted directly in the current chat — never assumed from a file, memory, " +
78
+ "or prior session. Read .commandcode/skills/hs-res/SKILL.md and follow its 9-step method " +
79
+ "instead of the built-in deep-research harness.";
80
+
81
+ /**
82
+ * Pure guard decision — no fs, no process, no env lookup (that lives in
83
+ * `evaluate()` below so the kill switch stays testable without a subprocess).
84
+ *
85
+ * @param {{ toolName: string, toolInput: Record<string, unknown> }} call
86
+ * @returns {{ allow: boolean, reason?: string }}
87
+ */
88
+ export function decide(call) {
89
+ // Tool name compared by exact string (Skill/Workflow are the harness's own
90
+ // fixed tool names, not something an agent can rename) — case IS checked,
91
+ // matching the task's request to verify rather than assume.
92
+ const toolName = String(call?.toolName ?? "");
93
+ const input = call?.toolInput && typeof call.toolInput === "object" ? call.toolInput : {};
94
+
95
+ if (toolName === "Skill") {
96
+ const skill = String(input?.skill ?? "");
97
+ const bare = stripPluginPrefix(skill);
98
+ if (FORBIDDEN_PROFILES.includes(bare)) {
99
+ return {
100
+ allow: false,
101
+ reason:
102
+ `Blocked: Skill({ skill: "${skill}" }) is one of the built-in Claude Code deep-research profiles.\n` +
103
+ REDIRECT_MESSAGE,
104
+ };
105
+ }
106
+ return { allow: true };
107
+ }
108
+
109
+ if (toolName === "Workflow") {
110
+ const name = String(input?.name ?? "");
111
+ if (FORBIDDEN_PROFILES.includes(name)) {
112
+ return {
113
+ allow: false,
114
+ reason:
115
+ `Blocked: Workflow({ name: "${name}" }) is one of the built-in Claude Code deep-research profiles.\n` +
116
+ REDIRECT_MESSAGE,
117
+ };
118
+ }
119
+ const scriptPath = String(input?.scriptPath ?? "");
120
+ const matched = FORBIDDEN_PROFILES.find((profile) => scriptPath.includes(profile));
121
+ if (matched) {
122
+ return {
123
+ allow: false,
124
+ reason:
125
+ `Blocked: Workflow({ scriptPath: "${scriptPath}" }) targets the built-in deep-research profile "${matched}".\n` +
126
+ REDIRECT_MESSAGE,
127
+ };
128
+ }
129
+ return { allow: true };
130
+ }
131
+
132
+ return { allow: true };
133
+ }
134
+
135
+ /**
136
+ * `decide()` plus the kill switch, so the switch is covered by the same
137
+ * fixture table as the guard logic instead of needing a subprocess spawn.
138
+ * @param {{ tool_name?: string, tool_input?: Record<string, unknown> }} payload
139
+ * @param {NodeJS.ProcessEnv} env
140
+ */
141
+ export function evaluate(payload, env) {
142
+ if ((env ?? {}).HEYSPEX_RESEARCH_GUARD_OFF === "1") return { allow: true };
143
+ return decide({ toolName: payload?.tool_name, toolInput: payload?.tool_input ?? {} });
144
+ }
145
+
146
+ // ---------------------------------------------------------------------------
147
+
148
+ async function readStdinJson() {
149
+ if (process.stdin.isTTY) return null;
150
+ let buf = "";
151
+ process.stdin.setEncoding("utf8");
152
+ for await (const chunk of process.stdin) buf += chunk;
153
+ if (!buf.trim()) return null;
154
+ try {
155
+ return JSON.parse(buf);
156
+ } catch {
157
+ return null;
158
+ }
159
+ }
160
+
161
+ const SELF_TEST_CASES = [
162
+ // --- Skill: forbidden profiles → block --------------------------------
163
+ {
164
+ tag: "skill-block",
165
+ name: 'Skill(skill: "deep-research-quick") → block',
166
+ call: { toolName: "Skill", toolInput: { skill: "deep-research-quick" } },
167
+ want: false,
168
+ },
169
+ {
170
+ tag: "skill-block",
171
+ name: 'Skill(skill: "deep-research-medium") → block',
172
+ call: { toolName: "Skill", toolInput: { skill: "deep-research-medium" } },
173
+ want: false,
174
+ },
175
+ {
176
+ tag: "skill-block",
177
+ name: 'Skill(skill: "deep-research-thorough") → block',
178
+ call: { toolName: "Skill", toolInput: { skill: "deep-research-thorough" } },
179
+ want: false,
180
+ },
181
+ // --- Workflow name: forbidden profiles → block -------------------------
182
+ {
183
+ tag: "workflow-name-block",
184
+ name: 'Workflow(name: "deep-research-quick") → block',
185
+ call: { toolName: "Workflow", toolInput: { name: "deep-research-quick" } },
186
+ want: false,
187
+ },
188
+ {
189
+ tag: "workflow-name-block",
190
+ name: 'Workflow(name: "deep-research-medium") → block',
191
+ call: { toolName: "Workflow", toolInput: { name: "deep-research-medium" } },
192
+ want: false,
193
+ },
194
+ {
195
+ tag: "workflow-name-block",
196
+ name: 'Workflow(name: "deep-research-thorough") → block',
197
+ call: { toolName: "Workflow", toolInput: { name: "deep-research-thorough" } },
198
+ want: false,
199
+ },
200
+ // --- Workflow scriptPath: forbidden profiles as substring → block ------
201
+ {
202
+ tag: "workflow-scriptpath-block",
203
+ name: "Workflow(scriptPath: .../deep-research-quick.js) → block",
204
+ call: {
205
+ toolName: "Workflow",
206
+ toolInput: { scriptPath: ".claude/workflows/deep-research-quick.js", args: "q" },
207
+ },
208
+ want: false,
209
+ },
210
+ {
211
+ tag: "workflow-scriptpath-block",
212
+ name: "Workflow(scriptPath: .../deep-research-medium.js) → block",
213
+ call: {
214
+ toolName: "Workflow",
215
+ toolInput: { scriptPath: ".claude/workflows/deep-research-medium.js", args: "q" },
216
+ },
217
+ want: false,
218
+ },
219
+ {
220
+ tag: "workflow-scriptpath-block",
221
+ name: "Workflow(scriptPath: .../deep-research-thorough.js) → block",
222
+ call: {
223
+ toolName: "Workflow",
224
+ toolInput: { scriptPath: ".claude/workflows/deep-research-thorough.js", args: "q" },
225
+ },
226
+ want: false,
227
+ },
228
+ // --- Bare "deep-research" and plugin-prefixed names → block ------------
229
+ {
230
+ tag: "skill-block",
231
+ name: 'Skill(skill: "deep-research") (bare profile) → block',
232
+ call: { toolName: "Skill", toolInput: { skill: "deep-research" } },
233
+ want: false,
234
+ },
235
+ {
236
+ tag: "skill-block",
237
+ name: 'Skill(skill: "some-plugin:deep-research-medium") (plugin-prefixed) → block',
238
+ call: { toolName: "Skill", toolInput: { skill: "some-plugin:deep-research-medium" } },
239
+ want: false,
240
+ },
241
+ // --- Negative controls: the real /hs-res path and unrelated calls ------
242
+ {
243
+ tag: "allow",
244
+ name: "Skill(skill: hs-res) → allow (the real method)",
245
+ call: { toolName: "Skill", toolInput: { skill: "hs-res" } },
246
+ want: true,
247
+ },
248
+ {
249
+ tag: "allow",
250
+ name: "Skill with an unrelated skill name → allow",
251
+ call: { toolName: "Skill", toolInput: { skill: "prototype" } },
252
+ want: true,
253
+ },
254
+ {
255
+ tag: "allow",
256
+ name: "Workflow with an unrelated name → allow",
257
+ call: { toolName: "Workflow", toolInput: { name: "some-other-workflow" } },
258
+ want: true,
259
+ },
260
+ {
261
+ tag: "allow",
262
+ name: "Workflow with an unrelated scriptPath → allow",
263
+ call: {
264
+ toolName: "Workflow",
265
+ toolInput: { scriptPath: ".claude/workflows/some-other-workflow.js" },
266
+ },
267
+ want: true,
268
+ },
269
+ {
270
+ tag: "allow",
271
+ name: "Workflow with neither name nor scriptPath → allow (nothing to match)",
272
+ call: { toolName: "Workflow", toolInput: { args: "q" } },
273
+ want: true,
274
+ },
275
+ // --- Unrelated tools pass through untouched -----------------------------
276
+ {
277
+ tag: "allow-unrelated-tool",
278
+ name: "Read tool → allow",
279
+ call: { toolName: "Read", toolInput: { file_path: "C:/dev-projects/workflow-protocol/README.md" } },
280
+ want: true,
281
+ },
282
+ {
283
+ tag: "allow-unrelated-tool",
284
+ name: "Bash tool → allow",
285
+ call: { toolName: "Bash", toolInput: { command: "npm test" } },
286
+ want: true,
287
+ },
288
+ {
289
+ tag: "allow-unrelated-tool",
290
+ name: "Edit tool → allow",
291
+ call: { toolName: "Edit", toolInput: { file_path: "foo.mjs", old_string: "a", new_string: "b" } },
292
+ want: true,
293
+ },
294
+ // --- Case sensitivity is deliberate: a differently-cased name is NOT a
295
+ // known forbidden profile string, so it is not matched here. ------------
296
+ {
297
+ tag: "case-sensitivity",
298
+ name: 'Skill(skill: "Deep-Research-Medium") (different case) → allow (exact-match only)',
299
+ call: { toolName: "Skill", toolInput: { skill: "Deep-Research-Medium" } },
300
+ want: true,
301
+ },
302
+ // --- Malformed / empty input → fail-safe allow --------------------------
303
+ {
304
+ tag: "fail-safe",
305
+ name: "Skill with no skill field → allow",
306
+ call: { toolName: "Skill", toolInput: {} },
307
+ want: true,
308
+ },
309
+ {
310
+ tag: "fail-safe",
311
+ name: "missing toolInput entirely → allow",
312
+ call: { toolName: "Skill" },
313
+ want: true,
314
+ },
315
+ ];
316
+
317
+ // Kill-switch cases run through `evaluate()` (payload + env), not `decide()`.
318
+ const KILL_SWITCH_CASES = [
319
+ {
320
+ name: "kill switch ON: Skill(deep-research-medium) → allow (HEYSPEX_RESEARCH_GUARD_OFF=1)",
321
+ payload: { tool_name: "Skill", tool_input: { skill: "deep-research-medium" } },
322
+ env: { HEYSPEX_RESEARCH_GUARD_OFF: "1" },
323
+ want: true,
324
+ },
325
+ {
326
+ name: "kill switch OFF: Skill(deep-research-medium) → block (no env var)",
327
+ payload: { tool_name: "Skill", tool_input: { skill: "deep-research-medium" } },
328
+ env: {},
329
+ want: false,
330
+ },
331
+ {
332
+ name: 'kill switch value "0" does NOT disarm → block',
333
+ payload: { tool_name: "Skill", tool_input: { skill: "deep-research-medium" } },
334
+ env: { HEYSPEX_RESEARCH_GUARD_OFF: "0" },
335
+ want: false,
336
+ },
337
+ ];
338
+
339
+ function selfTest() {
340
+ let failed = 0;
341
+ let total = 0;
342
+
343
+ for (const c of SELF_TEST_CASES) {
344
+ total += 1;
345
+ const got = decide(c.call);
346
+ const ok = got.allow === c.want;
347
+ if (!ok) failed += 1;
348
+ process.stdout.write(
349
+ `${ok ? "ok " : "FAIL"} [${c.tag}] ${c.name}` +
350
+ `${ok ? "" : ` → got allow=${got.allow} want=${c.want}`}\n`,
351
+ );
352
+ }
353
+
354
+ for (const c of KILL_SWITCH_CASES) {
355
+ total += 1;
356
+ const got = evaluate(c.payload, c.env);
357
+ const ok = got.allow === c.want;
358
+ if (!ok) failed += 1;
359
+ process.stdout.write(
360
+ `${ok ? "ok " : "FAIL"} [kill-switch] ${c.name}` +
361
+ `${ok ? "" : ` → got allow=${got.allow} want=${c.want}`}\n`,
362
+ );
363
+ }
364
+
365
+ process.stdout.write(
366
+ failed === 0
367
+ ? `\nresearch-method-guard self-test: ${total} passed\n`
368
+ : `\n${failed}/${total} FAILED\n`,
369
+ );
370
+ process.exit(failed === 0 ? 0 : 1);
371
+ }
372
+
373
+ async function main() {
374
+ if (process.argv.includes("--self-test")) return selfTest();
375
+
376
+ const payload = await readStdinJson();
377
+ if (!payload) return;
378
+ if (payload.hook_event_name && payload.hook_event_name !== "PreToolUse") return;
379
+
380
+ const verdict = evaluate(payload, process.env);
381
+ if (!verdict.allow) {
382
+ process.stderr.write(`\n[HeySpex research-method guard]\n${verdict.reason}\n`);
383
+ process.exit(2);
384
+ }
385
+ }
386
+
387
+ // Only run the hook entrypoint (including its stdin read) when this file is
388
+ // executed directly — mirrors env-vault-guard.mjs's guard so importing this
389
+ // module (e.g. from a future regression test) never blocks on stdin.
390
+ const isDirectExecution =
391
+ process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
392
+
393
+ if (isDirectExecution) {
394
+ main().catch(() => {
395
+ // Fail-open: never block legitimate work on a hook crash.
396
+ process.exit(0);
397
+ });
398
+ }
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env node
2
+ // GENERATED by scripts/generate-hook-shells.mjs (HS-IQBF) — do not hand-edit.
3
+ // This file only wires Claude Code's hook stdin/stdout contract to the
4
+ // spexharness engine port of this gate. To change what this gate DECIDES,
5
+ // edit spexharness/src/gates/research-protocol-gate.mjs. To change the SHAPE of this shell,
6
+ // edit the generator, then rerun: node scripts/generate-hook-shells.mjs
7
+
8
+ import { runGateAsHook } from '../../spexharness/src/adapters/claude-code.mjs';
9
+ import gate from '../../spexharness/src/gates/research-protocol-gate.mjs';
10
+
11
+ await runGateAsHook(gate);