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,300 @@
1
+ /**
2
+ * http-transport.mjs — MCP Streamable HTTP transport, JSON-response mode.
3
+ *
4
+ * A thin, dependency-free JSON-RPC 2.0 front for the EXISTING tool dispatch
5
+ * (`tool-dispatch.mjs#dispatchHeySpexTool`), built for the first remote MCP
6
+ * endpoint (`POST /api/mcp` on the console, WP6 of the cloud-first track).
7
+ * `cli.mjs` remains the stdio wiring; this module is the HTTP wiring. Neither
8
+ * knows about tools — both hand every `tools/call` to the same dispatch.
9
+ *
10
+ * Scope (deliberately small):
11
+ * - methods: initialize, notifications/*, ping, tools/list, tools/call
12
+ * - single messages AND batches (JSON-RPC arrays)
13
+ * - stateless: `initialize` mints a session id, later calls echo the
14
+ * client's `Mcp-Session-Id`; nothing is kept server-side
15
+ * - JSON responses only (`application/json`). SSE streaming is NOT
16
+ * implemented: the server never pushes notifications or progress, so a
17
+ * plain JSON reply is a complete, spec-valid response for every method
18
+ * here. Clients that accept only `text/event-stream` still get JSON.
19
+ *
20
+ * The handler is a factory (`createRemoteMcpHandler`) fed its dependencies —
21
+ * `dispatch`, the visible `tools`, the allowlist predicate — so this file
22
+ * never imports the heavy dispatch graph and stays unit-testable in
23
+ * isolation. Every `tools/call` runs inside `runWithRequestContext`, which is
24
+ * how the caller's own console origin + bearer reach `console-transport.mjs`
25
+ * without touching `process.env` (see request-context.mjs).
26
+ */
27
+ import { randomUUID } from 'node:crypto';
28
+
29
+ import { runWithRequestContext } from './request-context.mjs';
30
+
31
+ export const JSONRPC_VERSION = '2.0';
32
+
33
+ /** Protocol revisions this transport speaks; the client's pick is echoed when listed. */
34
+ export const SUPPORTED_PROTOCOL_VERSIONS = ['2025-06-18', '2025-03-26', '2024-11-05'];
35
+ export const DEFAULT_PROTOCOL_VERSION = '2025-03-26';
36
+
37
+ export const JSONRPC_ERROR = Object.freeze({
38
+ PARSE_ERROR: -32700,
39
+ INVALID_REQUEST: -32600,
40
+ METHOD_NOT_FOUND: -32601,
41
+ INVALID_PARAMS: -32602,
42
+ INTERNAL_ERROR: -32603,
43
+ });
44
+
45
+ /** Error code for a tool that exists in the catalog but is not served remotely. */
46
+ export const TOOL_NOT_AVAILABLE_REMOTELY = 'TOOL_NOT_AVAILABLE_REMOTELY';
47
+
48
+ export function negotiateProtocolVersion(requested) {
49
+ return typeof requested === 'string' && SUPPORTED_PROTOCOL_VERSIONS.includes(requested)
50
+ ? requested
51
+ : DEFAULT_PROTOCOL_VERSION;
52
+ }
53
+
54
+ export function newSessionId() {
55
+ return randomUUID();
56
+ }
57
+
58
+ export function jsonRpcError(id, code, message, data) {
59
+ return {
60
+ jsonrpc: JSONRPC_VERSION,
61
+ id: id === undefined ? null : id,
62
+ error: { code, message, ...(data !== undefined ? { data } : {}) },
63
+ };
64
+ }
65
+
66
+ export function jsonRpcResult(id, result) {
67
+ return { jsonrpc: JSONRPC_VERSION, id, result };
68
+ }
69
+
70
+ /** The body to send when the request body was not valid JSON at all. */
71
+ export function parseErrorBody() {
72
+ return jsonRpcError(null, JSONRPC_ERROR.PARSE_ERROR, 'Parse error: request body is not valid JSON');
73
+ }
74
+
75
+ function isPlainObject(value) {
76
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
77
+ }
78
+
79
+ function isValidId(id) {
80
+ return typeof id === 'string' || (typeof id === 'number' && Number.isFinite(id));
81
+ }
82
+
83
+ /** Strip the catalog-only `budget` field: it is a server contract, not MCP wire shape. */
84
+ export function toWireTool(tool) {
85
+ const { budget: _budget, ...wire } = tool;
86
+ return wire;
87
+ }
88
+
89
+ /**
90
+ * Human-readable refusal for a catalog tool that is not on the remote surface.
91
+ * Returned as a JSON-RPC `-32602` (the MCP convention for an unknown tool) so
92
+ * the client surfaces it as a protocol error rather than a tool result.
93
+ */
94
+ export function toolNotAvailableRemotelyError(id, name) {
95
+ return jsonRpcError(
96
+ id,
97
+ JSONRPC_ERROR.INVALID_PARAMS,
98
+ `Tool "${name}" is not available on the remote HeySpex MCP endpoint. It reads or writes the local vault checkout (disk/git), so it needs the local stdio server: install the heyspex package and add it with \`claude mcp add heyspex heyspex-mcp\` (see docs/mcp-client-config.md).`,
99
+ { error: TOOL_NOT_AVAILABLE_REMOTELY, tool: name },
100
+ );
101
+ }
102
+
103
+ /**
104
+ * @typedef {object} RemoteMcpDeps
105
+ * @property {(name: string, args: Record<string, unknown>, vaultRoot: string, config: object) => unknown} dispatch
106
+ * The tool dispatch (`dispatchHeySpexTool`). May return a value or a Promise.
107
+ * @property {Array<{ name: string, [k: string]: unknown }>} tools
108
+ * The tools to advertise from `tools/list` — already reduced to the remote surface.
109
+ * @property {(name: string) => boolean} [isToolAllowed]
110
+ * Allowlist predicate for `tools/call` (default: membership in `tools`).
111
+ * @property {(name: string) => boolean} [isKnownTool]
112
+ * Whether a name exists in the FULL catalog — distinguishes "needs the local
113
+ * server" from "no such tool" (default: same as `isToolAllowed`).
114
+ * @property {{ name: string, version: string }} serverInfo
115
+ * @property {string} [instructions]
116
+ * @property {string} vaultRoot
117
+ * Passed through to `dispatch`. For the remote endpoint this is a path that
118
+ * does not exist: every allowlisted handler works via the console API in the
119
+ * neon read tier and never touches it.
120
+ * @property {object} [config] Passed through to `dispatch` (default: `{ vaultRoot }`).
121
+ */
122
+
123
+ /**
124
+ * @typedef {object} RemoteMcpCallContext
125
+ * @property {string|null} [sessionId] The client's `Mcp-Session-Id`, if any.
126
+ * @property {string} [consoleUrl] Origin the in-process handlers should call back to (the deployment itself).
127
+ * @property {Record<string, string>} [authHeader] The caller's own credential to forward on those calls.
128
+ * @property {string} [agentName] Default `agent` for tools whose schema declares one.
129
+ */
130
+
131
+ /**
132
+ * Build the remote handler. Returns `{ handleMessage, handleBody }`:
133
+ * - `handleMessage(message, ctx)` → one JSON-RPC response object, or `null`
134
+ * for a notification / client response (nothing to send back)
135
+ * - `handleBody(parsedBody, ctx)` → `{ status, body, sessionId }` for a
136
+ * single message or a batch; `status` 202 + `body` null when there is
137
+ * nothing to send (notification-only input)
138
+ *
139
+ * @param {RemoteMcpDeps} deps
140
+ */
141
+ export function createRemoteMcpHandler(deps) {
142
+ const {
143
+ dispatch,
144
+ tools,
145
+ serverInfo,
146
+ instructions,
147
+ vaultRoot,
148
+ } = deps;
149
+ if (typeof dispatch !== 'function') throw new TypeError('createRemoteMcpHandler: dispatch is required');
150
+ if (!Array.isArray(tools)) throw new TypeError('createRemoteMcpHandler: tools must be an array');
151
+ if (typeof vaultRoot !== 'string' || !vaultRoot) throw new TypeError('createRemoteMcpHandler: vaultRoot is required');
152
+
153
+ const toolsByName = new Map(tools.map((t) => [t.name, t]));
154
+ const isToolAllowed = deps.isToolAllowed ?? ((name) => toolsByName.has(name));
155
+ const isKnownTool = deps.isKnownTool ?? isToolAllowed;
156
+ const config = deps.config ?? { vaultRoot };
157
+ const wireTools = tools.map(toWireTool);
158
+
159
+ function initializeResult(params) {
160
+ return {
161
+ protocolVersion: negotiateProtocolVersion(params?.protocolVersion),
162
+ // No listChanged: the remote surface is fixed (no progressive loader),
163
+ // and JSON-response mode has no channel to push a notification anyway.
164
+ capabilities: { tools: { listChanged: false } },
165
+ serverInfo: { name: serverInfo?.name ?? 'heyspex', version: serverInfo?.version ?? '0.0.0-unknown' },
166
+ ...(instructions ? { instructions } : {}),
167
+ };
168
+ }
169
+
170
+ /**
171
+ * Fill `session_id` / `agent` from the transport when the tool's schema
172
+ * declares them and the caller left them out — a remote client has no
173
+ * CLAUDE_CODE_SESSION_ID env for `detectAgentIdentity` to find, so without
174
+ * this every remote claim would read as "External agent · local".
175
+ */
176
+ function withIdentityDefaults(tool, args, ctx) {
177
+ const props = tool?.inputSchema?.properties;
178
+ if (!isPlainObject(props)) return args;
179
+ const out = { ...args };
180
+ if ('session_id' in props && out.session_id === undefined && ctx?.sessionId) {
181
+ out.session_id = ctx.sessionId;
182
+ }
183
+ if ('agent' in props && out.agent === undefined && ctx?.agentName) {
184
+ out.agent = ctx.agentName;
185
+ }
186
+ return out;
187
+ }
188
+
189
+ async function callTool(id, params, ctx) {
190
+ const name = params?.name;
191
+ if (typeof name !== 'string' || !name.trim()) {
192
+ return jsonRpcError(id, JSONRPC_ERROR.INVALID_PARAMS, 'tools/call requires params.name (string)');
193
+ }
194
+ if (!isToolAllowed(name)) {
195
+ return isKnownTool(name)
196
+ ? toolNotAvailableRemotelyError(id, name)
197
+ : jsonRpcError(id, JSONRPC_ERROR.INVALID_PARAMS, `Unknown tool: ${name}`, { tool: name });
198
+ }
199
+ const rawArgs = params?.arguments;
200
+ if (rawArgs !== undefined && !isPlainObject(rawArgs)) {
201
+ return jsonRpcError(id, JSONRPC_ERROR.INVALID_PARAMS, 'tools/call params.arguments must be an object');
202
+ }
203
+ const args = withIdentityDefaults(toolsByName.get(name), rawArgs ?? {}, ctx);
204
+ const requestContext = {
205
+ ...(ctx?.consoleUrl ? { consoleUrl: ctx.consoleUrl } : {}),
206
+ ...(ctx?.authHeader ? { authHeader: ctx.authHeader } : {}),
207
+ };
208
+ try {
209
+ const result = await runWithRequestContext(requestContext, () => dispatch(name, args, vaultRoot, config));
210
+ if (!isPlainObject(result) || !Array.isArray(result.content)) {
211
+ return jsonRpcError(id, JSONRPC_ERROR.INTERNAL_ERROR, `Tool "${name}" returned a malformed result`);
212
+ }
213
+ return jsonRpcResult(id, result);
214
+ } catch (err) {
215
+ return jsonRpcError(
216
+ id,
217
+ JSONRPC_ERROR.INTERNAL_ERROR,
218
+ `Tool "${name}" failed: ${err?.message ?? String(err)}`,
219
+ { tool: name },
220
+ );
221
+ }
222
+ }
223
+
224
+ /**
225
+ * @param {unknown} message
226
+ * @param {RemoteMcpCallContext} [ctx]
227
+ * @returns {Promise<object|null>}
228
+ */
229
+ async function handleMessage(message, ctx = {}) {
230
+ if (!isPlainObject(message) || message.jsonrpc !== JSONRPC_VERSION) {
231
+ return jsonRpcError(null, JSONRPC_ERROR.INVALID_REQUEST, 'Invalid Request: expected a JSON-RPC 2.0 message');
232
+ }
233
+ const { id, method, params } = message;
234
+
235
+ // A response from the client (to a server request) — we never send
236
+ // requests, so there is nothing to correlate. Ignore.
237
+ if (method === undefined && ('result' in message || 'error' in message)) return null;
238
+
239
+ if (typeof method !== 'string') {
240
+ return jsonRpcError(isValidId(id) ? id : null, JSONRPC_ERROR.INVALID_REQUEST, 'Invalid Request: method must be a string');
241
+ }
242
+
243
+ // Notifications carry no id and get no response (notifications/initialized, cancelled, ...).
244
+ if (id === undefined || id === null) return null;
245
+
246
+ if (!isValidId(id)) {
247
+ return jsonRpcError(null, JSONRPC_ERROR.INVALID_REQUEST, 'Invalid Request: id must be a string or number');
248
+ }
249
+ if (params !== undefined && !isPlainObject(params)) {
250
+ return jsonRpcError(id, JSONRPC_ERROR.INVALID_PARAMS, 'params must be an object');
251
+ }
252
+
253
+ switch (method) {
254
+ case 'initialize':
255
+ return jsonRpcResult(id, initializeResult(params));
256
+ case 'ping':
257
+ return jsonRpcResult(id, {});
258
+ case 'tools/list':
259
+ return jsonRpcResult(id, { tools: wireTools });
260
+ case 'tools/call':
261
+ return callTool(id, params, ctx);
262
+ default:
263
+ return jsonRpcError(id, JSONRPC_ERROR.METHOD_NOT_FOUND, `Method not found: ${method}`);
264
+ }
265
+ }
266
+
267
+ /**
268
+ * @param {unknown} parsed The JSON-decoded request body (object or array).
269
+ * @param {RemoteMcpCallContext} [ctx]
270
+ * @returns {Promise<{ status: number, body: object|object[]|null, sessionId: string|null }>}
271
+ */
272
+ async function handleBody(parsed, ctx = {}) {
273
+ const messages = Array.isArray(parsed) ? parsed : [parsed];
274
+ if (Array.isArray(parsed) && parsed.length === 0) {
275
+ return {
276
+ status: 400,
277
+ body: jsonRpcError(null, JSONRPC_ERROR.INVALID_REQUEST, 'Invalid Request: empty batch'),
278
+ sessionId: ctx.sessionId ?? null,
279
+ };
280
+ }
281
+
282
+ // `initialize` mints the session id the client will echo from then on;
283
+ // any other message just echoes what the client sent (stateless server).
284
+ const initializes = messages.some((m) => isPlainObject(m) && m.method === 'initialize');
285
+ const sessionId = initializes ? newSessionId() : (ctx.sessionId ?? null);
286
+ const callCtx = { ...ctx, sessionId };
287
+
288
+ const responses = [];
289
+ for (const message of messages) {
290
+ const response = await handleMessage(message, callCtx);
291
+ if (response !== null) responses.push(response);
292
+ }
293
+
294
+ if (responses.length === 0) return { status: 202, body: null, sessionId };
295
+ if (Array.isArray(parsed)) return { status: 200, body: responses, sessionId };
296
+ return { status: 200, body: responses[0], sessionId };
297
+ }
298
+
299
+ return { handleMessage, handleBody };
300
+ }
@@ -0,0 +1,257 @@
1
+ // Keyset paging + byte budget for every MCP tool that lists specs
2
+ // ([[HS-E66W-8KK5-TASK-mcp-list-tools-page-with-a-cursor-and-a]] under
3
+ // [[HS-0R2X-8KK5-EPIC-keyset-paging-on-every-list-and-agent]]).
4
+ //
5
+ // The contract every list-shaped tool in the registry follows:
6
+ // - accepts `limit` (clamped to the tool's max) plus an opaque `after`
7
+ // cursor, and hands back ONE page under `<rows_key>` plus `next_cursor`
8
+ // (a string when more rows exist, `null` on the last page — the field is
9
+ // always present);
10
+ // - never a total count — the response says what it returned, not how many
11
+ // rows exist ([[mcp-tool-response-size-budget]]: ~2k tokens per list call);
12
+ // - the serialized response stays under the tool's byte budget: when a page
13
+ // would exceed it, rows are dropped from the END of the page and
14
+ // `next_cursor` is re-pointed at the last row kept, so the caller loses
15
+ // nothing — it just follows the cursor. The cursor is never dropped.
16
+ //
17
+ // `LIST_SPEC_TOOLS` is the registry `list-tools.byte-budget.test.mjs` walks:
18
+ // every tool named here must declare `limit` + `after` in its catalog schema
19
+ // and prove the page + budget behaviour against a fixture vault. A tool
20
+ // whose name looks list-shaped but whose rows are NOT specs is classified in
21
+ // `NON_SPEC_LIST_TOOLS` with a reason, so a new list tool that is neither
22
+ // fails the registry test until someone classifies it.
23
+ //
24
+ // Cursor shape mirrors the console's keyset endpoint
25
+ // (`spex-console/src/lib/specs/list-query.ts`): base64url JSON over the sort
26
+ // key + the id tie-breaker, opaque to callers, fails closed on garbage.
27
+
28
+ /** ~2k tokens ([[mcp-tool-response-size-budget]]) at ~4 bytes per token. */
29
+ export const LIST_TOOL_BYTE_BUDGET = 8 * 1024;
30
+
31
+ /**
32
+ * Every MCP tool whose rows are specs. Keys are catalog tool names.
33
+ * rows_key — the response field carrying the page
34
+ * default_limit — page size when the caller passes no `limit`
35
+ * max_limit — hard clamp on a caller's `limit`
36
+ * byte_budget — ceiling on `Buffer.byteLength(content[0].text)`
37
+ */
38
+ export const LIST_SPEC_TOOLS = Object.freeze({
39
+ heyspex_recall_memories: Object.freeze({
40
+ rows_key: 'memories',
41
+ default_limit: 10,
42
+ max_limit: 30,
43
+ byte_budget: LIST_TOOL_BYTE_BUDGET,
44
+ }),
45
+ heyspex_list_procedures: Object.freeze({
46
+ rows_key: 'procedures',
47
+ default_limit: 10,
48
+ max_limit: 50,
49
+ byte_budget: LIST_TOOL_BYTE_BUDGET,
50
+ }),
51
+ });
52
+
53
+ /**
54
+ * Tools whose NAME reads as a list but whose rows are not specs — exempt from
55
+ * the spec-paging contract, each with the reason on record. The registry test
56
+ * requires every list-looking catalog tool to appear in exactly one of the two
57
+ * maps.
58
+ */
59
+ export const NON_SPEC_LIST_TOOLS = Object.freeze({
60
+ heyspex_list_known_tags: 'tag vocabulary with per-tag counts (GET /api/scheduler/known-tags) — not spec rows',
61
+ heyspex_claim_list: 'active claim rows from the Neon control plane — not spec rows',
62
+ heyspex_design_list_patterns: 'design-system registry entries — not spec rows',
63
+ heyspex_list_recipes: 'workflow-recipe entries — not spec rows',
64
+ heyspex_fleet_queue_list: 'fleet-queue task rows (bearer-authed) — not spec rows',
65
+ heyspex_settings_list: 'settings registry entries — not spec rows',
66
+ heyspex_hooks_list: 'active hook configuration — not spec rows',
67
+ heyspex_events_show: 'per-spec audit-event rows, already `limit`-capped — not spec rows',
68
+ heyspex_milestone_show: 'ONE milestone plus its needs-breakdown shape (GET /api/specs/:id) — a single spec, not a list',
69
+ heyspex_evidence_show: 'evidence rows attached to ONE spec — not a list of specs',
70
+ heyspex_load_tools: 'progressive-disclosure tool-surface loader — not a list of anything in the vault',
71
+ });
72
+
73
+ /** The tool names the registry test treats as "list-shaped by name". */
74
+ export const LIST_SHAPED_NAME_PATTERN = /^heyspex_(list_|recall_|load_)|_list$|_show$/;
75
+
76
+ /**
77
+ * Cursors are opaque to callers by design — malformed input fails closed as
78
+ * "bad cursor", never a silent restart from page 1 or a full re-scan.
79
+ */
80
+ export class InvalidListCursorError extends Error {
81
+ constructor() {
82
+ super('invalid or corrupted `after` cursor — pass a `next_cursor` value from a prior page, or omit it for page 1');
83
+ this.name = 'INVALID_CURSOR';
84
+ }
85
+ }
86
+
87
+ /** `{ k, id }` → opaque base64url string. `k` is the sort key, `id` the tie-breaker. */
88
+ export function encodeListCursor({ k, id }) {
89
+ return Buffer.from(JSON.stringify({ k: String(k ?? ''), id: String(id ?? '') }), 'utf8').toString('base64url');
90
+ }
91
+
92
+ export function decodeListCursor(cursor) {
93
+ if (typeof cursor !== 'string' || cursor.length === 0) throw new InvalidListCursorError();
94
+ try {
95
+ const parsed = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8'));
96
+ if (parsed && typeof parsed === 'object' && typeof parsed.k === 'string' && typeof parsed.id === 'string') {
97
+ return { k: parsed.k, id: parsed.id };
98
+ }
99
+ } catch {
100
+ // fall through
101
+ }
102
+ throw new InvalidListCursorError();
103
+ }
104
+
105
+ /** Clamp a caller's `limit` into `[1, max_limit]`, defaulting when absent/invalid. */
106
+ export function resolvePageLimit(raw, { default_limit, max_limit }) {
107
+ const n = Number(raw);
108
+ const wanted = Number.isFinite(n) && n > 0 ? Math.floor(n) : default_limit;
109
+ return Math.max(1, Math.min(wanted, max_limit));
110
+ }
111
+
112
+ function compareKeyset(a, b) {
113
+ return a.k.localeCompare(b.k) || a.id.localeCompare(b.id);
114
+ }
115
+
116
+ /**
117
+ * One page of an in-memory row set on the keyset contract.
118
+ *
119
+ * Rows are sorted by `(keyOf(row), idOf(row))` — both must be immutable for
120
+ * the row's lifetime, or an edit would move it between pages — then the page
121
+ * is the first `limit` rows strictly AFTER the cursor position. Fetches
122
+ * nothing beyond what the caller already loaded; the disk tier has no index
123
+ * to seek (retiring that whole-vault read is
124
+ * [[HS-49AL-8KK5-EPIC-hot-row-cache-and-bounded-agent-tools]]'s job).
125
+ *
126
+ * @returns {{ rows: any[], next_cursor: string|null }}
127
+ */
128
+ export function paginateRowsKeyset(rows, { limit, after, keyOf, idOf }) {
129
+ const keyed = rows.map((row) => ({ row, k: String(keyOf(row) ?? ''), id: String(idOf(row) ?? '') }));
130
+ keyed.sort(compareKeyset);
131
+ let start = 0;
132
+ if (after) {
133
+ const cursor = decodeListCursor(after);
134
+ start = keyed.findIndex((entry) => compareKeyset(entry, cursor) > 0);
135
+ if (start === -1) return { rows: [], next_cursor: null };
136
+ }
137
+ const page = keyed.slice(start, start + limit);
138
+ const hasMore = start + limit < keyed.length;
139
+ const last = page[page.length - 1];
140
+ return {
141
+ rows: page.map((entry) => entry.row),
142
+ next_cursor: hasMore && last ? encodeListCursor({ k: last.k, id: last.id }) : null,
143
+ };
144
+ }
145
+
146
+ /** The serializer every list tool's `content[0].text` goes through — the budget is measured on exactly this. */
147
+ export function serializeListResult(result) {
148
+ return JSON.stringify(result, null, 2);
149
+ }
150
+
151
+ /**
152
+ * Shrink a page until its serialized form fits `byte_budget`.
153
+ *
154
+ * Drops rows from the END of `result[rows_key]` and re-points `next_cursor`
155
+ * at the last row kept (via `cursorFor(row)`), so the dropped rows are the
156
+ * first rows of the NEXT page — nothing is lost, the cursor is never dropped.
157
+ * `page.budget_trimmed: true` marks a trim. The last row is never dropped:
158
+ * a single row that alone exceeds the budget is returned as-is (better one
159
+ * over-budget row than an empty page with no way forward).
160
+ *
161
+ * @returns {{ result: object, text: string, bytes: number }}
162
+ */
163
+ export function fitPageToByteBudget(result, { rows_key, byte_budget, cursorFor }) {
164
+ let current = result;
165
+ let text = serializeListResult(current);
166
+ let bytes = Buffer.byteLength(text, 'utf8');
167
+ while (bytes > byte_budget && Array.isArray(current[rows_key]) && current[rows_key].length > 1) {
168
+ const kept = current[rows_key].slice(0, -1);
169
+ const last = kept[kept.length - 1];
170
+ current = {
171
+ ...current,
172
+ [rows_key]: kept,
173
+ next_cursor: cursorFor(last),
174
+ page: { ...(current.page ?? {}), returned: kept.length, budget_trimmed: true },
175
+ };
176
+ text = serializeListResult(current);
177
+ bytes = Buffer.byteLength(text, 'utf8');
178
+ }
179
+ return { result: current, text, bytes };
180
+ }
181
+
182
+ /**
183
+ * The whole contract in one call, for a tool that already has its filtered
184
+ * rows in memory: clamp `limit`, decode `after`, page, attach the `page`
185
+ * envelope, then fit to the byte budget.
186
+ *
187
+ * Returns `{ result, text, bytes }` on success or `{ error }` (an
188
+ * `INVALID_CURSOR` payload shaped like every other tool error) when `after`
189
+ * does not decode. `extra` fields are spread onto the result AFTER the page
190
+ * (e.g. `filters_applied`), so a tool keeps its existing companion fields.
191
+ */
192
+ export function pageListToolResult(toolName, rows, args = {}, { keyOf, idOf, extra = {} }) {
193
+ const spec = LIST_SPEC_TOOLS[toolName];
194
+ if (!spec) throw new Error(`pageListToolResult: "${toolName}" is not in LIST_SPEC_TOOLS`);
195
+ const limit = resolvePageLimit(args.limit, spec);
196
+ let page;
197
+ try {
198
+ page = paginateRowsKeyset(rows, { limit, after: args.after, keyOf, idOf });
199
+ } catch (err) {
200
+ if (err instanceof InvalidListCursorError) {
201
+ return { error: { error: 'INVALID_CURSOR', message: err.message, tool: toolName } };
202
+ }
203
+ throw err;
204
+ }
205
+ return finishPagedListToolResult(toolName, { rows: page.rows, next_cursor: page.next_cursor, limit }, { keyOf, idOf, extra });
206
+ }
207
+
208
+ /**
209
+ * The envelope + byte-budget half of {@link pageListToolResult}, for a tool
210
+ * that paged EARLIER (e.g. it paged summary rows, then fetched bodies for
211
+ * just that page) and must not re-page — re-paging an already-paged set
212
+ * would report `next_cursor: null` because the set fits in one page.
213
+ */
214
+ export function finishPagedListToolResult(toolName, { rows, next_cursor, limit }, { keyOf, idOf, extra = {} }) {
215
+ const spec = LIST_SPEC_TOOLS[toolName];
216
+ if (!spec) throw new Error(`finishPagedListToolResult: "${toolName}" is not in LIST_SPEC_TOOLS`);
217
+ const result = {
218
+ [spec.rows_key]: rows,
219
+ next_cursor: next_cursor ?? null,
220
+ page: { limit, returned: rows.length, byte_budget: spec.byte_budget, budget_trimmed: false },
221
+ ...extra,
222
+ };
223
+ const cursorFor = (row) => encodeListCursor({ k: keyOf(row), id: idOf(row) });
224
+ return fitPageToByteBudget(result, { rows_key: spec.rows_key, byte_budget: spec.byte_budget, cursorFor });
225
+ }
226
+
227
+ /**
228
+ * Follow `GET /api/specs` pages to exhaustion for an INTERNAL scan that
229
+ * genuinely needs every row of a projection (today: the on-deck amendment
230
+ * scan over `fields=amends,basename,type`). The console route now always
231
+ * answers a bounded page ([[HS-C6FO-8KK5-TASK-keyset-spec-list-endpoint-query-layer]]),
232
+ * so a caller that reads `body.specs` once silently sees only the first 100
233
+ * rows. This walks `next_cursor` at the route's max page size; `maxPages`
234
+ * bounds the walk so a cursor that never terminates cannot loop forever.
235
+ *
236
+ * Not for tool output — a tool that lists specs pages to the CALLER instead.
237
+ */
238
+ export async function walkSpecListPages(consoleFetch, path, { limit = 500, maxPages = 200 } = {}) {
239
+ const rows = [];
240
+ let after = null;
241
+ for (let i = 0; i < maxPages; i += 1) {
242
+ const url = new URL(path, 'http://console.invalid');
243
+ url.searchParams.set('limit', String(limit));
244
+ if (after) url.searchParams.set('after', after);
245
+ const pagePath = `${url.pathname}${url.search}`;
246
+ const res = await consoleFetch(pagePath);
247
+ if (!res.ok) {
248
+ throw new Error(`GET ${pagePath} returned ${res.status}: ${res.error || JSON.stringify(res.body ?? {})}`);
249
+ }
250
+ const specs = Array.isArray(res.body?.specs) ? res.body.specs : [];
251
+ rows.push(...specs);
252
+ const next = typeof res.body?.next_cursor === 'string' && res.body.next_cursor ? res.body.next_cursor : null;
253
+ if (!next || next === after || specs.length === 0) break;
254
+ after = next;
255
+ }
256
+ return rows;
257
+ }
@@ -96,7 +96,10 @@ export function statesMatch(received, expected) {
96
96
  *
97
97
  * <consoleUrl>/companion/connect?port=<port>&state=<nonce>&client=cli[&label=<text>]
98
98
  */
99
- export function buildConnectUrl({ consoleUrl, port, state, client = LOGIN_CLIENT, label } = {}) {
99
+ export function buildConnectUrl({ consoleUrl, port, state, client = LOGIN_CLIENT, label, prompt } = {}) {
100
+ if (prompt !== undefined && prompt !== null && !LOGIN_PROMPTS.includes(prompt)) {
101
+ throw new Error(`buildConnectUrl: prompt must be one of ${LOGIN_PROMPTS.join(', ')}`);
102
+ }
100
103
  if (typeof consoleUrl !== 'string' || !consoleUrl.trim()) {
101
104
  throw new Error('buildConnectUrl: consoleUrl is required');
102
105
  }
@@ -116,9 +119,20 @@ export function buildConnectUrl({ consoleUrl, port, state, client = LOGIN_CLIENT
116
119
  url.searchParams.set('state', state);
117
120
  url.searchParams.set('client', client || LOGIN_CLIENT);
118
121
  if (typeof label === 'string' && label.trim()) url.searchParams.set('label', label.trim());
122
+ if (prompt) url.searchParams.set('prompt', prompt);
119
123
  return url.toString();
120
124
  }
121
125
 
126
+ /**
127
+ * `prompt=` values the console's /companion/connect page understands
128
+ * (spex-console/src/app/companion/connect/connect-flow.ts `CONNECT_PROMPTS`).
129
+ * With a prompt the page stops minting silently and asks "Signed in as X —
130
+ * continue / use a different account / create a new account" first
131
+ * ([[HS-HAQI-KODY-DIAG-hs-install-and-hs-connect-bind-a-new]]). Same names
132
+ * as OpenID Connect's `prompt` parameter.
133
+ */
134
+ export const LOGIN_PROMPTS = Object.freeze(['select_account', 'create']);
135
+
122
136
  /**
123
137
  * Reasons arrive from a browser redirect, i.e. from outside this process.
124
138
  * Clamp them to a short opaque token before they reach a terminal, a log, or
@@ -0,0 +1,94 @@
1
+ // Resolved MCP config: vault root, console URL, agent identity. Extracted from
2
+ // cli.mjs under [[HS-BLNF-6W61-DIAG-cli-mjs-has-grown-into-a-13-665-line]]
3
+ // (no behavior change).
4
+
5
+ import { contextConsoleUrl } from './request-context.mjs';
6
+ import { getActiveConfig } from './vault-resolve.mjs';
7
+ import { resolveConsoleUrl } from '../../scripts/lib/console-url-resolver.mjs';
8
+
9
+ export function getConfig() {
10
+ // Vault resolution is delegated to the shared 5-tier resolver
11
+ // (vault-resolve.mjs) so the MCP face agrees with the console face on which
12
+ // vault is active ([[HS-ICDI-TASK]] / [[HS-PNH2-RES]]). vaultRoot stays the
13
+ // repo root — every downstream call site joins `specs/` off it. Backward
14
+ // compatible: with only HEYSPEX_VAULT_ROOT set and no registry, the resolver
15
+ // returns that same repo root (tier 3).
16
+ // vaultId + vaultSource ([[HS-M95C]]): the locally resolved identity, so a
17
+ // cloud console request can be scoped to THIS repo's vault instead of the
18
+ // account's active-vault pointer (see console-fetch.mjs `getVaultIdHeader`).
19
+ const { repoRootAbs, vaultId, source: vaultSource } = getActiveConfig();
20
+ const vaultRoot = repoRootAbs;
21
+
22
+ const defaultRoadmapId = process.env.HEYSPEX_ROADMAP_ID || 'HS-ROADMAP-PRODUCT';
23
+
24
+ return { vaultRoot, vaultId, vaultSource, defaultRoadmapId };
25
+ }
26
+
27
+ // Resolved on every call (no caching): the MCP process is spawned once at
28
+ // session start, but the console dev server may restart on a different port
29
+ // later. Delegates to the ONE shared resolver
30
+ // (scripts/lib/console-url-resolver.mjs, [[HS-GD45-MCPC-TASK]]) — MODE-AWARE
31
+ // precedence ([[HS-VOJ7-YL1C-DIAG-console-url-resolver-ignores-dev-mode]]):
32
+ // HEYSPEX_CONSOLE_URL env → ~/.heyspex/credentials.yaml `console_url` (written
33
+ // by `hs mcp pair` / `hs login` / `hs dev mode:<...>`) → `.heyspex/console-url`
34
+ // discovery file (written by `npm run dev`, see
35
+ // `spex-console/scripts/ensure-next-dev.mjs`) but ONLY in local reliance →
36
+ // the console URL derived from the resolved dev mode (prod / dev+cloud →
37
+ // https://app.heyspex.com, dev+local → http://localhost:3000).
38
+ export function getConsoleUrl() {
39
+ const { vaultRoot } = getConfig();
40
+ return resolveConsoleUrl({ repoRoot: vaultRoot });
41
+ }
42
+
43
+ /**
44
+ * The console origin THIS call actually talks to.
45
+ *
46
+ * `getConsoleUrl()` answers the PROCESS-wide question (env → credentials →
47
+ * discovery file → dev-mode default), which is the right answer for the stdio
48
+ * server. The console's remote endpoint (`POST /api/mcp`) runs the same
49
+ * handlers in-process and binds the deployment's OWN origin to the async call
50
+ * chain (request-context.mjs), so there `getConsoleUrl()` names a host the
51
+ * request never contacted — which is how a failure got reported "against
52
+ * console https://app.heyspex.com" when the fetch had gone to `selfOrigin()`.
53
+ * Every diagnostic that NAMES the console (error labels, `attempted`
54
+ * payloads, tier reports) must use this, not `getConsoleUrl()`, so the URL in
55
+ * the message is the URL that was used.
56
+ */
57
+ export function effectiveConsoleUrl() {
58
+ return contextConsoleUrl() ?? getConsoleUrl();
59
+ }
60
+
61
+ export function detectAgentIdentity(argAgent, argSession) {
62
+ const trimmedAgent = typeof argAgent === 'string' ? argAgent.trim() : '';
63
+ const trimmedSession = typeof argSession === 'string' ? argSession.trim() : '';
64
+ if (trimmedAgent && trimmedSession) {
65
+ return { agent: trimmedAgent, sessionId: trimmedSession };
66
+ }
67
+ const claudeSession =
68
+ (process.env.CLAUDE_CODE_SESSION_ID || process.env.CLAUDE_SESSION_ID || '').trim();
69
+ if (claudeSession) {
70
+ return {
71
+ agent: trimmedAgent || 'Claude Code',
72
+ sessionId: trimmedSession || claudeSession,
73
+ };
74
+ }
75
+ const cursorSession =
76
+ (process.env.CURSOR_TRACE_ID
77
+ || process.env.CURSOR_SESSION_ID
78
+ || process.env.COMPOSER_SESSION_ID
79
+ || '').trim();
80
+ if (cursorSession) {
81
+ return {
82
+ agent: trimmedAgent || 'Cursor',
83
+ sessionId: trimmedSession || cursorSession,
84
+ };
85
+ }
86
+ return {
87
+ agent: trimmedAgent || process.env.HEYSPEX_AGENT?.trim() || 'External agent',
88
+ sessionId:
89
+ trimmedSession
90
+ || process.env.HEYSPEX_SESSION_ID?.trim()
91
+ || process.env.HEYSPEX_AGENT_SESSION?.trim()
92
+ || 'local',
93
+ };
94
+ }