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
@@ -5,11 +5,11 @@
5
5
  */
6
6
 
7
7
  import { readFileSync, existsSync, readdirSync, mkdirSync } from 'node:fs';
8
- import { join } from 'node:path';
8
+ import { join, isAbsolute, resolve, sep } from 'node:path';
9
9
 
10
10
  import { allocateRandomVaultId } from '../../scripts/vault-id-lib.mjs';
11
11
  import { yamlScalar } from './yaml-scalar.mjs';
12
- import { mintFamilyKey, mintFileKey, parseSpecId } from './spec-id-lib.mjs';
12
+ import { collectOccupiedKeysEitherKind, mintFamilyKey, mintFileKey, parseSpecId } from './spec-id-lib.mjs';
13
13
  import {
14
14
  isoNow,
15
15
  deriveSlug,
@@ -22,12 +22,15 @@ import {
22
22
  } from './promotion-helpers.mjs';
23
23
  import { extractHandoffEnvelopeYaml } from '../../spex-console/src/lib/indexer/handoff-envelope.mjs';
24
24
  import { buildDiagnoseSkeletonSections } from './diagnose-skeleton.mjs';
25
+ import { assertPathUnderRoot } from '../../scripts/lib/shared-path-helpers.mjs';
25
26
 
26
27
  /** Default cap when frontmatter omits `attempt_count` ([[HS-TASK-6OU5]]). */
27
28
  export const DEFAULT_DEFER_RECLAIM_ATTEMPT_CAP = 3;
28
29
 
30
+
29
31
  function collectOccupiedDiagnoseIds(vaultRoot) {
30
32
  const occupied = new Set();
33
+ // Disk-only scan — misses Neon-only (cloud-filed) diagnoses; see [[HS-7UJZ-O86I-DIAG]].
31
34
  const dir = join(vaultRoot, 'specs', 'diagnoses');
32
35
  if (!existsSync(dir)) return occupied;
33
36
  for (const file of readdirSync(dir)) {
@@ -232,7 +235,24 @@ export function executeDeferredSplitReclaim(vaultRoot, task, opts = {}) {
232
235
  parseSpecId(allocatedFamilyId)?.familyKey ?? mintFamilyKey(vaultRoot, { randomUnit: opts.randomUnit });
233
236
  // Per-file FILE KEY (owner ruling 2026-07-10) — filename + `file_id:` SSOT,
234
237
  // and (V3) the anchor for `id:` itself.
235
- const fileKey = mintFileKey(vaultRoot, { randomUnit: opts.randomUnit });
238
+ // FILE KEY occupancy ([[HS-FJWL]]). `opts.occupiedKeys` lets a tier-aware
239
+ // caller hand in the set resolved for ITS read tier
240
+ // (`collectOccupiedKeysEitherKindViaTier`); absent one, this falls back to
241
+ // the local `specs/**` walk.
242
+ //
243
+ // The fallback is safe HERE and nowhere else, for a reason worth stating:
244
+ // this function is synchronous and its only caller is the console wrapper
245
+ // `spex-console/src/lib/delegation/deferred-reclaim.ts`, which `readFileSync`s
246
+ // the task out of `vaultRoot()` and lets this module `commitWrites` the DLQ
247
+ // diagnose back into `specs/diagnoses/` — a disk-backed vault end to end (the
248
+ // line below already notes the cloud-mode gap, [[HS-7UJZ-O86I-DIAG]]). There
249
+ // is no MCP caller. A session with no local vault fails at the file READ long
250
+ // before it reaches the mint. Give this an async caller and pass
251
+ // `occupiedKeys`; do not let the fallback become the cloud path.
252
+ const fileKey = mintFileKey(
253
+ opts.occupiedKeys instanceof Set ? opts.occupiedKeys : collectOccupiedKeysEitherKind(vaultRoot),
254
+ { randomUnit: opts.randomUnit },
255
+ );
236
256
  const diagnoseId = `HS-${fileKey}`;
237
257
  const built = buildDlqDiagnoseContent({
238
258
  diagnoseId,
@@ -246,7 +266,13 @@ export function executeDeferredSplitReclaim(vaultRoot, task, opts = {}) {
246
266
  now,
247
267
  });
248
268
 
269
+ // Disk-only write target — a cloud-mode session leaves no Neon row; see [[HS-7UJZ-O86I-DIAG]].
249
270
  const diagnoseDir = join(vaultRoot, 'specs', 'diagnoses');
271
+ try {
272
+ assertPathUnderRoot(vaultRoot, diagnoseDir, 'deferred-reclaim write');
273
+ } catch (err) {
274
+ return { ok: false, error: 'BAD_VAULT_ROOT', message: err.message };
275
+ }
250
276
  if (!existsSync(diagnoseDir)) mkdirSync(diagnoseDir, { recursive: true });
251
277
  const diagnoseAbsPath = join(diagnoseDir, `${built.basename}.md`);
252
278
  writes.push({ kind: 'create', absPath: diagnoseAbsPath, content: built.content });
@@ -0,0 +1,31 @@
1
+ // heyspex_design_* handlers (thin wrappers over design-tools.mjs). Extracted from cli.mjs under
2
+ // [[HS-BLNF-6W61-DIAG-cli-mjs-has-grown-into-a-13-665-line]] (no behavior change).
3
+
4
+ import { detectAgentIdentity } from './mcp-config.mjs';
5
+ import { wrapDesignResult } from './tool-helpers.mjs';
6
+ import { executeGetDoc as executeDesignGetDoc, executeGetPattern as executeDesignGetPattern, executeGetToken as executeDesignGetToken, executeListPatterns as executeDesignListPatterns, executeProposePattern as executeDesignProposePattern } from './design-tools.mjs';
7
+
8
+ export function handleDesignListPatterns(vaultRoot, args) {
9
+ return wrapDesignResult(executeDesignListPatterns(vaultRoot, args));
10
+ }
11
+
12
+ export function handleDesignGetPattern(vaultRoot, args) {
13
+ return wrapDesignResult(executeDesignGetPattern(vaultRoot, args));
14
+ }
15
+
16
+ export function handleDesignGetToken(vaultRoot, args) {
17
+ return wrapDesignResult(executeDesignGetToken(vaultRoot, args));
18
+ }
19
+
20
+ export function handleDesignGetDoc(vaultRoot, args) {
21
+ return wrapDesignResult(executeDesignGetDoc(vaultRoot, args));
22
+ }
23
+
24
+ export function handleDesignProposePattern(vaultRoot, args) {
25
+ const identity = detectAgentIdentity();
26
+ const enriched = {
27
+ ...args,
28
+ proposed_by: args.proposed_by ?? `${identity.agent} · ${identity.sessionId}`,
29
+ };
30
+ return wrapDesignResult(executeDesignProposePattern(vaultRoot, enriched));
31
+ }
@@ -11,7 +11,9 @@
11
11
  */
12
12
 
13
13
  import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
14
- import { join, relative } from 'node:path';
14
+ import { join, relative, isAbsolute, resolve, sep } from 'node:path';
15
+ import { assertPathUnderRoot } from '../../scripts/lib/shared-path-helpers.mjs';
16
+
15
17
 
16
18
  const REGISTRY_DIR = ['spex-console', 'public', 'design-system'];
17
19
  const ITEMS_DIR = ['spex-console', 'public', 'design-system', 'r'];
@@ -416,6 +418,7 @@ export function executeProposePattern(vaultRoot, args = {}) {
416
418
 
417
419
  try {
418
420
  if (!existsSync(proposalsDir)) {
421
+ assertPathUnderRoot(vaultRoot, proposalsDir, 'design-tools write');
419
422
  mkdirSync(proposalsDir, { recursive: true });
420
423
  }
421
424
  writeFileSync(filepath, JSON.stringify(proposal, null, 2) + '\n', 'utf8');
@@ -4,11 +4,11 @@
4
4
  *
5
5
  * Why this file exists ([[HS-ZJSP-0JNK-TASK-ship-the-heyspex-mcp-server-as-an]]):
6
6
  * `research-tools.mjs` used to `import` two modules from
7
- * `../../dev-factory/dream-research/` at the top level. Those two static imports
7
+ * `../../dev-factory/spexcrawl/` at the top level. Those two static imports
8
8
  * dragged **25 files** into the MCP server's static import closure —
9
9
  * `dev-factory/router.mjs` (model routing), `dev-factory/cloud-clients.mjs`
10
10
  * (provider clients + env loading), `dev-factory/cost-tracker.mjs`, and the whole
11
- * `dev-factory/dream-research/` engine. Any npm package that ships a working MCP
11
+ * `dev-factory/spexcrawl/` engine. Any npm package that ships a working MCP
12
12
  * server therefore had to ship all of them, and `npm publish` is a one-way door.
13
13
  *
14
14
  * Loading them through `await import()` instead means:
@@ -40,8 +40,8 @@ export function loadDreamResearch() {
40
40
  dreamResearchPromise = (async () => {
41
41
  try {
42
42
  const [adapter, tool] = await Promise.all([
43
- import('../../dev-factory/dream-research/firecrawl-adapter.mjs'),
44
- import('../../dev-factory/dream-research/interactive-firecrawl-tool.mjs'),
43
+ import('../../dev-factory/spexcrawl/firecrawl-adapter.mjs'),
44
+ import('../../dev-factory/spexcrawl/interactive-firecrawl-tool.mjs'),
45
45
  ]);
46
46
  return {
47
47
  available: true,
@@ -0,0 +1,45 @@
1
+ /**
2
+ * dev-mode-core.mjs — one bundler-safe loader for `scripts/lib/dev-mode.mjs`.
3
+ *
4
+ * Several handlers on the cloud-safe surface (next_on_deck's WIP pre-fetch,
5
+ * file_spec's landing probe, read-path's tier selector) load the shared
6
+ * dev-mode core through a PATH they build at runtime
7
+ * (`pathToFileURL(join(root, 'scripts', 'lib', 'dev-mode.mjs'))`). That is
8
+ * correct for the stdio server — `HS_PKG_ROOT` and the pre-split vault-root
9
+ * layout both have to keep working — but it is invisible to a bundler, so on
10
+ * the console's `POST /api/mcp` deployment the built path pointed at the
11
+ * BUILD machine's checkout, missed, and the caller silently degraded (no WIP
12
+ * block, no landing probe).
13
+ *
14
+ * {@link importDevModeCore} keeps the runtime candidates first (identical
15
+ * stdio behaviour) and falls back to a STATIC relative specifier, which
16
+ * webpack traces at build time and therefore bundles. Packed installs resolve
17
+ * it to the same file the path candidates name
18
+ * (`node_modules/heyspex/scripts/lib/dev-mode.mjs`, shipped via package.json
19
+ * `files`), so the fallback is the same module by another route — never a
20
+ * different implementation.
21
+ */
22
+ import { existsSync } from 'node:fs';
23
+ import { pathToFileURL } from 'node:url';
24
+
25
+ /**
26
+ * Load the shared dev-mode core.
27
+ *
28
+ * @param {Array<string|null|undefined>} [candidatePaths]
29
+ * Absolute paths to try, in order, before the bundled fallback. A path that
30
+ * does not exist (or whose import throws) is skipped.
31
+ * @returns {Promise<typeof import('../../scripts/lib/dev-mode.mjs')>}
32
+ */
33
+ export async function importDevModeCore(candidatePaths = []) {
34
+ for (const candidate of candidatePaths) {
35
+ if (typeof candidate !== 'string' || !candidate) continue;
36
+ try {
37
+ if (existsSync(candidate)) return await import(pathToFileURL(candidate).href);
38
+ } catch {
39
+ // Unreadable/unloadable candidate — try the next one, then the fallback.
40
+ }
41
+ }
42
+ // Static specifier: the bundler resolves this at build time, so the module
43
+ // travels with the serverless chunk instead of being looked up on disk.
44
+ return import('../../scripts/lib/dev-mode.mjs');
45
+ }
@@ -0,0 +1,124 @@
1
+ // heyspex_dev_mode and heyspex_dev_server_status. Extracted from cli.mjs under
2
+ // [[HS-BLNF-6W61-DIAG-cli-mjs-has-grown-into-a-13-665-line]] (no behavior change).
3
+
4
+ import { pathToFileURL } from 'node:url';
5
+ import { join } from 'node:path';
6
+ import { existsSync } from 'node:fs';
7
+ import { PACKAGE_ROOT } from './package-root.mjs';
8
+ import { resolveCodeRoot } from '../../scripts/lib/shared-path-helpers.mjs';
9
+
10
+ // scripts/dev-server-status.mjs and scripts/lib/dev-mode.mjs are CODE-repo
11
+ // artifacts that ship with this package — since the repo split (2026-08-31)
12
+ // the vault root no longer holds them, so resolve them relative to THIS
13
+ // file's checkout first and fall back to the vault root only for pre-split
14
+ // layouts where they were the same repo. `HS_PKG_ROOT` (set by the `hs.mjs`
15
+ // dispatcher for checks-running verbs — see `scripts/lib/checks-code-root.test.mjs`)
16
+ // wins over the derived root when a caller knows the real code checkout is
17
+ // elsewhere; same precedence plan-review.mjs's `resolveCodeAuditRoots` uses.
18
+ const CHECKOUT_ROOT = resolveCodeRoot({ packageRoot: PACKAGE_ROOT });
19
+
20
+ export async function handleDevServerStatus(vaultRoot) {
21
+ try {
22
+ const local = join(CHECKOUT_ROOT, 'scripts', 'dev-server-status.mjs');
23
+ const scriptPath = existsSync(local)
24
+ ? local
25
+ : join(vaultRoot, 'scripts', 'dev-server-status.mjs');
26
+ const coreUrl = pathToFileURL(scriptPath).href;
27
+ const mod = await import(coreUrl);
28
+ const status = await mod.getDevServerStatus({ vaultRoot });
29
+ return { content: [{ type: 'text', text: JSON.stringify(status, null, 2) }] };
30
+ } catch (err) {
31
+ return {
32
+ isError: true,
33
+ content: [{
34
+ type: 'text',
35
+ text: JSON.stringify(
36
+ { error: 'DEV_SERVER_STATUS_FAILED', message: String(err?.message ?? err) },
37
+ null,
38
+ 2,
39
+ ),
40
+ }],
41
+ };
42
+ }
43
+ }
44
+
45
+ /**
46
+ * heyspex_dev_mode — get/set the dev/prod mode + local/cloud reliance (epic
47
+ * HS-Q6M2). Same self-containment pattern as handleDevServerStatus above:
48
+ * vendor/ stays dependency-free by dynamically importing the shared core
49
+ * (scripts/lib/dev-mode.mjs) from the vault root at call time.
50
+ */
51
+ export async function handleDevMode(vaultRoot, args = {}) {
52
+ try {
53
+ const local = join(CHECKOUT_ROOT, 'scripts', 'lib', 'dev-mode.mjs');
54
+ const scriptPath = existsSync(local)
55
+ ? local
56
+ : join(vaultRoot, 'scripts', 'lib', 'dev-mode.mjs');
57
+ const coreUrl = pathToFileURL(scriptPath).href;
58
+ const mod = await import(coreUrl);
59
+ if (args.action === 'set') {
60
+ if (!args.mode) {
61
+ return {
62
+ isError: true,
63
+ content: [{
64
+ type: 'text',
65
+ text: JSON.stringify({ error: 'MISSING_MODE', message: 'action:"set" requires mode (prod | dev | local | cloud)' }),
66
+ }],
67
+ };
68
+ }
69
+ let result;
70
+ try {
71
+ result = await mod.applyDevMode(
72
+ { mode: args.mode, reliance: args.reliance },
73
+ { source: 'mcp' },
74
+ );
75
+ } catch (err) {
76
+ if (err?.name === 'DevModeNotAdminError') {
77
+ return {
78
+ isError: true,
79
+ content: [{
80
+ type: 'text',
81
+ text: JSON.stringify({ error: 'NOT_ADMIN', message: err.message }),
82
+ }],
83
+ };
84
+ }
85
+ throw err;
86
+ }
87
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
88
+ }
89
+ // get (default): two-pass — resolve without the cloud rung to learn which
90
+ // console to ask, then fold the fetched cloud setting into the resolution.
91
+ const pre = mod.resolveDevMode();
92
+ const cloud = await mod.fetchCloudDevMode({ consoleUrl: pre.consoleUrl });
93
+ const resolved = mod.resolveDevMode({
94
+ cloudSetting: cloud.ok ? cloud.setting : undefined,
95
+ });
96
+ // Same `mode_override` object `hs dev --json` / `hs doctor --json` carry
97
+ // (HS-EVVI — the MCP must never disagree with the CLI on mode). Guarded:
98
+ // an older shipped core without the helper still answers.
99
+ const modeOverride =
100
+ typeof mod.modeOverrideOf === 'function' ? mod.modeOverrideOf(resolved) : null;
101
+ return {
102
+ content: [{
103
+ type: 'text',
104
+ text: JSON.stringify(
105
+ { resolved, cloudError: cloud.error ?? null, mode_override: modeOverride },
106
+ null,
107
+ 2,
108
+ ),
109
+ }],
110
+ };
111
+ } catch (err) {
112
+ return {
113
+ isError: true,
114
+ content: [{
115
+ type: 'text',
116
+ text: JSON.stringify(
117
+ { error: 'DEV_MODE_FAILED', message: String(err?.message ?? err) },
118
+ null,
119
+ 2,
120
+ ),
121
+ }],
122
+ };
123
+ }
124
+ }
@@ -0,0 +1,54 @@
1
+ // heyspex_verify_reproduction and heyspex_promote_diagnose handlers. Extracted from cli.mjs under
2
+ // [[HS-BLNF-6W61-DIAG-cli-mjs-has-grown-into-a-13-665-line]] (no behavior change).
3
+
4
+ import { executePromoteDiagnose } from './promote-diagnose.mjs';
5
+ import { consoleFetch } from './console-transport.mjs';
6
+ import { resolveConsoleReliance } from './console-fetch.mjs';
7
+ import { resolveSpecIdForMutation } from './tool-helpers.mjs';
8
+ import { executeVerifyReproduction } from './verify-reproduction.mjs';
9
+ import { detectAgentIdentity } from './mcp-config.mjs';
10
+
11
+ /**
12
+ * `heyspex_promote_diagnose` — the breakdown stage entered from a bug write-up
13
+ * ([[HS-ALBV]]). Same stage as `heyspex_promote_research`, second input kind.
14
+ */
15
+ export async function handlePromoteDiagnose(vaultRoot, args = {}) {
16
+ const result = await executePromoteDiagnose({ vaultRoot, args, deps: { consoleFetch } });
17
+ if (result.error) {
18
+ return {
19
+ isError: true,
20
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
21
+ };
22
+ }
23
+ return {
24
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
25
+ };
26
+ }
27
+
28
+ /**
29
+ * `heyspex_verify_reproduction` — record a reproduction re-run on a diagnose and
30
+ * close it on a clean run ([[HS-09W1]] of [[HS-ALBV]]). The close goes through the
31
+ * status API so the reproduction gate inside it is what decides, not this handler.
32
+ * In cloud reliance the diagnose is resolved and the verdict recorded through the
33
+ * console — a cloud-filed diagnose has no file on disk ([[HS-7UJZ-O86I-DIAG]]).
34
+ */
35
+ export async function handleVerifyReproduction(vaultRoot, args = {}) {
36
+ const resolved = await resolveSpecIdForMutation(args.spec_id);
37
+ if (resolved.response) return resolved.response;
38
+ const result = await executeVerifyReproduction({
39
+ vaultRoot,
40
+ consoleFetch,
41
+ args: { ...args, spec_id: resolved.specId },
42
+ agent: detectAgentIdentity(args.by, undefined).agent,
43
+ getReliance: resolveConsoleReliance,
44
+ });
45
+ if (result.error) {
46
+ return {
47
+ isError: true,
48
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
49
+ };
50
+ }
51
+ return {
52
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
53
+ };
54
+ }
@@ -0,0 +1,207 @@
1
+ // Read-only audit views: heyspex_events_show, export_events, evidence_show, milestone_show. Extracted from cli.mjs under
2
+ // [[HS-BLNF-6W61-DIAG-cli-mjs-has-grown-into-a-13-665-line]] (no behavior change).
3
+
4
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
5
+ import { join } from 'node:path';
6
+
7
+ import { consoleFetch, controlError, controlOk } from './console-transport.mjs';
8
+ import { consoleErrorCode, consoleErrorMessage } from './console-fetch.mjs';
9
+ import { resolveActiveVault } from './vault-resolve.mjs';
10
+
11
+ /**
12
+ * Read the current on-deck pin — [[HS-EPIC-NCPS]] focus_pin / [[HS-EPIC-CLMW]]
13
+ * `hs pin get`. GET /api/control/pin → { pin: row | null }.
14
+ */
15
+ /**
16
+ * Locked empty-state copy — byte-identical to the CLI core
17
+ * (`spex-console/src/lib/cli/milestone-show.ts` `NEEDS_BREAKDOWN_MESSAGE`) and
18
+ * the MCEP pill sub-line. [[HS-TASK-HMSE]] / [[HS-EPIC-MNBD]].
19
+ */
20
+ export const MILESTONE_NEEDS_BREAKDOWN_MESSAGE = 'Needs breakdown — file the first epic to start';
21
+
22
+ /**
23
+ * Show a milestone + its structured needs-breakdown shape ([[HS-TASK-HMSE]]).
24
+ * Reads the spec via GET /api/specs/:id, counts `epics:`, and returns
25
+ * epics_count / needs_breakdown / needs_breakdown_message so console and agent
26
+ * consumers read the empty state without re-deriving it.
27
+ */
28
+ export async function handleMilestoneShow(args = {}) {
29
+ if (!args.spec_id || typeof args.spec_id !== 'string') {
30
+ return {
31
+ isError: true,
32
+ content: [
33
+ { type: 'text', text: JSON.stringify({ error: 'INVALID_ARGS', message: 'spec_id is required' }) },
34
+ ],
35
+ };
36
+ }
37
+ const result = await consoleFetch(`/api/specs/${encodeURIComponent(args.spec_id)}`);
38
+ if (!result.ok) {
39
+ const payload = {
40
+ error: consoleErrorCode(result, 'MILESTONE_SHOW_FAILED'),
41
+ message: consoleErrorMessage(result, `GET /api/specs/${args.spec_id} returned ${result.status}`),
42
+ status: result.status,
43
+ url: result.url,
44
+ console_response: result.body,
45
+ attempted: { spec_id: args.spec_id },
46
+ };
47
+ return { isError: true, content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
48
+ }
49
+ const spec = result.body?.spec ?? null;
50
+ if (!spec || spec.type !== 'milestone') {
51
+ return {
52
+ isError: true,
53
+ content: [
54
+ {
55
+ type: 'text',
56
+ text: JSON.stringify({
57
+ error: 'NOT_A_MILESTONE',
58
+ message: `${args.spec_id} is ${spec ? `a ${spec.type}` : 'not found'}, not a milestone`,
59
+ }),
60
+ },
61
+ ],
62
+ };
63
+ }
64
+ const epics = spec.frontmatter?.epics;
65
+ const epicsCount = Array.isArray(epics) ? epics.length : 0;
66
+ const needsBreakdown = epicsCount === 0;
67
+ const payload = {
68
+ id: spec.id,
69
+ name: spec.name ?? null,
70
+ status: spec.status ?? null,
71
+ created: spec.frontmatter?.created ?? null,
72
+ epics_count: epicsCount,
73
+ needs_breakdown: needsBreakdown,
74
+ needs_breakdown_message: needsBreakdown ? MILESTONE_NEEDS_BREAKDOWN_MESSAGE : null,
75
+ };
76
+ return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
77
+ }
78
+
79
+ /**
80
+ * Read the per-spec audit log — [[HS-EPIC-NCPS]] spec_events / [[HS-EPIC-CLMW]]
81
+ * `hs events show`. GET /api/control/spec-events?spec_id=&kind=&since=&limit=.
82
+ */
83
+ export async function handleEventsShow(args = {}) {
84
+ const params = new URLSearchParams();
85
+ if (typeof args.spec_id === 'string') params.set('spec_id', args.spec_id);
86
+ if (typeof args.kind === 'string') params.set('kind', args.kind);
87
+ if (typeof args.since === 'string') params.set('since', args.since);
88
+ if (args.limit !== undefined && args.limit !== null) params.set('limit', String(args.limit));
89
+ const result = await consoleFetch(`/api/control/spec-events?${params.toString()}`);
90
+ if (!result.ok) return controlError(result, 'EVENTS_SHOW_FAILED', { spec_id: args.spec_id });
91
+ return controlOk(result.body);
92
+ }
93
+
94
+ /**
95
+ * Consolidated evidence-trail read path
96
+ * ([[HS-BNDJ-TASK-consolidated-evidence-read-path-evidence]]). Thin wrapper
97
+ * around GET /api/evidence/:specId — the same `getEvidenceTrail` logic
98
+ * (`spex-console/src/lib/evidence-trail.ts`) that backs the console UI, so
99
+ * an assisting agent gets one consolidated answer in one call.
100
+ */
101
+ export async function handleEvidenceShow(args = {}) {
102
+ if (!args.spec_id || typeof args.spec_id !== 'string') {
103
+ return {
104
+ isError: true,
105
+ content: [
106
+ { type: 'text', text: JSON.stringify({ error: 'INVALID_ARGS', message: 'spec_id is required' }) },
107
+ ],
108
+ };
109
+ }
110
+ const result = await consoleFetch(`/api/evidence/${encodeURIComponent(args.spec_id)}`);
111
+ if (!result.ok) return controlError(result, 'EVIDENCE_SHOW_FAILED', { spec_id: args.spec_id });
112
+ return controlOk(result.body);
113
+ }
114
+
115
+ /**
116
+ * Idempotent union-and-sort merge of one month file — the SAME rule the
117
+ * console's `mergeMonthLines` (`spex-console/src/lib/control-plane/audit-export.ts`)
118
+ * applies: existing lines ∪ fresh lines, deduped, sorted (every line starts
119
+ * with `{"timestamp":"<iso>"` so the sort is timestamp-primary), trailing
120
+ * newline. Re-running over the same events is byte-identical; existing lines
121
+ * are never dropped.
122
+ */
123
+ export function mergeMonthLinesLocal(existing, freshContent) {
124
+ const set = new Set();
125
+ for (const src of [existing ?? '', freshContent ?? '']) {
126
+ for (const line of src.split('\n')) {
127
+ const trimmed = line.trim();
128
+ if (trimmed) set.add(trimmed);
129
+ }
130
+ }
131
+ if (set.size === 0) return '';
132
+ return `${[...set].sort().join('\n')}\n`;
133
+ }
134
+
135
+ /**
136
+ * Write the month files a CLOUD export returned (`mode: "return"`) into the
137
+ * caller's vault repo at `<specsDir>/events/<name>`, merging idempotently.
138
+ * Returns the local receipt to splice into the tool result.
139
+ */
140
+ export function writeReturnedMonthFiles(files, specsDirAbs, fsDeps = {}) {
141
+ const exists = fsDeps.existsSync ?? existsSync;
142
+ const read = fsDeps.readFileSync ?? readFileSync;
143
+ const write = fsDeps.writeFileSync ?? writeFileSync;
144
+ const mkdir = fsDeps.mkdirSync ?? mkdirSync;
145
+ const eventsDir = join(specsDirAbs, 'events');
146
+ const written = [];
147
+ const unchanged = [];
148
+ for (const file of files) {
149
+ if (!file || typeof file.name !== 'string' || !/^\d{4}-\d{2}\.jsonl$/.test(file.name)) continue;
150
+ const target = join(eventsDir, file.name);
151
+ const existing = exists(target) ? read(target, 'utf8') : null;
152
+ const merged = mergeMonthLinesLocal(existing, file.content);
153
+ if (merged === (existing ?? '')) {
154
+ unchanged.push(target);
155
+ continue;
156
+ }
157
+ mkdir(eventsDir, { recursive: true });
158
+ write(target, merged, 'utf8');
159
+ written.push(target);
160
+ }
161
+ return { vault_events_dir: eventsDir, files_written_locally: written, unchanged_locally: unchanged };
162
+ }
163
+
164
+ /**
165
+ * Run the idempotent audit export — [[HS-EPIC-AEHR]] §1. POST
166
+ * /api/control/export-events. The on-close flush surface for the
167
+ * session-close protocol (no automatic harness hook yet).
168
+ *
169
+ * Two contracts, chosen by the CONSOLE ([[HS-ZD70-OX3V-DIAG-heyspex-export-events-always-409s-in]]):
170
+ * a local console writes `specs/events/YYYY-MM.jsonl` itself (`mode:
171
+ * "vault"`); a cloud console has no vault on disk, so it RETURNS the month
172
+ * files (`mode: "return"`) and this tool merges them into the caller's vault
173
+ * repo — the same file, written from the other side.
174
+ */
175
+ export async function handleExportEvents(args = {}) {
176
+ const body = {};
177
+ if (typeof args.since === 'string' && args.since.trim()) body.since = args.since.trim();
178
+ // A LOCAL console writes into ITS OWN vault root; naming ours lets it refuse
179
+ // (409 vault_root_mismatch) instead of rewriting a different vault's
180
+ // specs/events/ when this session points at the wrong console. A cloud
181
+ // console ignores it (it returns the files instead).
182
+ let vault = null;
183
+ try {
184
+ vault = resolveActiveVault();
185
+ if (vault?.specsDirAbs) body.expected_vault_root = vault.specsDirAbs;
186
+ } catch { /* best-effort — the export still runs unguarded when unresolvable */ }
187
+ const result = await consoleFetch('/api/control/export-events', { method: 'POST', body });
188
+ if (!result.ok) return controlError(result, 'EXPORT_EVENTS_FAILED');
189
+ const payload = result.body ?? {};
190
+ if (payload.mode === 'return' && Array.isArray(payload.files)) {
191
+ if (!vault?.specsDirAbs) {
192
+ return controlOk({
193
+ ...payload,
194
+ files: payload.files.map((f) => ({ name: f?.name, bytes: (f?.content ?? '').length })),
195
+ note:
196
+ 'The cloud console returned the month files but no local vault could be resolved from this session, so nothing was written. Run from a checkout of the vault repo (heyspex.yaml at its root) to land specs/events/.',
197
+ });
198
+ }
199
+ const receipt = writeReturnedMonthFiles(payload.files, vault.specsDirAbs);
200
+ return controlOk({
201
+ ...payload,
202
+ files: payload.files.map((f) => ({ name: f?.name, bytes: (f?.content ?? '').length })),
203
+ ...receipt,
204
+ });
205
+ }
206
+ return controlOk(payload);
207
+ }