heyspex 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (352) hide show
  1. package/.claude-plugin/marketplace.json +14 -0
  2. package/README.md +21 -0
  3. package/bin/hs.mjs +1089 -104
  4. package/bin/spexcode.mjs +262 -0
  5. package/dev-factory/engine/worktree.mjs +740 -0
  6. package/dev-factory/lib/code-repo-root.mjs +67 -0
  7. package/dev-factory/lib/context-budget.mjs +84 -0
  8. package/dev-factory/lib/epic-detect.mjs +190 -0
  9. package/dev-factory/lib/fleet-queue-file.mjs +846 -0
  10. package/dev-factory/lib/fleet-queue.mjs +426 -0
  11. package/dev-factory/lib/fleet-state.mjs +14 -18
  12. package/dev-factory/lib/model-catalog.mjs +338 -0
  13. package/dev-factory/lib/seat-context.mjs +199 -0
  14. package/dev-factory/lib/spec-io.mjs +93 -0
  15. package/dev-factory/lib/sufficiency-score.mjs +8 -2
  16. package/dev-factory/lib/worktree-allocator.mjs +744 -0
  17. package/dev-factory/model-catalog.json +222 -0
  18. package/dist/cli/hs-check.mjs +1127 -103
  19. package/dist/cli/hs-claim.mjs +981 -63
  20. package/dist/cli/hs-connect.mjs +10059 -1729
  21. package/dist/cli/hs-defer.mjs +5507 -3072
  22. package/dist/cli/hs-dev.mjs +576 -0
  23. package/dist/cli/hs-env.mjs +416 -302
  24. package/dist/cli/hs-index-rebuild.mjs +7334 -0
  25. package/dist/cli/hs-index-reconcile.mjs +8138 -0
  26. package/dist/cli/hs-init.mjs +2840 -149
  27. package/dist/cli/hs-install.mjs +7214 -594
  28. package/dist/cli/hs-join.mjs +10640 -2421
  29. package/dist/cli/hs-vault-pull.mjs +8157 -6370
  30. package/dist/cli/hs-vault-push.mjs +6433 -0
  31. package/dist/cli/hs-vault.mjs +4029 -1555
  32. package/dist/cli/hs-workspace.mjs +2702 -538
  33. package/dist/cli-templates/customer-vault/specs/entity-model.md +594 -601
  34. package/dist/cli-templates/customer-vault/specs/protocols/canonical-frontmatter-templates.md +110 -177
  35. package/dist/cli-templates/customer-vault/specs/protocols/destructive-action-checklist.md +122 -0
  36. package/dist/cli-templates/customer-vault/specs/protocols/memory-protocol.md +13 -15
  37. package/dist/cli-templates/customer-vault/specs/protocols/session-close-protocol.md +91 -0
  38. package/dist/cli-templates/customer-vault/specs/protocols/spec-hierarchy.md +150 -168
  39. package/dist/cli-templates/customer-vault/specs/protocols/spec-summary-authoring.md +83 -0
  40. package/dist/cli-templates/harness-commands/claude/hs-auto.md +36 -0
  41. package/dist/cli-templates/harness-commands/claude/hs-backfill.md +28 -0
  42. package/dist/cli-templates/harness-commands/claude/hs-capture.md +62 -0
  43. package/dist/cli-templates/harness-commands/claude/hs-defer.md +29 -0
  44. package/dist/cli-templates/harness-commands/claude/hs-dev.md +20 -0
  45. package/dist/cli-templates/harness-commands/claude/hs-doctor.md +32 -0
  46. package/dist/cli-templates/harness-commands/claude/hs-ground.md +30 -0
  47. package/dist/cli-templates/harness-commands/claude/hs-handoff.md +33 -0
  48. package/dist/cli-templates/harness-commands/claude/hs-log.md +27 -0
  49. package/dist/cli-templates/harness-commands/claude/hs-next.md +29 -0
  50. package/dist/cli-templates/harness-commands/claude/hs-promote.md +70 -0
  51. package/dist/cli-templates/harness-commands/claude/hs-push.md +35 -0
  52. package/dist/cli-templates/harness-commands/claude/hs-qc.md +27 -0
  53. package/dist/cli-templates/harness-commands/claude/hs-res.md +63 -0
  54. package/dist/cli-templates/harness-commands/claude/hs-resolve.md +23 -0
  55. package/dist/cli-templates/harness-commands/claude/hs-review.md +23 -0
  56. package/dist/cli-templates/harness-commands/claude/hs-view.md +28 -0
  57. package/dist/cli-templates/harness-commands/claude/kill-ports.md +32 -0
  58. package/dist/cli-templates/harness-commands/claude/prototype.md +91 -0
  59. package/dist/cli-templates/harness-commands/cursor/hs-auto.md +40 -0
  60. package/dist/cli-templates/harness-commands/cursor/hs-backfill.md +31 -0
  61. package/dist/cli-templates/harness-commands/cursor/hs-capture.md +65 -0
  62. package/dist/cli-templates/harness-commands/cursor/hs-defer.md +32 -0
  63. package/dist/cli-templates/harness-commands/cursor/hs-dev.md +24 -0
  64. package/dist/cli-templates/harness-commands/cursor/hs-doctor.md +36 -0
  65. package/dist/cli-templates/harness-commands/cursor/hs-ground.md +33 -0
  66. package/dist/cli-templates/harness-commands/cursor/hs-handoff.md +37 -0
  67. package/dist/cli-templates/harness-commands/cursor/hs-log.md +31 -0
  68. package/dist/cli-templates/harness-commands/cursor/hs-next.md +32 -0
  69. package/dist/cli-templates/harness-commands/cursor/hs-promote.md +73 -0
  70. package/dist/cli-templates/harness-commands/cursor/hs-push.md +38 -0
  71. package/dist/cli-templates/harness-commands/cursor/hs-qc.md +30 -0
  72. package/dist/cli-templates/harness-commands/cursor/hs-res.md +67 -0
  73. package/dist/cli-templates/harness-commands/cursor/hs-resolve.md +26 -0
  74. package/dist/cli-templates/harness-commands/cursor/hs-review.md +26 -0
  75. package/dist/cli-templates/harness-commands/cursor/hs-view.md +32 -0
  76. package/dist/cli-templates/harness-commands/cursor/kill-ports.md +35 -0
  77. package/dist/cli-templates/harness-commands/cursor/prototype.md +95 -0
  78. package/docs/copilot-plugin-install.md +162 -0
  79. package/docs/driving-heyspex-from-copilot.md +165 -0
  80. package/docs/mcp-client-config.md +259 -6
  81. package/package.json +249 -124
  82. package/plugins/heyspex/.claude-plugin/plugin.json +12 -0
  83. package/plugins/heyspex/.cursor-plugin/plugin.json +15 -0
  84. package/plugins/heyspex/.mcp.json +12 -0
  85. package/plugins/heyspex/README.md +309 -0
  86. package/plugins/heyspex/bin/launch-mcp.mjs +95 -0
  87. package/plugins/heyspex/bin/run-hook.mjs +80 -0
  88. package/plugins/heyspex/hooks/cursor-hooks.json +308 -0
  89. package/plugins/heyspex/hooks/hooks.json +418 -0
  90. package/plugins/heyspex/mcp.json +13 -0
  91. package/scripts/check-mcp-freshness.mjs +17 -9
  92. package/scripts/claude-hooks/_env-vault-events.mjs +68 -0
  93. package/scripts/claude-hooks/_guardrail-events.mjs +96 -0
  94. package/scripts/claude-hooks/_guardrail-loader.mjs +494 -0
  95. package/scripts/claude-hooks/_intent-classifier-router.mjs +456 -0
  96. package/scripts/claude-hooks/_intent-classifier-semantic.mjs +356 -0
  97. package/scripts/claude-hooks/_run.mjs +413 -0
  98. package/scripts/claude-hooks/_transcript-text.mjs +60 -0
  99. package/scripts/claude-hooks/_vault-retrieval.mjs +322 -0
  100. package/scripts/claude-hooks/agent-spawn-delegation-gate.mjs +133 -0
  101. package/scripts/claude-hooks/capture-execution-gate.mjs +51 -0
  102. package/scripts/claude-hooks/capture-workflow-gate.mjs +11 -0
  103. package/scripts/claude-hooks/check-auto-memory-budget.mjs +396 -0
  104. package/scripts/claude-hooks/check-claude-md-budget.mjs +327 -0
  105. package/scripts/claude-hooks/check-mcp-freshness-hook.mjs +14 -0
  106. package/scripts/claude-hooks/claim-before-code-gate.mjs +11 -0
  107. package/scripts/claude-hooks/claim-liveness-shared.mjs +577 -0
  108. package/scripts/claude-hooks/close-terminal-on-ship.mjs +64 -0
  109. package/scripts/claude-hooks/coo-denylist-gate.mjs +11 -0
  110. package/scripts/claude-hooks/coo-guard-check.mjs +11 -0
  111. package/scripts/claude-hooks/deep-research-cost-gate.mjs +11 -0
  112. package/scripts/claude-hooks/dev-server-task-reminder.mjs +15 -0
  113. package/scripts/claude-hooks/diagnose-capture-reminder.mjs +11 -0
  114. package/scripts/claude-hooks/directive-obligation-gate.mjs +11 -0
  115. package/scripts/claude-hooks/emit-claim-status.mjs +41 -0
  116. package/scripts/claude-hooks/enforce-session-sequence.mjs +11 -0
  117. package/scripts/claude-hooks/enforce-vault-naming.mjs +11 -0
  118. package/scripts/claude-hooks/env-vault-guard.mjs +11 -0
  119. package/scripts/claude-hooks/epic-milestone-phrasing-gate.mjs +11 -0
  120. package/scripts/claude-hooks/git-identity-guard.mjs +11 -0
  121. package/scripts/claude-hooks/git-reflog-audit.mjs +15 -0
  122. package/scripts/claude-hooks/git-stash-gate.mjs +11 -0
  123. package/scripts/claude-hooks/guard-auto-memory-writes.mjs +137 -0
  124. package/scripts/claude-hooks/id-display-format-gate.mjs +11 -0
  125. package/scripts/claude-hooks/inject-communication-style.mjs +86 -0
  126. package/scripts/claude-hooks/inject-coo-seat.mjs +78 -0
  127. package/scripts/claude-hooks/inject-dev-server-awareness.mjs +11 -0
  128. package/scripts/claude-hooks/inject-doctrine.mjs +13 -0
  129. package/scripts/claude-hooks/inject-vault-context.mjs +14 -0
  130. package/scripts/claude-hooks/intent-classifier-trigger.mjs +279 -0
  131. package/scripts/claude-hooks/log-session-activity.mjs +341 -0
  132. package/scripts/claude-hooks/naughty-list-gate.mjs +11 -0
  133. package/scripts/claude-hooks/notify-status-flip.mjs +72 -0
  134. package/scripts/claude-hooks/plan-exit-ingest-gate.mjs +170 -0
  135. package/scripts/claude-hooks/plan-ingest-close-reminder.mjs +66 -0
  136. package/scripts/claude-hooks/plan-review-gate.mjs +11 -0
  137. package/scripts/claude-hooks/preflight-reminder.mjs +262 -0
  138. package/scripts/claude-hooks/protected-action-intent.mjs +221 -0
  139. package/scripts/claude-hooks/research-method-guard.mjs +398 -0
  140. package/scripts/claude-hooks/research-protocol-gate.mjs +11 -0
  141. package/scripts/claude-hooks/session-end.mjs +451 -0
  142. package/scripts/claude-hooks/settings-write-gate.mjs +11 -0
  143. package/scripts/claude-hooks/settlement-check-gate.mjs +39 -0
  144. package/scripts/claude-hooks/status-flip-detect.mjs +17 -0
  145. package/scripts/claude-hooks/terminal-close-shared.mjs +426 -0
  146. package/scripts/claude-hooks/terminal-close-watcher.mjs +122 -0
  147. package/scripts/claude-hooks/vault-mirror-catchup.mjs +11 -0
  148. package/scripts/claude-hooks/vault-write-tier-guard.mjs +11 -0
  149. package/scripts/claude-hooks/wip-checkpoint.mjs +302 -0
  150. package/scripts/cursor-hooks/manifest.mjs +281 -0
  151. package/scripts/dev-server-status.mjs +34 -13
  152. package/scripts/dev-server-stop.mjs +313 -0
  153. package/scripts/heyspex-mode.mjs +288 -0
  154. package/scripts/hs-auto-resolve.mjs +240 -0
  155. package/scripts/hs-auto-source.mjs +398 -0
  156. package/scripts/hs-auto.mjs +134 -0
  157. package/scripts/hs-capture-cli.mjs +326 -0
  158. package/scripts/hs-classifier.mjs +136 -0
  159. package/scripts/hs-companion.mjs +88 -0
  160. package/scripts/hs-gateway.mjs +115 -0
  161. package/scripts/hs-mode.mjs +125 -0
  162. package/scripts/hs-next.mjs +441 -0
  163. package/scripts/hs-settings.mjs +338 -0
  164. package/scripts/hs-view.mjs +496 -0
  165. package/scripts/hs-worktree.mjs +201 -0
  166. package/scripts/install-claude-code-hooks.mjs +272 -0
  167. package/scripts/install-cursor-hooks.mjs +471 -0
  168. package/scripts/lib/agent-env-markers.mjs +38 -0
  169. package/scripts/lib/console-url-resolver.mjs +199 -61
  170. package/scripts/lib/customer-kit.mjs +1203 -0
  171. package/scripts/lib/dev-mode.mjs +249 -32
  172. package/scripts/lib/directive-obligations.mjs +349 -0
  173. package/scripts/lib/heyspex-home.mjs +72 -0
  174. package/scripts/lib/hook-dispositions.mjs +210 -0
  175. package/scripts/lib/hook-vault-header.mjs +30 -0
  176. package/scripts/lib/hs-command.mjs +277 -0
  177. package/scripts/lib/hs-companion-settings.mjs +472 -0
  178. package/scripts/lib/hs-source-branch.mjs +196 -0
  179. package/scripts/lib/hs-source-commit.mjs +59 -5
  180. package/scripts/lib/hs-source-identity.mjs +526 -0
  181. package/scripts/lib/hs-source-log.mjs +13 -22
  182. package/scripts/lib/hs-source-preflight.mjs +18 -4
  183. package/scripts/lib/hs-source-push.mjs +386 -307
  184. package/scripts/lib/hs-worktree-args.mjs +115 -0
  185. package/scripts/lib/shared-path-helpers.mjs +134 -0
  186. package/scripts/lib/standalone-doctor.mjs +540 -3
  187. package/scripts/lib/subagent-git-lock.mjs +109 -0
  188. package/scripts/lib/template-pin.mjs +34 -0
  189. package/scripts/lib/window-placement.mjs +109 -0
  190. package/scripts/provenance-lib.mjs +91 -0
  191. package/scripts/stamp-provenance-on-edit.mjs +222 -0
  192. package/scripts/wip-ledger-health.mjs +653 -0
  193. package/spexharness/doctrine/baseline/SPEXCODE-DOCTRINE.baseline.json +29 -0
  194. package/spexharness/src/adapters/audit.mjs +197 -0
  195. package/spexharness/src/adapters/claude-code.mjs +449 -0
  196. package/spexharness/src/adapters/copilot.mjs +236 -0
  197. package/spexharness/src/adapters/detect-harness.mjs +34 -0
  198. package/spexharness/src/chat/tui/ansi.mjs +448 -0
  199. package/spexharness/src/chat/tui/palette.mjs +22 -0
  200. package/spexharness/src/chat/tui/select.mjs +208 -0
  201. package/spexharness/src/config-root.mjs +54 -0
  202. package/spexharness/src/context-budget.mjs +141 -0
  203. package/spexharness/src/dev-server-control.mjs +389 -0
  204. package/spexharness/src/doctrine/behaviour.mjs +371 -0
  205. package/spexharness/src/doctrine/corpus.mjs +733 -0
  206. package/spexharness/src/doctrine/inject.mjs +338 -0
  207. package/spexharness/src/doctrine/merge.mjs +184 -0
  208. package/spexharness/src/doctrine/sources.mjs +252 -0
  209. package/spexharness/src/doctrine/synthesize.mjs +401 -0
  210. package/spexharness/src/doctrine/vault-source.mjs +574 -0
  211. package/spexharness/src/doctrine/writer.mjs +176 -0
  212. package/spexharness/src/events.mjs +84 -0
  213. package/spexharness/src/gates/_shell-write-targets.mjs +77 -0
  214. package/spexharness/src/gates/capture-workflow-gate.mjs +202 -0
  215. package/spexharness/src/gates/claim-before-code-gate.decide.mjs +33 -0
  216. package/spexharness/src/gates/claim-before-code-gate.mjs +129 -0
  217. package/spexharness/src/gates/deep-research-cost-gate.mjs +275 -0
  218. package/spexharness/src/gates/diagnose-capture-reminder.mjs +112 -0
  219. package/spexharness/src/gates/directive-obligation-gate.decide.mjs +132 -0
  220. package/spexharness/src/gates/directive-obligation-gate.mjs +183 -0
  221. package/spexharness/src/gates/enforce-session-sequence.mjs +895 -0
  222. package/spexharness/src/gates/enforce-vault-naming.mjs +215 -0
  223. package/spexharness/src/gates/epic-milestone-phrasing-gate.mjs +233 -0
  224. package/spexharness/src/gates/git-identity-guard.decide.mjs +436 -0
  225. package/spexharness/src/gates/git-identity-guard.mjs +61 -0
  226. package/spexharness/src/gates/git-reflog-audit.mjs +151 -0
  227. package/spexharness/src/gates/git-stash-gate.decide.mjs +361 -0
  228. package/spexharness/src/gates/git-stash-gate.mjs +159 -0
  229. package/spexharness/src/gates/id-display-format-gate.mjs +190 -0
  230. package/spexharness/src/gates/inject-doctrine-gate.mjs +89 -0
  231. package/spexharness/src/gates/inject-vault-context.mjs +239 -0
  232. package/spexharness/src/gates/naughty-list-gate.mjs +213 -0
  233. package/spexharness/src/gates/plan-review-gate.mjs +233 -0
  234. package/spexharness/src/gates/preflight-reminder-gate.mjs +265 -0
  235. package/spexharness/src/gates/research-protocol-gate.mjs +175 -0
  236. package/spexharness/src/gates/settings-write-gate.decide.mjs +96 -0
  237. package/spexharness/src/gates/settings-write-gate.mjs +104 -0
  238. package/spexharness/src/gates/vault-mirror-catchup.mjs +124 -0
  239. package/spexharness/src/gates/vault-write-tier-guard.decide.mjs +125 -0
  240. package/spexharness/src/gates/vault-write-tier-guard.mjs +42 -0
  241. package/spexharness/src/git-reflog-audit.mjs +294 -0
  242. package/spexharness/src/intent-classifier.mjs +196 -0
  243. package/spexharness/src/intent-directive.mjs +626 -0
  244. package/spexharness/src/profile/learn.mjs +551 -0
  245. package/spexharness/src/profile/proposals.mjs +357 -0
  246. package/spexharness/src/profile/vault-inclusion.mjs +386 -0
  247. package/spexharness/src/redact.mjs +151 -0
  248. package/spexharness/src/rule-table.mjs +849 -0
  249. package/spexharness/src/seat-context.mjs +122 -0
  250. package/spexharness/src/session-bootstrap.mjs +512 -0
  251. package/spexharness/src/session-record.mjs +815 -0
  252. package/spexharness/src/token-inventory.mjs +207 -0
  253. package/vendor/heyspex-mcp/account-switch.mjs +96 -0
  254. package/vendor/heyspex-mcp/alignment-gate.mjs +315 -1
  255. package/vendor/heyspex-mcp/blocker-resolver.mjs +37 -2
  256. package/vendor/heyspex-mcp/breakdown-ladder.mjs +119 -4
  257. package/vendor/heyspex-mcp/breakdown-source.mjs +271 -0
  258. package/vendor/heyspex-mcp/breakdown-writer.mjs +23 -3
  259. package/vendor/heyspex-mcp/capture-buffer-patches.mjs +77 -8
  260. package/vendor/heyspex-mcp/capture-channel.mjs +55 -5
  261. package/vendor/heyspex-mcp/capture-console-client.mjs +117 -3
  262. package/vendor/heyspex-mcp/capture-focus.mjs +1061 -31
  263. package/vendor/heyspex-mcp/capture-outbox.mjs +15 -5
  264. package/vendor/heyspex-mcp/capture-sources.mjs +195 -0
  265. package/vendor/heyspex-mcp/capture-tools.mjs +1264 -0
  266. package/vendor/heyspex-mcp/claim-tools.mjs +1147 -0
  267. package/vendor/heyspex-mcp/cli-verbs.mjs +2074 -0
  268. package/vendor/heyspex-mcp/cli.mjs +207 -15524
  269. package/vendor/heyspex-mcp/close-branch-report.mjs +102 -0
  270. package/vendor/heyspex-mcp/cloud-tool-surface.mjs +128 -0
  271. package/vendor/heyspex-mcp/cloud-vault-writes.mjs +602 -0
  272. package/vendor/heyspex-mcp/config-seed.yaml +15 -0
  273. package/vendor/heyspex-mcp/config-settings.mjs +290 -7
  274. package/vendor/heyspex-mcp/console-fetch.mjs +822 -17
  275. package/vendor/heyspex-mcp/console-transport.mjs +172 -0
  276. package/vendor/heyspex-mcp/credentials.mjs +585 -0
  277. package/vendor/heyspex-mcp/defer-spec.mjs +44 -4
  278. package/vendor/heyspex-mcp/deferred-reclaim.mjs +29 -3
  279. package/vendor/heyspex-mcp/design-handlers.mjs +31 -0
  280. package/vendor/heyspex-mcp/design-tools.mjs +4 -1
  281. package/vendor/heyspex-mcp/dev-factory-bridge.mjs +4 -4
  282. package/vendor/heyspex-mcp/dev-mode-core.mjs +45 -0
  283. package/vendor/heyspex-mcp/dev-tools.mjs +131 -0
  284. package/vendor/heyspex-mcp/diagnose-handlers.mjs +54 -0
  285. package/vendor/heyspex-mcp/events-tools.mjs +207 -0
  286. package/vendor/heyspex-mcp/filing-tools.mjs +723 -0
  287. package/vendor/heyspex-mcp/fleet-handlers.mjs +37 -0
  288. package/vendor/heyspex-mcp/fleet-queue-tools.mjs +71 -15
  289. package/vendor/heyspex-mcp/focus-tools.mjs +190 -0
  290. package/vendor/heyspex-mcp/folder-trust-store.mjs +60 -0
  291. package/vendor/heyspex-mcp/http-transport.mjs +302 -0
  292. package/vendor/heyspex-mcp/list-tools.mjs +257 -0
  293. package/vendor/heyspex-mcp/login-handoff.mjs +15 -1
  294. package/vendor/heyspex-mcp/mcp-config.mjs +107 -0
  295. package/vendor/heyspex-mcp/memory-tools.mjs +523 -0
  296. package/vendor/heyspex-mcp/mvp-mode.mjs +53 -6
  297. package/vendor/heyspex-mcp/obligation-backstop.mjs +103 -0
  298. package/vendor/heyspex-mcp/occupied-keys.mjs +142 -0
  299. package/vendor/heyspex-mcp/on-deck-brief.mjs +3377 -0
  300. package/vendor/heyspex-mcp/on-deck-scheduler.mjs +1708 -0
  301. package/vendor/heyspex-mcp/package-root.mjs +32 -0
  302. package/vendor/heyspex-mcp/peer-presence.mjs +8 -1
  303. package/vendor/heyspex-mcp/plan-ground.mjs +621 -652
  304. package/vendor/heyspex-mcp/plan-push.mjs +908 -22
  305. package/vendor/heyspex-mcp/plan-review.mjs +124 -30
  306. package/vendor/heyspex-mcp/preflight-gate.mjs +0 -0
  307. package/vendor/heyspex-mcp/procedure-handlers.mjs +327 -0
  308. package/vendor/heyspex-mcp/procedure-tools.mjs +1181 -970
  309. package/vendor/heyspex-mcp/promote-capture.mjs +388 -69
  310. package/vendor/heyspex-mcp/promote-diagnose.mjs +74 -0
  311. package/vendor/heyspex-mcp/promote-research-to-diagnose.mjs +9 -2
  312. package/vendor/heyspex-mcp/promote-research.mjs +666 -53
  313. package/vendor/heyspex-mcp/promotion-helpers.mjs +251 -0
  314. package/vendor/heyspex-mcp/prompts.mjs +1460 -0
  315. package/vendor/heyspex-mcp/read-path.mjs +191 -15
  316. package/vendor/heyspex-mcp/receipt-shape.mjs +146 -0
  317. package/vendor/heyspex-mcp/recipe-handlers.mjs +117 -0
  318. package/vendor/heyspex-mcp/regen-tasks.mjs +210 -3
  319. package/vendor/heyspex-mcp/register-spec.mjs +266 -54
  320. package/vendor/heyspex-mcp/request-context.mjs +83 -0
  321. package/vendor/heyspex-mcp/research-focus.mjs +207 -4
  322. package/vendor/heyspex-mcp/research-handlers.mjs +625 -0
  323. package/vendor/heyspex-mcp/research-tools.mjs +4 -4
  324. package/vendor/heyspex-mcp/response-budget.mjs +348 -0
  325. package/vendor/heyspex-mcp/review-focus.mjs +25 -1
  326. package/vendor/heyspex-mcp/roadmap-default.mjs +70 -0
  327. package/vendor/heyspex-mcp/search-specs.mjs +280 -0
  328. package/vendor/heyspex-mcp/seat-block-ledger.mjs +113 -0
  329. package/vendor/heyspex-mcp/seat-identity.mjs +2 -6
  330. package/vendor/heyspex-mcp/session-owner.mjs +109 -0
  331. package/vendor/heyspex-mcp/session-tools.mjs +162 -0
  332. package/vendor/heyspex-mcp/settings-registry.mjs +232 -0
  333. package/vendor/heyspex-mcp/settings-tools.mjs +85 -12
  334. package/vendor/heyspex-mcp/spec-edit-tools.mjs +453 -0
  335. package/vendor/heyspex-mcp/spec-id-lib.mjs +93 -17
  336. package/vendor/heyspex-mcp/split-research.mjs +304 -56
  337. package/vendor/heyspex-mcp/stdio-rpc.mjs +22 -0
  338. package/vendor/heyspex-mcp/submit-for-review.mjs +150 -7
  339. package/vendor/heyspex-mcp/summary-contract.mjs +105 -0
  340. package/vendor/heyspex-mcp/summary-write-tools.mjs +228 -0
  341. package/vendor/heyspex-mcp/symptom-query.mjs +146 -0
  342. package/vendor/heyspex-mcp/test-helpers.mjs +123 -1
  343. package/vendor/heyspex-mcp/tool-catalog.mjs +2819 -0
  344. package/vendor/heyspex-mcp/tool-dispatch.mjs +657 -0
  345. package/vendor/heyspex-mcp/tool-helpers.mjs +325 -0
  346. package/vendor/heyspex-mcp/validate-linkage.mjs +414 -27
  347. package/vendor/heyspex-mcp/vault-io.mjs +720 -0
  348. package/vendor/heyspex-mcp/vault-less-endpoints.mjs +52 -0
  349. package/vendor/heyspex-mcp/vault-resolve.mjs +554 -68
  350. package/vendor/heyspex-mcp/verify-reproduction.mjs +270 -30
  351. package/vendor/heyspex-mcp/whoami.mjs +92 -0
  352. package/vendor/heyspex-mcp/write-outbox.mjs +512 -0
