heyspex 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (352) hide show
  1. package/.claude-plugin/marketplace.json +14 -0
  2. package/README.md +21 -0
  3. package/bin/hs.mjs +1089 -104
  4. package/bin/spexcode.mjs +262 -0
  5. package/dev-factory/engine/worktree.mjs +740 -0
  6. package/dev-factory/lib/code-repo-root.mjs +67 -0
  7. package/dev-factory/lib/context-budget.mjs +84 -0
  8. package/dev-factory/lib/epic-detect.mjs +190 -0
  9. package/dev-factory/lib/fleet-queue-file.mjs +846 -0
  10. package/dev-factory/lib/fleet-queue.mjs +426 -0
  11. package/dev-factory/lib/fleet-state.mjs +14 -18
  12. package/dev-factory/lib/model-catalog.mjs +338 -0
  13. package/dev-factory/lib/seat-context.mjs +199 -0
  14. package/dev-factory/lib/spec-io.mjs +93 -0
  15. package/dev-factory/lib/sufficiency-score.mjs +8 -2
  16. package/dev-factory/lib/worktree-allocator.mjs +744 -0
  17. package/dev-factory/model-catalog.json +222 -0
  18. package/dist/cli/hs-check.mjs +1127 -103
  19. package/dist/cli/hs-claim.mjs +981 -63
  20. package/dist/cli/hs-connect.mjs +10059 -1729
  21. package/dist/cli/hs-defer.mjs +5507 -3072
  22. package/dist/cli/hs-dev.mjs +576 -0
  23. package/dist/cli/hs-env.mjs +416 -302
  24. package/dist/cli/hs-index-rebuild.mjs +7334 -0
  25. package/dist/cli/hs-index-reconcile.mjs +8138 -0
  26. package/dist/cli/hs-init.mjs +2840 -149
  27. package/dist/cli/hs-install.mjs +7214 -594
  28. package/dist/cli/hs-join.mjs +10640 -2421
  29. package/dist/cli/hs-vault-pull.mjs +8157 -6370
  30. package/dist/cli/hs-vault-push.mjs +6433 -0
  31. package/dist/cli/hs-vault.mjs +4029 -1555
  32. package/dist/cli/hs-workspace.mjs +2702 -538
  33. package/dist/cli-templates/customer-vault/specs/entity-model.md +594 -601
  34. package/dist/cli-templates/customer-vault/specs/protocols/canonical-frontmatter-templates.md +110 -177
  35. package/dist/cli-templates/customer-vault/specs/protocols/destructive-action-checklist.md +122 -0
  36. package/dist/cli-templates/customer-vault/specs/protocols/memory-protocol.md +13 -15
  37. package/dist/cli-templates/customer-vault/specs/protocols/session-close-protocol.md +91 -0
  38. package/dist/cli-templates/customer-vault/specs/protocols/spec-hierarchy.md +150 -168
  39. package/dist/cli-templates/customer-vault/specs/protocols/spec-summary-authoring.md +83 -0
  40. package/dist/cli-templates/harness-commands/claude/hs-auto.md +36 -0
  41. package/dist/cli-templates/harness-commands/claude/hs-backfill.md +28 -0
  42. package/dist/cli-templates/harness-commands/claude/hs-capture.md +62 -0
  43. package/dist/cli-templates/harness-commands/claude/hs-defer.md +29 -0
  44. package/dist/cli-templates/harness-commands/claude/hs-dev.md +20 -0
  45. package/dist/cli-templates/harness-commands/claude/hs-doctor.md +32 -0
  46. package/dist/cli-templates/harness-commands/claude/hs-ground.md +30 -0
  47. package/dist/cli-templates/harness-commands/claude/hs-handoff.md +33 -0
  48. package/dist/cli-templates/harness-commands/claude/hs-log.md +27 -0
  49. package/dist/cli-templates/harness-commands/claude/hs-next.md +29 -0
  50. package/dist/cli-templates/harness-commands/claude/hs-promote.md +70 -0
  51. package/dist/cli-templates/harness-commands/claude/hs-push.md +35 -0
  52. package/dist/cli-templates/harness-commands/claude/hs-qc.md +27 -0
  53. package/dist/cli-templates/harness-commands/claude/hs-res.md +63 -0
  54. package/dist/cli-templates/harness-commands/claude/hs-resolve.md +23 -0
  55. package/dist/cli-templates/harness-commands/claude/hs-review.md +23 -0
  56. package/dist/cli-templates/harness-commands/claude/hs-view.md +28 -0
  57. package/dist/cli-templates/harness-commands/claude/kill-ports.md +32 -0
  58. package/dist/cli-templates/harness-commands/claude/prototype.md +91 -0
  59. package/dist/cli-templates/harness-commands/cursor/hs-auto.md +40 -0
  60. package/dist/cli-templates/harness-commands/cursor/hs-backfill.md +31 -0
  61. package/dist/cli-templates/harness-commands/cursor/hs-capture.md +65 -0
  62. package/dist/cli-templates/harness-commands/cursor/hs-defer.md +32 -0
  63. package/dist/cli-templates/harness-commands/cursor/hs-dev.md +24 -0
  64. package/dist/cli-templates/harness-commands/cursor/hs-doctor.md +36 -0
  65. package/dist/cli-templates/harness-commands/cursor/hs-ground.md +33 -0
  66. package/dist/cli-templates/harness-commands/cursor/hs-handoff.md +37 -0
  67. package/dist/cli-templates/harness-commands/cursor/hs-log.md +31 -0
  68. package/dist/cli-templates/harness-commands/cursor/hs-next.md +32 -0
  69. package/dist/cli-templates/harness-commands/cursor/hs-promote.md +73 -0
  70. package/dist/cli-templates/harness-commands/cursor/hs-push.md +38 -0
  71. package/dist/cli-templates/harness-commands/cursor/hs-qc.md +30 -0
  72. package/dist/cli-templates/harness-commands/cursor/hs-res.md +67 -0
  73. package/dist/cli-templates/harness-commands/cursor/hs-resolve.md +26 -0
  74. package/dist/cli-templates/harness-commands/cursor/hs-review.md +26 -0
  75. package/dist/cli-templates/harness-commands/cursor/hs-view.md +32 -0
  76. package/dist/cli-templates/harness-commands/cursor/kill-ports.md +35 -0
  77. package/dist/cli-templates/harness-commands/cursor/prototype.md +95 -0
  78. package/docs/copilot-plugin-install.md +162 -0
  79. package/docs/driving-heyspex-from-copilot.md +165 -0
  80. package/docs/mcp-client-config.md +259 -6
  81. package/package.json +249 -124
  82. package/plugins/heyspex/.claude-plugin/plugin.json +12 -0
  83. package/plugins/heyspex/.cursor-plugin/plugin.json +15 -0
  84. package/plugins/heyspex/.mcp.json +12 -0
  85. package/plugins/heyspex/README.md +309 -0
  86. package/plugins/heyspex/bin/launch-mcp.mjs +95 -0
  87. package/plugins/heyspex/bin/run-hook.mjs +80 -0
  88. package/plugins/heyspex/hooks/cursor-hooks.json +308 -0
  89. package/plugins/heyspex/hooks/hooks.json +418 -0
  90. package/plugins/heyspex/mcp.json +13 -0
  91. package/scripts/check-mcp-freshness.mjs +17 -9
  92. package/scripts/claude-hooks/_env-vault-events.mjs +68 -0
  93. package/scripts/claude-hooks/_guardrail-events.mjs +96 -0
  94. package/scripts/claude-hooks/_guardrail-loader.mjs +494 -0
  95. package/scripts/claude-hooks/_intent-classifier-router.mjs +456 -0
  96. package/scripts/claude-hooks/_intent-classifier-semantic.mjs +356 -0
  97. package/scripts/claude-hooks/_run.mjs +413 -0
  98. package/scripts/claude-hooks/_transcript-text.mjs +60 -0
  99. package/scripts/claude-hooks/_vault-retrieval.mjs +322 -0
  100. package/scripts/claude-hooks/agent-spawn-delegation-gate.mjs +133 -0
  101. package/scripts/claude-hooks/capture-execution-gate.mjs +51 -0
  102. package/scripts/claude-hooks/capture-workflow-gate.mjs +11 -0
  103. package/scripts/claude-hooks/check-auto-memory-budget.mjs +396 -0
  104. package/scripts/claude-hooks/check-claude-md-budget.mjs +327 -0
  105. package/scripts/claude-hooks/check-mcp-freshness-hook.mjs +14 -0
  106. package/scripts/claude-hooks/claim-before-code-gate.mjs +11 -0
  107. package/scripts/claude-hooks/claim-liveness-shared.mjs +577 -0
  108. package/scripts/claude-hooks/close-terminal-on-ship.mjs +64 -0
  109. package/scripts/claude-hooks/coo-denylist-gate.mjs +11 -0
  110. package/scripts/claude-hooks/coo-guard-check.mjs +11 -0
  111. package/scripts/claude-hooks/deep-research-cost-gate.mjs +11 -0
  112. package/scripts/claude-hooks/dev-server-task-reminder.mjs +15 -0
  113. package/scripts/claude-hooks/diagnose-capture-reminder.mjs +11 -0
  114. package/scripts/claude-hooks/directive-obligation-gate.mjs +11 -0
  115. package/scripts/claude-hooks/emit-claim-status.mjs +41 -0
  116. package/scripts/claude-hooks/enforce-session-sequence.mjs +11 -0
  117. package/scripts/claude-hooks/enforce-vault-naming.mjs +11 -0
  118. package/scripts/claude-hooks/env-vault-guard.mjs +11 -0
  119. package/scripts/claude-hooks/epic-milestone-phrasing-gate.mjs +11 -0
  120. package/scripts/claude-hooks/git-identity-guard.mjs +11 -0
  121. package/scripts/claude-hooks/git-reflog-audit.mjs +15 -0
  122. package/scripts/claude-hooks/git-stash-gate.mjs +11 -0
  123. package/scripts/claude-hooks/guard-auto-memory-writes.mjs +137 -0
  124. package/scripts/claude-hooks/id-display-format-gate.mjs +11 -0
  125. package/scripts/claude-hooks/inject-communication-style.mjs +86 -0
  126. package/scripts/claude-hooks/inject-coo-seat.mjs +78 -0
  127. package/scripts/claude-hooks/inject-dev-server-awareness.mjs +11 -0
  128. package/scripts/claude-hooks/inject-doctrine.mjs +13 -0
  129. package/scripts/claude-hooks/inject-vault-context.mjs +14 -0
  130. package/scripts/claude-hooks/intent-classifier-trigger.mjs +279 -0
  131. package/scripts/claude-hooks/log-session-activity.mjs +341 -0
  132. package/scripts/claude-hooks/naughty-list-gate.mjs +11 -0
  133. package/scripts/claude-hooks/notify-status-flip.mjs +72 -0
  134. package/scripts/claude-hooks/plan-exit-ingest-gate.mjs +170 -0
  135. package/scripts/claude-hooks/plan-ingest-close-reminder.mjs +66 -0
  136. package/scripts/claude-hooks/plan-review-gate.mjs +11 -0
  137. package/scripts/claude-hooks/preflight-reminder.mjs +262 -0
  138. package/scripts/claude-hooks/protected-action-intent.mjs +221 -0
  139. package/scripts/claude-hooks/research-method-guard.mjs +398 -0
  140. package/scripts/claude-hooks/research-protocol-gate.mjs +11 -0
  141. package/scripts/claude-hooks/session-end.mjs +451 -0
  142. package/scripts/claude-hooks/settings-write-gate.mjs +11 -0
  143. package/scripts/claude-hooks/settlement-check-gate.mjs +39 -0
  144. package/scripts/claude-hooks/status-flip-detect.mjs +17 -0
  145. package/scripts/claude-hooks/terminal-close-shared.mjs +426 -0
  146. package/scripts/claude-hooks/terminal-close-watcher.mjs +122 -0
  147. package/scripts/claude-hooks/vault-mirror-catchup.mjs +11 -0
  148. package/scripts/claude-hooks/vault-write-tier-guard.mjs +11 -0
  149. package/scripts/claude-hooks/wip-checkpoint.mjs +302 -0
  150. package/scripts/cursor-hooks/manifest.mjs +281 -0
  151. package/scripts/dev-server-status.mjs +34 -13
  152. package/scripts/dev-server-stop.mjs +313 -0
  153. package/scripts/heyspex-mode.mjs +288 -0
  154. package/scripts/hs-auto-resolve.mjs +240 -0
  155. package/scripts/hs-auto-source.mjs +398 -0
  156. package/scripts/hs-auto.mjs +134 -0
  157. package/scripts/hs-capture-cli.mjs +326 -0
  158. package/scripts/hs-classifier.mjs +136 -0
  159. package/scripts/hs-companion.mjs +88 -0
  160. package/scripts/hs-gateway.mjs +115 -0
  161. package/scripts/hs-mode.mjs +125 -0
  162. package/scripts/hs-next.mjs +441 -0
  163. package/scripts/hs-settings.mjs +338 -0
  164. package/scripts/hs-view.mjs +496 -0
  165. package/scripts/hs-worktree.mjs +201 -0
  166. package/scripts/install-claude-code-hooks.mjs +272 -0
  167. package/scripts/install-cursor-hooks.mjs +471 -0
  168. package/scripts/lib/agent-env-markers.mjs +38 -0
  169. package/scripts/lib/console-url-resolver.mjs +199 -61
  170. package/scripts/lib/customer-kit.mjs +1203 -0
  171. package/scripts/lib/dev-mode.mjs +249 -32
  172. package/scripts/lib/directive-obligations.mjs +349 -0
  173. package/scripts/lib/heyspex-home.mjs +72 -0
  174. package/scripts/lib/hook-dispositions.mjs +210 -0
  175. package/scripts/lib/hook-vault-header.mjs +30 -0
  176. package/scripts/lib/hs-command.mjs +277 -0
  177. package/scripts/lib/hs-companion-settings.mjs +472 -0
  178. package/scripts/lib/hs-source-branch.mjs +196 -0
  179. package/scripts/lib/hs-source-commit.mjs +59 -5
  180. package/scripts/lib/hs-source-identity.mjs +526 -0
  181. package/scripts/lib/hs-source-log.mjs +13 -22
  182. package/scripts/lib/hs-source-preflight.mjs +18 -4
  183. package/scripts/lib/hs-source-push.mjs +386 -307
  184. package/scripts/lib/hs-worktree-args.mjs +115 -0
  185. package/scripts/lib/shared-path-helpers.mjs +134 -0
  186. package/scripts/lib/standalone-doctor.mjs +540 -3
  187. package/scripts/lib/subagent-git-lock.mjs +109 -0
  188. package/scripts/lib/template-pin.mjs +34 -0
  189. package/scripts/lib/window-placement.mjs +109 -0
  190. package/scripts/provenance-lib.mjs +91 -0
  191. package/scripts/stamp-provenance-on-edit.mjs +222 -0
  192. package/scripts/wip-ledger-health.mjs +653 -0
  193. package/spexharness/doctrine/baseline/SPEXCODE-DOCTRINE.baseline.json +29 -0
  194. package/spexharness/src/adapters/audit.mjs +197 -0
  195. package/spexharness/src/adapters/claude-code.mjs +449 -0
  196. package/spexharness/src/adapters/copilot.mjs +236 -0
  197. package/spexharness/src/adapters/detect-harness.mjs +34 -0
  198. package/spexharness/src/chat/tui/ansi.mjs +448 -0
  199. package/spexharness/src/chat/tui/palette.mjs +22 -0
  200. package/spexharness/src/chat/tui/select.mjs +208 -0
  201. package/spexharness/src/config-root.mjs +54 -0
  202. package/spexharness/src/context-budget.mjs +141 -0
  203. package/spexharness/src/dev-server-control.mjs +389 -0
  204. package/spexharness/src/doctrine/behaviour.mjs +371 -0
  205. package/spexharness/src/doctrine/corpus.mjs +733 -0
  206. package/spexharness/src/doctrine/inject.mjs +338 -0
  207. package/spexharness/src/doctrine/merge.mjs +184 -0
  208. package/spexharness/src/doctrine/sources.mjs +252 -0
  209. package/spexharness/src/doctrine/synthesize.mjs +401 -0
  210. package/spexharness/src/doctrine/vault-source.mjs +574 -0
  211. package/spexharness/src/doctrine/writer.mjs +176 -0
  212. package/spexharness/src/events.mjs +84 -0
  213. package/spexharness/src/gates/_shell-write-targets.mjs +77 -0
  214. package/spexharness/src/gates/capture-workflow-gate.mjs +202 -0
  215. package/spexharness/src/gates/claim-before-code-gate.decide.mjs +33 -0
  216. package/spexharness/src/gates/claim-before-code-gate.mjs +129 -0
  217. package/spexharness/src/gates/deep-research-cost-gate.mjs +275 -0
  218. package/spexharness/src/gates/diagnose-capture-reminder.mjs +112 -0
  219. package/spexharness/src/gates/directive-obligation-gate.decide.mjs +132 -0
  220. package/spexharness/src/gates/directive-obligation-gate.mjs +183 -0
  221. package/spexharness/src/gates/enforce-session-sequence.mjs +895 -0
  222. package/spexharness/src/gates/enforce-vault-naming.mjs +215 -0
  223. package/spexharness/src/gates/epic-milestone-phrasing-gate.mjs +233 -0
  224. package/spexharness/src/gates/git-identity-guard.decide.mjs +436 -0
  225. package/spexharness/src/gates/git-identity-guard.mjs +61 -0
  226. package/spexharness/src/gates/git-reflog-audit.mjs +151 -0
  227. package/spexharness/src/gates/git-stash-gate.decide.mjs +361 -0
  228. package/spexharness/src/gates/git-stash-gate.mjs +159 -0
  229. package/spexharness/src/gates/id-display-format-gate.mjs +190 -0
  230. package/spexharness/src/gates/inject-doctrine-gate.mjs +89 -0
  231. package/spexharness/src/gates/inject-vault-context.mjs +239 -0
  232. package/spexharness/src/gates/naughty-list-gate.mjs +213 -0
  233. package/spexharness/src/gates/plan-review-gate.mjs +233 -0
  234. package/spexharness/src/gates/preflight-reminder-gate.mjs +265 -0
  235. package/spexharness/src/gates/research-protocol-gate.mjs +175 -0
  236. package/spexharness/src/gates/settings-write-gate.decide.mjs +96 -0
  237. package/spexharness/src/gates/settings-write-gate.mjs +104 -0
  238. package/spexharness/src/gates/vault-mirror-catchup.mjs +124 -0
  239. package/spexharness/src/gates/vault-write-tier-guard.decide.mjs +125 -0
  240. package/spexharness/src/gates/vault-write-tier-guard.mjs +42 -0
  241. package/spexharness/src/git-reflog-audit.mjs +294 -0
  242. package/spexharness/src/intent-classifier.mjs +196 -0
  243. package/spexharness/src/intent-directive.mjs +626 -0
  244. package/spexharness/src/profile/learn.mjs +551 -0
  245. package/spexharness/src/profile/proposals.mjs +357 -0
  246. package/spexharness/src/profile/vault-inclusion.mjs +386 -0
  247. package/spexharness/src/redact.mjs +151 -0
  248. package/spexharness/src/rule-table.mjs +849 -0
  249. package/spexharness/src/seat-context.mjs +122 -0
  250. package/spexharness/src/session-bootstrap.mjs +512 -0
  251. package/spexharness/src/session-record.mjs +815 -0
  252. package/spexharness/src/token-inventory.mjs +207 -0
  253. package/vendor/heyspex-mcp/account-switch.mjs +96 -0
  254. package/vendor/heyspex-mcp/alignment-gate.mjs +315 -1
  255. package/vendor/heyspex-mcp/blocker-resolver.mjs +37 -2
  256. package/vendor/heyspex-mcp/breakdown-ladder.mjs +119 -4
  257. package/vendor/heyspex-mcp/breakdown-source.mjs +271 -0
  258. package/vendor/heyspex-mcp/breakdown-writer.mjs +23 -3
  259. package/vendor/heyspex-mcp/capture-buffer-patches.mjs +77 -8
  260. package/vendor/heyspex-mcp/capture-channel.mjs +55 -5
  261. package/vendor/heyspex-mcp/capture-console-client.mjs +117 -3
  262. package/vendor/heyspex-mcp/capture-focus.mjs +1061 -31
  263. package/vendor/heyspex-mcp/capture-outbox.mjs +15 -5
  264. package/vendor/heyspex-mcp/capture-sources.mjs +195 -0
  265. package/vendor/heyspex-mcp/capture-tools.mjs +1264 -0
  266. package/vendor/heyspex-mcp/claim-tools.mjs +1147 -0
  267. package/vendor/heyspex-mcp/cli-verbs.mjs +2074 -0
  268. package/vendor/heyspex-mcp/cli.mjs +207 -15524
  269. package/vendor/heyspex-mcp/close-branch-report.mjs +102 -0
  270. package/vendor/heyspex-mcp/cloud-tool-surface.mjs +128 -0
  271. package/vendor/heyspex-mcp/cloud-vault-writes.mjs +602 -0
  272. package/vendor/heyspex-mcp/config-seed.yaml +15 -0
  273. package/vendor/heyspex-mcp/config-settings.mjs +290 -7
  274. package/vendor/heyspex-mcp/console-fetch.mjs +822 -17
  275. package/vendor/heyspex-mcp/console-transport.mjs +172 -0
  276. package/vendor/heyspex-mcp/credentials.mjs +585 -0
  277. package/vendor/heyspex-mcp/defer-spec.mjs +44 -4
  278. package/vendor/heyspex-mcp/deferred-reclaim.mjs +29 -3
  279. package/vendor/heyspex-mcp/design-handlers.mjs +31 -0
  280. package/vendor/heyspex-mcp/design-tools.mjs +4 -1
  281. package/vendor/heyspex-mcp/dev-factory-bridge.mjs +4 -4
  282. package/vendor/heyspex-mcp/dev-mode-core.mjs +45 -0
  283. package/vendor/heyspex-mcp/dev-tools.mjs +131 -0
  284. package/vendor/heyspex-mcp/diagnose-handlers.mjs +54 -0
  285. package/vendor/heyspex-mcp/events-tools.mjs +207 -0
  286. package/vendor/heyspex-mcp/filing-tools.mjs +723 -0
  287. package/vendor/heyspex-mcp/fleet-handlers.mjs +37 -0
  288. package/vendor/heyspex-mcp/fleet-queue-tools.mjs +71 -15
  289. package/vendor/heyspex-mcp/focus-tools.mjs +190 -0
  290. package/vendor/heyspex-mcp/folder-trust-store.mjs +60 -0
  291. package/vendor/heyspex-mcp/http-transport.mjs +302 -0
  292. package/vendor/heyspex-mcp/list-tools.mjs +257 -0
  293. package/vendor/heyspex-mcp/login-handoff.mjs +15 -1
  294. package/vendor/heyspex-mcp/mcp-config.mjs +107 -0
  295. package/vendor/heyspex-mcp/memory-tools.mjs +523 -0
  296. package/vendor/heyspex-mcp/mvp-mode.mjs +53 -6
  297. package/vendor/heyspex-mcp/obligation-backstop.mjs +103 -0
  298. package/vendor/heyspex-mcp/occupied-keys.mjs +142 -0
  299. package/vendor/heyspex-mcp/on-deck-brief.mjs +3377 -0
  300. package/vendor/heyspex-mcp/on-deck-scheduler.mjs +1708 -0
  301. package/vendor/heyspex-mcp/package-root.mjs +32 -0
  302. package/vendor/heyspex-mcp/peer-presence.mjs +8 -1
  303. package/vendor/heyspex-mcp/plan-ground.mjs +621 -652
  304. package/vendor/heyspex-mcp/plan-push.mjs +908 -22
  305. package/vendor/heyspex-mcp/plan-review.mjs +124 -30
  306. package/vendor/heyspex-mcp/preflight-gate.mjs +0 -0
  307. package/vendor/heyspex-mcp/procedure-handlers.mjs +327 -0
  308. package/vendor/heyspex-mcp/procedure-tools.mjs +1181 -970
  309. package/vendor/heyspex-mcp/promote-capture.mjs +388 -69
  310. package/vendor/heyspex-mcp/promote-diagnose.mjs +74 -0
  311. package/vendor/heyspex-mcp/promote-research-to-diagnose.mjs +9 -2
  312. package/vendor/heyspex-mcp/promote-research.mjs +666 -53
  313. package/vendor/heyspex-mcp/promotion-helpers.mjs +251 -0
  314. package/vendor/heyspex-mcp/prompts.mjs +1460 -0
  315. package/vendor/heyspex-mcp/read-path.mjs +191 -15
  316. package/vendor/heyspex-mcp/receipt-shape.mjs +146 -0
  317. package/vendor/heyspex-mcp/recipe-handlers.mjs +117 -0
  318. package/vendor/heyspex-mcp/regen-tasks.mjs +210 -3
  319. package/vendor/heyspex-mcp/register-spec.mjs +266 -54
  320. package/vendor/heyspex-mcp/request-context.mjs +83 -0
  321. package/vendor/heyspex-mcp/research-focus.mjs +207 -4
  322. package/vendor/heyspex-mcp/research-handlers.mjs +625 -0
  323. package/vendor/heyspex-mcp/research-tools.mjs +4 -4
  324. package/vendor/heyspex-mcp/response-budget.mjs +348 -0
  325. package/vendor/heyspex-mcp/review-focus.mjs +25 -1
  326. package/vendor/heyspex-mcp/roadmap-default.mjs +70 -0
  327. package/vendor/heyspex-mcp/search-specs.mjs +280 -0
  328. package/vendor/heyspex-mcp/seat-block-ledger.mjs +113 -0
  329. package/vendor/heyspex-mcp/seat-identity.mjs +2 -6
  330. package/vendor/heyspex-mcp/session-owner.mjs +109 -0
  331. package/vendor/heyspex-mcp/session-tools.mjs +162 -0
  332. package/vendor/heyspex-mcp/settings-registry.mjs +232 -0
  333. package/vendor/heyspex-mcp/settings-tools.mjs +85 -12
  334. package/vendor/heyspex-mcp/spec-edit-tools.mjs +453 -0
  335. package/vendor/heyspex-mcp/spec-id-lib.mjs +93 -17
  336. package/vendor/heyspex-mcp/split-research.mjs +304 -56
  337. package/vendor/heyspex-mcp/stdio-rpc.mjs +22 -0
  338. package/vendor/heyspex-mcp/submit-for-review.mjs +150 -7
  339. package/vendor/heyspex-mcp/summary-contract.mjs +105 -0
  340. package/vendor/heyspex-mcp/summary-write-tools.mjs +228 -0
  341. package/vendor/heyspex-mcp/symptom-query.mjs +146 -0
  342. package/vendor/heyspex-mcp/test-helpers.mjs +123 -1
  343. package/vendor/heyspex-mcp/tool-catalog.mjs +2819 -0
  344. package/vendor/heyspex-mcp/tool-dispatch.mjs +657 -0
  345. package/vendor/heyspex-mcp/tool-helpers.mjs +325 -0
  346. package/vendor/heyspex-mcp/validate-linkage.mjs +414 -27
  347. package/vendor/heyspex-mcp/vault-io.mjs +720 -0
  348. package/vendor/heyspex-mcp/vault-less-endpoints.mjs +52 -0
  349. package/vendor/heyspex-mcp/vault-resolve.mjs +554 -68
  350. package/vendor/heyspex-mcp/verify-reproduction.mjs +270 -30
  351. package/vendor/heyspex-mcp/whoami.mjs +92 -0
  352. package/vendor/heyspex-mcp/write-outbox.mjs +512 -0
