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,361 @@
1
+ // spexharness/src/gates/git-stash-gate.decide.mjs
2
+ //
3
+ // Pure classifier functions relocated from scripts/claude-hooks/git-stash-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 ./git-stash-gate.mjs (the gate) is the only place this
7
+ // logic lives; nothing here is a second copy that can drift). No stdin/
8
+ // stdout/process.exit anywhere in this file. See ./git-stash-gate.mjs for the
9
+ // gate's own orchestration (worktree bypass, sub-agent lock, protected-
10
+ // action-intent) built on top of these primitives.
11
+
12
+ import fs from 'node:fs';
13
+ import path from 'node:path';
14
+ import { spawnSync } from 'node:child_process';
15
+ import { fileURLToPath } from 'node:url';
16
+
17
+ // `isWorktreePaths` moved out of scripts/claude-hooks/dev-server-task-reminder.mjs
18
+ // into the shared ../dev-server-control.mjs when that hook became a thin shim
19
+ // over its own engine gate (wave 6 of [[HS-AZ61-NYPR]]). Same function, same
20
+ // behavior — only the import path moved.
21
+ import { isWorktreePaths } from '../dev-server-control.mjs';
22
+ import { checkSubagentLock } from '../../../scripts/lib/subagent-git-lock.mjs';
23
+ import {
24
+ readAndValidateIntent,
25
+ consumeIntent,
26
+ buildNoIntentDenyMessage,
27
+ buildIntentAskMessage,
28
+ buildLockDenyMessage,
29
+ } from '../../../scripts/claude-hooks/protected-action-intent.mjs';
30
+
31
+ /** Shell out to git to answer "is this cwd a linked worktree (not the main checkout)?" */
32
+ export function detectWorktreeIsolation(cwd = process.cwd()) {
33
+ try {
34
+ const dir = spawnSync('git', ['rev-parse', '--git-dir'], { cwd, encoding: 'utf8', timeout: 3000 });
35
+ const common = spawnSync('git', ['rev-parse', '--git-common-dir'], { cwd, encoding: 'utf8', timeout: 3000 });
36
+ if (dir.status !== 0 || common.status !== 0 || !dir.stdout?.trim() || !common.stdout?.trim()) return false;
37
+ return isWorktreePaths(path.resolve(cwd, dir.stdout.trim()), path.resolve(cwd, common.stdout.trim()));
38
+ } catch {
39
+ return false; // undetectable → stay gated (fail toward protection, not bypass)
40
+ }
41
+ }
42
+
43
+ // Read-only stash subcommands — inspecting a stash never discards anything.
44
+ const READ_ONLY_SUBCOMMANDS = new Set(['list', 'show']);
45
+
46
+ /**
47
+ * Find every `git stash <subcommand>` invocation in a (possibly compound)
48
+ * shell command, returning the lowercased subcommand for each (bare `git
49
+ * stash` with no subcommand normalizes to 'push', matching git's own default).
50
+ * Exported for tests.
51
+ */
52
+ export function stashInvocations(command) {
53
+ const invocations = [];
54
+ const re = /\bgit\s+stash\b(?:\s+([A-Za-z-]+))?/gi;
55
+ let m;
56
+ while ((m = re.exec(command))) {
57
+ invocations.push((m[1] || 'push').toLowerCase());
58
+ }
59
+ return invocations;
60
+ }
61
+
62
+ /**
63
+ * Classify a tool call's git-stash content:
64
+ * null — no `git stash` invocation at all (not applicable)
65
+ * 'read-only' — every invocation present is `list`/`show` (pass silently)
66
+ * 'gated' — at least one invocation is a real stash action (ask, always)
67
+ * Exported for tests.
68
+ */
69
+ export function classifyGitStash(toolName, toolInput) {
70
+ if (toolName !== 'Bash' && toolName !== 'PowerShell') return null;
71
+ const command = toolInput?.command;
72
+ if (typeof command !== 'string' || !command.trim()) return null;
73
+ const invocations = stashInvocations(command);
74
+ if (invocations.length === 0) return null;
75
+ const allReadOnly = invocations.every((sub) => READ_ONLY_SUBCOMMANDS.has(sub));
76
+ return allReadOnly ? 'read-only' : 'gated';
77
+ }
78
+
79
+ // ── Generalized destructive-verb gate (HS-IXEY) ─────────────────────────────
80
+ // Every rule here mirrors stashInvocations/classifyGitStash's technique: a
81
+ // whole-command regex scan (not anchored to the start of the string), so a
82
+ // `cd X && ` chain never hides the verb. `segment` is the raw text following
83
+ // `git <verb>` up to the next shell control operator (&&, ||, ;, |, newline)
84
+ // or end of string — bounded the same way, so a LATER command in the same
85
+ // chain never leaks into this verb's own flag check.
86
+ const VERB_RULES = [
87
+ {
88
+ verb: 'reset',
89
+ pattern: 'reset',
90
+ // Scope: only --hard/--merge discard work; plain/--mixed/--soft reset does not.
91
+ classify: (seg) => (/(^|\s)--hard\b/.test(seg) || /(^|\s)--merge\b/.test(seg) ? 'gated' : null),
92
+ },
93
+ {
94
+ verb: 'checkout',
95
+ pattern: 'checkout',
96
+ classify: (seg) => {
97
+ if (/(^|\s)--\s+\S/.test(seg)) return 'gated'; // `checkout -- <path>` (incl. `checkout <ref> -- <path>`)
98
+ if (/^\s*\.(\s|$)/.test(seg)) return 'gated'; // bare `checkout .`
99
+ return null; // plain branch switch (`checkout <branch>`) is out of scope
100
+ },
101
+ },
102
+ {
103
+ verb: 'clean',
104
+ pattern: 'clean',
105
+ classify: (seg) => (/(^|\s)-[a-zA-Z]*f[a-zA-Z]*(\s|$)/.test(seg) || /--force\b/.test(seg) ? 'gated' : null),
106
+ },
107
+ {
108
+ verb: 'restore',
109
+ pattern: 'restore',
110
+ // Both the worktree form (default) and --staged rewrite state — always gated.
111
+ classify: () => 'gated',
112
+ },
113
+ {
114
+ verb: 'rebase',
115
+ pattern: 'rebase',
116
+ // Rewrites history on a shared branch unconditionally — always gated.
117
+ classify: () => 'gated',
118
+ },
119
+ {
120
+ verb: 'push',
121
+ pattern: 'push',
122
+ classify: (seg) => (/--force(-with-lease)?\b/.test(seg) || /(^|\s)-f(\s|$)/.test(seg) ? 'gated' : null),
123
+ },
124
+ {
125
+ verb: 'branch',
126
+ pattern: 'branch',
127
+ classify: (seg) =>
128
+ /(^|\s)-D\b/.test(seg) || (/--delete\b/.test(seg) && /--force\b/.test(seg)) ? 'gated' : null,
129
+ },
130
+ {
131
+ verb: 'commit',
132
+ pattern: 'commit',
133
+ classify: (seg) => (/--amend\b/.test(seg) ? 'gated' : null),
134
+ },
135
+ ];
136
+
137
+ const VERB_RULES_BY_NAME = new Map(VERB_RULES.map((rule) => [rule.verb, rule]));
138
+
139
+ /**
140
+ * Find every non-stash destructive-candidate invocation in a (possibly
141
+ * compound) shell command. Returns [{ verb, segment }] — `segment` is the raw
142
+ * text after `git <verb>` up to the next shell control operator or end of
143
+ * string. Exported for tests.
144
+ */
145
+ export function destructiveVerbInvocations(command) {
146
+ const invocations = [];
147
+ for (const rule of VERB_RULES) {
148
+ const re = new RegExp(`\\bgit\\s+${rule.pattern}\\b([^;&|\\n]*)`, 'gi');
149
+ let m;
150
+ while ((m = re.exec(command))) {
151
+ invocations.push({ verb: rule.verb, segment: m[1] || '' });
152
+ }
153
+ }
154
+ return invocations;
155
+ }
156
+
157
+ /**
158
+ * Classify a tool call's non-stash destructive-verb content:
159
+ * null — no gated invocation of any of these verbs is present
160
+ * array — the list of verbs that matched a gated flag/form (non-empty)
161
+ * Exported for tests.
162
+ */
163
+ export function classifyGitVerbs(toolName, toolInput) {
164
+ if (toolName !== 'Bash' && toolName !== 'PowerShell') return null;
165
+ const command = toolInput?.command;
166
+ if (typeof command !== 'string' || !command.trim()) return null;
167
+ const gated = destructiveVerbInvocations(command)
168
+ .map(({ verb, segment }) => (VERB_RULES_BY_NAME.get(verb).classify(segment) === 'gated' ? verb : null))
169
+ .filter(Boolean);
170
+ return gated.length > 0 ? gated : null;
171
+ }
172
+
173
+ /**
174
+ * Unified classifier across stash AND every other gated verb — what the gate
175
+ * driver AND the test file both call.
176
+ * null — nothing gated or read-only present (not applicable — pass)
177
+ * 'read-only' — only read-only forms present (stash list/show)
178
+ * 'gated' — at least one gated invocation of any verb — always ask
179
+ * Exported for tests.
180
+ */
181
+ export function classifyGitDestructive(toolName, toolInput) {
182
+ const stashKind = classifyGitStash(toolName, toolInput);
183
+ const gatedVerbs = classifyGitVerbs(toolName, toolInput);
184
+ if (stashKind === 'gated' || gatedVerbs) return 'gated';
185
+ if (stashKind === 'read-only') return 'read-only';
186
+ return null;
187
+ }
188
+
189
+ // ── Restored below: runGitDestructiveGate and its private support ──────────
190
+ // These were dropped (not carried forward) by the HS-IQBF relocation — the
191
+ // engine port at ./git-stash-gate.mjs deliberately reconstructs only the
192
+ // ORCHESTRATION using these primitives (see that file's own header) and does
193
+ // NOT reproduce the original hook's stdout-emitting / audit-writing side
194
+ // effects. scripts/claude-hooks/protected-action-intent.test.mjs still needs
195
+ // the original side-effecting `runGitDestructiveGate` (it asserts on
196
+ // captured stdout writes), so it is restored here verbatim from git history
197
+ // (scripts/claude-hooks/git-stash-gate.mjs pre-cutover) — same behavior, same
198
+ // stdout/audit side effects, no logic changes.
199
+
200
+ function repoRoot() {
201
+ const fromEnv = process.env.CLAUDE_PROJECT_DIR?.trim();
202
+ if (fromEnv) return fromEnv.replace(/[\\/]+$/, '');
203
+ return path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..');
204
+ }
205
+
206
+ /**
207
+ * NAMED PLACEHOLDER for the not-yet-built governing process
208
+ * (protected-shared-actions-gate.md "Future placeholder"). Deliberately
209
+ * inert: always returns null (no routing target), so the ask always goes to
210
+ * the user. Exported so a future patch (and a self-test proving it stays a
211
+ * no-op today) can target it directly.
212
+ */
213
+ export function routeToGoverningProcess() {
214
+ return null; // NOT BUILT — see protected-shared-actions-gate.md. Ask always goes to the user.
215
+ }
216
+
217
+ function auditHistory(repoRootPath, record) {
218
+ try {
219
+ const dir = path.join(repoRootPath, '.heyspex');
220
+ fs.mkdirSync(dir, { recursive: true });
221
+ fs.appendFileSync(
222
+ path.join(dir, 'dev-server-history.jsonl'),
223
+ JSON.stringify({ timestamp: new Date().toISOString(), ...record }) + '\n',
224
+ );
225
+ } catch {
226
+ /* observability must never break the gate */
227
+ }
228
+ }
229
+
230
+ function emitAsk(reason) {
231
+ process.stdout.write(
232
+ JSON.stringify({
233
+ hookSpecificOutput: {
234
+ hookEventName: 'PreToolUse',
235
+ permissionDecision: 'ask',
236
+ permissionDecisionReason: reason,
237
+ },
238
+ }),
239
+ );
240
+ }
241
+
242
+ // HS-H8KI: deny is the one decision stronger than ask — used only to
243
+ // ESCALATE (no valid intent, or an active sub-agent lock), never to soften
244
+ // anything toward allow (specs/memory/decisions/protected-shared-tree-actions-assisted.md).
245
+ function emitDeny(reason) {
246
+ process.stdout.write(
247
+ JSON.stringify({
248
+ hookSpecificOutput: {
249
+ hookEventName: 'PreToolUse',
250
+ permissionDecision: 'deny',
251
+ permissionDecisionReason: reason,
252
+ },
253
+ }),
254
+ );
255
+ }
256
+
257
+ const ASK_REASON =
258
+ 'git stash (any form) is a PROTECTED action on a shared working tree ' +
259
+ '(protected-shared-actions-gate) — it sweeps EVERY uncommitted change in the tree, not just ' +
260
+ 'yours, and has already caused a real incident (git-stash-sweeps-parallel-agent-work: 266 ' +
261
+ 'modified + 173 untracked files swept, then 266 left with conflict markers on pop). This ' +
262
+ 'ALWAYS requires explicit user permission in BOTH Assisted and Autonomous front — there is no ' +
263
+ 'fleet-queue exception (a claim only proves exclusivity over one named resource; stash\'s blast ' +
264
+ 'radius is not scoped to one resource). Approving IS the permission. Prefer a non-destructive ' +
265
+ 'alternative first: `git worktree add ../verify-clean <commit>` to test a clean tree, ' +
266
+ '`git show <commit>:<path>` to read a prior revision, or `git diff` / `git log -p` to compare ' +
267
+ 'without touching the tree.';
268
+
269
+ const VERB_ASK_REASON =
270
+ 'This git command is a PROTECTED action on a shared working tree (protected-shared-actions-gate) — ' +
271
+ 'it can permanently discard uncommitted work or rewrite history other agents may be relying on (the ' +
272
+ 'same class of action that wiped 36 files via an unguarded `git reset --hard` with no gate at all: ' +
273
+ 'never-chain-cd-with-destructive-git-a). This ALWAYS requires explicit user permission in BOTH ' +
274
+ 'Assisted and Autonomous front — approving IS the permission. Prefer a non-destructive alternative ' +
275
+ 'first: `git worktree add ../verify-clean <commit>` to test a clean tree, `git show <commit>:<path>` ' +
276
+ 'to read a prior revision, or `git diff` / `git log -p` to compare without touching the tree.';
277
+
278
+ /**
279
+ * Run the gate for one PreToolUse payload. Returns true when this hook call
280
+ * was fully handled (an ask was emitted, or a read-only/bypass pass was
281
+ * logged) — false when nothing gated/read-only applies at all (not a
282
+ * destructive-git invocation), matching the runHardGate() convention in
283
+ * dev-server-task-reminder.mjs.
284
+ *
285
+ * HS-H8KI adds two checks BEFORE the always-ask floor, each one an
286
+ * escalation from ask to deny (never a softening toward allow — the locked
287
+ * decision protected-shared-tree-actions-assisted.md forbids that):
288
+ * 1. A sub-agent hard lock on this session (scripts/lib/subagent-git-lock.mjs)
289
+ * → deny outright, no intent can override it.
290
+ * 2. No fresh, command-matched, correctly-shaped protected-action intent
291
+ * on disk (scripts/claude-hooks/protected-action-intent.mjs) → deny
292
+ * with instructions naming the file path and required fields.
293
+ * Only once both checks pass does the gate fall through to its prior
294
+ * behavior: emit an ask (now enriched with the intent's expected_breakage
295
+ * and peer list), and — because the intent is single-use — consume it so a
296
+ * later, different gated command can't ride the same written assessment.
297
+ */
298
+ export function runGitDestructiveGate(
299
+ toolName,
300
+ toolInput,
301
+ { repoRootPath = repoRoot(), cwd = process.cwd(), sessionId = null } = {},
302
+ ) {
303
+ const kind = classifyGitDestructive(toolName, toolInput);
304
+ if (!kind) return false;
305
+
306
+ if (kind === 'read-only') {
307
+ auditHistory(repoRootPath, { event_kind: 'git_destructive.read_only_pass', tool: toolName });
308
+ return true; // explicitly exempt — never a silent "not applicable", always logged
309
+ }
310
+
311
+ if (detectWorktreeIsolation(cwd)) {
312
+ auditHistory(repoRootPath, { event_kind: 'git_destructive.worktree_bypass', tool: toolName });
313
+ return true;
314
+ }
315
+
316
+ // HS-H8KI check 1: an active sub-agent hard lock always wins — deny, full
317
+ // stop. No intent file, however fresh or well-shaped, can override it.
318
+ const lock = checkSubagentLock({ root: repoRootPath, sessionId });
319
+ if (lock) {
320
+ auditHistory(repoRootPath, { event_kind: 'git_destructive.lock_deny', tool: toolName, sessionId, lock });
321
+ emitDeny(buildLockDenyMessage(lock));
322
+ return true;
323
+ }
324
+
325
+ // HS-H8KI check 2: no valid, fresh, command-matched intent → deny with
326
+ // exactly what to write and where.
327
+ const command = toolInput?.command;
328
+ const intentResult = readAndValidateIntent({ root: repoRootPath, command });
329
+ if (!intentResult.ok) {
330
+ auditHistory(repoRootPath, {
331
+ event_kind: 'git_destructive.no_intent_deny',
332
+ tool: toolName,
333
+ reason: intentResult.reason,
334
+ });
335
+ emitDeny(buildNoIntentDenyMessage({ root: repoRootPath, reason: intentResult.reason }));
336
+ return true;
337
+ }
338
+
339
+ // Single-use — consume now so a later gated command (even a re-run of this
340
+ // exact one) can't ride the same written assessment.
341
+ consumeIntent(repoRootPath);
342
+
343
+ // Reserved for a future governing process — always null today (see
344
+ // routeToGoverningProcess's doc comment). Recorded in the audit line so a
345
+ // later activation is visible in history, not silently different.
346
+ const routed = routeToGoverningProcess();
347
+
348
+ // Stash keeps its own detailed incident-specific reason text; every other
349
+ // gated verb gets the generic one above. The intent's breakage assessment
350
+ // and peer list are always appended on top (AC2).
351
+ const baseReason = classifyGitStash(toolName, toolInput) === 'gated' ? ASK_REASON : VERB_ASK_REASON;
352
+
353
+ auditHistory(repoRootPath, {
354
+ event_kind: 'git_destructive.ask',
355
+ tool: toolName,
356
+ routedTo: routed,
357
+ claim_spec_id: intentResult.intent.claim_spec_id,
358
+ });
359
+ emitAsk(buildIntentAskMessage({ intent: intentResult.intent, baseReason }));
360
+ return true;
361
+ }
@@ -0,0 +1,159 @@
1
+ // spexharness/src/gates/git-stash-gate.mjs
2
+ //
3
+ // Engine port of scripts/claude-hooks/git-stash-gate.mjs — the PreToolUse
4
+ // guard over every destructive git verb on a SHARED working tree (stash,
5
+ // reset --hard, checkout -- <path>, clean -f, restore, rebase, push --force,
6
+ // branch -D, commit --amend — see the original's own header for the full
7
+ // incident history: HS-IXEY / HS-H8KI, epic HS-GG01-P3TJ). See ./PORTING.md
8
+ // for the general recipe this follows.
9
+ //
10
+ // JUDGMENT CALL — read before touching this file: the original script does
11
+ // NOT export a pure decision function. Its full decision (worktree bypass,
12
+ // sub-agent lock, protected-action-intent check, ask/deny composition) lives
13
+ // inside `runGitDestructiveGate`, which writes its verdict straight to
14
+ // standard output as a side effect of deciding. PORTING.md's step-1 default
15
+ // remedy is to extract a pure function INSIDE the original script — but this
16
+ // porting task's own brief forbids touching anything under
17
+ // scripts/claude-hooks/ in this pass ("shells come later"). So this module
18
+ // reconstructs ONLY the orchestration (the order the checks run in, and
19
+ // which one wins) using every decision PRIMITIVE the original already
20
+ // exports or keeps in a separate pure module: classifyGitDestructive /
21
+ // classifyGitStash / detectWorktreeIsolation (relocated to
22
+ // ./git-stash-gate.decide.mjs, HS-IQBF — see that module's header),
23
+ // checkSubagentLock (scripts/lib/subagent-git-lock.mjs), and
24
+ // readAndValidateIntent / consumeIntent / buildNoIntentDenyMessage /
25
+ // buildIntentAskMessage / buildLockDenyMessage
26
+ // (scripts/claude-hooks/protected-action-intent.mjs). No regex/rule/
27
+ // validation logic is re-derived anywhere in this file — every
28
+ // classification and every deny/ask message body is imported, not
29
+ // rewritten. The one piece of text this module owns that the original does
30
+ // NOT export is the base "why this is protected" sentence — the original
31
+ // keeps it in two private (non-exported) consts, ASK_REASON / VERB_ASK_REASON.
32
+ // This module carries an equivalent-substance summary
33
+ // (STASH_BASE_REASON / VERB_BASE_REASON below) instead of the byte-identical
34
+ // string. That is safe because no golden fixture exercises the ask/rewrite
35
+ // path for this gate (the recorded examples cover only deny-for-no-intent,
36
+ // allow-for-read-only, and not-applicable) — the summary is asserted for
37
+ // substance in this module's own test, never against a golden.
38
+ //
39
+ // Event mapping: PreToolUse (Bash|PowerShell) → before_tool, same as every
40
+ // other gate here.
41
+ // Decision mapping: not-applicable / read-only → allow; a sub-agent lock or
42
+ // a missing/invalid protected-action intent → deny (the original's rule may
43
+ // only ESCALATE toward deny, never soften toward allow — see
44
+ // specs/memory/decisions/protected-shared-tree-actions-assisted.md); a
45
+ // valid, fresh, command-matched intent → the original's `ask` outcome,
46
+ // carried forward as a `rewrite` envelope (`{ ask: true, reason }`) — the
47
+ // same mapping env-vault-guard.mjs's reference port uses for its own `ask`
48
+ // case, since the engine has no synchronous-approval primitive either.
49
+ //
50
+ // Env hatch (unchanged): HEYSPEX_STASH_GATE_OFF=1.
51
+ //
52
+ // This gate deliberately does NOT write the original hook's audit trail
53
+ // (.heyspex/dev-server-history.jsonl) — that writer is private to the
54
+ // original hook, and audit logging is a hook-specific side effect, not part
55
+ // of this module's decision contract. A future front that wants an audit
56
+ // line for the engine's decision can write one at the call site.
57
+ //
58
+ // `ctx` contract (all optional):
59
+ // ctx.env - env var overrides (falls back to process.env).
60
+ // ctx.cwd - the tool call's working directory, used ONLY for the
61
+ // worktree-isolation check (detectWorktreeIsolation).
62
+ // Defaults to process.cwd().
63
+ // ctx.repoRoot - where the protected-action-intent file and sub-agent
64
+ // lock directory live. Defaults to CLAUDE_PROJECT_DIR, else
65
+ // this repo's root — same resolution as the original's
66
+ // own repoRoot().
67
+ // ctx.sessionId - session id for the sub-agent lock lookup. Defaults to
68
+ // null (matches an unresolvable session in the original).
69
+
70
+ import path from 'node:path';
71
+ import { fileURLToPath } from 'node:url';
72
+
73
+ import {
74
+ classifyGitDestructive,
75
+ classifyGitStash,
76
+ detectWorktreeIsolation,
77
+ } from './git-stash-gate.decide.mjs';
78
+ import { checkSubagentLock } from '../../../scripts/lib/subagent-git-lock.mjs';
79
+ import {
80
+ readAndValidateIntent,
81
+ consumeIntent,
82
+ buildNoIntentDenyMessage,
83
+ buildIntentAskMessage,
84
+ buildLockDenyMessage,
85
+ } from '../../../scripts/claude-hooks/protected-action-intent.mjs';
86
+
87
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
88
+ const REPO_ROOT = path.resolve(HERE, '..', '..', '..');
89
+
90
+ // Equivalent-substance summaries of the original's private ASK_REASON /
91
+ // VERB_ASK_REASON consts (see the judgment-call note above) — not asserted
92
+ // against any golden fixture, only against this module's own test.
93
+ const STASH_BASE_REASON =
94
+ "git stash (any form) is a PROTECTED action on a shared working tree (protected-shared-actions-gate) " +
95
+ "— it sweeps every uncommitted change in the tree, not just the caller's own, and has already caused " +
96
+ 'a real incident. This always requires explicit user permission — approving IS the permission.';
97
+ const VERB_BASE_REASON =
98
+ 'This git command is a PROTECTED action on a shared working tree (protected-shared-actions-gate) — it ' +
99
+ 'can permanently discard uncommitted work or rewrite history other agents may be relying on. This ' +
100
+ 'always requires explicit user permission — approving IS the permission.';
101
+
102
+ /** The git-destructive-verbs gate's id — unique across the built-in registry. */
103
+ export const id = 'git-stash-gate';
104
+ /** Runs pre-tool, same as its PreToolUse Claude Code hook registration. */
105
+ export const event = 'before_tool';
106
+ /** Caller-defined severity label; this gate can deny, so it is "block". */
107
+ export const severity = 'block';
108
+
109
+ /**
110
+ * @param {{ point: string, toolName?: string, toolInput?: Record<string, unknown> }} evt
111
+ * @param {{ cwd?: string, repoRoot?: string, sessionId?: string | null,
112
+ * env?: Record<string, string | undefined> }} [ctx]
113
+ * @returns {{ decision: 'allow'|'deny'|'rewrite', reason?: string, rewrite?: object }}
114
+ */
115
+ export function run(evt, ctx = {}) {
116
+ const env = ctx.env ?? process.env;
117
+ if (String(env.HEYSPEX_STASH_GATE_OFF ?? '') === '1') {
118
+ return { decision: 'allow' };
119
+ }
120
+
121
+ const toolName = evt?.toolName;
122
+ const toolInput = evt?.toolInput ?? {};
123
+ const kind = classifyGitDestructive(toolName, toolInput);
124
+ if (!kind || kind === 'read-only') {
125
+ return { decision: 'allow' };
126
+ }
127
+
128
+ const cwd = ctx.cwd ?? process.cwd();
129
+ if (detectWorktreeIsolation(cwd)) {
130
+ return { decision: 'allow' };
131
+ }
132
+
133
+ const root = ctx.repoRoot ?? process.env.CLAUDE_PROJECT_DIR ?? REPO_ROOT;
134
+
135
+ // HS-H8KI check 1: an active sub-agent hard lock always wins — deny, full
136
+ // stop. No intent file, however fresh or well-shaped, can override it.
137
+ const lock = checkSubagentLock({ root, sessionId: ctx.sessionId ?? null });
138
+ if (lock) {
139
+ return { decision: 'deny', reason: buildLockDenyMessage(lock) };
140
+ }
141
+
142
+ // HS-H8KI check 2: no valid, fresh, command-matched intent → deny with
143
+ // exactly what to write and where.
144
+ const command = toolInput?.command;
145
+ const intentResult = readAndValidateIntent({ root, command });
146
+ if (!intentResult.ok) {
147
+ return { decision: 'deny', reason: buildNoIntentDenyMessage({ root, reason: intentResult.reason }) };
148
+ }
149
+
150
+ // Single-use, same as the original — consume now so a later gated command
151
+ // (even a re-run of this exact one) can't ride the same written assessment.
152
+ consumeIntent(root);
153
+
154
+ const baseReason = classifyGitStash(toolName, toolInput) === 'gated' ? STASH_BASE_REASON : VERB_BASE_REASON;
155
+ const reason = buildIntentAskMessage({ intent: intentResult.intent, baseReason });
156
+ return { decision: 'rewrite', reason, rewrite: { ask: true, reason } };
157
+ }
158
+
159
+ export default { id, event, severity, run };