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,815 @@
1
+ // spexharness/src/session-record.mjs
2
+ //
3
+ // The harness's session record: what one bounded loop run (spexharness/src/loop.mjs) did.
4
+ // A record carries the run itself — what was asked, the tool calls made in order, and what
5
+ // the model produced — plus the events the harness saw (events.mjs) with the decision
6
+ // decide.mjs made for each. This is the ONLY thing this module builds: the record. Per the
7
+ // owner's ruling (research HS-9XWJ-KB6H, filed 2026-08-26), no training path, export, or
8
+ // collector is built on top of it here — the classifier log, SpexDoctrine and any future
9
+ // adapter work are separate mechanisms on the same engine, none feeding another's training
10
+ // data from this module.
11
+ //
12
+ // Shape and persistence are modeled on dev-factory/lib/run-events.mjs — not a fresh
13
+ // implementation of "append-only JSONL that survives a crash mid-write": one JSONL line per
14
+ // record (open "a" + one writeSync + close, so concurrent writers never interleave into a
15
+ // corrupt line), and a corrupt/partial trailing line is skipped on read rather than thrown.
16
+ // The domain is different (one line = one whole session's record, not one lifecycle
17
+ // transition), so this is a sibling module, not a re-export of run-events.mjs.
18
+ //
19
+ // Pure-ish: the record-building helpers (createSessionRecord, recordEvent, recordToolCall,
20
+ // finishSessionRecord) do no I/O; only appendSessionRecord/readSessionRecords touch disk.
21
+ //
22
+ // ---------------------------------------------------------------------------------------
23
+ // DURABLE SESSION STORE (task HS-WJIR, epic HS-Y646 AC4)
24
+ // ---------------------------------------------------------------------------------------
25
+ // The JSONL half above is per-machine: a record appended on `specops-gpu0` is invisible to a
26
+ // seat opened on another box. The epic's fourth criterion — "a session closed on one machine
27
+ // is resumed by id on another with its claims and WIP intact" — needs a shared store, so the
28
+ // second half of this module adds one: a session record keyed by session id that carries the
29
+ // session's CLAIMS, its hand-off WIP block and its TOOL-CALL LOG, backed by Neon.
30
+ //
31
+ // Storage is behind a two-method backend (`read`/`write`, plus optional `list`/`delete`) and
32
+ // is INJECTABLE. Neon is the default; the tests inject an in-memory backend, so no test in
33
+ // this package ever needs a live database or a DATABASE_URL. That split is the same posture
34
+ // the rest of the harness takes with its loaders (see session-bootstrap.mjs's `loaders`).
35
+ //
36
+ // The Neon path is not a new connection style: it is the plain-.mjs pattern the repo already
37
+ // uses off the Next.js app — a lazy `await import('@neondatabase/serverless')`, `neon(url)`,
38
+ // and DATABASE_URL_UNPOOLED ?? DATABASE_URL ?? ~/.heyspex/credentials.yaml (identical to
39
+ // scripts/backfill-active-claims-to-neon.mjs's --direct-db path). The row shape mirrors
40
+ // spex-console's `wip_ledger` (`payload jsonb` + `updated_at`) rather than exploding the
41
+ // record into columns, because the record's interior is the harness's own evolving shape and
42
+ // the console never queries into it.
43
+
44
+ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, writeSync } from 'node:fs';
45
+ import { homedir, hostname } from 'node:os';
46
+ import { dirname, join } from 'node:path';
47
+
48
+ import { redactRecord } from './redact.mjs';
49
+
50
+ /**
51
+ * Create a fresh, empty session record. Mutated in place by the record* helpers below as a
52
+ * loop runs, then persisted once via {@link appendSessionRecord}.
53
+ *
54
+ * @param {{ sessionId?: string|null, ask?: unknown }} [opts]
55
+ * @returns {{
56
+ * sessionId: string|null,
57
+ * ask: unknown,
58
+ * startedAt: string,
59
+ * events: Array<{ point: string, ts: number, decision: object|null }>,
60
+ * toolCalls: Array<{ name: string|null, input: unknown, output: unknown }>,
61
+ * confirms: Array<{ id: string|null, stepIndex: number|null, skill: string|null, approved: boolean, used: boolean, at: string }>,
62
+ * output: string|null,
63
+ * endedAt: string|null,
64
+ * }}
65
+ */
66
+ export function createSessionRecord({ sessionId = null, ask = null } = {}) {
67
+ return {
68
+ sessionId,
69
+ ask, // what was asked — the run's initiating prompt/intent
70
+ startedAt: new Date().toISOString(),
71
+ events: [], // ordered — every gate-checked event point the harness saw, with its decision
72
+ toolCalls: [], // ordered — every tool call the loop made
73
+ // ordered — every confirm point a skill (skill-invoke.mjs's invokeSkill) or a scripted
74
+ // run (scripted-mode.mjs's scriptedConfirmHandler) answered, approved or not. This is the
75
+ // durable record spexharness/src/gates/confirm-gate.mjs reads to decide whether a
76
+ // promote/file tool call may proceed ([[HS-U0MC-NYPR-TASK-add-a-gate-that-refuses-a-promote-or]]).
77
+ confirms: [],
78
+ output: null, // what the model produced — the loop's final answer text
79
+ endedAt: null,
80
+ };
81
+ }
82
+
83
+ /**
84
+ * Append one answered confirm point to the record, in the order it was answered. The shape
85
+ * mirrors skill-invoke.mjs's own `confirms` entries (`{ id, stepIndex, approved, cues }`) so a
86
+ * caller wiring `invokeSkill`'s per-step confirm callback can record here with no translation.
87
+ * `skill` is optional context a caller may supply (skill-invoke.mjs's `plan.skill`) so
88
+ * {@link consumeApprovedConfirm} can match on it when no `id` is available.
89
+ *
90
+ * `used` starts `false` — an approval is a single-use token
91
+ * ([[HS-U0MC-NYPR-TASK-add-a-gate-that-refuses-a-promote-or]] QC fix: a session that answered
92
+ * ONE confirm must not unlock EVERY later confirm-required exit). Only
93
+ * {@link consumeApprovedConfirm} ever flips it to `true`.
94
+ *
95
+ * @param {ReturnType<typeof createSessionRecord>} record
96
+ * @param {{ id?: string|null, stepIndex?: number|null, skill?: string|null, approved: boolean }} confirm
97
+ * @returns {ReturnType<typeof createSessionRecord>} the same record, for chaining.
98
+ */
99
+ export function recordConfirm(record, { id = null, stepIndex = null, skill = null, approved } = {}) {
100
+ record.confirms.push({
101
+ id,
102
+ stepIndex: stepIndex ?? null,
103
+ skill: skill ?? null,
104
+ approved: Boolean(approved),
105
+ used: false,
106
+ at: new Date().toISOString(),
107
+ });
108
+ return record;
109
+ }
110
+
111
+ /**
112
+ * Has ANY confirm point on `record` been recorded as approved? A read-only existence check —
113
+ * it does NOT consume anything and is unaffected by a prior {@link consumeApprovedConfirm}
114
+ * call, so it stays useful for "did this session ever answer a confirm at all" reporting.
115
+ * The engine's `before_tool` decision itself must never be gated on this alone — see
116
+ * {@link consumeApprovedConfirm}, which is the predicate spexharness/src/gates/confirm-gate.mjs
117
+ * actually runs before letting a promote/file tool call through.
118
+ *
119
+ * @param {{ confirms?: Array<{ approved?: boolean }> }|null|undefined} record
120
+ * @returns {boolean}
121
+ */
122
+ export function hasApprovedConfirm(record) {
123
+ return Array.isArray(record?.confirms) && record.confirms.some((c) => c && c.approved === true);
124
+ }
125
+
126
+ /**
127
+ * Consume ONE approved, not-yet-used confirm off `record` and return whether one was found.
128
+ * This is the fix for the QC finding on {@link hasApprovedConfirm}'s old ANY-check: that
129
+ * predicate never marked anything used, so one approved confirm (e.g. a `/hs-capture`
130
+ * confirm) satisfied every later confirm-required exit in the session with no further
131
+ * confirm answered — filing 40 specs, then submitting for review, all on one "yes". A
132
+ * confirm is now a single-use token: the first gated call that consumes it marks it
133
+ * `used: true` on `record` (mutating in place, so the SAME record object a caller keeps
134
+ * passing to the gate sees the consumption on the very next check), and every subsequent
135
+ * confirm-required action needs a FRESH approved-and-unused entry.
136
+ *
137
+ * Matching (most to least specific, first match wins — matches the granularity the caller
138
+ * actually has available; see spexharness/src/gates/confirm-gate.mjs's header for why a
139
+ * bare `before_tool` event usually carries none of this):
140
+ * 1. `query.id` given — exact `id` match required (skill-invoke.mjs's `confirmPoint.id`,
141
+ * e.g. `"hs-promote.step-3"`, or a caller-minted confirm id).
142
+ * 2. else `query.namespace` given — the entry's id must be that namespace exactly, or sit
143
+ * under it (`<namespace>` + `.` / `:` / `/` + rest). See {@link confirmIdMatchesNamespace}.
144
+ * 3. else `query.skill` and/or `query.stepIndex` given — match whichever of those the
145
+ * caller supplied, against entries that recorded the same field.
146
+ * 4. else (no query fields at all) — the oldest unused approved entry, in recorded order,
147
+ * SKIPPING every namespace-BOUND entry (see {@link isNamespaceBoundConfirmId}): a
148
+ * confirm answered for another gate's ask (`dev-server-control:start`) or for a
149
+ * specific tool-level exit (`tool:heyspex_file_spec`) is bound to that ask and must be
150
+ * claimed by name, never picked up by an unqualified "oldest unused" sweep.
151
+ * In every case only `approved === true && used !== true` entries are eligible.
152
+ *
153
+ * A caller-supplied `confirm: async () => true` handler can never satisfy this by itself —
154
+ * this function (and the gate calling it) reads only `record.confirms`, never a callback's
155
+ * return value, so a handler wired straight to `true` with no matching entry actually
156
+ * `recordConfirm`-ed on the session still gets refused.
157
+ *
158
+ * @param {ReturnType<typeof createSessionRecord>|null|undefined} record
159
+ * @param {{ id?: string|null, namespace?: string|null, skill?: string|null, stepIndex?: number|null }} [query]
160
+ * @returns {boolean} true (and marks the matched entry `used: true`) iff a match was consumed.
161
+ */
162
+ export function consumeApprovedConfirm(
163
+ record,
164
+ { id = null, namespace = null, skill = null, stepIndex = null } = {},
165
+ ) {
166
+ if (!Array.isArray(record?.confirms)) return false;
167
+
168
+ const eligible = (c) => c && c.approved === true && c.used !== true;
169
+
170
+ let match = null;
171
+ if (id != null) {
172
+ match = record.confirms.find((c) => eligible(c) && c.id === id) ?? null;
173
+ } else if (namespace != null) {
174
+ match = record.confirms.find((c) => eligible(c) && confirmIdMatchesNamespace(c.id, namespace)) ?? null;
175
+ } else if (skill != null || stepIndex != null) {
176
+ match = record.confirms.find((c) => {
177
+ if (!eligible(c)) return false;
178
+ if (isNamespaceBoundConfirmId(c.id)) return false; // bound to another ask — claim by name only
179
+ if (skill != null && c.skill !== skill) return false;
180
+ if (stepIndex != null && c.stepIndex !== stepIndex) return false;
181
+ return true;
182
+ }) ?? null;
183
+ } else {
184
+ match = record.confirms.find((c) => eligible(c) && !isNamespaceBoundConfirmId(c.id)) ?? null;
185
+ }
186
+
187
+ if (!match) return false;
188
+ match.used = true;
189
+ return true;
190
+ }
191
+
192
+ /**
193
+ * The delimiters a confirm id uses between a namespace and the rest of the id. `.` is the
194
+ * skill-step shape (`hs-capture.step-2` — skill-invoke.mjs's `confirmPoint.id`); `:` is the
195
+ * BOUND shape a gate or a tool-level exit mints (`dev-server-control:start`,
196
+ * `tool:heyspex_file_spec`); `/` is accepted for symmetry with path-shaped ids.
197
+ */
198
+ const CONFIRM_NAMESPACE_DELIMITERS = Object.freeze(['.', ':', '/']);
199
+
200
+ /**
201
+ * Is `confirmId` inside `namespace`? True when the id IS the namespace, or sits under it
202
+ * behind one of {@link CONFIRM_NAMESPACE_DELIMITERS} — so `"hs-capture"` matches
203
+ * `"hs-capture.step-2"` (every confirm point of that skill), and `"tool:heyspex_file_spec"`
204
+ * matches only itself, never `"tool:heyspex_push_plan"`.
205
+ *
206
+ * @param {unknown} confirmId
207
+ * @param {string} namespace
208
+ * @returns {boolean}
209
+ */
210
+ export function confirmIdMatchesNamespace(confirmId, namespace) {
211
+ if (typeof confirmId !== 'string' || typeof namespace !== 'string' || namespace === '') return false;
212
+ if (confirmId === namespace) return true;
213
+ return CONFIRM_NAMESPACE_DELIMITERS.some((d) => confirmId.startsWith(`${namespace}${d}`));
214
+ }
215
+
216
+ /**
217
+ * Is this confirm id BOUND to one specific ask — i.e. minted by a gate or an exit that
218
+ * names its own namespace with a `:` (`dev-server-control:start` from
219
+ * gates/dev-server-task-reminder.mjs's `confirmIdFor`, `tool:heyspex_file_spec` from
220
+ * gates/confirm-gate.mjs's `toolConfirmId`)?
221
+ *
222
+ * Why the distinction exists (regression R2, 2026-09-09): with no binding, the unqualified
223
+ * "oldest unused approved" fallback let a confirm answered for the dev-server RESTART ask be
224
+ * spent by the very next `heyspex_file_spec` — one ask silently authorizing an unrelated
225
+ * exit. A bound id is claimed by `id`/`namespace` only; the unqualified fallback skips it.
226
+ * A bare skill-step id (`hs-capture.step-2`) is NOT bound — it keeps the coarse behaviour
227
+ * confirm-gate.mjs's header documents for a front that cannot say which exit it is at.
228
+ *
229
+ * @param {unknown} confirmId
230
+ * @returns {boolean}
231
+ */
232
+ export function isNamespaceBoundConfirmId(confirmId) {
233
+ return typeof confirmId === 'string' && confirmId.includes(':');
234
+ }
235
+
236
+ /**
237
+ * Append one harness event (from events.mjs) and the decision decide.mjs made for it
238
+ * (from decide.mjs) to the record, in the order it happened.
239
+ *
240
+ * @param {ReturnType<typeof createSessionRecord>} record
241
+ * @param {{ point: string, ts?: number }} event
242
+ * @param {object|null} [decision] the decide() result, or null when the event carries no decision
243
+ * @returns {ReturnType<typeof createSessionRecord>} the same record, for chaining.
244
+ */
245
+ export function recordEvent(record, event, decision = null) {
246
+ record.events.push({ point: event?.point ?? null, ts: event?.ts ?? Date.now(), decision });
247
+ return record;
248
+ }
249
+
250
+ /**
251
+ * Append one tool call to the record, in the order it happened.
252
+ *
253
+ * @param {ReturnType<typeof createSessionRecord>} record
254
+ * @param {{ name?: string|null, input?: unknown, output?: unknown }} toolCall
255
+ * @returns {ReturnType<typeof createSessionRecord>} the same record, for chaining.
256
+ */
257
+ export function recordToolCall(record, { name = null, input = null, output = null } = {}) {
258
+ record.toolCalls.push({ name, input, output });
259
+ return record;
260
+ }
261
+
262
+ /**
263
+ * Seal the record with the run's final output and an end timestamp. Safe to call more than
264
+ * once (e.g. a caller that finishes a record on every loop exit path); the last call wins.
265
+ *
266
+ * @param {ReturnType<typeof createSessionRecord>} record
267
+ * @param {string|null} [output]
268
+ * @returns {ReturnType<typeof createSessionRecord>} the same record, for chaining.
269
+ */
270
+ export function finishSessionRecord(record, output = null) {
271
+ record.output = output ?? null;
272
+ record.endedAt = new Date().toISOString();
273
+ return record;
274
+ }
275
+
276
+ /**
277
+ * Persist one session record as a single JSONL line. Mirrors
278
+ * dev-factory/lib/run-events.mjs's append-only, line-atomic write (open "a" + one
279
+ * writeSync + close) so concurrent writers from different fronts never interleave into a
280
+ * corrupt line.
281
+ *
282
+ * Redacted via {@link redactRecord} ([[HS-Y424-NYPR-TASK-add-one-redaction-module-that-strips]])
283
+ * before serialization — a fixture key, a bearer token, or a marked ingested-prose block never
284
+ * reaches this file. Redaction produces a COPY for the write; the `record` object this function
285
+ * returns (for chaining) is the caller's own, untouched.
286
+ *
287
+ * @param {string} filePath
288
+ * @param {object} record
289
+ * @returns {object} the same record, for chaining.
290
+ */
291
+ export function appendSessionRecord(filePath, record) {
292
+ if (!filePath) throw new Error('spexharness/session-record: filePath is required');
293
+ mkdirSync(dirname(filePath), { recursive: true });
294
+ const line = `${JSON.stringify(redactRecord(record))}\n`;
295
+ const fd = openSync(filePath, 'a');
296
+ try {
297
+ writeSync(fd, line);
298
+ } finally {
299
+ closeSync(fd);
300
+ }
301
+ return record;
302
+ }
303
+
304
+ /**
305
+ * Read all session records from a file, oldest first. Missing file → []. A corrupt/partial
306
+ * trailing line (e.g. the process died mid-write) is skipped rather than throwing — mirrors
307
+ * run-events.mjs readRunEvents' corrupt-safe read.
308
+ *
309
+ * @param {string} filePath
310
+ * @returns {object[]}
311
+ */
312
+ export function readSessionRecords(filePath) {
313
+ if (!filePath || !existsSync(filePath)) return [];
314
+ const text = readFileSync(filePath, 'utf8');
315
+ const records = [];
316
+ for (const line of text.split('\n')) {
317
+ const trimmed = line.trim();
318
+ if (!trimmed) continue;
319
+ try {
320
+ records.push(JSON.parse(trimmed));
321
+ } catch {
322
+ // corrupt/partial line — skip, don't throw
323
+ }
324
+ }
325
+ return records;
326
+ }
327
+
328
+ /* =========================================================================================
329
+ * Durable session store — claims + hand-off WIP + tool-call log, resumable by id.
330
+ * ========================================================================================= */
331
+
332
+ /** The Neon table the durable session record lives in. */
333
+ export const SESSION_RECORD_TABLE = 'harness_session_records';
334
+
335
+ /** Postgres identifiers are not parameterizable — anything spliced into SQL passes this. */
336
+ function assertIdentifier(name, what = 'table') {
337
+ if (typeof name !== 'string' || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
338
+ throw new Error(`spexharness/session-record: invalid ${what} identifier ${JSON.stringify(name)}`);
339
+ }
340
+ return name;
341
+ }
342
+
343
+ /**
344
+ * The idempotent DDL for the durable session table. Exported (rather than hidden) so a
345
+ * migration runner, a provisioning script or a test can assert against the exact shape —
346
+ * the Neon backend runs it lazily on first use unless `ensureSchema: false`.
347
+ *
348
+ * @param {string} [table]
349
+ * @returns {string}
350
+ */
351
+ export function sessionRecordSchemaSql(table = SESSION_RECORD_TABLE) {
352
+ const t = assertIdentifier(table);
353
+ return `CREATE TABLE IF NOT EXISTS ${t} (
354
+ session_id text PRIMARY KEY,
355
+ seat_id text,
356
+ front text,
357
+ machine text,
358
+ record jsonb NOT NULL,
359
+ created_at timestamptz NOT NULL DEFAULT now(),
360
+ updated_at timestamptz NOT NULL DEFAULT now()
361
+ )`;
362
+ }
363
+
364
+ /**
365
+ * @typedef {object} DurableSessionRecord
366
+ * @property {string} sessionId the resume key — the only thing another machine needs.
367
+ * @property {string|null} seatId the seat the session runs as.
368
+ * @property {string|null} front the entry point that opened it ('chat', 'deepseek', …).
369
+ * @property {string|null} machine the machine that last wrote the record.
370
+ * @property {unknown} ask what was asked.
371
+ * @property {Array<object>} claims the specs this session holds.
372
+ * @property {unknown} wip the hand-off WIP block (null when there is none).
373
+ * @property {Array<object>} events harness events with their decisions, in order.
374
+ * @property {Array<object>} toolCalls the session's tool-call log, in order.
375
+ * @property {string|null} output what the model produced.
376
+ * @property {Array<{machine: string|null, at: string}>} resumes every pick-up of this id.
377
+ * @property {string} startedAt
378
+ * @property {string} updatedAt
379
+ * @property {string|null} endedAt
380
+ */
381
+
382
+ /**
383
+ * Create a durable session record: everything {@link createSessionRecord} carries, plus the
384
+ * three things a resume on another machine needs — `claims`, `wip` and the identity
385
+ * (`seatId`/`front`/`machine`) of the seat that opened it.
386
+ *
387
+ * Deliberately a SEPARATE constructor rather than a change to createSessionRecord: loop.mjs
388
+ * builds the plain record today and must keep getting exactly that shape.
389
+ *
390
+ * @param {{ sessionId?: string|null, seatId?: string|null, front?: string|null,
391
+ * machine?: string|null, ask?: unknown, claims?: Array<object>, wip?: unknown }} [opts]
392
+ * @returns {DurableSessionRecord}
393
+ */
394
+ export function createDurableSessionRecord({
395
+ sessionId = null,
396
+ seatId = null,
397
+ front = null,
398
+ machine = undefined,
399
+ ask = null,
400
+ claims = [],
401
+ wip = null,
402
+ } = {}) {
403
+ const now = new Date().toISOString();
404
+ return {
405
+ ...createSessionRecord({ sessionId, ask }),
406
+ seatId,
407
+ front,
408
+ machine: machine === undefined ? currentMachine() : machine,
409
+ claims: Array.isArray(claims) ? claims.slice() : [],
410
+ wip,
411
+ resumes: [],
412
+ startedAt: now,
413
+ updatedAt: now,
414
+ };
415
+ }
416
+
417
+ /** The machine identity stamped on a write — hostname, or null when it can't be read. */
418
+ export function currentMachine() {
419
+ try {
420
+ return process.env.HEYSPEX_MACHINE || hostname() || null;
421
+ } catch {
422
+ return null;
423
+ }
424
+ }
425
+
426
+ /**
427
+ * Fill in a record read back from storage so a caller never has to defend against a row
428
+ * written by an older shape (or by a different front): the three collections are always
429
+ * arrays, and the scalar fields are always present.
430
+ *
431
+ * @param {object|null} raw
432
+ * @returns {DurableSessionRecord|null}
433
+ */
434
+ export function normalizeSessionRecord(raw) {
435
+ if (!raw || typeof raw !== 'object') return null;
436
+ return {
437
+ sessionId: raw.sessionId ?? null,
438
+ seatId: raw.seatId ?? null,
439
+ front: raw.front ?? null,
440
+ machine: raw.machine ?? null,
441
+ ask: raw.ask ?? null,
442
+ claims: Array.isArray(raw.claims) ? raw.claims : [],
443
+ wip: raw.wip ?? null,
444
+ events: Array.isArray(raw.events) ? raw.events : [],
445
+ toolCalls: Array.isArray(raw.toolCalls) ? raw.toolCalls : [],
446
+ output: raw.output ?? null,
447
+ resumes: Array.isArray(raw.resumes) ? raw.resumes : [],
448
+ startedAt: raw.startedAt ?? null,
449
+ updatedAt: raw.updatedAt ?? null,
450
+ endedAt: raw.endedAt ?? null,
451
+ };
452
+ }
453
+
454
+ /** Structural copy — the store hands out copies so a caller's edits never mutate the store. */
455
+ function clone(value) {
456
+ return value == null ? value : JSON.parse(JSON.stringify(value));
457
+ }
458
+
459
+ /* ------------------------------------------------------------------------- backends --- */
460
+
461
+ /**
462
+ * In-memory session backend. This is what the tests inject: same contract as the Neon
463
+ * backend (async, copy-on-read, copy-on-write), no database. Also useful to a front running
464
+ * fully offline, where a session simply does not survive the process.
465
+ *
466
+ * @param {Iterable<[string, object]>|Record<string, object>} [seed]
467
+ * @returns {{ read: Function, write: Function, list: Function, delete: Function, size: () => number }}
468
+ */
469
+ export function createMemorySessionBackend(seed = undefined) {
470
+ const rows = new Map(
471
+ seed == null ? [] : (Symbol.iterator in Object(seed) ? seed : Object.entries(seed)),
472
+ );
473
+ return {
474
+ async read(sessionId) {
475
+ const row = rows.get(sessionId);
476
+ return row ? normalizeSessionRecord(clone(row)) : null;
477
+ },
478
+ async write(sessionId, record) {
479
+ rows.set(sessionId, clone(record));
480
+ return normalizeSessionRecord(clone(record));
481
+ },
482
+ async list({ seatId = null, limit = 100 } = {}) {
483
+ const all = [...rows.values()].map((row) => normalizeSessionRecord(clone(row)));
484
+ const filtered = seatId ? all.filter((r) => r.seatId === seatId) : all;
485
+ filtered.sort((a, b) => String(b.updatedAt ?? '').localeCompare(String(a.updatedAt ?? '')));
486
+ return filtered.slice(0, limit);
487
+ },
488
+ async delete(sessionId) {
489
+ return rows.delete(sessionId);
490
+ },
491
+ size: () => rows.size,
492
+ };
493
+ }
494
+
495
+ /**
496
+ * Resolve the Neon connection string the same way the repo's other plain-.mjs Neon callers
497
+ * do: `DATABASE_URL_UNPOOLED` first (the durable session store is a handful of point
498
+ * lookups, not a hot pool), then `DATABASE_URL`, then `~/.heyspex/credentials.yaml`.
499
+ *
500
+ * @param {{ env?: Record<string, string|undefined>, home?: string }} [opts] injectable for tests.
501
+ * @returns {string}
502
+ */
503
+ export function loadHarnessDatabaseUrl({ env = process.env, home = undefined } = {}) {
504
+ const fromEnv = env.DATABASE_URL_UNPOOLED ?? env.DATABASE_URL;
505
+ if (fromEnv) return fromEnv;
506
+ let credentialsPath;
507
+ try {
508
+ credentialsPath = join(home ?? homedir(), '.heyspex', 'credentials.yaml');
509
+ } catch {
510
+ credentialsPath = null;
511
+ }
512
+ if (credentialsPath && existsSync(credentialsPath)) {
513
+ const yaml = readFileSync(credentialsPath, 'utf8');
514
+ const unpooled = yaml.match(/^database_url_unpooled:\s*(.+)$/m)?.[1]?.trim();
515
+ const pooled = yaml.match(/^database_url:\s*(.+)$/m)?.[1]?.trim();
516
+ const url = unpooled || pooled;
517
+ if (url) return url;
518
+ }
519
+ throw new Error(
520
+ 'spexharness/session-record: no DATABASE_URL configured (env DATABASE_URL_UNPOOLED/DATABASE_URL or ~/.heyspex/credentials.yaml)',
521
+ );
522
+ }
523
+
524
+ /** neon()'s two return shapes: rows array (default) or { rows } under fullResults. */
525
+ function firstRow(result) {
526
+ if (Array.isArray(result)) return result[0] ?? null;
527
+ if (result && Array.isArray(result.rows)) return result.rows[0] ?? null;
528
+ return null;
529
+ }
530
+
531
+ function allRows(result) {
532
+ if (Array.isArray(result)) return result;
533
+ if (result && Array.isArray(result.rows)) return result.rows;
534
+ return [];
535
+ }
536
+
537
+ /** jsonb comes back parsed; a text column (or a stand-in driver) may hand back a string. */
538
+ function decodeRecordColumn(value) {
539
+ if (typeof value !== 'string') return value;
540
+ try {
541
+ return JSON.parse(value);
542
+ } catch {
543
+ return null;
544
+ }
545
+ }
546
+
547
+ /**
548
+ * Neon-backed session backend — the default. The driver import is LAZY (first call, not
549
+ * module load) so importing this module in an offline/test context costs nothing and needs
550
+ * no DATABASE_URL, and the schema DDL is idempotent and runs at most once per backend.
551
+ *
552
+ * @param {{
553
+ * sql?: ((text: string, params?: unknown[]) => Promise<unknown>)|null,
554
+ * databaseUrl?: string|null,
555
+ * table?: string,
556
+ * ensureSchema?: boolean,
557
+ * }} [options] `sql` injects a query function (a live `neon()` handle the caller already
558
+ * holds, or a stand-in); otherwise one is built from {@link loadHarnessDatabaseUrl}.
559
+ * @returns {{ read: Function, write: Function, list: Function, delete: Function }}
560
+ */
561
+ export function createNeonSessionBackend(options = {}) {
562
+ const { sql: injectedSql = null, databaseUrl = null, table = SESSION_RECORD_TABLE, ensureSchema = true } = options;
563
+ const t = assertIdentifier(table);
564
+
565
+ let sqlPromise = null;
566
+ let schemaPromise = null;
567
+
568
+ async function connect() {
569
+ if (injectedSql) return injectedSql;
570
+ if (!sqlPromise) {
571
+ sqlPromise = (async () => {
572
+ const { neon } = await import('@neondatabase/serverless');
573
+ return neon(databaseUrl ?? loadHarnessDatabaseUrl());
574
+ })();
575
+ }
576
+ return sqlPromise;
577
+ }
578
+
579
+ async function ready() {
580
+ const sql = await connect();
581
+ if (ensureSchema) {
582
+ if (!schemaPromise) schemaPromise = Promise.resolve(sql(sessionRecordSchemaSql(t), []));
583
+ await schemaPromise;
584
+ }
585
+ return sql;
586
+ }
587
+
588
+ return {
589
+ async read(sessionId) {
590
+ const sql = await ready();
591
+ const row = firstRow(await sql(`SELECT record FROM ${t} WHERE session_id = $1`, [sessionId]));
592
+ return row ? normalizeSessionRecord(decodeRecordColumn(row.record)) : null;
593
+ },
594
+ async write(sessionId, record) {
595
+ const sql = await ready();
596
+ // Redacted before it ever reaches Neon — same module, same contract as
597
+ // appendSessionRecord's JSONL write (HS-Y424): a fixture key, a bearer
598
+ // token, or a marked ingested-prose block never lands in this row.
599
+ await sql(
600
+ `INSERT INTO ${t} (session_id, seat_id, front, machine, record, updated_at)
601
+ VALUES ($1, $2, $3, $4, $5::jsonb, now())
602
+ ON CONFLICT (session_id) DO UPDATE SET
603
+ seat_id = EXCLUDED.seat_id,
604
+ front = EXCLUDED.front,
605
+ machine = EXCLUDED.machine,
606
+ record = EXCLUDED.record,
607
+ updated_at = now()`,
608
+ [sessionId, record.seatId ?? null, record.front ?? null, record.machine ?? null, JSON.stringify(redactRecord(record))],
609
+ );
610
+ return normalizeSessionRecord(clone(record));
611
+ },
612
+ async list({ seatId = null, limit = 100 } = {}) {
613
+ const sql = await ready();
614
+ const result = seatId
615
+ ? await sql(
616
+ `SELECT record FROM ${t} WHERE seat_id = $1 ORDER BY updated_at DESC LIMIT $2`,
617
+ [seatId, limit],
618
+ )
619
+ : await sql(`SELECT record FROM ${t} ORDER BY updated_at DESC LIMIT $1`, [limit]);
620
+ return allRows(result).map((row) => normalizeSessionRecord(decodeRecordColumn(row.record)));
621
+ },
622
+ async delete(sessionId) {
623
+ const sql = await ready();
624
+ const result = await sql(`DELETE FROM ${t} WHERE session_id = $1 RETURNING session_id`, [sessionId]);
625
+ return allRows(result).length > 0;
626
+ },
627
+ };
628
+ }
629
+
630
+ /* ---------------------------------------------------------------------------- store --- */
631
+
632
+ function requireSessionId(sessionId) {
633
+ if (!sessionId || typeof sessionId !== 'string') {
634
+ throw new Error('spexharness/session-record: sessionId is required');
635
+ }
636
+ return sessionId;
637
+ }
638
+
639
+ /**
640
+ * The durable session store. Every method is async and keyed by session id; the store holds
641
+ * no state of its own, so two store handles over the same backend — including two on
642
+ * DIFFERENT machines — see exactly the same session.
643
+ *
644
+ * @param {{ backend?: object, machine?: string|null, neon?: object }} [options]
645
+ * `backend` injects storage (the tests pass {@link createMemorySessionBackend}); omitted,
646
+ * it defaults to {@link createNeonSessionBackend} (constructed lazily — no connection is
647
+ * opened until a method is actually called). `neon` is passed through to that default.
648
+ * @returns {object}
649
+ */
650
+ export function createSessionStore(options = {}) {
651
+ const { backend: injectedBackend = null, machine = undefined, neon: neonOptions = undefined } = options;
652
+ const writerMachine = machine === undefined ? currentMachine() : machine;
653
+
654
+ let backendInstance = injectedBackend;
655
+ function backend() {
656
+ if (!backendInstance) backendInstance = createNeonSessionBackend(neonOptions ?? {});
657
+ return backendInstance;
658
+ }
659
+
660
+ /** Read a session, or throw with the id — every mutation goes through this. */
661
+ async function mustRead(sessionId) {
662
+ const record = await backend().read(requireSessionId(sessionId));
663
+ if (!record) {
664
+ throw new Error(`spexharness/session-record: no session record for id ${sessionId}`);
665
+ }
666
+ return record;
667
+ }
668
+
669
+ /** Stamp the writer + time and persist. */
670
+ async function persist(record) {
671
+ const next = { ...record, machine: writerMachine, updatedAt: new Date().toISOString() };
672
+ return backend().write(next.sessionId, next);
673
+ }
674
+
675
+ /** Read-modify-write one session. */
676
+ async function mutate(sessionId, fn) {
677
+ const record = await mustRead(sessionId);
678
+ return persist(fn(record) ?? record);
679
+ }
680
+
681
+ return {
682
+ /** The backend in use — exposed so a caller can assert which storage it got. */
683
+ backend,
684
+
685
+ /**
686
+ * Create (or replace) a session record under `sessionId` and persist it. Returns the
687
+ * stored record. Idempotent by id: calling it twice with the same id restarts the record.
688
+ */
689
+ async createSession(init = {}) {
690
+ const sessionId = requireSessionId(init.sessionId ?? null);
691
+ const record = createDurableSessionRecord({ ...init, sessionId, machine: writerMachine });
692
+ return persist(record);
693
+ },
694
+
695
+ /** Persist an already-built record (e.g. one a loop mutated in memory). */
696
+ async saveSession(record) {
697
+ return persist({ ...normalizeSessionRecord(record), sessionId: requireSessionId(record?.sessionId) });
698
+ },
699
+
700
+ /** The full session record for `sessionId`, or null when the id is unknown. */
701
+ async getSession(sessionId) {
702
+ return backend().read(requireSessionId(sessionId));
703
+ },
704
+
705
+ /**
706
+ * Pick a session up by id — the resume path. Returns the full record (claims, WIP and
707
+ * tool-call log intact) and records the pick-up in `resumes`, so a hand-off is auditable
708
+ * rather than silent. Unknown id → null, so a caller can branch on "no session there".
709
+ */
710
+ async resumeSession(sessionId, { machine: onMachine = writerMachine } = {}) {
711
+ const record = await backend().read(requireSessionId(sessionId));
712
+ if (!record) return null;
713
+ record.resumes = [...record.resumes, { machine: onMachine ?? null, at: new Date().toISOString() }];
714
+ return persist(record);
715
+ },
716
+
717
+ /** Append one tool call to the session's log, in the order it happened. */
718
+ async appendToolCall(sessionId, { name = null, input = null, output = null } = {}) {
719
+ return mutate(sessionId, (record) => {
720
+ record.toolCalls = [...record.toolCalls, { name, input, output }];
721
+ return record;
722
+ });
723
+ },
724
+
725
+ /** Append several tool calls in one write — the same order, one round trip. */
726
+ async appendToolCalls(sessionId, toolCalls = []) {
727
+ return mutate(sessionId, (record) => {
728
+ record.toolCalls = [
729
+ ...record.toolCalls,
730
+ ...toolCalls.map(({ name = null, input = null, output = null } = {}) => ({ name, input, output })),
731
+ ];
732
+ return record;
733
+ });
734
+ },
735
+
736
+ /** Append one harness event + its decision, mirroring {@link recordEvent}'s shape. */
737
+ async appendEvent(sessionId, event, decision = null) {
738
+ return mutate(sessionId, (record) => {
739
+ record.events = [
740
+ ...record.events,
741
+ { point: event?.point ?? null, ts: event?.ts ?? Date.now(), decision },
742
+ ];
743
+ return record;
744
+ });
745
+ },
746
+
747
+ /** Replace the session's claim set wholesale. */
748
+ async updateClaims(sessionId, claims = []) {
749
+ return mutate(sessionId, (record) => {
750
+ record.claims = Array.isArray(claims) ? claims.slice() : [];
751
+ return record;
752
+ });
753
+ },
754
+
755
+ /** Add one claim, replacing any existing claim on the same `specId` (claims are a set). */
756
+ async addClaim(sessionId, claim) {
757
+ if (!claim?.specId) throw new Error('spexharness/session-record: claim.specId is required');
758
+ return mutate(sessionId, (record) => {
759
+ record.claims = [
760
+ ...record.claims.filter((c) => c?.specId !== claim.specId),
761
+ { claimedAt: new Date().toISOString(), ...claim },
762
+ ];
763
+ return record;
764
+ });
765
+ },
766
+
767
+ /** Drop the claim on `specId`. A spec that was not claimed is a no-op, not an error. */
768
+ async releaseClaim(sessionId, specId) {
769
+ return mutate(sessionId, (record) => {
770
+ record.claims = record.claims.filter((c) => c?.specId !== specId);
771
+ return record;
772
+ });
773
+ },
774
+
775
+ /** Replace the session's hand-off WIP block. */
776
+ async updateWip(sessionId, wip) {
777
+ return mutate(sessionId, (record) => {
778
+ record.wip = wip ?? null;
779
+ return record;
780
+ });
781
+ },
782
+
783
+ /** Merge fields into the hand-off WIP block, keeping what is already there. */
784
+ async mergeWip(sessionId, patch = {}) {
785
+ return mutate(sessionId, (record) => {
786
+ const base = record.wip && typeof record.wip === 'object' && !Array.isArray(record.wip) ? record.wip : {};
787
+ record.wip = { ...base, ...patch };
788
+ return record;
789
+ });
790
+ },
791
+
792
+ /** Seal the session with its final output and an end timestamp. */
793
+ async finishSession(sessionId, output = null) {
794
+ return mutate(sessionId, (record) => {
795
+ record.output = output ?? null;
796
+ record.endedAt = new Date().toISOString();
797
+ return record;
798
+ });
799
+ },
800
+
801
+ /** Sessions in the store, newest first (backends that support listing). */
802
+ async listSessions(opts = {}) {
803
+ const b = backend();
804
+ if (typeof b.list !== 'function') return [];
805
+ return b.list(opts);
806
+ },
807
+
808
+ /** Remove a session record. Returns whether a row was actually removed. */
809
+ async deleteSession(sessionId) {
810
+ const b = backend();
811
+ if (typeof b.delete !== 'function') return false;
812
+ return b.delete(requireSessionId(sessionId));
813
+ },
814
+ };
815
+ }