@@ -0,0 +1,577 @@
1
+ // Shared claim-liveness helpers for Claude Code + Cursor CLI hooks.
2
+ // Resolves session id from host env / hook payload and PATCHes the tier-dispatched
3
+ // heartbeat API — never reads `.heyspex/active-claims.json` ([[HS-TASK-A3FE]]).
4
+
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import http from 'node:http';
8
+ import https from 'node:https';
9
+ import { fileURLToPath } from 'node:url';
10
+
11
+ import { resolveConsoleUrl as resolveConsoleUrlShared } from '../lib/console-url-resolver.mjs';
12
+ // Bearer for the cloud-gated heartbeat PATCH — same reader every other consumer uses, never
13
+ // re-implemented and never printed.
14
+ import { readCredentialsMcpToken } from '../lib/dev-mode.mjs';
15
+ import { hookVaultHeaders } from '../lib/hook-vault-header.mjs';
16
+ // Read-only reuse of the resource governor's OWN liveness rule + config resolver — never a
17
+ // second, hand-invented rule at this layer. See the "Lease liveness" section below.
18
+ import { describeLivenessRule, resolveGovernorConfig } from '../../dev-factory/lib/fleet-queue-file.mjs';
19
+
20
+ // The console's heartbeat PATCH is a WAN round-trip to Neon (measured 420–1400ms
21
+ // on a home connection). The old 800ms budget sat *inside* that spread, so a
22
+ // large fraction of heartbeats were killed mid-flight by `req.destroy()`, which
23
+ // RSTs the socket and surfaces on the dev server as
24
+ // `⨯ uncaughtException: [Error: aborted] { code: 'ECONNRESET' }`. Because the
25
+ // gating below cuts request volume by ~10x, we can now afford a budget that sits
26
+ // *above* the real latency spread, so a heartbeat completes instead of resetting.
27
+ // Still well under the 5s hook timeout in `.claude/settings.json`.
28
+ export const REQUEST_TIMEOUT_MS = 2500;
29
+ export const PENDING_EXCERPT_MAX = 140;
30
+ export const ERROR_MESSAGE_MAX = 280;
31
+
32
+ // PreToolUse is registered with matcher `.*`, so without gating every single tool
33
+ // call fires a blocking PATCH. Claims go stale after 10 minutes
34
+ // (`staleTimeoutMs()`), so re-sending an *unchanged* status more than once per
35
+ // 20s buys nothing. Status *transitions* (working→pending→blocked) always send
36
+ // immediately — this only collapses repeats.
37
+ export const HEARTBEAT_THROTTLE_MS = 20_000;
38
+ // A session that holds no claim 404s on every tool call, and each 404 still costs
39
+ // a full Neon round-trip server-side. Remember the negative answer and stop
40
+ // asking. Safe to make this long because acquiring a claim invalidates it
41
+ // immediately (see `CLAIM_ACQUIRE_TOOL_RE`) and acquire already stamps
42
+ // `heartbeat_at` server-side — so a suppressed heartbeat cannot make a live
43
+ // claim look stale (staleness is a 10-minute window).
44
+ export const NO_CLAIM_TTL_MS = 180_000;
45
+ // Console unreachable / timed out: back off briefly so we don't burn the full
46
+ // request budget on every tool call while the dev server is down or restarting.
47
+ export const UNREACHABLE_BACKOFF_MS = 15_000;
48
+ const CACHE_MAX_SESSIONS = 32;
49
+
50
+ // ── Lease liveness (HS-JM67, [[HS-AU7I-2U3R-EPIC-resource-governor-leases-and-wait]]) ───────────
51
+ //
52
+ // This hook is the client for the governor's `spec:HS-XXXX` resource kind (see the epic's
53
+ // resource-key table): each session's cached entry below IS a holder record, so it must obey
54
+ // the SAME liveness rule the governor states for every other resource kind — never a second,
55
+ // hand-invented one. THE RULE is owned by dev-factory/lib/fleet-queue-file.mjs's
56
+ // createResourceGovernor (isDead()) and imported here via describeLivenessRule(), not restated
57
+ // by hand, so the two layers cannot drift apart:
58
+ //
59
+ // a holder is DEAD (its lease reclaimable) iff it is NOT paused AND
60
+ // now - lastLivenessAt > leaseTtlMs. A PAUSED holder is NEVER reclaimed by liveness — only a
61
+ // resume (a tool call settling) or an administrator's forceBreak ends a pause.
62
+ //
63
+ // THE GAP THIS CLOSES (named in the task's Scope): PreToolUse fires once, then this hook goes
64
+ // silent for the tool call's ENTIRE duration — a long Bash/build/model call can easily outlast
65
+ // leaseTtlMs, and until now nothing distinguished that silence from a crashed session. Fix:
66
+ // PreToolUse marks the session PAUSED (with a fresh liveness stamp — starting a call is itself
67
+ // proof of life); PostToolUse/Stop/SubagentStop resume it (also a fresh liveness stamp, per the
68
+ // governor's gov-resumed semantics). A session that pauses and then truly crashes (no further
69
+ // hook calls at all) stays paused=true forever and is correctly exempt from reclaim under the
70
+ // SAME rule the governor applies elsewhere — this module never invents a "paused too long"
71
+ // second rule.
72
+ //
73
+ // CONFIGURATION, NOT A BARE CONSTANT ([[dev-factory-rules-are-configuration-not]]): leaseTtlMs
74
+ // below is resolveGovernorConfig()'s own resolution — explicit override >
75
+ // HEYSPEX_GOVERNOR_LEASE_TTL_MS env > the governor's GOVERNOR_DEFAULTS — the exact same
76
+ // resolution the governor itself uses, so an owner can retune both layers with one env var.
77
+
78
+ /** Same config source as the governor: explicit override > HEYSPEX_GOVERNOR_* env > defaults. */
79
+ export function resolveLivenessConfig(overrides = {}) {
80
+ return resolveGovernorConfig(overrides);
81
+ }
82
+
83
+ /** The governor's own rule text, unchanged — never hand-restated at this layer. */
84
+ export function describeHookLivenessRule(config = resolveLivenessConfig()) {
85
+ return describeLivenessRule(config);
86
+ }
87
+
88
+ /**
89
+ * THE liveness predicate, applied to one cached session entry. Mirrors
90
+ * createResourceGovernor()'s internal `isDead()` exactly: a paused holder is NEVER dead; an
91
+ * unpaused holder is dead once its last liveness stamp is older than `leaseTtlMs`. An entry
92
+ * with no liveness stamp at all reads as "unknown", not dead — same fail-open posture as every
93
+ * other read in this module (a session this hook has never heard a liveness signal from yet is
94
+ * not evidence of death).
95
+ */
96
+ export function isHolderDead(entry, nowMs = Date.now(), config = resolveLivenessConfig()) {
97
+ if (!entry) return false;
98
+ if (entry.paused) return false;
99
+ const lastLivenessAt = entry.lastLivenessAt ?? entry.sentAt ?? entry.touchedAt;
100
+ if (typeof lastLivenessAt !== 'number' || !Number.isFinite(lastLivenessAt)) return false;
101
+ return nowMs - lastLivenessAt > config.leaseTtlMs;
102
+ }
103
+
104
+ /**
105
+ * PreToolUse: a tool call is starting whose duration is unknown and may outlast leaseTtlMs —
106
+ * mark the session PAUSED so it is never reclaimed mid-call, per the governor's rule. Starting
107
+ * the call is itself a fresh liveness signal, so lastLivenessAt is stamped too.
108
+ */
109
+ export function markToolInFlight(cache, sessionId, nowMs) {
110
+ const entry = { ...(cache[sessionId] ?? {}), paused: true, lastLivenessAt: nowMs, touchedAt: nowMs };
111
+ cache[sessionId] = entry;
112
+ return entry;
113
+ }
114
+
115
+ /**
116
+ * PostToolUse / Stop / SubagentStop: no tool is in flight any more — resume, which (per the
117
+ * governor's gov-resumed handling) is itself a fresh liveness signal.
118
+ */
119
+ export function markToolSettled(cache, sessionId, nowMs) {
120
+ const entry = { ...(cache[sessionId] ?? {}), paused: false, lastLivenessAt: nowMs, touchedAt: nowMs };
121
+ cache[sessionId] = entry;
122
+ return entry;
123
+ }
124
+
125
+ /**
126
+ * Enforce the rule across every cached session — the cache file
127
+ * (`.heyspex/claim-liveness-cache.json`) is shared by every session/process on this machine, so
128
+ * this mirrors the governor's reclaimAndPromote() running at the top of every mutating call: ANY
129
+ * hook invocation from ANY still-live session sweeps every OTHER session's dead entries too. A
130
+ * live-but-paused holder's entry is left completely untouched. Returns the list of reclaimed
131
+ * session ids (empty when nothing was dead).
132
+ */
133
+ export function reclaimDeadSessions(cache, nowMs = Date.now(), config = resolveLivenessConfig()) {
134
+ const reclaimed = [];
135
+ for (const sessionId of Object.keys(cache)) {
136
+ if (isHolderDead(cache[sessionId], nowMs, config)) {
137
+ delete cache[sessionId];
138
+ reclaimed.push(sessionId);
139
+ }
140
+ }
141
+ return reclaimed;
142
+ }
143
+
144
+ export function repoRoot(fromUrl = import.meta.url) {
145
+ // claim-liveness-shared.mjs lives in scripts/claude-hooks/, so up two levels.
146
+ return path.resolve(path.dirname(fileURLToPath(fromUrl)), '..', '..');
147
+ }
148
+
149
+ // Delegates to the ONE shared resolver (scripts/lib/console-url-resolver.mjs,
150
+ // [[HS-GD45-MCPC-TASK]]) — both MCP claims and this hook's PATCHes must hit
151
+ // the same console, so they share one precedence implementation instead of
152
+ // two hand-duplicated copies.
153
+ export function resolveConsoleUrl(root = repoRoot()) {
154
+ return resolveConsoleUrlShared({ repoRoot: root });
155
+ }
156
+
157
+ export async function readStdin() {
158
+ if (process.stdin.isTTY) return null;
159
+ return new Promise((resolve) => {
160
+ let buf = '';
161
+ let resolved = false;
162
+ const finish = (value) => {
163
+ if (resolved) return;
164
+ resolved = true;
165
+ resolve(value);
166
+ };
167
+ const timer = setTimeout(() => finish(buf || null), 200);
168
+ process.stdin.setEncoding('utf8');
169
+ process.stdin.on('data', (chunk) => {
170
+ buf += chunk;
171
+ });
172
+ process.stdin.on('end', () => {
173
+ clearTimeout(timer);
174
+ finish(buf || null);
175
+ });
176
+ process.stdin.on('error', () => {
177
+ clearTimeout(timer);
178
+ finish(buf || null);
179
+ });
180
+ });
181
+ }
182
+
183
+ export function parseArgs(argv) {
184
+ const out = {};
185
+ for (const arg of argv.slice(2)) {
186
+ const m = /^--([^=]+)=(.*)$/.exec(arg);
187
+ if (m) out[m[1]] = m[2];
188
+ }
189
+ return out;
190
+ }
191
+
192
+ /** Normalize Claude (`PreToolUse`) and Cursor (`preToolUse`) event names. */
193
+ export function normalizeHookEvent(event) {
194
+ if (!event || typeof event !== 'string') return null;
195
+ const key = event.trim();
196
+ const map = {
197
+ PreToolUse: 'PreToolUse',
198
+ preToolUse: 'PreToolUse',
199
+ PostToolUse: 'PostToolUse',
200
+ postToolUse: 'PostToolUse',
201
+ Stop: 'Stop',
202
+ stop: 'Stop',
203
+ SubagentStop: 'SubagentStop',
204
+ subagentStop: 'SubagentStop',
205
+ };
206
+ return map[key] ?? key;
207
+ }
208
+
209
+ /**
210
+ * Resolve the agent session id from hook stdin payload + host env.
211
+ * Claude: `session_id` / CLAUDE_CODE_SESSION_ID. Cursor: `conversation_id` /
212
+ * CURSOR_TRACE_ID (same id MCP `heyspex_claim_spec` writes on acquire).
213
+ */
214
+ export function resolveSessionId(payload) {
215
+ const fromPayload =
216
+ (typeof payload?.session_id === 'string' && payload.session_id.trim()) ||
217
+ (typeof payload?.conversation_id === 'string' && payload.conversation_id.trim()) ||
218
+ '';
219
+ if (fromPayload) return fromPayload;
220
+
221
+ return (
222
+ process.env.CLAUDE_CODE_SESSION_ID?.trim() ||
223
+ process.env.CLAUDE_SESSION_ID?.trim() ||
224
+ process.env.CURSOR_TRACE_ID?.trim() ||
225
+ process.env.CURSOR_SESSION_ID?.trim() ||
226
+ process.env.COMPOSER_SESSION_ID?.trim() ||
227
+ process.env.HEYSPEX_SESSION_ID?.trim() ||
228
+ ''
229
+ );
230
+ }
231
+
232
+ // Claim lifecycle tools observed through the hook payload's `tool_name`. Seeing
233
+ // one is authoritative news about whether this session holds a claim, so it
234
+ // resets the negative cache rather than waiting out NO_CLAIM_TTL_MS.
235
+ const CLAIM_ACQUIRE_TOOL_RE = /heyspex_(claim_spec|claim_acquire|claim_file)$/;
236
+ const CLAIM_RELEASE_TOOL_RE = /heyspex_(release_spec|claim_release|defer_spec)$/;
237
+
238
+ /**
239
+ * Classify a hook payload's tool name as a claim lifecycle event.
240
+ * Returns `"acquire"`, `"release"`, or `null`.
241
+ */
242
+ export function claimLifecycleEvent(toolName) {
243
+ if (typeof toolName !== 'string' || !toolName) return null;
244
+ if (CLAIM_ACQUIRE_TOOL_RE.test(toolName)) return 'acquire';
245
+ if (CLAIM_RELEASE_TOOL_RE.test(toolName)) return 'release';
246
+ return null;
247
+ }
248
+
249
+ function truncate(value, limit) {
250
+ if (typeof value !== 'string') return undefined;
251
+ const trimmed = value.trim();
252
+ if (!trimmed) return undefined;
253
+ return trimmed.length > limit ? trimmed.slice(0, limit) : trimmed;
254
+ }
255
+
256
+ // Build the PATCH body from the event + payload. Returns null when no update
257
+ // should be emitted (e.g. a successful PostToolUse — we don't downgrade from
258
+ // pending to working on every tool result; PreToolUse already covers that).
259
+ export function buildPatch(event, payload) {
260
+ if (event === 'PreToolUse') {
261
+ const toolName = payload?.tool_name;
262
+ if (toolName === 'AskUserQuestion') {
263
+ const questions = payload?.tool_input?.questions;
264
+ const first = Array.isArray(questions) && questions.length > 0 ? questions[0] : null;
265
+ const excerpt = truncate(first?.question, PENDING_EXCERPT_MAX);
266
+ return {
267
+ status: 'pending',
268
+ pendingReason: 'question',
269
+ ...(excerpt ? { pendingExcerpt: excerpt } : {}),
270
+ };
271
+ }
272
+ return { status: 'working' };
273
+ }
274
+
275
+ if (event === 'Stop' || event === 'SubagentStop') {
276
+ return { status: 'pending', pendingReason: 'response' };
277
+ }
278
+
279
+ if (event === 'PostToolUse') {
280
+ const response = payload?.tool_response;
281
+ let errorText;
282
+ if (response && typeof response === 'object') {
283
+ if (response.is_error === true) {
284
+ errorText =
285
+ truncate(response.error, ERROR_MESSAGE_MAX) ??
286
+ truncate(response.message, ERROR_MESSAGE_MAX) ??
287
+ truncate(response.content, ERROR_MESSAGE_MAX) ??
288
+ 'Tool returned is_error=true';
289
+ } else if (typeof response.error === 'string' && response.error.trim()) {
290
+ errorText = truncate(response.error, ERROR_MESSAGE_MAX);
291
+ }
292
+ }
293
+ if (errorText) {
294
+ return { status: 'blocked', errorMessage: errorText };
295
+ }
296
+ return null;
297
+ }
298
+
299
+ return null;
300
+ }
301
+
302
+ /**
303
+ * PATCH the heartbeat endpoint. Resolves to `{ status }` where `status` is the
304
+ * HTTP status code, or `null` when the request never got an answer (bad URL,
305
+ * connection refused, timeout).
306
+ *
307
+ * `Connection: close` is deliberate: the hook is a one-shot process, so keeping
308
+ * the socket alive only means the socket dies with the process — which the
309
+ * server sees as a reset. Asking for a clean close lets the server tear the
310
+ * connection down on its own terms.
311
+ *
312
+ * `token` (from `readCredentialsMcpToken()`) is sent as `Authorization: Bearer
313
+ * <token>` when present — the endpoint is bearer-gated server-side, so a hook
314
+ * running against the cloud console with no resolvable token 401s every time.
315
+ */
316
+ export function patchHeartbeat(consoleUrl, sessionId, body, token) {
317
+ return new Promise((resolve) => {
318
+ let url;
319
+ try {
320
+ url = new URL('/api/control/active-claims/heartbeat', consoleUrl);
321
+ } catch {
322
+ resolve(null);
323
+ return;
324
+ }
325
+ const transport = url.protocol === 'https:' ? https : http;
326
+ const payload = JSON.stringify({ session_id: sessionId, ...body });
327
+ let settled = false;
328
+ const finish = (value) => {
329
+ if (settled) return;
330
+ settled = true;
331
+ resolve(value);
332
+ };
333
+ const req = transport.request(
334
+ {
335
+ method: 'PATCH',
336
+ hostname: url.hostname,
337
+ port: url.port || (url.protocol === 'https:' ? 443 : 80),
338
+ path: `${url.pathname}${url.search}`,
339
+ headers: {
340
+ 'content-type': 'application/json',
341
+ 'content-length': Buffer.byteLength(payload),
342
+ connection: 'close',
343
+ ...(token ? { authorization: `Bearer ${token}` } : {}),
344
+ ...hookVaultHeaders(),
345
+ },
346
+ timeout: REQUEST_TIMEOUT_MS,
347
+ },
348
+ (res) => {
349
+ const status = res.statusCode ?? 0;
350
+ // Drain the body before settling: bailing early would leave unread bytes
351
+ // on the socket, and destroying a socket with unread data is exactly what
352
+ // produces a server-side ECONNRESET.
353
+ res.on('data', () => {});
354
+ res.on('end', () => finish({ status }));
355
+ res.on('error', () => finish({ status }));
356
+ },
357
+ );
358
+ req.on('error', () => finish(null));
359
+ req.on('timeout', () => {
360
+ req.destroy();
361
+ finish(null);
362
+ });
363
+ req.write(payload);
364
+ req.end();
365
+ });
366
+ }
367
+
368
+ /** Where the per-session send-gate state lives. Best-effort; never load-bearing. */
369
+ export function livenessCachePath(root = repoRoot()) {
370
+ return path.join(root, '.heyspex', 'claim-liveness-cache.json');
371
+ }
372
+
373
+ function readLivenessCache(cachePath) {
374
+ try {
375
+ const parsed = JSON.parse(fs.readFileSync(cachePath, 'utf8'));
376
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
377
+ } catch {
378
+ return {};
379
+ }
380
+ }
381
+
382
+ /**
383
+ * Read-only check: does the negative-cache CURRENTLY say this session holds no
384
+ * claim (a heartbeat 404'd and `NO_CLAIM_TTL_MS` hasn't elapsed)? Local-file-only
385
+ * — no network round-trip — so callers with a tight PreToolUse budget (e.g. the
386
+ * claim-before-code hard gate, HS-38J5) can use it without adding request latency.
387
+ *
388
+ * Deliberately conservative in both directions:
389
+ * - `true` only on a FRESH negative signal (this session's own heartbeat
390
+ * recently 404'd) — never inferred from "no entry at all", since an absent
391
+ * entry just means no heartbeat has round-tripped yet (console cold-start,
392
+ * offline dev, or this is the session's very first tool call).
393
+ * - Never throws; a missing/corrupt cache file reads as "unknown" (false),
394
+ * same fail-open posture as every other read in this module.
395
+ *
396
+ * @param {string} sessionId
397
+ * @param {string} [root]
398
+ * @returns {boolean}
399
+ */
400
+ export function hasFreshNoClaimSignal(sessionId, root = repoRoot()) {
401
+ const trimmed = typeof sessionId === 'string' ? sessionId.trim() : '';
402
+ if (!trimmed) return false;
403
+ const cache = readLivenessCache(livenessCachePath(root));
404
+ const entry = cache[trimmed];
405
+ return typeof entry?.noClaimUntil === 'number' && entry.noClaimUntil > Date.now();
406
+ }
407
+
408
+ function writeLivenessCache(cachePath, cache) {
409
+ // Drop the oldest sessions past the cap so the file cannot grow without bound.
410
+ const entries = Object.entries(cache).sort(
411
+ (a, b) => (b[1]?.touchedAt ?? 0) - (a[1]?.touchedAt ?? 0),
412
+ );
413
+ const trimmed = Object.fromEntries(entries.slice(0, CACHE_MAX_SESSIONS));
414
+ try {
415
+ fs.mkdirSync(path.dirname(cachePath), { recursive: true });
416
+ // Write-then-rename: concurrent hook processes must never observe a
417
+ // half-written file. A lost race just costs one extra heartbeat.
418
+ const tmp = `${cachePath}.${process.pid}.tmp`;
419
+ fs.writeFileSync(tmp, `${JSON.stringify(trimmed, null, 2)}\n`, 'utf8');
420
+ fs.renameSync(tmp, cachePath);
421
+ } catch {
422
+ // Read-only checkout / racing rename — gating degrades to "always send".
423
+ }
424
+ }
425
+
426
+ /**
427
+ * Decide whether this heartbeat is worth a network round-trip.
428
+ * Returns `{ send: boolean, reason: string }`.
429
+ *
430
+ * Skips when the session is negative-cached (no claim), when the console was
431
+ * just unreachable, or when an identical status was sent within the throttle
432
+ * window. A status *change* always sends.
433
+ */
434
+ export function shouldSendHeartbeat(entry, signature, now = Date.now()) {
435
+ if (!entry) return { send: true, reason: 'first-send' };
436
+ if (typeof entry.noClaimUntil === 'number' && entry.noClaimUntil > now) {
437
+ return { send: false, reason: 'no-claim-cached' };
438
+ }
439
+ if (typeof entry.retryAfter === 'number' && entry.retryAfter > now) {
440
+ return { send: false, reason: 'console-unreachable-backoff' };
441
+ }
442
+ if (entry.signature !== signature) return { send: true, reason: 'status-changed' };
443
+ if (typeof entry.sentAt !== 'number' || now - entry.sentAt >= HEARTBEAT_THROTTLE_MS) {
444
+ return { send: true, reason: 'throttle-window-elapsed' };
445
+ }
446
+ return { send: false, reason: 'throttled' };
447
+ }
448
+
449
+ /**
450
+ * Forget the "this session holds no claim" answer for `sessionId`. Call after a
451
+ * claim is acquired so the very next hook heartbeats instead of waiting out the
452
+ * negative-cache TTL.
453
+ */
454
+ export function clearNoClaimCache(sessionId, root = repoRoot()) {
455
+ const trimmed = typeof sessionId === 'string' ? sessionId.trim() : '';
456
+ if (!trimmed) return;
457
+ const cachePath = livenessCachePath(root);
458
+ const cache = readLivenessCache(cachePath);
459
+ if (!cache[trimmed]) return;
460
+ delete cache[trimmed].noClaimUntil;
461
+ delete cache[trimmed].retryAfter;
462
+ writeLivenessCache(cachePath, cache);
463
+ }
464
+
465
+ export async function emitClaimLivenessFromHook({
466
+ argv = process.argv,
467
+ eventArg,
468
+ payload,
469
+ // Injectable so the smoke test can exercise the real gate against a temp repo
470
+ // instead of the developer's live .heyspex/ state.
471
+ root = repoRoot(),
472
+ }) {
473
+ const event = normalizeHookEvent(eventArg ?? payload?.hook_event_name);
474
+ if (!event) return;
475
+
476
+ const sessionId = resolveSessionId(payload);
477
+ if (!sessionId) return;
478
+
479
+ const cachePath = livenessCachePath(root);
480
+ const cache = readLivenessCache(cachePath);
481
+ const now = Date.now();
482
+
483
+ // Enforce the liveness rule FIRST (governor order: reclaimAndPromote() runs at the top of
484
+ // every mutating call) — this session's own hook invocation is itself proof of life for
485
+ // itself, and doubles as the poller that sweeps every OTHER cached session's dead entries too
486
+ // (the cache file is shared across every session/process on this machine). A live-but-paused
487
+ // holder is left untouched by this sweep.
488
+ reclaimDeadSessions(cache, now);
489
+
490
+ // Close the long-tool-call gap: PreToolUse marks this session PAUSED (exempt from reclaim)
491
+ // for the call's unknown duration; PostToolUse/Stop/SubagentStop resume it. Both edges are a
492
+ // fresh liveness stamp, matching the governor's gov-resumed semantics.
493
+ if (event === 'PreToolUse') {
494
+ markToolInFlight(cache, sessionId, now);
495
+ } else if (event === 'PostToolUse' || event === 'Stop' || event === 'SubagentStop') {
496
+ markToolSettled(cache, sessionId, now);
497
+ }
498
+ // Persist the reclaim sweep + pause/resume state now — several paths below return early
499
+ // (no lifecycle patch, no patch at all, throttled) without reaching the write at the bottom
500
+ // of this function, and the liveness state must never depend on a heartbeat actually sending.
501
+ writeLivenessCache(cachePath, cache);
502
+
503
+ // Claim lifecycle news arrives on the PostToolUse edge (the tool has actually
504
+ // run by then). Record it and stop — a lifecycle call carries no status patch
505
+ // of its own; the next PreToolUse sends against the corrected cache.
506
+ const toolErrored = payload?.tool_response?.is_error === true;
507
+ const lifecycle =
508
+ event === 'PostToolUse' && !toolErrored ? claimLifecycleEvent(payload?.tool_name) : null;
509
+ if (lifecycle) {
510
+ const entry = { ...(cache[sessionId] ?? {}), touchedAt: now };
511
+ if (lifecycle === 'acquire') {
512
+ delete entry.noClaimUntil;
513
+ delete entry.retryAfter;
514
+ // Force the next heartbeat through: acquire may have set a status the
515
+ // throttle would otherwise suppress as "unchanged".
516
+ delete entry.signature;
517
+ delete entry.sentAt;
518
+ } else {
519
+ entry.noClaimUntil = now + NO_CLAIM_TTL_MS;
520
+ }
521
+ cache[sessionId] = entry;
522
+ writeLivenessCache(cachePath, cache);
523
+ return;
524
+ }
525
+
526
+ const patch = buildPatch(event, payload);
527
+ if (!patch) return;
528
+
529
+ const signature = JSON.stringify(patch);
530
+ const entry = cache[sessionId];
531
+
532
+ const { send } = shouldSendHeartbeat(entry, signature, now);
533
+ if (!send) return;
534
+
535
+ const token = readCredentialsMcpToken();
536
+ const result = await patchHeartbeat(resolveConsoleUrl(root), sessionId, patch, token);
537
+
538
+ const next = { ...(entry ?? {}), touchedAt: now };
539
+ if (result === null) {
540
+ // No answer at all — console down or mid-restart. Back off; leave the last
541
+ // successful signature in place so the next send still counts as a change.
542
+ next.retryAfter = now + UNREACHABLE_BACKOFF_MS;
543
+ } else if (result.status === 404) {
544
+ // The endpoint answered "no active claim for this session". Stop asking.
545
+ next.noClaimUntil = now + NO_CLAIM_TTL_MS;
546
+ delete next.retryAfter;
547
+ } else if (result.status === 401 || result.status === 403) {
548
+ // The console rejected this machine's token — never cache that as success
549
+ // (a fixed/re-paired token must retry, not wait out the throttle window),
550
+ // and warn the operator exactly once per session. Still fails open: no
551
+ // throw, the tool call is never blocked — just back off so a persistently
552
+ // bad token can't turn into a per-tool-call flood.
553
+ if (!entry?.authWarned) {
554
+ console.error(
555
+ `HeySpex: console rejected this machine's token (${result.status}) — run \`hs login\`.`,
556
+ );
557
+ }
558
+ next.authWarned = true;
559
+ next.retryAfter = now + UNREACHABLE_BACKOFF_MS;
560
+ delete next.signature;
561
+ delete next.sentAt;
562
+ } else if (result.status >= 200 && result.status < 300) {
563
+ next.signature = signature;
564
+ next.sentAt = now;
565
+ delete next.noClaimUntil;
566
+ delete next.retryAfter;
567
+ } else {
568
+ // 4xx/5xx other than 404/401/403 — a malformed patch or a server fault.
569
+ // Throttle it like a success so a persistent error can't turn into a
570
+ // per-tool-call flood.
571
+ next.signature = signature;
572
+ next.sentAt = now;
573
+ delete next.retryAfter;
574
+ }
575
+ cache[sessionId] = next;
576
+ writeLivenessCache(cachePath, cache);
577
+ }
@@ -0,0 +1,64 @@
1
+ #!/usr/bin/env node
2
+ // Hook entrypoint for auto-close terminal (Claude Code + Cursor).
3
+ // See terminal-close-shared.mjs for detection/kill logic.
4
+ //
5
+ // Arms on ship; closes after heyspex_record_wip updates wip-state.json
6
+ // (or on SessionEnd backstop). Opt-in debug trace (off by default):
7
+ // HEYSPEX_TERMINAL_CLOSE_DEBUG=1 → .heyspex/terminal-close-debug.jsonl
8
+ // Mute: HEYSPEX_CLOSE_TERMINAL_ON_SHIP_OFF=1
9
+
10
+ import {
11
+ armPendingTerminalClose,
12
+ isRecordWipSuccess,
13
+ isSessionEndEvent,
14
+ isShipEvent,
15
+ readStdin,
16
+ resolveCloseTerminalOnTaskCompleteFromSettings,
17
+ tryCloseIfReady,
18
+ } from './terminal-close-shared.mjs';
19
+
20
+ const KILL_SWITCH = 'HEYSPEX_CLOSE_TERMINAL_ON_SHIP_OFF';
21
+
22
+ function resolveSessionId(payload) {
23
+ return (
24
+ payload?.session_id ??
25
+ payload?.conversation_id ??
26
+ process.env.CLAUDE_CODE_SESSION_ID ??
27
+ process.env.CURSOR_TRACE_ID ??
28
+ null
29
+ );
30
+ }
31
+
32
+ async function main() {
33
+ if (process.env[KILL_SWITCH] === '1') return;
34
+
35
+ const stdinRaw = await readStdin();
36
+ if (!stdinRaw) return;
37
+
38
+ let payload;
39
+ try {
40
+ payload = JSON.parse(stdinRaw);
41
+ } catch {
42
+ return;
43
+ }
44
+
45
+ if (!resolveCloseTerminalOnTaskCompleteFromSettings()) return;
46
+
47
+ if (isSessionEndEvent(payload)) {
48
+ tryCloseIfReady('session_end');
49
+ return;
50
+ }
51
+
52
+ if (isShipEvent(payload)) {
53
+ armPendingTerminalClose(resolveSessionId(payload), 'ship');
54
+ return;
55
+ }
56
+
57
+ if (isRecordWipSuccess(payload)) {
58
+ tryCloseIfReady('record_wip');
59
+ }
60
+ }
61
+
62
+ main().catch(() => {
63
+ // Swallow — never break the host agent.
64
+ });
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env node
2
+ // GENERATED by scripts/generate-hook-shells.mjs (HS-IQBF) — do not hand-edit.
3
+ // This file only wires Claude Code's hook stdin/stdout contract to the
4
+ // spexharness engine port of this gate. To change what this gate DECIDES,
5
+ // edit spexharness/src/gates/coo-denylist-gate.mjs. To change the SHAPE of this shell,
6
+ // edit the generator, then rerun: node scripts/generate-hook-shells.mjs
7
+
8
+ import { runGateAsHook } from '../../spexharness/src/adapters/claude-code.mjs';
9
+ import gate from '../../spexharness/src/gates/coo-denylist-gate.mjs';
10
+
11
+ await runGateAsHook(gate);
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env node
2
+ // GENERATED by scripts/generate-hook-shells.mjs (HS-IQBF) — do not hand-edit.
3
+ // This file only wires Claude Code's hook stdin/stdout contract to the
4
+ // spexharness engine port of this gate. To change what this gate DECIDES,
5
+ // edit spexharness/src/gates/coo-guard-gate.mjs. To change the SHAPE of this shell,
6
+ // edit the generator, then rerun: node scripts/generate-hook-shells.mjs
7
+
8
+ import { runGateAsHook } from '../../spexharness/src/adapters/claude-code.mjs';
9
+ import { hookGate as gate } from '../../spexharness/src/gates/coo-guard-gate.mjs';
10
+
11
+ await runGateAsHook(gate);
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env node
2
+ // GENERATED by scripts/generate-hook-shells.mjs (HS-IQBF) — do not hand-edit.
3
+ // This file only wires Claude Code's hook stdin/stdout contract to the
4
+ // spexharness engine port of this gate. To change what this gate DECIDES,
5
+ // edit spexharness/src/gates/deep-research-cost-gate.mjs. To change the SHAPE of this shell,
6
+ // edit the generator, then rerun: node scripts/generate-hook-shells.mjs
7
+
8
+ import { runGateAsHook } from '../../spexharness/src/adapters/claude-code.mjs';
9
+ import gate from '../../spexharness/src/gates/deep-research-cost-gate.mjs';
10
+
11
+ await runGateAsHook(gate);