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,176 @@
1
+ // spexharness/src/doctrine/writer.mjs
2
+ //
3
+ // DR2 (epic [[HS-KNHZ-8CMW-EPIC-spexcode-doctrine-v0-the-foundational]], task
4
+ // [[HS-YMPY-8CMW-TASK-dr2-doctrine-synthesis-a-routed]]) — renders the merged
5
+ // principle set (merge.mjs's output) into the two committed product artifacts:
6
+ // `spexharness/doctrine/SPEXCODE-DOCTRINE.json` (schema-versioned, machine-readable,
7
+ // cited) and `SPEXCODE-DOCTRINE.md` (the human-readable, sectioned document). Pure
8
+ // rendering — {@link renderDoctrineMarkdown}/{@link buildDoctrineJson} do no I/O, so
9
+ // the golden Markdown test never touches disk; {@link writeDoctrineFiles} is the one
10
+ // function that does.
11
+
12
+ import { mkdirSync, writeFileSync } from 'node:fs';
13
+ import { join } from 'node:path';
14
+
15
+ export const DOCTRINE_SCHEMA_VERSION = 1;
16
+
17
+ /** Section order the Markdown writer renders in — matches the epic's own category list. */
18
+ export const CATEGORY_ORDER = [
19
+ 'delegation',
20
+ 'verification',
21
+ 'communication',
22
+ 'naming',
23
+ 'safety-permissions',
24
+ 'research-grounding',
25
+ 'session-hygiene',
26
+ 'product-taste',
27
+ ];
28
+
29
+ export const CATEGORY_LABELS = {
30
+ delegation: 'Delegation',
31
+ verification: 'Verification',
32
+ communication: 'Communication',
33
+ naming: 'Naming',
34
+ 'safety-permissions': 'Safety & Permissions',
35
+ 'research-grounding': 'Research & Grounding',
36
+ 'session-hygiene': 'Session Hygiene',
37
+ 'product-taste': 'Product Taste',
38
+ };
39
+
40
+ const DOCTRINE_HEADER =
41
+ 'This is SpexCode Doctrine — the foundational operating principles distilled from the owner\'s ' +
42
+ 'Claude Code and Command Code sessions and the Command Code taste profile. It is layer 2 of three: ' +
43
+ '1) SpexDoctrine (the enforced gates in spexharness/src/gates/, never learned), 2) SpexCode Doctrine ' +
44
+ '(this document — the base every developer profile is built on), 3) SpexCode Profile (the per-' +
45
+ 'developer layer on top, which may refine a principle here but may not silently contradict one).';
46
+
47
+ /**
48
+ * Assemble the schema-versioned JSON document. Pure — `learnedAt` must be supplied by
49
+ * the caller (never `Date.now()` internally) so the golden test is deterministic.
50
+ *
51
+ * @param {{ principles: object[], conflicts?: object[], sources?: object, learnedAt: string }} input
52
+ * @returns {{ schemaVersion: number, learnedAt: string, sources: object, principles: object[], conflicts: object[] }}
53
+ */
54
+ export function buildDoctrineJson({ principles, conflicts = [], sources = {}, learnedAt }) {
55
+ return {
56
+ schemaVersion: DOCTRINE_SCHEMA_VERSION,
57
+ learnedAt,
58
+ sources,
59
+ principles,
60
+ conflicts,
61
+ };
62
+ }
63
+
64
+ /** `claude-code ×5, taste ×1, command-code ×1` — counts an evidence-id list by its `<source>:` prefix. */
65
+ function evidenceBreakdown(evidence) {
66
+ const counts = new Map();
67
+ for (const id of evidence ?? []) {
68
+ const src = typeof id === 'string' && id.includes(':') ? id.slice(0, id.indexOf(':')) : 'unknown';
69
+ counts.set(src, (counts.get(src) ?? 0) + 1);
70
+ }
71
+ return [...counts.entries()].map(([k, v]) => `${k} ×${v}`).join(', ');
72
+ }
73
+
74
+ function renderPrincipleLine(p) {
75
+ const conf = typeof p.confidence === 'number' ? p.confidence.toFixed(2) : '0.00';
76
+ const evidence = Array.isArray(p.evidence) ? p.evidence : [];
77
+ return `- ${p.text} — confidence ${conf} · evidence ${evidence.length} (${evidenceBreakdown(evidence)})`;
78
+ }
79
+
80
+ /**
81
+ * Render the sectioned Markdown document from a JSON-shaped doctrine object
82
+ * ({@link buildDoctrineJson}'s output, or anything with the same shape). Deterministic:
83
+ * the same `doc` always renders the same string (no clock reads, no random order —
84
+ * principles within a category render in their given array order).
85
+ *
86
+ * @param {{ learnedAt: string, principles: object[], conflicts?: object[], sources?: object }} doc
87
+ * @returns {string}
88
+ */
89
+ export function renderDoctrineMarkdown(doc) {
90
+ const lines = [];
91
+ lines.push('# SpexCode Doctrine');
92
+ lines.push('');
93
+ lines.push(`_Learned ${doc.learnedAt}_`);
94
+ lines.push('');
95
+ lines.push(DOCTRINE_HEADER);
96
+ lines.push('');
97
+
98
+ const byCategory = new Map();
99
+ for (const p of doc.principles ?? []) {
100
+ const cat = p.category ?? 'product-taste';
101
+ const list = byCategory.get(cat) ?? [];
102
+ list.push(p);
103
+ byCategory.set(cat, list);
104
+ }
105
+
106
+ const renderSection = (cat, list) => {
107
+ lines.push(`## ${CATEGORY_LABELS[cat] ?? cat}`);
108
+ lines.push('');
109
+ for (const p of list) lines.push(renderPrincipleLine(p));
110
+ lines.push('');
111
+ };
112
+
113
+ for (const cat of CATEGORY_ORDER) {
114
+ const list = byCategory.get(cat);
115
+ if (list && list.length) renderSection(cat, list);
116
+ byCategory.delete(cat);
117
+ }
118
+ // Defensive: any category the seat invented outside CATEGORY_ORDER still renders,
119
+ // in the stable order it was first seen, rather than being silently dropped.
120
+ for (const [cat, list] of byCategory) renderSection(cat, list);
121
+
122
+ lines.push('## Conflicts');
123
+ lines.push('');
124
+ const conflicts = doc.conflicts ?? [];
125
+ if (!conflicts.length) {
126
+ lines.push('_None recorded._');
127
+ } else {
128
+ for (const c of conflicts) lines.push(`- **${c.category}**: "${c.a}" vs "${c.b}" — headline: ${c.headline}`);
129
+ }
130
+ lines.push('');
131
+
132
+ lines.push('## Sources');
133
+ lines.push('');
134
+ const sources = doc.sources ?? {};
135
+ lines.push(`- Observations: ${sources.totalObservations ?? 0}`);
136
+ const bySource = sources.bySource ?? {};
137
+ for (const [k, v] of Object.entries(bySource)) lines.push(` - ${k}: ${v}`);
138
+ // DR5 ([[HS-7Z9E-8CMW-TASK-dr5-vault-knowledge-as-a-doctrine]]) — the vault sets
139
+ // (decisions, lessons, protocol enforcement, ADRs, the naughty list, CLAUDE.md, ...)
140
+ // that fed the `vault` row above, broken out with their own per-set counts.
141
+ const vaultSets = sources.vaultSets ?? {};
142
+ if (Object.keys(vaultSets).length) {
143
+ lines.push(' - vault sets:');
144
+ for (const [k, v] of Object.entries(vaultSets)) lines.push(` - ${k}: ${v}`);
145
+ }
146
+ lines.push('');
147
+
148
+ return lines.join('\n');
149
+ }
150
+
151
+ export function defaultDoctrineOutDir(repoRoot) {
152
+ return join(repoRoot, 'spexharness', 'doctrine');
153
+ }
154
+
155
+ export function defaultDoctrineMdPath(repoRoot) {
156
+ return join(defaultDoctrineOutDir(repoRoot), 'SPEXCODE-DOCTRINE.md');
157
+ }
158
+
159
+ export function defaultDoctrineJsonPath(repoRoot) {
160
+ return join(defaultDoctrineOutDir(repoRoot), 'SPEXCODE-DOCTRINE.json');
161
+ }
162
+
163
+ /**
164
+ * Write both product artifacts for `repoRoot`. The only I/O in this module.
165
+ * @param {string} repoRoot
166
+ * @param {ReturnType<typeof buildDoctrineJson>} doc
167
+ * @returns {{ jsonPath: string, mdPath: string }}
168
+ */
169
+ export function writeDoctrineFiles(repoRoot, doc) {
170
+ mkdirSync(defaultDoctrineOutDir(repoRoot), { recursive: true });
171
+ const jsonPath = defaultDoctrineJsonPath(repoRoot);
172
+ const mdPath = defaultDoctrineMdPath(repoRoot);
173
+ writeFileSync(jsonPath, `${JSON.stringify(doc, null, 2)}\n`);
174
+ writeFileSync(mdPath, `${renderDoctrineMarkdown(doc)}\n`);
175
+ return { jsonPath, mdPath };
176
+ }
@@ -0,0 +1,84 @@
1
+ // spexharness/src/events.mjs
2
+ //
3
+ // The harness's normalized event model. Every front (Claude Code plugin, `hs
4
+ // chat`, a headless seat) translates whatever its own runtime hands it into
5
+ // one of these six shapes before calling into the gate registry / decider —
6
+ // so a gate is written once against this shape and never against a
7
+ // front-specific payload.
8
+ //
9
+ // This module is pure: no I/O, no process.stdin/stdout, no imports outside
10
+ // node:* stdlib.
11
+
12
+ /**
13
+ * The six event points a gate may run on, in the order they occur during a
14
+ * session:
15
+ * 1. session_start — a session begins.
16
+ * 2. prompt_submit — the user (or caller) submits a prompt.
17
+ * 3. before_tool — a tool is about to run (pre-flight; may deny/rewrite).
18
+ * 4. after_tool — a tool finished running (post-flight; may inject).
19
+ * 5. stop — the agent is about to stop / hand control back.
20
+ * 6. session_end — a session ends.
21
+ * @type {ReadonlyArray<string>}
22
+ */
23
+ export const EVENT_POINTS = Object.freeze([
24
+ 'session_start',
25
+ 'prompt_submit',
26
+ 'before_tool',
27
+ 'after_tool',
28
+ 'stop',
29
+ 'session_end',
30
+ ]);
31
+
32
+ const EVENT_POINT_SET = new Set(EVENT_POINTS);
33
+
34
+ /**
35
+ * Documented field set per event point. `sessionId` and `ts` are common to
36
+ * every event (added by {@link createEvent} itself); the arrays below are
37
+ * the additional fields each event point's `fields` argument is expected to
38
+ * carry. This is documentation, not a runtime schema — createEvent does not
39
+ * reject extra or missing fields, so a front can pass whatever its own
40
+ * runtime already has without a translation step per gate.
41
+ *
42
+ * - session_start: cwd, agent
43
+ * - prompt_submit: prompt
44
+ * - before_tool: toolName, toolInput
45
+ * - after_tool: toolName, toolInput, toolResult
46
+ * - stop: (none beyond the common fields)
47
+ * - session_end: reason
48
+ * @type {Readonly<Record<string, ReadonlyArray<string>>>}
49
+ */
50
+ export const EVENT_FIELDS = Object.freeze({
51
+ session_start: Object.freeze(['cwd', 'agent']),
52
+ prompt_submit: Object.freeze(['prompt']),
53
+ before_tool: Object.freeze(['toolName', 'toolInput']),
54
+ after_tool: Object.freeze(['toolName', 'toolInput', 'toolResult']),
55
+ stop: Object.freeze([]),
56
+ session_end: Object.freeze(['reason']),
57
+ });
58
+
59
+ /**
60
+ * Build one normalized event object for a gate to run against.
61
+ *
62
+ * @param {string} point - one of {@link EVENT_POINTS}. Throws on any other
63
+ * value, including a Claude-Code-shaped name like `"PreToolUse"` — callers
64
+ * translate into this vocabulary at the edge, not inside a gate.
65
+ * @param {Record<string, unknown>} [fields] - the event point's documented
66
+ * fields (see {@link EVENT_FIELDS}), plus any extra context a front wants
67
+ * to carry through to its own gates. Not validated against the field list;
68
+ * the list is documentation for gate authors, not a runtime schema.
69
+ * @returns {Readonly<{ point: string, ts: number } & Record<string, unknown>>}
70
+ * A frozen, normalized event object. `ts` defaults to `Date.now()` when
71
+ * `fields.ts` is not supplied.
72
+ */
73
+ export function createEvent(point, fields = {}) {
74
+ if (!EVENT_POINT_SET.has(point)) {
75
+ throw new Error(
76
+ `spexharness: unknown event point "${point}" — expected one of: ${EVENT_POINTS.join(', ')}`,
77
+ );
78
+ }
79
+ return Object.freeze({
80
+ ts: Date.now(),
81
+ ...fields,
82
+ point,
83
+ });
84
+ }
@@ -0,0 +1,202 @@
1
+ // spexharness/src/gates/capture-workflow-gate.mjs
2
+ //
3
+ // Engine port of scripts/claude-hooks/capture-workflow-gate.mjs — the
4
+ // PreToolUse EARLY-WARNING gate for filing an implementation spec
5
+ // (epic/task/milestone) that references the capture currently on deck. See
6
+ // ./PORTING.md for the recipe this follows and ./env-vault-guard.mjs for the
7
+ // reference port.
8
+ //
9
+ // THIS HOOK NEVER DENIES (by design — see the original's header). The real
10
+ // refusal lives in heyspex_file_spec / vendor/heyspex-mcp/capture-stage-gate.mjs,
11
+ // which reads the LIVE capture-on-deck pin (the console / Neon control
12
+ // plane); this hook can only see the OFFLINE file pin
13
+ // (.heyspex/capture-focus.yaml), so it is kept as a narrow, always-silent-
14
+ // unless-matched early warning, never a blocker.
15
+ //
16
+ // The original script does NOT export a pure decision function — its logic
17
+ // is tangled inside `main()` (stdin read, env checks, then the match logic).
18
+ // Per PORTING.md step 1's "otherwise" branch that logic would normally be
19
+ // extracted into the original script itself, but this task's Do-nots forbid
20
+ // touching anything under scripts/claude-hooks/. Rather than hand-copy the
21
+ // original's rule/regex logic (the thing PORTING.md warns will drift), this
22
+ // port imports the SAME shared, already-pure library modules the original
23
+ // hook itself imports for the actual decision surface:
24
+ // - vendor/heyspex-mcp/capture-stage-gate.mjs — BLOCKED_FILE_SPEC_TYPES,
25
+ // ALLOWED_FILE_SPEC_TYPES, referencesCapture(). This is the canonical
26
+ // module the original hook's header says it shares "so the two can
27
+ // never disagree about which filings count" — importing it here keeps
28
+ // that same guarantee for the port.
29
+ // - vendor/heyspex-mcp/capture-focus.mjs — resolveActiveCapture(), which
30
+ // reads the offline pin + the pinned capture's frontmatter/body.
31
+ // The only code re-derived here is the original's small, non-security
32
+ // glue: stripping the MCP tool-name prefix (bareToolName), and the tiny
33
+ // offline frontmatter scanner + spec loader the original defines locally
34
+ // (parseFrontmatter / loadSpec) purely to satisfy resolveActiveCapture's
35
+ // callback-shaped contract — neither is a rule/regex guard, both are
36
+ // deterministic parsing utilities with no security surface of their own.
37
+ //
38
+ // Decision mapping (documented judgment call, same shape as
39
+ // env-vault-guard.mjs's "ask" → rewrite mapping): a match produces a WARNING
40
+ // carrying the capture id and the sanctioned-exit message — richer than a
41
+ // plain allow, and the original never denies, so silently collapsing this to
42
+ // `allow` would drop information a caller might want to surface (e.g. show
43
+ // the same warning text the Claude Code hook would have written to stderr).
44
+ // Mapped to `rewrite` with `{ warn: true, reason, captureId, type }` rather
45
+ // than `deny` (the hook's own header is explicit: "it never denies") or a
46
+ // silent `allow` (loses the warning). No match, or any early-out (kill
47
+ // switch, wrong tool, allowed/unmatched type, no capture on deck, unrelated
48
+ // filing) → plain `allow`.
49
+ //
50
+ // This gate never hard-denies (matching the original) — "deny" coverage for
51
+ // AC3 ("allow, deny and rewrite each covered by at least one test per gate",
52
+ // checked as one combined `node --test` run across all three ported gates in
53
+ // this task) comes from the sibling enforce-session-sequence gate. See
54
+ // ./enforce-session-sequence.test.mjs.
55
+ //
56
+ // Env hatches (unchanged, either one silences the warning — matching the
57
+ // original, which honors the tool's own break-glass too so one variable
58
+ // covers both layers):
59
+ // HEYSPEX_CAPTURE_WORKFLOW_GATE_OFF=1
60
+ // HEYSPEX_CAPTURE_STAGE_GATE_OFF=1
61
+ //
62
+ // Not ported HERE: the original's `appendGuardrailEvent(...)` audit-log
63
+ // write on a match. A gate module stays a pure decision function (AC1: no
64
+ // stdout writes; by the same spirit, no other observability side effects
65
+ // either) — matching env-vault-guard.mjs's port. The `rewrite.captureId` /
66
+ // `rewrite.type` fields below already carry everything a front needs to
67
+ // rebuild that same audit row — see spexharness/src/adapters/audit.mjs,
68
+ // called from spexharness/src/adapters/claude-code.mjs's runGateAsHook.
69
+ //
70
+ // `ctx` contract (all optional; every field defaults to a real fs read, same
71
+ // as the original hook and as spexharness/fixtures/lib/setups.mjs's
72
+ // captureActiveVault — the golden/regression tests below point ctx.cwd at a
73
+ // real scratch vault rather than injecting a fake fs):
74
+ // ctx.cwd - repo/vault root to resolve the offline capture pin from.
75
+ // Defaults to CLAUDE_PROJECT_DIR, else this repo's root.
76
+ // ctx.env - env var overrides (tests use this instead of mutating
77
+ // process.env).
78
+
79
+ import fs from 'node:fs';
80
+ import path from 'node:path';
81
+ import { fileURLToPath } from 'node:url';
82
+
83
+ import { resolveActiveCapture } from '../../../vendor/heyspex-mcp/capture-focus.mjs';
84
+ import {
85
+ ALLOWED_FILE_SPEC_TYPES,
86
+ BLOCKED_FILE_SPEC_TYPES,
87
+ referencesCapture,
88
+ } from '../../../vendor/heyspex-mcp/capture-stage-gate.mjs';
89
+
90
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
91
+ const REPO_ROOT = path.resolve(HERE, '..', '..', '..');
92
+
93
+ // The bare (server-stripped) MCP tool name this gate acts on.
94
+ const FILE_SPEC_TOOL = 'heyspex_file_spec';
95
+ const OFF_ENV_WORKFLOW = 'HEYSPEX_CAPTURE_WORKFLOW_GATE_OFF';
96
+ const OFF_ENV_STAGE = 'HEYSPEX_CAPTURE_STAGE_GATE_OFF';
97
+
98
+ // Minimal, sync, offline frontmatter parser — mirrors the original hook's
99
+ // local helper of the same name byte-for-byte. Enough for
100
+ // resolveActiveCapture's callbacks (it only reads frontmatter.id / status /
101
+ // promoted_to and the body). Not a full YAML parser; capture frontmatter is
102
+ // flat scalars + simple arrays.
103
+ function parseFrontmatter(content) {
104
+ const m = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
105
+ if (!m) return { frontmatter: {}, body: content };
106
+ const frontmatter = {};
107
+ for (const line of m[1].split(/\r?\n/)) {
108
+ const trimmed = line.trim();
109
+ if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith('- ')) continue;
110
+ const colon = trimmed.indexOf(':');
111
+ if (colon === -1) continue;
112
+ const key = trimmed.slice(0, colon).trim();
113
+ let val = trimmed.slice(colon + 1).trim();
114
+ val = val.replace(/^["']|["']$/g, '');
115
+ frontmatter[key] = val;
116
+ }
117
+ return { frontmatter, body: m[2] ?? '' };
118
+ }
119
+
120
+ // readFileSync loader for resolveActiveCapture — returns { frontmatter, body }
121
+ // or null when the pinned capture file is missing/unreadable. Mirrors the
122
+ // original hook's local helper of the same name.
123
+ function loadSpec(vaultRoot, specPath) {
124
+ try {
125
+ const full = path.join(vaultRoot, specPath);
126
+ const content = fs.readFileSync(full, 'utf8');
127
+ return parseFrontmatter(content);
128
+ } catch {
129
+ return null;
130
+ }
131
+ }
132
+
133
+ // Strip the Claude Code MCP tool prefix (mcp__<server>__<tool>) → bare tool
134
+ // name. Mirrors the original hook's local helper of the same name.
135
+ function bareToolName(name) {
136
+ if (typeof name !== 'string') return '';
137
+ const m = name.match(/^mcp__[^_]+(?:_[^_]+)*?__(.+)$/);
138
+ if (m) return m[1];
139
+ const idx = name.lastIndexOf('__');
140
+ return idx === -1 ? name : name.slice(idx + 2);
141
+ }
142
+
143
+ function warnText(captureId) {
144
+ return (
145
+ `[capture-workflow-gate] warning — this filing links the open capture ${captureId}, ` +
146
+ `which a capture must not become. The sanctioned exit is promote-to-research (\`/hs-res\`). ` +
147
+ `This hook only warns. heyspex_file_spec is where the rule is enforced, and it ` +
148
+ `reads the live capture pin rather than the offline file this hook can see.`
149
+ );
150
+ }
151
+
152
+ /** The capture-workflow gate's id — unique across the built-in registry. */
153
+ export const id = 'capture-workflow-gate';
154
+ /** Runs pre-tool, same as its PreToolUse Claude Code hook registration. */
155
+ export const event = 'before_tool';
156
+ /** Caller-defined severity label; this gate only ever warns (rewrites) or allows. */
157
+ export const severity = 'warn';
158
+
159
+ /**
160
+ * @param {{ point: string, toolName?: string, toolInput?: Record<string, unknown> }} evt
161
+ * @param {{ cwd?: string, env?: Record<string, string | undefined> }} [ctx]
162
+ * @returns {{ decision: 'allow'|'rewrite', reason?: string, rewrite?: object }}
163
+ */
164
+ export function run(evt, ctx = {}) {
165
+ const env = ctx.env ?? process.env;
166
+ if (String(env[OFF_ENV_WORKFLOW] ?? '') === '1') return { decision: 'allow' };
167
+ if (String(env[OFF_ENV_STAGE] ?? '') === '1') return { decision: 'allow' };
168
+
169
+ const tool = bareToolName(evt?.toolName);
170
+ if (tool !== FILE_SPEC_TOOL) return { decision: 'allow' };
171
+
172
+ const toolInput = evt?.toolInput ?? {};
173
+ const type = String(toolInput?.type ?? '').trim().toLowerCase();
174
+
175
+ // Only implementation specs are the premature-promotion case. Research /
176
+ // diagnose / memory / pillar / review filings are sanctioned exits or
177
+ // unrelated — never warned about.
178
+ if (!BLOCKED_FILE_SPEC_TYPES.has(type) || ALLOWED_FILE_SPEC_TYPES.has(type)) {
179
+ return { decision: 'allow' };
180
+ }
181
+
182
+ const root = ctx.cwd ?? process.env.CLAUDE_PROJECT_DIR ?? REPO_ROOT;
183
+ let resolved;
184
+ try {
185
+ resolved = resolveActiveCapture(root, parseFrontmatter, loadSpec);
186
+ } catch {
187
+ return { decision: 'allow' }; // discovery failure → stay quiet, fail-safe
188
+ }
189
+ if (!resolved || resolved.error || !resolved.spec_id) return { decision: 'allow' };
190
+
191
+ // Arg-targeted: only warn when the spec being filed references THIS capture.
192
+ if (!referencesCapture(toolInput, resolved.spec_id)) return { decision: 'allow' };
193
+
194
+ const reason = warnText(resolved.spec_id);
195
+ return {
196
+ decision: 'rewrite',
197
+ reason,
198
+ rewrite: { warn: true, reason, captureId: resolved.spec_id, type },
199
+ };
200
+ }
201
+
202
+ export default { id, event, severity, run };
@@ -0,0 +1,33 @@
1
+ // spexharness/src/gates/claim-before-code-gate.decide.mjs
2
+ //
3
+ // Pure decision logic relocated from scripts/claude-hooks/claim-before-code-gate.mjs
4
+ // (HS-IQBF, per PORTING.md's "move the pure decision logic into the gate
5
+ // module itself or a sibling pure module" — the original hook now delegates
6
+ // to this file too, so ./claim-before-code-gate.mjs (the gate) and the
7
+ // original hook's own smoke test both import the SAME code; nothing here is
8
+ // a second copy that can drift). No stdin/stdout/process.exit anywhere in
9
+ // this file.
10
+
11
+ const EDIT_TOOL_NAMES = new Set(['Edit', 'Write', 'MultiEdit']);
12
+
13
+ // Paths this gate never blocks: the vault itself (filing the epic IS the
14
+ // required step), hook/state machinery, and anything outside the repo tree.
15
+ // Everything else counts as "code" for this gate's purposes.
16
+ const EXEMPT_PATH_RE = /(?:^|[\\/])(?:specs|\.heyspex|\.claude|\.git)(?:[\\/]|$)/;
17
+
18
+ function isCodeEditPath(filePath) {
19
+ if (typeof filePath !== 'string' || !filePath.trim()) return false;
20
+ const normalized = filePath.replace(/\\/g, '/');
21
+ return !EXEMPT_PATH_RE.test(normalized);
22
+ }
23
+
24
+ /**
25
+ * Pure decision function (exported for the smoke test and the gate): given
26
+ * the resolved inputs, should this edit be denied?
27
+ */
28
+ export function shouldDenyEdit({ toolName, filePath, hasMarker, hasNoClaimSignal }) {
29
+ if (!EDIT_TOOL_NAMES.has(toolName)) return false;
30
+ if (!isCodeEditPath(filePath)) return false;
31
+ if (!hasMarker) return false;
32
+ return hasNoClaimSignal;
33
+ }
@@ -0,0 +1,129 @@
1
+ // spexharness/src/gates/claim-before-code-gate.mjs
2
+ //
3
+ // Engine port of scripts/claude-hooks/claim-before-code-gate.mjs — the
4
+ // PreToolUse gate that denies the first post-plan CODE edit made by a
5
+ // session that exited plan mode with no active claim
6
+ // ([[HS-38J5-2BXD-DIAG-plan-execute-boundary-has-no-ingest-clai]] item 2).
7
+ // See ./PORTING.md for the recipe this follows and ./env-vault-guard.mjs for
8
+ // the reference port.
9
+ //
10
+ // This module does NOT reimplement the guard's decision logic. The pure
11
+ // decision function — `shouldDenyEdit({ toolName, filePath, hasMarker,
12
+ // hasNoClaimSignal })` — with no stdin/stdout/process-exit of its own, lives
13
+ // in ./claim-before-code-gate.decide.mjs (relocated out of the original hook
14
+ // script, HS-IQBF; see PORTING.md and that sibling module's own header for
15
+ // why) and is IMPORTED here unchanged: it already encapsulates the
16
+ // tool-name filter (EDIT_TOOL_NAMES) and the code-path filter
17
+ // (isCodeEditPath) internally, so this module never needs its own copy of
18
+ // either.
19
+ //
20
+ // The two SIGNAL readers (hasMarker / hasNoClaimSignal) are also imported
21
+ // unchanged, not re-derived:
22
+ // - scripts/claude-hooks/plan-exit-ingest-gate.mjs — hasFreshPlanExitMarker,
23
+ // repoRoot. Also guarded by its own INVOKED_DIRECTLY check.
24
+ // - scripts/claude-hooks/claim-liveness-shared.mjs — resolveSessionId,
25
+ // hasFreshNoClaimSignal. A dependency-free shared module with no
26
+ // unconditional main() at all (its own async orchestration function is
27
+ // exported, never self-invoked at import time).
28
+ //
29
+ // Event mapping: PreToolUse (Edit|Write|MultiEdit) → before_tool.
30
+ // stdin.session_id / stdin.conversation_id → evt.sessionId (resolveSessionId
31
+ // also falls back to the real process env, unchanged).
32
+ // stdin.tool_input.file_path (or .path) → evt.toolInput.file_path/.path.
33
+ //
34
+ // Decision mapping — TWO rungs, same shape as plan-review-gate.mjs's port:
35
+ // CLAUDE_AGENT=true or HEYSPEX_CLAIM_GATE_INTERACTIVE=block ("forReal") maps
36
+ // a `shouldDenyEdit` true to a real `deny`; the interactive default maps the
37
+ // SAME finding to a `rewrite` envelope (`{ warn: true, reason, rule }`) — the
38
+ // edit still proceeds, matching the original's stderr-only warn path
39
+ // exactly. `shouldDenyEdit` false (no marker, no fresh no-claim signal, wrong
40
+ // tool, or a specs/.heyspex/.claude/.git path) → plain `allow`.
41
+ //
42
+ // Not ported: the original's `appendGuardrailEvent(...)` audit-log write on
43
+ // a fire — matching every other port in this set.
44
+ //
45
+ // Env hatches (unchanged):
46
+ // HEYSPEX_CLAIM_GATE_OFF=1 Hard kill switch — silences both rungs.
47
+ // HEYSPEX_CLAIM_GATE_INTERACTIVE=block Raise the interactive rung to a real deny.
48
+ // CLAUDE_AGENT=true Unattended session → real deny (unchanged).
49
+ //
50
+ // `ctx` contract (all optional; every field defaults to a real fs/env read):
51
+ // ctx.cwd - repo root to resolve the plan-exit marker and
52
+ // claim-liveness cache from. Defaults to
53
+ // plan-exit-ingest-gate.mjs's own repoRoot()
54
+ // (CLAUDE_PROJECT_DIR, else this repo's root).
55
+ // ctx.sessionId - override for the resolved session id (tests
56
+ // pin this directly instead of an env var).
57
+ // Defaults to resolveSessionId() against
58
+ // `{ session_id: evt.sessionId }`.
59
+ // ctx.env - env var overrides (tests use this instead of
60
+ // mutating process.env).
61
+
62
+ import { resolveSessionId, hasFreshNoClaimSignal } from '../../../scripts/claude-hooks/claim-liveness-shared.mjs';
63
+ import { hasFreshPlanExitMarker, repoRoot as planExitRepoRoot } from '../../../scripts/claude-hooks/plan-exit-ingest-gate.mjs';
64
+ import { shouldDenyEdit } from './claim-before-code-gate.decide.mjs';
65
+
66
+ // Copied verbatim from the original's own (unexported) denyReason.
67
+ function denyReason() {
68
+ return (
69
+ 'HEYSPEX claim gate: this session exited plan mode and has no active claim — ' +
70
+ 'the plan→execute boundary requires filing the epic (if not yet filed) and calling ' +
71
+ 'heyspex_claim_spec before the first code edit ([[HS-38J5-2BXD-DIAG-plan-execute-boundary-has-no-ingest-clai]]). ' +
72
+ 'Claim the spec you are working, then retry this edit. ' +
73
+ '(Break-glass: set HEYSPEX_CLAIM_GATE_OFF=1 for this session.)'
74
+ );
75
+ }
76
+
77
+ function shouldBlockForReal(env) {
78
+ return env.CLAUDE_AGENT === 'true' || env.HEYSPEX_CLAIM_GATE_INTERACTIVE === 'block';
79
+ }
80
+
81
+ /** The claim-before-code gate's id — unique across the built-in registry. */
82
+ export const id = 'claim-before-code-gate';
83
+ /** Runs pre-tool, same as its PreToolUse Claude Code hook registration. */
84
+ export const event = 'before_tool';
85
+ /** Caller-defined severity label; this gate can deny, so it is "block". */
86
+ export const severity = 'block';
87
+
88
+ /**
89
+ * @param {{ point: string, toolName?: string, toolInput?: Record<string, unknown>, sessionId?: string }} evt
90
+ * @param {{ cwd?: string, sessionId?: string, env?: Record<string, string | undefined> }} [ctx]
91
+ * @returns {{ decision: 'allow'|'deny'|'rewrite', reason?: string, rewrite?: object }}
92
+ */
93
+ export function run(evt, ctx = {}) {
94
+ const env = ctx.env ?? process.env;
95
+ if (String(env.HEYSPEX_CLAIM_GATE_OFF ?? '') === '1') {
96
+ return { decision: 'allow' };
97
+ }
98
+
99
+ const toolName = evt?.toolName;
100
+ const toolInput = evt?.toolInput ?? {};
101
+ const filePath = toolInput.file_path ?? toolInput.path;
102
+
103
+ const sessionId = ctx.sessionId ?? resolveSessionId({ session_id: evt?.sessionId });
104
+ if (!sessionId) return { decision: 'allow' }; // can't scope the check — fail open
105
+
106
+ const root = ctx.cwd ?? planExitRepoRoot();
107
+ const hasMarker = hasFreshPlanExitMarker(sessionId, root);
108
+ const hasNoClaimSignal = hasFreshNoClaimSignal(sessionId, root);
109
+
110
+ if (!shouldDenyEdit({ toolName, filePath, hasMarker, hasNoClaimSignal })) {
111
+ return { decision: 'allow' };
112
+ }
113
+
114
+ const reason = denyReason();
115
+ const forReal = shouldBlockForReal(env);
116
+ if (forReal) return { decision: 'deny', reason };
117
+
118
+ // Interactive warn path: byte-for-byte the original hook's fireDeny()
119
+ // stderr banner ([claim-before-code-gate] warning — ...\n(Interactive
120
+ // session: ...)\n) — kept here (not left to a generic adapter formatter)
121
+ // because it is gate-specific text, same convention as naughty-list-gate's
122
+ // and capture-workflow-gate's own self-contained warn reasons.
123
+ const warnReason =
124
+ `[claim-before-code-gate] warning — ${reason}\n` +
125
+ '(Interactive session: this only warns. Set HEYSPEX_CLAIM_GATE_INTERACTIVE=block to enforce it here too.)\n';
126
+ return { decision: 'rewrite', reason: warnReason, rewrite: { warn: true, reason: warnReason, rule: 'claim-before-code-gate' } };
127
+ }
128
+
129
+ export default { id, event, severity, run };