@@ -0,0 +1,302 @@
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|null} [vaultId] The caller's own `x-heyspex-vault-id`, forwarded so the call is scoped to the vault the CALLER named.
129
+ * @property {string} [agentName] Default `agent` for tools whose schema declares one.
130
+ */
131
+
132
+ /**
133
+ * Build the remote handler. Returns `{ handleMessage, handleBody }`:
134
+ * - `handleMessage(message, ctx)` → one JSON-RPC response object, or `null`
135
+ * for a notification / client response (nothing to send back)
136
+ * - `handleBody(parsedBody, ctx)` → `{ status, body, sessionId }` for a
137
+ * single message or a batch; `status` 202 + `body` null when there is
138
+ * nothing to send (notification-only input)
139
+ *
140
+ * @param {RemoteMcpDeps} deps
141
+ */
142
+ export function createRemoteMcpHandler(deps) {
143
+ const {
144
+ dispatch,
145
+ tools,
146
+ serverInfo,
147
+ instructions,
148
+ vaultRoot,
149
+ } = deps;
150
+ if (typeof dispatch !== 'function') throw new TypeError('createRemoteMcpHandler: dispatch is required');
151
+ if (!Array.isArray(tools)) throw new TypeError('createRemoteMcpHandler: tools must be an array');
152
+ if (typeof vaultRoot !== 'string' || !vaultRoot) throw new TypeError('createRemoteMcpHandler: vaultRoot is required');
153
+
154
+ const toolsByName = new Map(tools.map((t) => [t.name, t]));
155
+ const isToolAllowed = deps.isToolAllowed ?? ((name) => toolsByName.has(name));
156
+ const isKnownTool = deps.isKnownTool ?? isToolAllowed;
157
+ const config = deps.config ?? { vaultRoot };
158
+ const wireTools = tools.map(toWireTool);
159
+
160
+ function initializeResult(params) {
161
+ return {
162
+ protocolVersion: negotiateProtocolVersion(params?.protocolVersion),
163
+ // No listChanged: the remote surface is fixed (no progressive loader),
164
+ // and JSON-response mode has no channel to push a notification anyway.
165
+ capabilities: { tools: { listChanged: false } },
166
+ serverInfo: { name: serverInfo?.name ?? 'heyspex', version: serverInfo?.version ?? '0.0.0-unknown' },
167
+ ...(instructions ? { instructions } : {}),
168
+ };
169
+ }
170
+
171
+ /**
172
+ * Fill `session_id` / `agent` from the transport when the tool's schema
173
+ * declares them and the caller left them out — a remote client has no
174
+ * CLAUDE_CODE_SESSION_ID env for `detectAgentIdentity` to find, so without
175
+ * this every remote claim would read as "External agent · local".
176
+ */
177
+ function withIdentityDefaults(tool, args, ctx) {
178
+ const props = tool?.inputSchema?.properties;
179
+ if (!isPlainObject(props)) return args;
180
+ const out = { ...args };
181
+ if ('session_id' in props && out.session_id === undefined && ctx?.sessionId) {
182
+ out.session_id = ctx.sessionId;
183
+ }
184
+ if ('agent' in props && out.agent === undefined && ctx?.agentName) {
185
+ out.agent = ctx.agentName;
186
+ }
187
+ return out;
188
+ }
189
+
190
+ async function callTool(id, params, ctx) {
191
+ const name = params?.name;
192
+ if (typeof name !== 'string' || !name.trim()) {
193
+ return jsonRpcError(id, JSONRPC_ERROR.INVALID_PARAMS, 'tools/call requires params.name (string)');
194
+ }
195
+ if (!isToolAllowed(name)) {
196
+ return isKnownTool(name)
197
+ ? toolNotAvailableRemotelyError(id, name)
198
+ : jsonRpcError(id, JSONRPC_ERROR.INVALID_PARAMS, `Unknown tool: ${name}`, { tool: name });
199
+ }
200
+ const rawArgs = params?.arguments;
201
+ if (rawArgs !== undefined && !isPlainObject(rawArgs)) {
202
+ return jsonRpcError(id, JSONRPC_ERROR.INVALID_PARAMS, 'tools/call params.arguments must be an object');
203
+ }
204
+ const args = withIdentityDefaults(toolsByName.get(name), rawArgs ?? {}, ctx);
205
+ const requestContext = {
206
+ ...(ctx?.consoleUrl ? { consoleUrl: ctx.consoleUrl } : {}),
207
+ ...(ctx?.authHeader ? { authHeader: ctx.authHeader } : {}),
208
+ ...(ctx?.vaultId ? { vaultId: ctx.vaultId } : {}),
209
+ };
210
+ try {
211
+ const result = await runWithRequestContext(requestContext, () => dispatch(name, args, vaultRoot, config));
212
+ if (!isPlainObject(result) || !Array.isArray(result.content)) {
213
+ return jsonRpcError(id, JSONRPC_ERROR.INTERNAL_ERROR, `Tool "${name}" returned a malformed result`);
214
+ }
215
+ return jsonRpcResult(id, result);
216
+ } catch (err) {
217
+ return jsonRpcError(
218
+ id,
219
+ JSONRPC_ERROR.INTERNAL_ERROR,
220
+ `Tool "${name}" failed: ${err?.message ?? String(err)}`,
221
+ { tool: name },
222
+ );
223
+ }
224
+ }
225
+
226
+ /**
227
+ * @param {unknown} message
228
+ * @param {RemoteMcpCallContext} [ctx]
229
+ * @returns {Promise<object|null>}
230
+ */
231
+ async function handleMessage(message, ctx = {}) {
232
+ if (!isPlainObject(message) || message.jsonrpc !== JSONRPC_VERSION) {
233
+ return jsonRpcError(null, JSONRPC_ERROR.INVALID_REQUEST, 'Invalid Request: expected a JSON-RPC 2.0 message');
234
+ }
235
+ const { id, method, params } = message;
236
+
237
+ // A response from the client (to a server request) — we never send
238
+ // requests, so there is nothing to correlate. Ignore.
239
+ if (method === undefined && ('result' in message || 'error' in message)) return null;
240
+
241
+ if (typeof method !== 'string') {
242
+ return jsonRpcError(isValidId(id) ? id : null, JSONRPC_ERROR.INVALID_REQUEST, 'Invalid Request: method must be a string');
243
+ }
244
+
245
+ // Notifications carry no id and get no response (notifications/initialized, cancelled, ...).
246
+ if (id === undefined || id === null) return null;
247
+
248
+ if (!isValidId(id)) {
249
+ return jsonRpcError(null, JSONRPC_ERROR.INVALID_REQUEST, 'Invalid Request: id must be a string or number');
250
+ }
251
+ if (params !== undefined && !isPlainObject(params)) {
252
+ return jsonRpcError(id, JSONRPC_ERROR.INVALID_PARAMS, 'params must be an object');
253
+ }
254
+
255
+ switch (method) {
256
+ case 'initialize':
257
+ return jsonRpcResult(id, initializeResult(params));
258
+ case 'ping':
259
+ return jsonRpcResult(id, {});
260
+ case 'tools/list':
261
+ return jsonRpcResult(id, { tools: wireTools });
262
+ case 'tools/call':
263
+ return callTool(id, params, ctx);
264
+ default:
265
+ return jsonRpcError(id, JSONRPC_ERROR.METHOD_NOT_FOUND, `Method not found: ${method}`);
266
+ }
267
+ }
268
+
269
+ /**
270
+ * @param {unknown} parsed The JSON-decoded request body (object or array).
271
+ * @param {RemoteMcpCallContext} [ctx]
272
+ * @returns {Promise<{ status: number, body: object|object[]|null, sessionId: string|null }>}
273
+ */
274
+ async function handleBody(parsed, ctx = {}) {
275
+ const messages = Array.isArray(parsed) ? parsed : [parsed];
276
+ if (Array.isArray(parsed) && parsed.length === 0) {
277
+ return {
278
+ status: 400,
279
+ body: jsonRpcError(null, JSONRPC_ERROR.INVALID_REQUEST, 'Invalid Request: empty batch'),
280
+ sessionId: ctx.sessionId ?? null,
281
+ };
282
+ }
283
+
284
+ // `initialize` mints the session id the client will echo from then on;
285
+ // any other message just echoes what the client sent (stateless server).
286
+ const initializes = messages.some((m) => isPlainObject(m) && m.method === 'initialize');
287
+ const sessionId = initializes ? newSessionId() : (ctx.sessionId ?? null);
288
+ const callCtx = { ...ctx, sessionId };
289
+
290
+ const responses = [];
291
+ for (const message of messages) {
292
+ const response = await handleMessage(message, callCtx);
293
+ if (response !== null) responses.push(response);
294
+ }
295
+
296
+ if (responses.length === 0) return { status: 202, body: null, sessionId };
297
+ if (Array.isArray(parsed)) return { status: 200, body: responses, sessionId };
298
+ return { status: 200, body: responses[0], sessionId };
299
+ }
300
+
301
+ return { handleMessage, handleBody };
302
+ }
@@ -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 Object.assign(new Error(`GET ${pagePath} returned ${res.status}: ${res.error || JSON.stringify(res.body ?? {})}`), { status: res.status, errorKind: res.errorKind, authProblem: res.authProblem || res.refusal ? true : undefined });
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