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,348 @@
1
+ // Byte budget for every MCP READ tool
2
+ // ([[HS-4H8T-8KK5-TASK-every-mcp-read-tool-declares-a-byte]] under
3
+ // [[HS-49AL-8KK5-EPIC-hot-row-cache-and-bounded-agent-tools]]).
4
+ //
5
+ // Before this module no byte budget existed anywhere in the MCP server: memory
6
+ // recall bounded by COUNT and sliced, list tools that page
7
+ // ([[HS-E66W-8KK5-TASK-mcp-list-tools-page-with-a-cursor-and-a]], `list-tools.mjs`)
8
+ // fit their own 8 KB page, and everything else returned whatever it had —
9
+ // `heyspex_list_known_tags` once handed an agent 138 KB in one call
10
+ // ([[HS-7QM6-1Q1C-DIAG-heyspex-list-known-tags-response-busts-t]]).
11
+ //
12
+ // The contract:
13
+ // - every read-shaped catalog tool (name matching {@link READ_TOOL_NAME_PATTERN})
14
+ // declares `budget: { max_bytes }` in `tool-catalog.mjs`
15
+ // ({@link DEFAULT_TOOL_BUDGET_BYTES} unless the tool says otherwise);
16
+ // - `tool-dispatch.mjs` runs every declared budget through
17
+ // {@link applyToolBudget} on the way out, so a tool that pages natively
18
+ // (list-tools, known-tags) is merely re-checked, and a tool that does NOT
19
+ // page is bounded anyway;
20
+ // - {@link enforceByteBudget} measures `Buffer.byteLength(JSON.stringify(result))`
21
+ // — the whole MCP result envelope, a strict superset of the text the agent
22
+ // reads — and when over budget drops trailing items from the payload's list,
23
+ // re-points `next_cursor` at the FIRST DROPPED item, and sets
24
+ // `truncated: true`. It never returns more than `max_bytes`: a payload with
25
+ // no list (or whose single remaining item is itself too big) has its
26
+ // longest strings cut with a marker, and as the last resort the payload is
27
+ // replaced by a tiny `RESPONSE_OVER_BUDGET` envelope.
28
+ //
29
+ // The generic `next_cursor` is a BUDGET cursor: an opaque base64url token the
30
+ // dispatcher recognises on the way IN (`applyToolBudget` strips it, re-issues
31
+ // the tool's own `after`, and resumes from the first dropped item), so a tool
32
+ // with no native paging is still walkable to exhaustion one bounded page at a
33
+ // time, and a tool WITH native paging keeps its own cursor untouched (the
34
+ // budget cursor carries it inside).
35
+
36
+ /** 48 000 bytes ≈ 12k tokens at ~4 bytes/token — the default ceiling for one read-tool reply. */
37
+ export const DEFAULT_TOOL_BUDGET_BYTES = 48_000;
38
+
39
+ /**
40
+ * Read-shaped tool names: the verb segment (after any namespace segment such as
41
+ * `design_`) is one of list / get / recall / search / next / ground.
42
+ * `heyspex_get_procedure`, `heyspex_design_get_pattern`, `heyspex_list_known_tags`,
43
+ * `heyspex_next_on_deck`, `heyspex_ground_plan` all match; `heyspex_claim_spec`
44
+ * does not.
45
+ */
46
+ export const READ_TOOL_NAME_PATTERN = /^heyspex_(?:[a-z0-9]+_)*?(?:list|get|recall|search|next|ground)_/;
47
+
48
+ /**
49
+ * Suffix-shaped reads (`heyspex_claim_list`, `heyspex_events_show`,
50
+ * `heyspex_settings_get`, …) — not in the task's prefix list, but every one of
51
+ * them returns rows or a record to an agent and so declares a budget too.
52
+ */
53
+ export const READ_TOOL_SUFFIX_PATTERN = /_(?:list|show|get|load)$/;
54
+
55
+ export function isReadShapedToolName(name) {
56
+ return READ_TOOL_NAME_PATTERN.test(name) || READ_TOOL_SUFFIX_PATTERN.test(name);
57
+ }
58
+
59
+ export const TRUNCATION_MARKER = '…[truncated: byte budget]';
60
+
61
+ /** The measure every budget is checked against — the whole MCP result envelope. */
62
+ export function measureResultBytes(result) {
63
+ return Buffer.byteLength(JSON.stringify(result), 'utf8');
64
+ }
65
+
66
+ // ── Budget cursor ────────────────────────────────────────────────────────────
67
+
68
+ /**
69
+ * `{ key, at, index, after }` → opaque base64url token.
70
+ * key — the payload field holding the list that was trimmed
71
+ * at — identity ({@link itemIdentity}) of the FIRST DROPPED item, or null
72
+ * index — absolute position of that item in the tool's untrimmed list
73
+ * after — the tool's OWN `after` argument for this page (null when none),
74
+ * re-issued verbatim when the walker comes back with this cursor
75
+ */
76
+ export function encodeBudgetCursor({ key, at, index, after }) {
77
+ return Buffer.from(
78
+ JSON.stringify({ budget: 1, key: String(key), at: at == null ? null : String(at), index: Number(index) || 0, after: after ?? null }),
79
+ 'utf8',
80
+ ).toString('base64url');
81
+ }
82
+
83
+ /** Decode a budget cursor; `null` for anything that is not one (a tool's own cursor, garbage, non-strings). */
84
+ export function decodeBudgetCursor(cursor) {
85
+ if (typeof cursor !== 'string' || cursor.length === 0) return null;
86
+ try {
87
+ const parsed = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8'));
88
+ if (parsed && typeof parsed === 'object' && parsed.budget === 1 && typeof parsed.key === 'string') {
89
+ return {
90
+ key: parsed.key,
91
+ at: typeof parsed.at === 'string' ? parsed.at : null,
92
+ index: Number.isFinite(parsed.index) && parsed.index >= 0 ? Math.floor(parsed.index) : 0,
93
+ after: typeof parsed.after === 'string' && parsed.after ? parsed.after : null,
94
+ };
95
+ }
96
+ } catch {
97
+ // not a budget cursor
98
+ }
99
+ return null;
100
+ }
101
+
102
+ /** Stable identity for a list item — the field a resume can find it by again. Null when nothing fits. */
103
+ export function itemIdentity(item) {
104
+ if (item == null) return null;
105
+ if (typeof item !== 'object') return String(item);
106
+ for (const field of ['id', 'name', 'tag', 'key', 'path', 'spec_id']) {
107
+ const v = item[field];
108
+ if (typeof v === 'string' && v) return v;
109
+ if (typeof v === 'number') return String(v);
110
+ }
111
+ return null;
112
+ }
113
+
114
+ /** The top-level array field carrying the most bytes — the list a trim should shorten. */
115
+ export function pickListKey(payload) {
116
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return null;
117
+ let best = null;
118
+ let bestBytes = -1;
119
+ for (const [key, value] of Object.entries(payload)) {
120
+ if (!Array.isArray(value) || value.length === 0) continue;
121
+ const bytes = Buffer.byteLength(JSON.stringify(value), 'utf8');
122
+ if (bytes > bestBytes) {
123
+ best = key;
124
+ bestBytes = bytes;
125
+ }
126
+ }
127
+ return best;
128
+ }
129
+
130
+ function parsePayload(result) {
131
+ const block = result?.content?.[0];
132
+ if (!block || block.type !== 'text' || typeof block.text !== 'string') return { block: null, payload: null };
133
+ try {
134
+ const payload = JSON.parse(block.text);
135
+ return { block, payload: payload && typeof payload === 'object' && !Array.isArray(payload) ? payload : null };
136
+ } catch {
137
+ return { block, payload: null };
138
+ }
139
+ }
140
+
141
+ function withPayload(result, block, payload, pretty) {
142
+ const text = pretty ? JSON.stringify(payload, null, 2) : JSON.stringify(payload);
143
+ return { ...result, content: [{ ...block, text }, ...result.content.slice(1)] };
144
+ }
145
+
146
+ function isPretty(text) {
147
+ return /^\s*[{[]\s*\n/.test(text);
148
+ }
149
+
150
+ /**
151
+ * Resume a tool's untrimmed result from a budget cursor: drop every item before
152
+ * the first-dropped item the cursor names. Returns the sliced result plus the
153
+ * absolute `offset` the slice started at (so the NEXT budget cursor's `index`
154
+ * stays absolute). A cursor whose list key does not exist in the payload
155
+ * resumes nothing (offset 0) rather than failing — the walker still terminates
156
+ * because the page is re-fitted below.
157
+ */
158
+ export function resumeFromBudgetCursor(result, cursor) {
159
+ const { block, payload } = parsePayload(result);
160
+ if (!block || !payload || !Array.isArray(payload[cursor.key])) return { result, offset: 0 };
161
+ const list = payload[cursor.key];
162
+ let offset = -1;
163
+ if (cursor.at != null) offset = list.findIndex((item) => itemIdentity(item) === cursor.at);
164
+ if (offset === -1) offset = Math.min(cursor.index, list.length);
165
+ const next = { ...payload, [cursor.key]: list.slice(offset) };
166
+ return { result: withPayload(result, block, next, isPretty(block.text)), offset };
167
+ }
168
+
169
+ // ── Enforcement ──────────────────────────────────────────────────────────────
170
+
171
+ /** Largest `n` in `[lo, hi]` for which `fits(n)` holds (`fits` is monotone: smaller n ⇒ fewer bytes). */
172
+ function largestFitting(lo, hi, fits) {
173
+ let best = lo - 1;
174
+ while (lo <= hi) {
175
+ const mid = (lo + hi) >>> 1;
176
+ if (fits(mid)) {
177
+ best = mid;
178
+ lo = mid + 1;
179
+ } else {
180
+ hi = mid - 1;
181
+ }
182
+ }
183
+ return best;
184
+ }
185
+
186
+ /** Walk a payload for its string leaves (depth-limited) — `[path, length]`, longest first. */
187
+ function longestStrings(value, path = [], depth = 0, out = []) {
188
+ if (depth > 4 || value == null) return out;
189
+ if (typeof value === 'string') {
190
+ out.push({ path, length: value.length });
191
+ } else if (Array.isArray(value)) {
192
+ value.forEach((v, i) => longestStrings(v, [...path, i], depth + 1, out));
193
+ } else if (typeof value === 'object') {
194
+ for (const [k, v] of Object.entries(value)) longestStrings(v, [...path, k], depth + 1, out);
195
+ }
196
+ return out;
197
+ }
198
+
199
+ function getAt(obj, path) {
200
+ return path.reduce((acc, k) => (acc == null ? undefined : acc[k]), obj);
201
+ }
202
+
203
+ function setAt(obj, path, value) {
204
+ const clone = Array.isArray(obj) ? [...obj] : { ...obj };
205
+ if (path.length === 1) {
206
+ clone[path[0]] = value;
207
+ return clone;
208
+ }
209
+ clone[path[0]] = setAt(obj[path[0]], path.slice(1), value);
210
+ return clone;
211
+ }
212
+
213
+ function overBudgetEnvelope(result, { tool, bytes, maxBytes }) {
214
+ const text = JSON.stringify({
215
+ error: 'RESPONSE_OVER_BUDGET',
216
+ tool: tool ?? null,
217
+ truncated: true,
218
+ bytes,
219
+ max_bytes: maxBytes,
220
+ message: `The tool reply was ${bytes} bytes against a ${maxBytes}-byte budget and could not be trimmed to fit. Narrow the call (filters, a smaller limit, compact mode) and retry.`,
221
+ });
222
+ return { ...(result?.isError ? { isError: true } : {}), content: [{ type: 'text', text }] };
223
+ }
224
+
225
+ /**
226
+ * Bound one MCP tool result to `maxBytes` (measured on the whole envelope).
227
+ *
228
+ * @param result the MCP result `{ content: [{ type: 'text', text }], isError? }`
229
+ * @param options.maxBytes the budget ({@link DEFAULT_TOOL_BUDGET_BYTES} when omitted)
230
+ * @param options.listKey the payload field to trim; auto-detected ({@link pickListKey}) when omitted
231
+ * @param options.tool tool name, recorded in the trim envelope
232
+ * @param options.after the tool's own `after` arg for this page — carried inside the budget cursor
233
+ * @param options.indexOffset absolute index of the payload list's first item (a resumed page)
234
+ * @returns {{ result: object, bytes: number, truncated: boolean, dropped: number }}
235
+ */
236
+ export function enforceByteBudget(result, options = {}) {
237
+ const maxBytes = Number.isFinite(options.maxBytes) && options.maxBytes > 0 ? options.maxBytes : DEFAULT_TOOL_BUDGET_BYTES;
238
+ const tool = options.tool ?? null;
239
+ if (!result || !Array.isArray(result.content)) {
240
+ return { result, bytes: measureResultBytes(result ?? null), truncated: false, dropped: 0 };
241
+ }
242
+ let bytes = measureResultBytes(result);
243
+ if (bytes <= maxBytes) return { result, bytes, truncated: false, dropped: 0 };
244
+
245
+ const { block, payload } = parsePayload(result);
246
+ let dropped = 0;
247
+
248
+ if (block && payload) {
249
+ const pretty = isPretty(block.text);
250
+ let current = payload;
251
+
252
+ // 1. Drop trailing list items; the cursor points at the first dropped one.
253
+ const key = options.listKey && Array.isArray(payload[options.listKey]) ? options.listKey : pickListKey(payload);
254
+ if (key && Array.isArray(current[key]) && current[key].length > 1) {
255
+ const list = current[key];
256
+ const offset = Number(options.indexOffset) || 0;
257
+ const shaped = (n) => ({
258
+ ...current,
259
+ [key]: list.slice(0, n),
260
+ next_cursor: encodeBudgetCursor({ key, at: itemIdentity(list[n]), index: offset + n, after: options.after ?? null }),
261
+ truncated: true,
262
+ budget: { max_bytes: maxBytes, list_key: key, returned: n, dropped: list.length - n },
263
+ });
264
+ const fits = (n) => measureResultBytes(withPayload(result, block, shaped(n), pretty)) <= maxBytes;
265
+ // Never an empty page with a cursor: at least one item stays, and if that
266
+ // lone item still does not fit the string cut below handles it.
267
+ const keep = Math.max(1, largestFitting(1, list.length - 1, fits));
268
+ current = shaped(keep);
269
+ dropped = list.length - keep;
270
+ bytes = measureResultBytes(withPayload(result, block, current, pretty));
271
+ }
272
+
273
+ // 2. Still over: cut the longest strings, longest first, until it fits.
274
+ if (bytes > maxBytes) {
275
+ current = { ...current, truncated: true, budget: { ...(current.budget ?? {}), max_bytes: maxBytes, strings_cut: 0 } };
276
+ let guard = 0;
277
+ while (bytes > maxBytes && guard < 64) {
278
+ guard += 1;
279
+ const candidates = longestStrings(current).filter((c) => c.length > TRUNCATION_MARKER.length + 8 && c.path[0] !== 'next_cursor');
280
+ if (candidates.length === 0) break;
281
+ candidates.sort((a, b) => b.length - a.length);
282
+ const target = candidates[0];
283
+ const value = getAt(current, target.path);
284
+ const overshoot = bytes - maxBytes;
285
+ // Cut generously: the overshoot plus the marker, but never below 8 chars of the original.
286
+ const keepChars = Math.max(8, value.length - overshoot - TRUNCATION_MARKER.length);
287
+ current = setAt(current, target.path, value.slice(0, keepChars) + TRUNCATION_MARKER);
288
+ current.budget = { ...current.budget, strings_cut: (current.budget.strings_cut ?? 0) + 1 };
289
+ bytes = measureResultBytes(withPayload(result, block, current, pretty));
290
+ }
291
+ }
292
+
293
+ if (bytes <= maxBytes) {
294
+ return { result: withPayload(result, block, current, pretty), bytes, truncated: true, dropped };
295
+ }
296
+ } else if (block) {
297
+ // 3. Non-JSON text: cut the text itself to fit.
298
+ const text = block.text;
299
+ const fitsText = (n) => measureResultBytes({ ...result, content: [{ ...block, text: text.slice(0, n) + TRUNCATION_MARKER }, ...result.content.slice(1)] }) <= maxBytes;
300
+ const keep = largestFitting(0, text.length, fitsText);
301
+ if (keep >= 0) {
302
+ const cut = { ...result, content: [{ ...block, text: text.slice(0, keep) + TRUNCATION_MARKER }, ...result.content.slice(1)] };
303
+ return { result: cut, bytes: measureResultBytes(cut), truncated: true, dropped: 0 };
304
+ }
305
+ }
306
+
307
+ // 4. Last resort — the envelope is a few hundred bytes and always fits.
308
+ const envelope = overBudgetEnvelope(result, { tool, bytes, maxBytes });
309
+ return { result: envelope, bytes: measureResultBytes(envelope), truncated: true, dropped };
310
+ }
311
+
312
+ /**
313
+ * The dispatcher's hook: run `invoke(args)` for a tool that declares `budget`,
314
+ * resuming from a budget cursor on the way in and enforcing the budget on the
315
+ * way out. A tool without a budget is invoked untouched. `invoke` may return a
316
+ * Promise (several handlers are async) — the result is unwrapped either way.
317
+ */
318
+ export function applyToolBudget(name, args, invoke, budget) {
319
+ const maxBytes = Number(budget?.max_bytes);
320
+ if (!Number.isFinite(maxBytes) || maxBytes <= 0) return invoke(args);
321
+
322
+ let effectiveArgs = args ?? {};
323
+ let resume = null;
324
+ if (typeof effectiveArgs.after === 'string') {
325
+ resume = decodeBudgetCursor(effectiveArgs.after);
326
+ if (resume) {
327
+ effectiveArgs = { ...effectiveArgs };
328
+ if (resume.after) effectiveArgs.after = resume.after;
329
+ else delete effectiveArgs.after;
330
+ }
331
+ }
332
+
333
+ const finish = (raw) => {
334
+ let result = raw;
335
+ let indexOffset = 0;
336
+ if (resume) ({ result, offset: indexOffset } = resumeFromBudgetCursor(result, resume));
337
+ return enforceByteBudget(result, {
338
+ maxBytes,
339
+ listKey: budget.list_key,
340
+ tool: name,
341
+ after: typeof effectiveArgs.after === 'string' ? effectiveArgs.after : null,
342
+ indexOffset,
343
+ }).result;
344
+ };
345
+
346
+ const raw = invoke(effectiveArgs);
347
+ return raw && typeof raw.then === 'function' ? raw.then(finish) : finish(raw);
348
+ }
@@ -5,6 +5,23 @@
5
5
  * Mirrors `.heyspex/focus.yaml` (build pipeline) and `.heyspex/research-focus.yaml`
6
6
  * (research pipeline). Honored unconditionally when present, same semantics as
7
7
  * [[focus-yaml-always-wins-in-hs-next]].
8
+ *
9
+ * CLOUD WRITE MODE ([[HS-BYU4-CWPX-TASK-promotion-writes-go-through-the-console]]):
10
+ * this module is deliberately NOT routed through the console, and that is a
11
+ * decision rather than an omission. The pin it writes is LOCAL MACHINE STATE,
12
+ * not vault content — it lives in the repo's dot-directory, never under
13
+ * `specs/`, carries no id/type/frontmatter contract, and is not indexed by
14
+ * anything. It is therefore not a split brain the way a stray `specs/*.md`
15
+ * would be: a pin is per-workstation by design, and two machines are SUPPOSED
16
+ * to be able to hold different ones. HS-KR16 §Scope made exactly this call for
17
+ * the promote-research review-gate markers in the same dot-directory.
18
+ *
19
+ * The corollary: because it is not vault content, it also has no spec `type`
20
+ * and therefore no `POST /api/specs` route it could be filed through — routing
21
+ * it would mean inventing a type, which is the thing
22
+ * `CLOUD_MODE_UNSUPPORTED_WRITE` exists to refuse. Keeping the disk write is
23
+ * the correct behaviour in BOTH modes; it is not degraded and does not need to
24
+ * be reported.
8
25
  */
9
26
 
10
27
  import {
@@ -14,8 +31,10 @@ import {
14
31
  mkdirSync,
15
32
  rmSync,
16
33
  } from 'node:fs';
17
- import { join } from 'node:path';
34
+ import { join, isAbsolute, resolve, sep } from 'node:path';
18
35
  import { parseSimpleFocusYaml } from './capture-focus.mjs';
36
+ import { assertPathUnderRoot } from '../../scripts/lib/shared-path-helpers.mjs';
37
+
19
38
 
20
39
  export function reviewFocusPath(vaultRoot) {
21
40
  return join(vaultRoot, '.heyspex', 'review-focus.yaml');
@@ -36,6 +55,11 @@ export function writeReviewFocus(vaultRoot, { specId, reason }) {
36
55
  return { error: 'MISSING_SPEC_ID', message: 'spec_id is required' };
37
56
  }
38
57
  const dir = join(vaultRoot, '.heyspex');
58
+ try {
59
+ assertPathUnderRoot(vaultRoot, dir, 'review-focus write');
60
+ } catch (err) {
61
+ return { error: 'BAD_VAULT_ROOT', message: err.message };
62
+ }
39
63
  mkdirSync(dir, { recursive: true });
40
64
  const lines = [
41
65
  '# HeySpex review-on-deck — MCP /hs-review ([[HS-TASK-RVDC-heyspex-review-on-deck-mcp-and-hs-review]])',
@@ -0,0 +1,280 @@
1
+ /**
2
+ * search-specs.mjs — `heyspex_search_specs`, the vault's text search for agents
3
+ * ([[HS-YFLS]] fix 2).
4
+ *
5
+ * THE DEFECT THIS CLOSES
6
+ * ----------------------
7
+ * The bug-filing protocol tells an agent to "search specs/diagnoses/ before
8
+ * filing" — and no MCP tool searched specs by text. In PROD mode the vault is
9
+ * CLOUD-primary and the local mirror is incomplete (537 files on disk against
10
+ * 559 in the system of record), so a `grep` over the checkout is not a search
11
+ * of the vault: it answers "not on this disk", which is not the same claim.
12
+ * `heyspex_resolve_focus_intent` is the nearest thing and it is built for a
13
+ * different job — given the AC2 symptom sentence it returns 20 specs matched on
14
+ * a broad `security` tag and neither of the two that actually describe it.
15
+ *
16
+ * WHAT THIS IS
17
+ * ------------
18
+ * A duplicate check. It reads the CLOUD vault through `GET /api/search` — the
19
+ * system of record, never local disk — asks a handful of bounded angles
20
+ * ({@link ./symptom-query.mjs}, see that module for why one query cannot work),
21
+ * merges the windows, and returns enough per row to JUDGE a duplicate without a
22
+ * second call: id, name, type, status, a marked-up snippet, and which angles
23
+ * matched.
24
+ *
25
+ * A NEGATIVE MUST MEAN "NOT THERE", NEVER "COULD NOT LOOK" ([[HS-YFLS]] AC3)
26
+ * -------------------------------------------------------------------------
27
+ * Every way of not finding something is reported as a DIFFERENT thing:
28
+ *
29
+ * result: "no_match" — the corpus WAS searched and holds nothing. `searched`
30
+ * is true and `queries_tried` lists exactly what was
31
+ * asked. This is the only shape an agent may act on by
32
+ * filing a new spec.
33
+ * result: "no_query" — nothing searchable was extracted from the input.
34
+ * isError envelopes — CONSOLE_UNREACHABLE / CONSOLE_NOT_SIGNED_IN /
35
+ * VAULT_SCOPE_UNRESOLVED (a transient 503, retry) /
36
+ * VAULT_SCOPE_UNAUTHORIZED (the caller's vault could
37
+ * not be resolved, so the endpoint returned an empty
38
+ * page that means nothing about the corpus) /
39
+ * SEARCH_FAILED / SEARCH_RESPONSE_UNRECOGNIZED.
40
+ *
41
+ * An angle that fails fails the WHOLE call. A partial answer is indistinguishable
42
+ * from a complete one at the call site, and a silently partial duplicate check is
43
+ * exactly how the duplicate gets filed.
44
+ */
45
+
46
+ import { consoleFetch as defaultConsoleFetch, controlOk } from './console-transport.mjs';
47
+ import { buildSymptomQueries } from './symptom-query.mjs';
48
+
49
+ /** Rows requested per angle. Bounded: this tool never walks the vault. */
50
+ const ANGLE_LIMIT = 20;
51
+
52
+ /** Default / maximum rows returned to the agent. */
53
+ const DEFAULT_TOP_K = 10;
54
+ const MAX_TOP_K = 30;
55
+
56
+ /** Snippets are for judging a duplicate, not for reading the spec. */
57
+ const MAX_SNIPPET_CHARS = 320;
58
+
59
+ function errorResult(payload) {
60
+ return { isError: true, content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
61
+ }
62
+
63
+ /**
64
+ * Classify a non-OK `/api/search` response. Each code sends the agent somewhere
65
+ * different, which is the entire point of not collapsing them into "no results".
66
+ */
67
+ function searchFailure(res, query) {
68
+ const status = res?.status ?? 0;
69
+ const code =
70
+ status === 0
71
+ ? 'CONSOLE_UNREACHABLE'
72
+ : status === 401
73
+ ? 'CONSOLE_NOT_SIGNED_IN'
74
+ : status === 503
75
+ ? 'VAULT_SCOPE_UNRESOLVED'
76
+ : 'SEARCH_FAILED';
77
+ const message =
78
+ code === 'CONSOLE_UNREACHABLE'
79
+ ? 'The console did not answer, so the vault was NOT searched. Check heyspex_dev_server_status (or console reachability in cloud mode) — do NOT read this as "no matching spec".'
80
+ : code === 'CONSOLE_NOT_SIGNED_IN'
81
+ ? 'The console refused this caller (401), so the vault was NOT searched. Re-pair the MCP bearer — do NOT read this as "no matching spec".'
82
+ : code === 'VAULT_SCOPE_UNRESOLVED'
83
+ ? 'This console instance has not resolved its active vault yet (transient). Retry shortly — the vault was NOT searched.'
84
+ : `GET /api/search returned ${status}, so the vault was NOT searched.`;
85
+ return errorResult({
86
+ error: code,
87
+ message,
88
+ searched: false,
89
+ status,
90
+ url: res?.url ?? null,
91
+ attempted_query: query ?? null,
92
+ console_response: res?.body ?? null,
93
+ });
94
+ }
95
+
96
+ /** `<mark>`s are kept — they show WHERE the match landed — but the row is capped. */
97
+ function normalizeSnippet(value) {
98
+ if (typeof value !== 'string') return '';
99
+ const flat = value.replace(/\s+/g, ' ').trim();
100
+ return flat.length > MAX_SNIPPET_CHARS ? `${flat.slice(0, MAX_SNIPPET_CHARS)}…` : flat;
101
+ }
102
+
103
+ function normalizeRow(row) {
104
+ const id = typeof row?.id === 'string' ? row.id : null;
105
+ if (!id) return null;
106
+ const rank = Number(row?.rank);
107
+ return {
108
+ id,
109
+ name: String(row?.name ?? id),
110
+ type: String(row?.type ?? 'unknown'),
111
+ status: String(row?.status ?? 'unknown').toLowerCase(),
112
+ rank: Number.isFinite(rank) && rank > 0 ? rank : 0,
113
+ snippet: normalizeSnippet(row?.snippet),
114
+ };
115
+ }
116
+
117
+ /**
118
+ * One bounded page for one angle. Returns `{ rows, mode }` on success, or a
119
+ * ready-to-return error envelope under `{ failure }` — never an empty page
120
+ * standing in for a failure.
121
+ */
122
+ async function fetchAngle(fetchImpl, { q, limit, type, status }) {
123
+ const params = new URLSearchParams();
124
+ params.set('q', q);
125
+ params.set('limit', String(limit));
126
+ params.set('scope', 'body');
127
+ if (type) params.set('type', type);
128
+ if (status) params.set('status', status);
129
+
130
+ let res;
131
+ try {
132
+ res = await fetchImpl(`/api/search?${params.toString()}`);
133
+ } catch (err) {
134
+ return {
135
+ failure: errorResult({
136
+ error: 'CONSOLE_UNREACHABLE',
137
+ message: `The console search endpoint threw (${err?.message ?? err}), so the vault was NOT searched. Do NOT read this as "no matching spec".`,
138
+ searched: false,
139
+ attempted_query: q,
140
+ }),
141
+ };
142
+ }
143
+ if (!res || !res.ok) return { failure: searchFailure(res, q) };
144
+
145
+ const body = res.body && typeof res.body === 'object' ? res.body : {};
146
+ // The route answers a caller whose vault scope it could not authorize with a
147
+ // 200 and an EMPTY page carrying `vaultScope.authorized: false`. That page is
148
+ // a permission verdict, not a statement about the corpus — the one shape that
149
+ // would otherwise read as a clean "no duplicate exists".
150
+ if (body.vaultScope && body.vaultScope.authorized === false) {
151
+ return {
152
+ failure: errorResult({
153
+ error: 'VAULT_SCOPE_UNAUTHORIZED',
154
+ message: `The console could not resolve a vault for this caller (${body.vaultScope.reason ?? 'unknown'}), so the empty page it returned says nothing about the vault. Do NOT read this as "no matching spec".`,
155
+ searched: false,
156
+ reason: body.vaultScope.reason ?? null,
157
+ attempted_query: q,
158
+ }),
159
+ };
160
+ }
161
+ const rows = Array.isArray(body.rows) ? body.rows : Array.isArray(body.results) ? body.results : null;
162
+ // `mode` ("fts" | "bm25" | "trgm" | "memory") is the endpoint's proof that it
163
+ // actually ran a query. Its absence alongside no rows is an unrecognized
164
+ // shape, and an unrecognized shape is never a negative result.
165
+ if (rows === null || typeof body.mode !== 'string') {
166
+ return {
167
+ failure: errorResult({
168
+ error: 'SEARCH_RESPONSE_UNRECOGNIZED',
169
+ message: 'GET /api/search answered 200 with a body this tool cannot read as a search result (no `rows`/`mode`), so the vault was NOT searched.',
170
+ searched: false,
171
+ attempted_query: q,
172
+ console_response: body,
173
+ }),
174
+ };
175
+ }
176
+ return { rows: rows.map(normalizeRow).filter(Boolean), mode: body.mode };
177
+ }
178
+
179
+ /**
180
+ * `heyspex_search_specs` — find specs in the CLOUD vault by symptom text.
181
+ *
182
+ * args: { query, top_k?, type?, status? }
183
+ * deps: { fetchImpl } (tests inject; production uses the shared consoleFetch)
184
+ */
185
+ export async function handleSearchSpecs(args = {}, { fetchImpl = defaultConsoleFetch } = {}) {
186
+ const query = typeof args.query === 'string' ? args.query.trim() : '';
187
+ if (!query) {
188
+ return errorResult({
189
+ error: 'SEARCH_QUERY_REQUIRED',
190
+ message: '`query` is required — pass the symptom sentence (or the concept) you would otherwise grep for.',
191
+ searched: false,
192
+ });
193
+ }
194
+ const topKRaw = Number(args.top_k);
195
+ const topK = Number.isFinite(topKRaw) && topKRaw > 0 ? Math.min(MAX_TOP_K, Math.trunc(topKRaw)) : DEFAULT_TOP_K;
196
+ const type = typeof args.type === 'string' && args.type.trim() ? args.type.trim() : undefined;
197
+ const status = typeof args.status === 'string' && args.status.trim() ? args.status.trim() : undefined;
198
+
199
+ const { terms, queries } = buildSymptomQueries(query);
200
+ if (queries.length === 0) {
201
+ return controlOk({
202
+ query,
203
+ terms,
204
+ queries_tried: [],
205
+ searched: false,
206
+ result: 'no_query',
207
+ match_count: 0,
208
+ matches: [],
209
+ message: 'No searchable term survived normalization (the input was punctuation or stopwords only) — the vault was NOT searched. Rephrase with the concrete nouns from the symptom.',
210
+ });
211
+ }
212
+
213
+ /** id → merged candidate. */
214
+ const byId = new Map();
215
+ const queriesTried = [];
216
+ let mode = null;
217
+
218
+ for (const angle of queries) {
219
+ // eslint-disable-next-line no-await-in-loop -- a handful of bounded angles, deliberately sequential so a failure stops the walk.
220
+ const page = await fetchAngle(fetchImpl, { q: angle.q, limit: ANGLE_LIMIT, type, status });
221
+ if (page.failure) return page.failure;
222
+ mode = mode ?? page.mode;
223
+ queriesTried.push({ label: angle.label, q: angle.q, hits: page.rows.length, mode: page.mode });
224
+ page.rows.forEach((row, position) => {
225
+ const existing = byId.get(row.id);
226
+ if (!existing) {
227
+ byId.set(row.id, { ...row, matched_by: [angle.label], best_position: position });
228
+ return;
229
+ }
230
+ // Keep the strongest evidence each angle produced for this spec.
231
+ existing.rank = Math.max(existing.rank, row.rank);
232
+ existing.best_position = Math.min(existing.best_position, position);
233
+ if (!existing.snippet && row.snippet) existing.snippet = row.snippet;
234
+ if (!existing.matched_by.includes(angle.label)) existing.matched_by.push(angle.label);
235
+ });
236
+ }
237
+
238
+ // Agreement across angles first (a spec the quoted concept AND the loose term
239
+ // window both surfaced is the likeliest duplicate), then the endpoint's own
240
+ // `ts_rank_cd`, then the order it handed rows back — which is the only signal
241
+ // left on the disk read tier, where every rank is 0.
242
+ const matches = [...byId.values()]
243
+ .sort(
244
+ (a, b) =>
245
+ b.matched_by.length - a.matched_by.length ||
246
+ b.rank - a.rank ||
247
+ a.best_position - b.best_position ||
248
+ a.id.localeCompare(b.id),
249
+ )
250
+ .slice(0, topK)
251
+ .map(({ best_position: _position, ...row }) => row);
252
+
253
+ if (matches.length === 0) {
254
+ return controlOk({
255
+ query,
256
+ terms,
257
+ queries_tried: queriesTried,
258
+ searched: true,
259
+ corpus: 'cloud-vault (GET /api/search — the system of record, not local disk)',
260
+ result: 'no_match',
261
+ match_count: 0,
262
+ matches: [],
263
+ mode,
264
+ message: `Searched the vault on ${queriesTried.length} angle(s) and found NOTHING. This is an explicit negative: the corpus was read and holds no spec matching these terms — it is safe to file.`,
265
+ });
266
+ }
267
+
268
+ return controlOk({
269
+ query,
270
+ terms,
271
+ queries_tried: queriesTried,
272
+ searched: true,
273
+ corpus: 'cloud-vault (GET /api/search — the system of record, not local disk)',
274
+ result: 'match',
275
+ match_count: matches.length,
276
+ mode,
277
+ matches,
278
+ message: 'Read each match before filing: a spec that already describes this symptom must be commented on or reopened, not duplicated.',
279
+ });
280
+ }