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
@@ -1,11 +1,24 @@
1
1
  /**
2
2
  * Pre-plan grounding for HeySpex MCP (/hs-ground).
3
- * Holistic vault scan, north-star/pillar hooks, architecture placement,
4
- * stress-test prompts, enhancement seeds — read-only.
3
+ * North-star/pillar hooks, architecture placement, stress-test prompts,
4
+ * enhancement seeds — read-only.
5
+ *
6
+ * RETRIEVAL IS BOUNDED AND REMOTE ([[HS-YIR1-8KK5-TASK-planning-grounding-retrieves-through]]
7
+ * of [[HS-FI33-8KK5-EPIC-lexical-retrieval-in-postgres-generated]]). This module
8
+ * used to walk `specs/**` from disk on every call, parse every markdown file and
9
+ * build an in-process BM25 corpus + backlink graph — work proportional to vault
10
+ * size on a request path. It now issues a fixed, small number of bounded
11
+ * `consoleFetch` calls to `GET /api/search` (top-k per angle, k <=
12
+ * {@link SEARCH_ANGLE_LIMIT}, paged with the endpoint's `next_cursor`), so
13
+ * grounding a plan costs the same at six thousand specs and at a million.
14
+ *
15
+ * NOTHING in this file may read the filesystem — `plan-ground.guard.test.mjs`
16
+ * greps the source for `readFileSync` / `readdirSync` / `walkMdFiles` /
17
+ * `fs.glob` / `globSync` to keep that true.
5
18
  */
6
19
 
7
- import { readFileSync, existsSync, readdirSync } from 'node:fs';
8
- import { join, relative, basename } from 'node:path';
20
+ import { basename } from 'node:path';
21
+ import { consoleFetch as defaultConsoleFetch } from './console-transport.mjs';
9
22
  import {
10
23
  analyzeGroundingConflicts,
11
24
  computeSafeContextSpecs,
@@ -27,27 +40,89 @@ const ANCHOR_SPECS = [
27
40
  // WIP ledger (.heyspex/wip-state.json, surfaced in the /hs-next brief), not a markdown anchor.
28
41
  ];
29
42
 
30
- const SCAN_DIRS = [
31
- { rel: 'specs/epics', bucket: 'epics' },
32
- { rel: 'specs/tasks', bucket: 'tasks' },
33
- { rel: 'specs/protocols', bucket: 'protocols' },
34
- { rel: 'specs/adr', bucket: 'adrs' },
35
- { rel: 'specs/use-cases', bucket: 'use_cases' },
36
- { rel: 'specs/memory', bucket: 'memories' },
37
- { rel: 'specs/research', bucket: 'research' },
38
- // Captures hold the owner's not-yet-specced design vocabulary (e.g. named
39
- // operating modes). Excluding them was the [[HS-RES-2QX6]] root cause: a
40
- // pre-plan ground that could not surface [[HS-CAP-UIBF]] let an agent reinvent
41
- // an already-designed concept. Milestones + diagnoses round out the in-flight
42
- // surfaces a plan can collide with (mirrors plan-review.mjs OVERLAP_DIRS).
43
- { rel: 'specs/captures', bucket: 'captures' },
44
- { rel: 'specs/milestones', bucket: 'milestones' },
45
- { rel: 'specs/diagnoses', bucket: 'diagnoses' },
46
- { rel: 'specs/future-considerations', bucket: 'future_considerations' },
47
- ];
43
+ /**
44
+ * Frontmatter `type:` → discovery bucket. Replaces the old `SCAN_DIRS` folder
45
+ * walk: the search endpoint returns a row's `type`, so the bucket a candidate
46
+ * lands in is read off the row instead of off the directory it was walked from.
47
+ * Captures stay in the corpus — excluding them was the [[HS-RES-2QX6]] root
48
+ * cause (an agent reinvented an already-designed concept because /hs-ground
49
+ * could not see [[HS-CAP-UIBF]]); milestones + diagnoses round out the in-flight
50
+ * surfaces a plan can collide with.
51
+ */
52
+ const TYPE_BUCKETS = {
53
+ epic: 'epics',
54
+ task: 'tasks',
55
+ protocol: 'protocols',
56
+ adr: 'adrs',
57
+ 'use-case': 'use_cases',
58
+ use_case: 'use_cases',
59
+ 'memory-lesson': 'memories',
60
+ 'memory-decision': 'memories',
61
+ 'memory-reference': 'memories',
62
+ 'memory-ui-pattern': 'memories',
63
+ memory: 'memories',
64
+ research: 'research',
65
+ capture: 'captures',
66
+ milestone: 'milestones',
67
+ diagnose: 'diagnoses',
68
+ diagnosis: 'diagnoses',
69
+ 'future-consideration': 'future_considerations',
70
+ };
71
+
72
+ /** The `type=` filter values for the bucket-targeted recall angles (CSV, pushed into SQL). */
73
+ const PROTOCOL_TYPE_FILTER = 'protocol';
74
+ const MEMORY_TYPE_FILTER = 'memory-decision,memory-lesson,memory-reference,memory-ui-pattern';
75
+ const WORK_TYPE_FILTER = 'epic,task,milestone';
76
+
77
+ /**
78
+ * The only two numbers that bound retrieval cost. `SEARCH_ANGLE_LIMIT` is the
79
+ * per-call `?limit=` (the task contract: k <= 20); a wider recall window is
80
+ * assembled by following the endpoint's `next_cursor` for at most
81
+ * `MAX_PAGES_PER_ANGLE` pages, never by asking for the whole vault.
82
+ */
83
+ const SEARCH_ANGLE_LIMIT = 20;
84
+ const MAX_PAGES_PER_ANGLE = 3;
85
+
86
+ /**
87
+ * How many of the top-ranked candidates get a bounded metadata read
88
+ * (`GET /api/specs/<id>`) for their basename, tags, authored summary and
89
+ * `updated` stamp. Constant, so enrichment cost never tracks vault size. Rows
90
+ * outside the cap keep the endpoint's `id`/`name`/`type`/`status`/`rank`/
91
+ * `snippet` and score on rank + status alone.
92
+ */
93
+ const ENRICH_CAP = 12;
94
+
95
+ /** Known-tags page size for the focus resolver's strict-tag vocabulary lookup. */
96
+ const KNOWN_TAGS_PAGE = 200;
97
+
98
+ /**
99
+ * How many of the per-row calls may be in flight at once. Bounded work is not
100
+ * the same as polite work: firing the whole enrichment batch plus the anchor
101
+ * resolves at a single-process dev console at once made every one of them time
102
+ * out (measured on the disk read tier, where each `/api/specs/:id` is a vault
103
+ * read). Small and fixed — the wall-clock cost of the extra rounds is noise
104
+ * against one timeout.
105
+ */
106
+ const FETCH_CONCURRENCY = 4;
107
+
108
+ /** `Promise.all`-with-a-lid: run `fn` over `items`, at most {@link FETCH_CONCURRENCY} at a time. */
109
+ async function mapWithConcurrency(items, fn, concurrency = FETCH_CONCURRENCY) {
110
+ const results = new Array(items.length);
111
+ let cursor = 0;
112
+ const worker = async () => {
113
+ for (;;) {
114
+ const index = cursor++;
115
+ if (index >= items.length) return;
116
+ // eslint-disable-next-line no-await-in-loop -- that is the point: one at a time per worker.
117
+ results[index] = await fn(items[index], index);
118
+ }
119
+ };
120
+ await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, worker));
121
+ return results;
122
+ }
48
123
 
49
124
  const AREA_BOOSTS = {
50
- console: { paths: ['spex-console/'], tags: ['console', 'ui', 'cockpit'], surfaces: ['console'] }, // legacy-cockpit tag accepted
125
+ console: { paths: ['spex-console/'], tags: ['console', 'ui'], surfaces: ['console'] },
51
126
  companion: { paths: ['companion/'], tags: ['companion', 'windows'], surfaces: ['companion'] },
52
127
  mcp: { paths: ['vendor/heyspex-mcp/'], tags: ['mcp', 'slash'], surfaces: ['mcp'] },
53
128
  vault: { paths: ['specs/'], tags: ['vault', 'specs'], surfaces: ['vault'] },
@@ -71,24 +146,22 @@ const STRESS_TEST_PROMPTS = [
71
146
  'Is this in-scope for current milestones, or should it be deferred to horizon?',
72
147
  ];
73
148
 
74
- const SKIP_DIRS = new Set([
75
- 'node_modules', '.git', '.next', 'dist', 'build', 'coverage', '.heyspex',
76
- ]);
77
-
78
149
  // ─────────────────────────────────────────────────────────────────────────────
79
- // TF-IDF Search Index (BM25 + Backlinks + Recency)
150
+ // Reranking over a bounded retrieval window (Postgres lexical recall + signals)
80
151
  // ─────────────────────────────────────────────────────────────────────────────
81
152
  //
82
- // ## Algorithm Overview (HS-EPIC-74YP)
153
+ // ## Algorithm Overview (HS-EPIC-74YP, retrieval retired to Postgres by HS-YIR1)
83
154
  //
84
- // This module implements a Google-like relevance ranking system for the HeySpex
85
- // spec vault, combining multiple signals into a weighted score:
155
+ // Retrieval is `GET /api/search` — `search_vector @@ websearch_to_tsquery`,
156
+ // ordered by `ts_rank_cd`, GIN-index-backed, keyset-paged. This module no longer
157
+ // computes BM25 itself; it RERANKS the bounded window the endpoint returns,
158
+ // combining the endpoint's rank with the signals that live outside the tsvector:
86
159
  //
87
- // ### Signal 1: BM25 TF-IDF (primary signal, weight: 3.0)
88
- // - Industry-standard document retrieval algorithm (Okapi BM25)
89
- // - Rare terms score higher than common terms via IDF (inverse document frequency)
90
- // - Term frequency saturates to prevent keyword stuffing
91
- // - Document length normalization prevents long docs from dominating
160
+ // ### Signal 1: Retrieval recall (primary signal, weight: 3.0)
161
+ // - `ts_rank_cd` from the endpoint (title weight A over body weight B)
162
+ // - plus a positional term, so the disk read tier (which returns rank 0 for
163
+ // every row) still ranks by the order the endpoint handed rows back
164
+ // - plus a small bonus for a candidate that surfaced on more than one angle
92
165
  //
93
166
  // ### Signal 2: Title Match Boost (weight: 2.0)
94
167
  // - Full phrase match in title: 10 points
@@ -109,204 +182,317 @@ const SKIP_DIRS = new Set([
109
182
  // - in_review: 1.0 (pending completion)
110
183
  // - closed: 0 (completed, less relevant for planning)
111
184
  //
112
- // ### Signal 6: Backlink Importance (weight: 1.2)
113
- // - Specs with more inbound wikilinks are more "authoritative"
114
- // - Uses log scale: log1p(count), capped at 3
115
- // - Prevents heavily-linked specs from dominating while still boosting them
116
- //
117
- // ### Signal 7: Recency (weight: 0.6)
185
+ // ### Signal 6: Recency (weight: 0.6)
118
186
  // - Recently updated specs are more relevant for planning
119
187
  // - Decay tiers: <7d=2, <30d=1.5, <90d=1, <365d=0.5, older=0
188
+ // - Applies to candidates whose bounded metadata read returned an `updated`
189
+ // stamp; a row outside ENRICH_CAP simply scores 0 here (never a file read).
120
190
  //
121
191
  // ### Formula
122
- // Score = (BM25 × 3.0) + (title × 2.0) + (tag × 1.5) + (area × 1.0)
123
- // + (status × 0.8) + (backlink × 1.2) + (recency × 0.6)
192
+ // Score = (recall × 3.0) + (title × 2.0) + (tag × 1.5) + (area × 1.0)
193
+ // + (status × 0.8) + (recency × 0.6)
194
+ //
195
+ // ### Retired signal: backlink importance (was weight 1.2)
196
+ // The inbound-wikilink boost needed a whole-vault walk to build the backlink
197
+ // graph — exactly the cost this task exists to remove — so it is GONE, not
198
+ // approximated. A per-spec backlink count can return later through
199
+ // `/api/links/:id` / `/api/backlinks/:id`, which reads `spec_links` in one
200
+ // indexed query. ([[HS-YIR1-8KK5-TASK-planning-grounding-retrieves-through]])
124
201
  //
125
202
  // ### Tuning
126
203
  // - Weights chosen empirically to balance precision vs recall
127
- // - tfidf is primary signal (3.0) because it captures semantic relevance
204
+ // - recall is the primary signal (3.0): it is the endpoint's ranked relevance
128
205
  // - title gets 2.0 because explicit matches are high confidence
129
206
  // - status/recency are lower (0.6-0.8) as tiebreakers, not primary filters
130
207
  //
131
208
  // See: [[hs-ground-search-algorithm]] for design decision documentation
132
- // Tests: vendor/heyspex-mcp/plan-ground.test.mjs (64 unit tests)
209
+ // Tests: vendor/heyspex-mcp/plan-ground.test.mjs
133
210
  // ─────────────────────────────────────────────────────────────────────────────
134
211
 
135
- /** Lazy index singleton with cache invalidation */
136
- let cachedIndex = null;
137
- let cacheTimestamp = 0;
138
- let cachedVaultRoot = null;
139
- const CACHE_TTL_MS = 30_000; // 30 seconds
140
-
141
212
  /**
142
213
  * Scoring weights for combining relevance signals.
143
214
  *
144
215
  * These weights were tuned empirically to balance:
145
- * - tfidf (3.0): Primary signal for semantic relevance
216
+ * - rank (3.0): Primary signal — the search endpoint's ranked recall
146
217
  * - title (2.0): High-confidence explicit matches
147
218
  * - tag (1.5): User-defined categorization
148
219
  * - area (1.0): Code area relevance
149
220
  * - status (0.8): Prioritize actionable specs
150
- * - backlink (1.2): Graph-based importance
151
221
  * - recency (0.6): Time decay as tiebreaker
152
222
  *
153
223
  * @see [[hs-ground-search-algorithm]] for rationale
154
224
  */
155
225
  const SCORE_WEIGHTS = {
156
- tfidf: 3.0, // BM25 contribution (primary signal)
226
+ rank: 3.0, // Retrieval recall from /api/search (primary signal)
157
227
  title: 2.0, // Title match boost
158
228
  tag: 1.5, // Tag match
159
229
  area: 1.0, // Area/surface match
160
230
  status: 0.8, // Status boost
161
- backlink: 1.2, // Graph importance
162
231
  recency: 0.6, // Time decay
163
232
  };
164
233
 
234
+ // ─────────────────────────────────────────────────────────────────────────────
235
+ // Bounded console retrieval — every read below is one HTTP call with an
236
+ // explicit `limit`, optionally continued with the endpoint's opaque cursor.
237
+ // ─────────────────────────────────────────────────────────────────────────────
238
+
165
239
  /**
166
- * BM25 algorithm parameters.
167
- *
168
- * k1 (1.5): Term frequency saturation parameter
169
- * - Controls how quickly TF contribution saturates
170
- * - Range: 1.2-2.0 typical; 1.5 is a balanced default
171
- * - Higher = TF matters more; lower = saturates faster
172
- *
173
- * b (0.75): Document length normalization parameter
174
- * - Controls how much long documents are penalized
175
- * - Range: 0.0-1.0; 0.75 is the standard default
176
- * - Higher = more normalization; 0 = no length penalty
177
- *
178
- * These are the Robertson/Walker defaults from the original BM25 paper.
240
+ * Raised when the grounding path cannot retrieve at all (console unreachable,
241
+ * not signed in, 5xx). Callers turn this into a typed degraded/error result —
242
+ * grounding NEVER falls back to reading the vault off disk.
179
243
  */
180
- const BM25_K1 = 1.5; // Term frequency saturation
181
- const BM25_B = 0.75; // Document length normalization
244
+ class ConsoleRetrievalError extends Error {
245
+ constructor(message, { code = 'CONSOLE_UNREACHABLE', status = 0, url = null } = {}) {
246
+ super(message);
247
+ this.name = 'ConsoleRetrievalError';
248
+ this.code = code;
249
+ this.status = status;
250
+ this.url = url;
251
+ }
252
+ }
253
+
254
+ function clampInt(value, min, max, fallback) {
255
+ const n = Number(value);
256
+ if (!Number.isFinite(n)) return fallback;
257
+ return Math.min(Math.max(Math.trunc(n), min), max);
258
+ }
259
+
260
+ /** `<mark>` markers from `ts_headline` are for the console UI, not for an agent. */
261
+ function stripHeadlineMarks(snippet) {
262
+ return String(snippet || '')
263
+ .replace(/<\/?mark>/g, '')
264
+ .replace(/\s+/g, ' ')
265
+ .trim();
266
+ }
182
267
 
183
268
  /**
184
- * Build the in-memory search index over all specs.
185
- * Computes: corpus stats, term frequencies, backlink graph, recency data.
269
+ * Normalize one search row. The Neon tier answers `{ rows: [...] }` with a
270
+ * `ts_rank_cd` rank and a `ts_headline` snippet; the disk tier answers the same
271
+ * `rows` shape with `rank: 0` (and a legacy `results` alias). Read either.
186
272
  */
187
- function buildVaultIndex(vaultRoot) {
188
- const startMs = Date.now();
189
- const specs = new Map();
190
- const corpus = {
191
- totalDocs: 0,
192
- documentFrequency: new Map(),
193
- avgDocLength: 0,
273
+ function normalizeSearchRow(row) {
274
+ if (!row || typeof row !== 'object') return null;
275
+ const id = String(row.id ?? '').trim();
276
+ if (!id) return null;
277
+ const rank = Number(row.rank);
278
+ return {
279
+ id,
280
+ name: String(row.name ?? id),
281
+ type: String(row.type ?? 'unknown'),
282
+ status: String(row.status ?? 'open').toLowerCase(),
283
+ rank: Number.isFinite(rank) && rank > 0 ? rank : 0,
284
+ snippet: stripHeadlineMarks(row.snippet),
194
285
  };
195
- const backlinkGraph = new Map(); // target -> Set<source>
286
+ }
196
287
 
197
- // Phase 1: Parse all specs, extract tokens and links
198
- const specsRoot = join(vaultRoot, 'specs');
199
- const allFiles = walkMdFiles(specsRoot);
200
- let totalLength = 0;
288
+ /**
289
+ * ONE bounded page of `GET /api/search`. `limit` is hard-capped at
290
+ * {@link SEARCH_ANGLE_LIMIT} here, not by the caller — no code path in this
291
+ * module can ask the endpoint for an unbounded page.
292
+ */
293
+ async function fetchSearchPage(fetchImpl, { q, limit, type, status, after }) {
294
+ const params = new URLSearchParams();
295
+ params.set('q', q);
296
+ params.set('limit', String(clampInt(limit, 1, SEARCH_ANGLE_LIMIT, SEARCH_ANGLE_LIMIT)));
297
+ if (type) params.set('type', type);
298
+ if (status) params.set('status', status);
299
+ if (after) params.set('after', after);
300
+ const res = await fetchImpl(`/api/search?${params.toString()}`);
301
+ if (!res || !res.ok) {
302
+ const status = res?.status ?? 0;
303
+ // Split "the console is not there" from "the console answered badly": the
304
+ // first sends an agent to heyspex_dev_server_status, the second is a bug or
305
+ // a sign-in problem and no amount of restarting fixes it.
306
+ const code = status === 0
307
+ ? 'CONSOLE_UNREACHABLE'
308
+ : status === 401
309
+ ? 'CONSOLE_NOT_SIGNED_IN'
310
+ : 'SEARCH_FAILED';
311
+ throw new ConsoleRetrievalError(
312
+ `GET /api/search returned ${status || 'no response'}`,
313
+ { code, status, url: res?.url ?? null },
314
+ );
315
+ }
316
+ const body = res.body && typeof res.body === 'object' ? res.body : {};
317
+ const raw = Array.isArray(body.rows)
318
+ ? body.rows
319
+ : Array.isArray(body.results)
320
+ ? body.results
321
+ : [];
322
+ return {
323
+ rows: raw.map(normalizeSearchRow).filter(Boolean),
324
+ next_cursor: typeof body.next_cursor === 'string' && body.next_cursor ? body.next_cursor : null,
325
+ mode: typeof body.mode === 'string' ? body.mode : null,
326
+ };
327
+ }
201
328
 
202
- for (const filePath of allFiles) {
203
- try {
204
- const content = readFileSync(filePath, 'utf8');
205
- const { frontmatter, body } = parseFrontmatter(content);
206
- const fileBase = basename(filePath, '.md');
207
- const key = fileBase.toLowerCase();
208
-
209
- const titleTokens = tokenize(frontmatter.name || fileBase);
210
- const bodyTokens = tokenize(body);
211
- const allTokens = [...titleTokens, ...bodyTokens];
212
-
213
- // Build term frequency map
214
- const termFrequency = new Map();
215
- for (const token of allTokens) {
216
- termFrequency.set(token, (termFrequency.get(token) || 0) + 1);
217
- }
329
+ /**
330
+ * One recall ANGLE: up to `want` rows assembled from at most
331
+ * {@link MAX_PAGES_PER_ANGLE} bounded pages, following the endpoint's keyset
332
+ * cursor. Never `OFFSET`, never a total count, never a second query per row.
333
+ */
334
+ async function fetchSearchAngle(fetchImpl, { label, q, want, type, status }) {
335
+ const target = clampInt(want, 1, SEARCH_ANGLE_LIMIT * MAX_PAGES_PER_ANGLE, SEARCH_ANGLE_LIMIT);
336
+ const rows = [];
337
+ let after = null;
338
+ let mode = null;
339
+ for (let page = 0; page < MAX_PAGES_PER_ANGLE && rows.length < target; page++) {
340
+ const remaining = target - rows.length;
341
+ // eslint-disable-next-line no-await-in-loop -- keyset paging is inherently sequential.
342
+ const res = await fetchSearchPage(fetchImpl, {
343
+ q,
344
+ limit: Math.min(remaining, SEARCH_ANGLE_LIMIT),
345
+ type,
346
+ status,
347
+ after,
348
+ });
349
+ mode = mode ?? res.mode;
350
+ rows.push(...res.rows);
351
+ if (!res.next_cursor || res.rows.length === 0) break;
352
+ after = res.next_cursor;
353
+ }
354
+ return { label, mode, rows: rows.slice(0, target) };
355
+ }
218
356
 
219
- // Extract wikilinks for backlink graph
220
- const outgoingLinks = extractWikilinks(body, frontmatter);
221
- for (const target of outgoingLinks) {
222
- const normalized = target.toLowerCase();
223
- if (!backlinkGraph.has(normalized)) {
224
- backlinkGraph.set(normalized, new Set());
225
- }
226
- backlinkGraph.get(normalized).add(key);
357
+ /**
358
+ * Merge the angles into one deduplicated candidate window. A candidate keeps
359
+ * its best rank and best position across angles, plus the labels of every angle
360
+ * that surfaced it (the multi-angle agreement bonus, and the `why_matched`
361
+ * provenance the reranking agent reads).
362
+ */
363
+ function mergeAngles(angles) {
364
+ const byId = new Map();
365
+ for (const angle of angles) {
366
+ if (!angle) continue;
367
+ angle.rows.forEach((row, position) => {
368
+ const existing = byId.get(row.id);
369
+ if (existing) {
370
+ existing.rank = Math.max(existing.rank, row.rank);
371
+ existing.bestPosition = Math.min(existing.bestPosition, position);
372
+ if (!existing.snippet && row.snippet) existing.snippet = row.snippet;
373
+ if (!existing.angles.includes(angle.label)) existing.angles.push(angle.label);
374
+ return;
227
375
  }
228
-
229
- // Parse timestamps
230
- const updatedAt = parseIsoDate(frontmatter.updated);
231
- const createdAt = parseIsoDate(frontmatter.created);
232
-
233
- specs.set(key, {
234
- basename: fileBase,
235
- id: frontmatter.id || fileBase,
236
- name: frontmatter.name || fileBase,
237
- type: frontmatter.type || 'unknown',
238
- status: String(frontmatter.status || 'open').toLowerCase(),
239
- tags: normalizeTags(frontmatter.tags),
240
- // Authored frontmatter.summary, carried so candidate cards can prefer it
241
- // over the body-derived extractSummaryLine() fallback (authored wins).
242
- summary: typeof frontmatter.summary === 'string' ? frontmatter.summary : null,
243
- relPath: relative(vaultRoot, filePath).replace(/\\/g, '/'),
244
- titleTokens,
245
- termFrequency,
246
- docLength: allTokens.length,
247
- backlinkCount: 0, // computed in phase 3
248
- forwardRefCount: outgoingLinks.size,
249
- updatedAt,
250
- createdAt,
376
+ byId.set(row.id, {
377
+ id: row.id,
378
+ // `basename` is unknown until the bounded metadata read below; the id is
379
+ // a resolvable stand-in ([[HS-YIR1]] — `/api/specs/:id` takes either).
380
+ basename: row.id,
381
+ name: row.name,
382
+ type: row.type,
383
+ status: row.status,
384
+ rank: row.rank,
385
+ snippet: row.snippet,
386
+ bestPosition: position,
387
+ angles: [angle.label],
388
+ // Filled by enrichCandidates; defaults keep every scorer total.
389
+ tags: [],
390
+ relPath: '',
391
+ updatedAt: 0,
392
+ summary: null,
393
+ enriched: false,
251
394
  });
395
+ });
396
+ }
397
+ return [...byId.values()];
398
+ }
252
399
 
253
- corpus.totalDocs++;
254
- totalLength += allTokens.length;
400
+ /**
401
+ * Bounded metadata read for the top {@link ENRICH_CAP} candidates: basename (so
402
+ * the report can emit a resolving `[[wikilink]]`), tags (tag weight + the focus
403
+ * resolver's strict-tag test), authored `summary:` and the `updated` stamp
404
+ * (recency weight). Best-effort and parallel — a miss leaves the row scored on
405
+ * rank + status, it never falls back to a file read.
406
+ */
407
+ async function enrichCandidates(fetchImpl, candidates, cap = ENRICH_CAP) {
408
+ const targets = candidates.slice(0, Math.max(0, cap));
409
+ await mapWithConcurrency(targets, async (candidate) => {
410
+ try {
411
+ const res = await fetchImpl(`/api/specs/${encodeURIComponent(candidate.id)}`);
412
+ if (!res || !res.ok) return;
413
+ const spec = res.body && typeof res.body === 'object' ? res.body.spec : null;
414
+ if (!spec || typeof spec !== 'object') return;
415
+ applySpecMetadata(candidate, spec);
255
416
  } catch {
256
- // Skip unreadable files
417
+ // Best-effort: an enrichment miss degrades the row's signals, never the call.
257
418
  }
258
- }
419
+ });
420
+ return candidates;
421
+ }
259
422
 
260
- corpus.avgDocLength = corpus.totalDocs > 0 ? totalLength / corpus.totalDocs : 0;
423
+ /** Fold a `SpecSummary`-shaped record (list row or `/api/specs/:id`) onto a candidate. */
424
+ function applySpecMetadata(candidate, spec) {
425
+ candidate.enriched = true;
426
+ if (spec.basename) candidate.basename = String(spec.basename);
427
+ if (spec.relPath) candidate.relPath = String(spec.relPath);
428
+ if (spec.name) candidate.name = String(spec.name);
429
+ if (spec.type) candidate.type = String(spec.type);
430
+ if (spec.status) candidate.status = String(spec.status).toLowerCase();
431
+ candidate.tags = normalizeTags(spec.tags);
432
+ candidate.updatedAt = parseIsoDate(spec.updatedAt || spec.updated);
433
+ const authored = spec.frontmatter && typeof spec.frontmatter.summary === 'string'
434
+ ? spec.frontmatter.summary.trim()
435
+ : typeof spec.summary === 'string'
436
+ ? spec.summary.trim()
437
+ : '';
438
+ if (authored) candidate.summary = authored;
439
+ return candidate;
440
+ }
261
441
 
262
- // Phase 2: Compute document frequency (IDF denominator)
263
- for (const spec of specs.values()) {
264
- const uniqueTerms = new Set(spec.termFrequency.keys());
265
- for (const term of uniqueTerms) {
266
- corpus.documentFrequency.set(
267
- term,
268
- (corpus.documentFrequency.get(term) || 0) + 1
269
- );
270
- }
271
- }
442
+ /**
443
+ * `GET /api/specs?tag=…` — ONE bounded, tag-filtered list page. The filter is
444
+ * pushed into SQL by the endpoint, so this is the strict-tag lookup without a
445
+ * corpus: the rows it returns are exactly the specs carrying that tag, with the
446
+ * metadata (tags, basename, updated) the scorer needs already on them.
447
+ */
448
+ async function fetchTagPage(fetchImpl, tag, limit) {
449
+ const params = new URLSearchParams({
450
+ tag,
451
+ limit: String(clampInt(limit, 1, SEARCH_ANGLE_LIMIT, SEARCH_ANGLE_LIMIT)),
452
+ fields: 'id,name,type,status,tags,basename,relPath,updated,updatedAt',
453
+ });
454
+ const res = await fetchImpl(`/api/specs?${params.toString()}`);
455
+ if (!res || !res.ok) return [];
456
+ const body = res.body && typeof res.body === 'object' ? res.body : {};
457
+ return Array.isArray(body.specs) ? body.specs : [];
458
+ }
272
459
 
273
- // Phase 3: Compute backlink counts
274
- for (const spec of specs.values()) {
275
- const key = spec.basename.toLowerCase();
276
- const sources = backlinkGraph.get(key);
277
- spec.backlinkCount = sources ? sources.size : 0;
460
+ /** `GET /api/scheduler/known-tags` — the closed tag vocabulary, one bounded page. */
461
+ async function fetchKnownTags(fetchImpl, limit = KNOWN_TAGS_PAGE) {
462
+ try {
463
+ const res = await fetchImpl(`/api/scheduler/known-tags?limit=${clampInt(limit, 1, 500, KNOWN_TAGS_PAGE)}`);
464
+ if (!res || !res.ok) return [];
465
+ const body = res.body && typeof res.body === 'object' ? res.body : {};
466
+ return (Array.isArray(body.tags) ? body.tags : [])
467
+ .map((t) => (typeof t === 'string' ? t : t && t.name))
468
+ .filter((t) => typeof t === 'string' && t.trim())
469
+ .map((t) => t.trim().toLowerCase());
470
+ } catch {
471
+ return [];
278
472
  }
279
-
280
- const buildTimeMs = Date.now() - startMs;
281
-
282
- return {
283
- corpus,
284
- specs,
285
- backlinkGraph,
286
- lastBuilt: Date.now(),
287
- buildTimeMs,
288
- };
289
473
  }
290
474
 
291
475
  /**
292
- * Get or rebuild the cached index.
476
+ * `GET /api/specs/resolve?q=…` — the one bounded call that turns an anchor
477
+ * basename into `{ basename, id, relPath }`. Used only for the small static
478
+ * anchor list; returns null when the anchor is absent from this vault.
293
479
  */
294
- function getOrBuildIndex(vaultRoot) {
295
- const now = Date.now();
296
- // Invalidate on vaultRoot change as well as TTL: a single process is normally
297
- // one vault, but the cache must not hand back a different root's index (would
298
- // silently mis-resolve, and makes the substrate untestable across vaults).
299
- if (cachedIndex && cachedVaultRoot === vaultRoot && (now - cacheTimestamp) < CACHE_TTL_MS) {
300
- return cachedIndex;
480
+ async function resolveSpecRef(fetchImpl, token) {
481
+ try {
482
+ const res = await fetchImpl(`/api/specs/resolve?q=${encodeURIComponent(token)}`);
483
+ if (!res || !res.ok) return null;
484
+ const resolved = res.body && typeof res.body === 'object' ? res.body.resolved : null;
485
+ return resolved && typeof resolved === 'object' ? resolved : null;
486
+ } catch {
487
+ return null;
301
488
  }
302
- cachedIndex = buildVaultIndex(vaultRoot);
303
- cacheTimestamp = now;
304
- cachedVaultRoot = vaultRoot;
305
- return cachedIndex;
306
489
  }
307
490
 
308
491
  /**
309
- * Extract wikilinks from body text and frontmatter.
492
+ * Extract wikilinks from body text and frontmatter. Retained as a pure helper
493
+ * (the backlink GRAPH it used to feed is gone with the vault walk) — callers
494
+ * still use it to read a spec's declared edges out of a record they already
495
+ * hold, without touching disk.
310
496
  */
311
497
  function extractWikilinks(body, frontmatter) {
312
498
  const links = new Set();
@@ -364,30 +550,29 @@ function parseIsoDate(dateStr) {
364
550
  }
365
551
 
366
552
  /**
367
- * BM25 scoring - industry standard for document retrieval.
368
- * k1 controls term frequency saturation (1.2-2.0 typical)
369
- * b controls document length normalization (0.75 typical)
553
+ * Retrieval recall for one candidate — the primary signal, replacing the
554
+ * in-process BM25 that needed a whole-vault corpus.
555
+ *
556
+ * Three bounded terms, each in [0, 1]-ish so the weighted sum stays comparable
557
+ * to the old tfidf term:
558
+ * - `rank`: the endpoint's `ts_rank_cd`, clamped at 1 (values are small and
559
+ * long-tailed; the clamp stops one lucky row from dominating).
560
+ * - positional: how near the top of its best angle the row came back. This is
561
+ * what carries the DISK read tier, whose in-memory search returns `rank: 0`
562
+ * for every row but still returns them in relevance order.
563
+ * - agreement: +0.25 per extra angle that surfaced the same spec (capped),
564
+ * because a spec found by both the broad query and a type-filtered angle is
565
+ * more likely to be the one the planner wants.
370
566
  */
371
- function computeBM25(queryTerms, spec, corpus) {
372
- let score = 0;
373
- const { avgDocLength, totalDocs, documentFrequency } = corpus;
374
-
375
- for (const term of queryTerms) {
376
- const tf = spec.termFrequency.get(term) || 0;
377
- if (tf === 0) continue;
378
-
379
- const df = documentFrequency.get(term) || 0;
380
- // IDF with smoothing to handle rare terms
381
- const idf = Math.log((totalDocs - df + 0.5) / (df + 0.5) + 1);
382
-
383
- // Length-normalized term frequency
384
- const lengthNorm = 1 - BM25_B + BM25_B * (spec.docLength / (avgDocLength || 1));
385
- const tfNorm = (tf * (BM25_K1 + 1)) / (tf + BM25_K1 * lengthNorm);
386
-
387
- score += idf * tfNorm;
388
- }
389
-
390
- return score;
567
+ function computeRetrievalScore(candidate) {
568
+ const rank = Number.isFinite(candidate?.rank) && candidate.rank > 0
569
+ ? Math.min(candidate.rank, 1)
570
+ : 0;
571
+ const position = Number.isFinite(candidate?.bestPosition) ? candidate.bestPosition : 0;
572
+ const positional = Math.max(0, 1 - position / SEARCH_ANGLE_LIMIT);
573
+ const angles = Array.isArray(candidate?.angles) ? candidate.angles.length : 1;
574
+ const agreement = Math.min(Math.max(angles - 1, 0), 3) * 0.25;
575
+ return rank + positional + agreement;
391
576
  }
392
577
 
393
578
  /**
@@ -408,17 +593,6 @@ function computeTitleBoost(queryTerms, spec) {
408
593
  return presentCount * 2;
409
594
  }
410
595
 
411
- /**
412
- * Backlink importance - specs linked by many others are more authoritative.
413
- * Uses log scale to prevent runaway scores.
414
- */
415
- function computeBacklinkScore(spec) {
416
- const count = spec.backlinkCount;
417
- if (count === 0) return 0;
418
- // log1p handles 0 gracefully: 1 link ~= 0.69, 5 ~= 1.79, 10 ~= 2.40, 30 ~= 3.43
419
- return Math.min(Math.log1p(count), 3);
420
- }
421
-
422
596
  /**
423
597
  * Recency score - recently updated specs are more relevant.
424
598
  * Decay function: full score for last 7 days, decays over 90 days.
@@ -490,136 +664,59 @@ function computeAreaScore(areas, spec) {
490
664
  }
491
665
 
492
666
  /**
493
- * Enhanced scoreSpec using TF-IDF index.
494
- * Returns combined score with all factors.
667
+ * Rerank ONE candidate from the bounded retrieval window. The endpoint already
668
+ * did the lexical work (`ts_rank_cd` over a weighted tsvector); this adds the
669
+ * signals that do not live in the tsvector — the caller's explicit tags, the
670
+ * code area, the spec's status and how recently it moved.
671
+ *
672
+ * `candidate` is the shape {@link mergeAngles} mints: `{ name, status, tags,
673
+ * relPath, updatedAt, rank, bestPosition, angles }`. Tags / relPath / updatedAt
674
+ * are present only for rows the bounded metadata read covered; the untouched
675
+ * defaults (`[]`, `''`, `0`) make every scorer total, so a row outside
676
+ * {@link ENRICH_CAP} scores on recall + status rather than erroring.
495
677
  */
496
- function scoreSpecWithIndex(queryTokens, explicitTags, areas, spec, corpus) {
497
- const now = Date.now();
678
+ function scoreCandidate(queryTokens, explicitTags, areas, candidate, now = Date.now()) {
498
679
  const reasons = [];
499
680
 
500
- // Component scores
501
- const bm25 = computeBM25(queryTokens, spec, corpus);
502
- const titleBoost = computeTitleBoost(queryTokens, spec);
503
- const tagScore = computeTagScore(queryTokens, explicitTags, spec);
504
- const areaScore = computeAreaScore(areas, spec);
505
- const statusBoost = computeStatusBoost(spec.status);
506
- const backlinkScore = computeBacklinkScore(spec);
507
- const recencyScore = computeRecencyScore(spec, now);
508
-
509
- // Build reasons for debugging/display
510
- if (bm25 > 0.5) reasons.push(`tfidf:${bm25.toFixed(1)}`);
681
+ const recall = computeRetrievalScore(candidate);
682
+ const titleBoost = computeTitleBoost(queryTokens, candidate);
683
+ const tagScore = computeTagScore(queryTokens, explicitTags, candidate);
684
+ const areaScore = computeAreaScore(areas, candidate);
685
+ const statusBoost = computeStatusBoost(candidate.status);
686
+ const recencyScore = computeRecencyScore(candidate, now);
687
+
688
+ // Build reasons for debugging/display — these become `why_matched` on the card.
689
+ if (candidate.rank > 0) reasons.push(`rank:${candidate.rank.toFixed(3)}`);
690
+ else if (recall > 0) reasons.push(`recall:${recall.toFixed(2)}`);
691
+ if (Array.isArray(candidate.angles) && candidate.angles.length > 1) {
692
+ reasons.push(`angles:${candidate.angles.length}`);
693
+ }
511
694
  if (titleBoost >= 5) reasons.push(`title:${titleBoost}`);
512
695
  if (tagScore > 0) reasons.push(`tag:${tagScore}`);
513
- if (backlinkScore > 1) reasons.push(`links:${spec.backlinkCount}`);
514
696
  if (recencyScore > 1) reasons.push('recent');
515
- if (areaScore > 0) reasons.push(`area`);
697
+ if (areaScore > 0) reasons.push('area');
516
698
 
517
699
  // Weighted combination
518
700
  const score =
519
- (bm25 * SCORE_WEIGHTS.tfidf) +
701
+ (recall * SCORE_WEIGHTS.rank) +
520
702
  (titleBoost * SCORE_WEIGHTS.title) +
521
703
  (tagScore * SCORE_WEIGHTS.tag) +
522
704
  (areaScore * SCORE_WEIGHTS.area) +
523
705
  (statusBoost * SCORE_WEIGHTS.status) +
524
- (backlinkScore * SCORE_WEIGHTS.backlink) +
525
706
  (recencyScore * SCORE_WEIGHTS.recency);
526
707
 
527
708
  return {
528
709
  score,
529
710
  reasons,
530
- bm25,
711
+ recall,
531
712
  titleBoost,
532
713
  tagScore,
533
714
  areaScore,
534
715
  statusBoost,
535
- backlinkScore,
536
716
  recencyScore,
537
717
  };
538
718
  }
539
719
 
540
- function parseFrontmatter(content) {
541
- const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
542
- if (!match) return { frontmatter: {}, body: content };
543
- const yamlBlock = match[1];
544
- const body = content.slice(match[0].length).trim();
545
- return { frontmatter: parseSimpleYaml(yamlBlock), body };
546
- }
547
-
548
- function parseSimpleYaml(yaml) {
549
- const result = {};
550
- const lines = yaml.split(/\r?\n/);
551
- let currentArray = null;
552
- // Block-scalar collection state (`key: |` / `key: >`). The vast majority of
553
- // authored `summary:` values in this vault are literal block scalars, so the
554
- // brief consumer must read them as strings — not drop them — to agree with the
555
- // index consumer (which uses a real YAML parser). [[HS-TASK-ECOI]]
556
- let blockKey = null;
557
- let blockLines = null;
558
- let blockFold = false;
559
-
560
- const flushBlock = () => {
561
- if (blockKey === null) return;
562
- // Strip the common leading indentation of the block's content lines, then
563
- // join: literal (`|`) keeps line breaks; folded (`>`) joins with spaces.
564
- const contentful = blockLines.filter((l) => l.trim() !== '');
565
- const minIndent = contentful.length
566
- ? Math.min(...contentful.map((l) => l.match(/^(\s*)/)[1].length))
567
- : 0;
568
- const stripped = blockLines.map((l) => l.slice(minIndent));
569
- result[blockKey] = blockFold
570
- ? stripped.map((l) => l.trim()).filter(Boolean).join(' ')
571
- : stripped.join('\n').replace(/\n+$/, '');
572
- blockKey = null;
573
- blockLines = null;
574
- blockFold = false;
575
- };
576
-
577
- for (const line of lines) {
578
- if (blockKey !== null) {
579
- // Blank or indented lines belong to the active block scalar; a line at the
580
- // key column (non-indented, non-blank) ends it and is parsed normally.
581
- if (line.trim() === '' || /^\s/.test(line)) {
582
- blockLines.push(line);
583
- continue;
584
- }
585
- flushBlock();
586
- }
587
-
588
- if (!line.trim() || line.trim().startsWith('#')) continue;
589
-
590
- const arrayItemMatch = line.match(/^(\s+)-\s*(.*)$/);
591
- if (arrayItemMatch && currentArray) {
592
- const value = arrayItemMatch[2].trim().replace(/^["']|["']$/g, '');
593
- currentArray.push(value);
594
- continue;
595
- }
596
-
597
- const keyMatch = line.match(/^(\w[\w_-]*):\s*(.*)$/);
598
- if (keyMatch) {
599
- const key = keyMatch[1];
600
- const value = keyMatch[2].trim();
601
- currentArray = null;
602
-
603
- if (/^[|>][-+\d]*$/.test(value)) {
604
- // Block scalar: `|`, `>`, or a header variant (`|-`, `>+`, `|2`, …).
605
- blockKey = key;
606
- blockLines = [];
607
- blockFold = value.startsWith('>');
608
- result[key] = '';
609
- } else if (value === '') {
610
- result[key] = [];
611
- currentArray = result[key];
612
- } else if (value.startsWith('[') && value.endsWith(']')) {
613
- result[key] = value.slice(1, -1).split(',').map((s) => s.trim().replace(/^["']|["']$/g, ''));
614
- } else {
615
- result[key] = value.replace(/^["']|["']$/g, '');
616
- }
617
- }
618
- }
619
- flushBlock();
620
- return result;
621
- }
622
-
623
720
  function tokenize(text) {
624
721
  if (!text) return [];
625
722
  return text
@@ -629,123 +726,12 @@ function tokenize(text) {
629
726
  .filter((t) => t.length > 2 && !STOPWORDS.has(t));
630
727
  }
631
728
 
632
- function walkMdFiles(dir, out = []) {
633
- if (!existsSync(dir)) return out;
634
- for (const entry of readdirSync(dir, { withFileTypes: true })) {
635
- if (SKIP_DIRS.has(entry.name)) continue;
636
- const full = join(dir, entry.name);
637
- if (entry.isDirectory()) walkMdFiles(full, out);
638
- else if (entry.name.endsWith('.md')) out.push(full);
639
- }
640
- return out;
641
- }
642
-
643
- function resolveSpecPath(vaultRoot, anchor) {
644
- const specsRoot = join(vaultRoot, 'specs');
645
- const candidates = [
646
- join(specsRoot, `${anchor.basename}.md`),
647
- anchor.subdir ? join(specsRoot, anchor.subdir, `${anchor.basename}.md`) : null,
648
- ].filter(Boolean);
649
-
650
- for (const p of candidates) {
651
- if (existsSync(p)) return relative(vaultRoot, p).replace(/\\/g, '/');
652
- }
653
-
654
- const all = walkMdFiles(specsRoot);
655
- const found = all.find((f) => basename(f, '.md') === anchor.basename);
656
- return found ? relative(vaultRoot, found).replace(/\\/g, '/') : null;
657
- }
658
-
659
729
  function normalizeTags(tags) {
660
730
  if (!tags) return [];
661
731
  if (Array.isArray(tags)) return tags.map((t) => String(t).toLowerCase());
662
732
  return [String(tags).toLowerCase()];
663
733
  }
664
734
 
665
- /**
666
- * Score a spec using the TF-IDF index if available, otherwise fall back to simple scoring.
667
- * This function bridges the old API (file-based) with the new index-based scoring.
668
- */
669
- function scoreSpec(filePath, content, tokens, explicitTags, areas, index = null) {
670
- const fileBase = basename(filePath, '.md');
671
- const key = fileBase.toLowerCase();
672
-
673
- // If we have an index, use enhanced TF-IDF scoring
674
- if (index && index.specs.has(key)) {
675
- const spec = index.specs.get(key);
676
- const result = scoreSpecWithIndex(tokens, explicitTags, areas, spec, index.corpus);
677
-
678
- return {
679
- score: result.score,
680
- reason: result.reasons.slice(0, 4).join(', ') || 'low relevance',
681
- frontmatter: {
682
- id: spec.id,
683
- name: spec.name,
684
- type: spec.type,
685
- status: spec.status,
686
- tags: spec.tags,
687
- summary: spec.summary ?? null,
688
- },
689
- basename: spec.basename,
690
- status: spec.status,
691
- // Extra debug info for index-based scoring
692
- _indexed: true,
693
- _backlinkCount: spec.backlinkCount,
694
- _bm25: result.bm25,
695
- };
696
- }
697
-
698
- // Fallback: simple scoring for unindexed files (should be rare)
699
- const { frontmatter, body } = parseFrontmatter(content);
700
- const name = String(frontmatter.name || frontmatter.title || fileBase).toLowerCase();
701
- const specTags = normalizeTags(frontmatter.tags);
702
- const status = String(frontmatter.status || 'open').toLowerCase();
703
-
704
- let score = 0;
705
- const reasons = [];
706
-
707
- for (const tok of tokens) {
708
- if (name.includes(tok)) {
709
- score += 2;
710
- reasons.push(`title:${tok}`);
711
- }
712
- const bodyHits = (body.toLowerCase().match(new RegExp(`\\b${tok}\\b`, 'g')) || []).length;
713
- if (bodyHits > 0) {
714
- score += Math.min(bodyHits, 3);
715
- reasons.push(`body:${tok}`);
716
- }
717
- }
718
-
719
- for (const tag of explicitTags) {
720
- if (specTags.includes(tag.toLowerCase())) {
721
- score += 3;
722
- reasons.push(`tag:${tag}`);
723
- }
724
- }
725
-
726
- for (const area of areas) {
727
- const boost = AREA_BOOSTS[area];
728
- if (!boost) continue;
729
- const pathLower = filePath.replace(/\\/g, '/').toLowerCase();
730
- if (boost.paths.some((p) => pathLower.includes(p)) || boost.tags.some((t) => specTags.includes(t))) {
731
- score += 2;
732
- reasons.push(`area:${area}`);
733
- }
734
- }
735
-
736
- if (status === 'open' || status === 'in_progress') {
737
- score += 1;
738
- }
739
-
740
- return {
741
- score,
742
- reason: reasons.slice(0, 4).join(', ') || 'low relevance',
743
- frontmatter,
744
- basename: fileBase,
745
- status,
746
- _indexed: false,
747
- };
748
- }
749
735
 
750
736
  function detectPillars(text) {
751
737
  const lower = text.toLowerCase();
@@ -767,32 +753,6 @@ function detectSurfaces(text, areas) {
767
753
  return [...surfaces];
768
754
  }
769
755
 
770
- function findLargeMarkdownFiles(vaultRoot, limit = 8) {
771
- const roots = [vaultRoot];
772
- const large = [];
773
-
774
- for (const root of roots) {
775
- if (!existsSync(root)) continue;
776
- const files = walkMdFiles(root);
777
- for (const f of files) {
778
- if (f.includes('node_modules')) continue;
779
- try {
780
- const content = readFileSync(f, 'utf8');
781
- const lines = content.split(/\r?\n/).length;
782
- if (lines >= 500) {
783
- large.push({
784
- path: relative(vaultRoot, f).replace(/\\/g, '/'),
785
- lines,
786
- });
787
- }
788
- } catch {
789
- /* skip */
790
- }
791
- }
792
- }
793
-
794
- return large.sort((a, b) => b.lines - a.lines).slice(0, limit);
795
- }
796
756
 
797
757
  function buildCodebaseHints(topic, summary, areas, tokens) {
798
758
  const queries = [...new Set([...tokens.slice(0, 6), topic].filter(Boolean))];
@@ -811,23 +771,6 @@ function buildCodebaseHints(topic, summary, areas, tokens) {
811
771
  };
812
772
  }
813
773
 
814
- function guessManifestPhase(vaultRoot, tokens) {
815
- const roadmapPath = join(vaultRoot, 'specs/roadmaps/HS-ROADMAP-product-master.md');
816
- if (!existsSync(roadmapPath) || tokens.length === 0) return null;
817
-
818
- try {
819
- const content = readFileSync(roadmapPath, 'utf8').toLowerCase();
820
- const phases = content.match(/phase[_\s-]*id[:\s]+["']?(P\d+)/gi) || [];
821
- if (phases.length === 0) return 'See [[HS-ROADMAP-product-master]] — agent should read manifest';
822
- for (const tok of tokens) {
823
- if (content.includes(tok)) return 'Match found in product roadmap — read manifest for phase';
824
- }
825
- } catch {
826
- return null;
827
- }
828
- return null;
829
- }
830
-
831
774
  function buildPlanContextSection(topic, landscape, hooks, placement, suggestedRelated, conflictAnalysis) {
832
775
  const lines = [
833
776
  '## Grounding',
@@ -927,50 +870,6 @@ function buildOptimizedOutline(topic) {
927
870
  ].join('\n');
928
871
  }
929
872
 
930
- /**
931
- * Extract a one-line summary from a spec's markdown: the first meaningful prose
932
- * line after the frontmatter block, with heading/callout/link syntax stripped
933
- * and capped at `maxLen` chars. Used to build body-free candidate cards — the
934
- * LLM triages on these, then deep-reads only the shortlist it selects
935
- * ([[retrieval-intake-architecture]] §How the LLM-as-reranker works).
936
- * @param {string} content
937
- * @param {number} [maxLen]
938
- * @returns {string}
939
- */
940
- function extractSummaryLine(content, maxLen = 140) {
941
- let body = String(content || '');
942
- // Drop a leading YAML frontmatter block.
943
- if (body.startsWith('---')) {
944
- const close = body.indexOf('\n---', 3);
945
- if (close !== -1) {
946
- const nl = body.indexOf('\n', close + 1);
947
- body = nl !== -1 ? body.slice(nl + 1) : '';
948
- }
949
- }
950
- const cap = (s) => (s.length > maxLen ? `${s.slice(0, maxLen - 1).trimEnd()}…` : s);
951
- let firstHeading = ''; // fallback if the doc is heading-only (title echoes anyway)
952
- for (let raw of body.split('\n')) {
953
- let line = raw.trim();
954
- if (!line) continue;
955
- if (line.startsWith('|') || line.startsWith('<!--') || line.startsWith('---') || line.startsWith('```')) continue;
956
- const isHeading = /^#{1,6}\s/.test(line);
957
- line = line.replace(/^#{1,6}\s+/, ''); // de-head
958
- line = line.replace(/^>+\s?/, '').replace(/^\[!\w+\][-+]?\s*/, ''); // de-callout
959
- line = line.replace(/\[\[([^\]|]+)(?:\|[^\]]+)?\]\]/g, '$1'); // [[wikilink]] → text
960
- line = line.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1'); // [md](link) → text
961
- line = line.replace(/[*_`]/g, '').trim(); // strip emphasis
962
- if (!line) continue;
963
- // Prefer the first prose/callout line; the leading H1 usually just echoes
964
- // the title, so skip headings unless that is all the doc has.
965
- if (isHeading) {
966
- if (!firstHeading) firstHeading = line;
967
- continue;
968
- }
969
- return cap(line);
970
- }
971
- return cap(firstHeading);
972
- }
973
-
974
873
  /**
975
874
  * @param {string} vaultRoot
976
875
  * @param {object} args
@@ -999,7 +898,7 @@ function buildGroundReport(ctx) {
999
898
 
1000
899
  L.push('', '## 1. Scope');
1001
900
  L.push(`- Workspace: \`${basename(vaultRoot)}\` · surfaces: ${surfaces.join(', ') || '—'} · pillars: ${pillarIds.join(', ') || '—'}`);
1002
- L.push(`- Index: in-memory lexical (BM25 + signals), built per call · grounded ${groundedAt}`);
901
+ L.push(`- Retrieval: bounded lexical search via \`GET /api/search\` (top-k per angle, reranked) · grounded ${groundedAt}`);
1003
902
 
1004
903
  L.push('', '## 2. Relevant specs');
1005
904
  L.push('_Lexical wide-recall — YOU are the reranker. Open only the shortlist you judge relevant; do not read them all._');
@@ -1057,7 +956,7 @@ function buildGroundReport(ctx) {
1057
956
  return { report_markdown: report, token_estimate: estTokens(report), truncated };
1058
957
  }
1059
958
 
1060
- export function executeGroundPlan(vaultRoot, args = {}) {
959
+ export async function executeGroundPlan(vaultRoot, args = {}, { fetchImpl = defaultConsoleFetch } = {}) {
1061
960
  const topic = String(args.topic || '').trim();
1062
961
  if (!topic) {
1063
962
  return { error: 'MISSING_TOPIC', message: 'topic is required (short title of feature/request)' };
@@ -1066,10 +965,7 @@ export function executeGroundPlan(vaultRoot, args = {}) {
1066
965
  const summary = String(args.summary || '').trim();
1067
966
  const explicitTags = normalizeTags(args.tags || []);
1068
967
  const areas = Array.isArray(args.areas)
1069
- ? args.areas.map((a) => {
1070
- const v = String(a).toLowerCase();
1071
- return v === 'cockpit' ? 'console' : v; // legacy-cockpit area alias
1072
- })
968
+ ? args.areas.map((a) => String(a).toLowerCase())
1073
969
  : [];
1074
970
  // Wide-recall window: the user's own LLM is the reranker, so HeySpex retrieves
1075
971
  // WIDE (Recall@N is the metric, not nDCG@10 ordering). Default 30, max 50 — per
@@ -1085,17 +981,65 @@ export function executeGroundPlan(vaultRoot, args = {}) {
1085
981
  const combinedText = `${topic} ${summary}`;
1086
982
  const tokens = [...new Set([...tokenize(topic), ...tokenize(summary)])];
1087
983
 
1088
- // Build or retrieve cached TF-IDF index for enhanced scoring
1089
- const index = getOrBuildIndex(vaultRoot);
984
+ // ── Retrieval: a fixed set of bounded angles against /api/search ───────────
985
+ // The query text the endpoint parses (`websearch_to_tsquery`) is the topic and
986
+ // summary as typed; the type-filtered angles guarantee the buckets a plan needs
987
+ // (governing protocols, memory decisions/lessons) are represented even when the
988
+ // broad angle is crowded by epics and tasks. Every call is `?limit=` bounded.
989
+ const searchQuery = combinedText.trim() || topic;
990
+ const tagQuery = explicitTags.join(' ').trim();
991
+ const angleSpecs = [
992
+ { label: 'broad', q: searchQuery, want: limit },
993
+ { label: 'work', q: searchQuery, want: 10, type: WORK_TYPE_FILTER },
994
+ { label: 'protocols', q: searchQuery, want: 8, type: PROTOCOL_TYPE_FILTER },
995
+ { label: 'memories', q: searchQuery, want: 8, type: MEMORY_TYPE_FILTER },
996
+ ...(tagQuery && tagQuery !== searchQuery
997
+ ? [{ label: 'tags', q: tagQuery, want: 10 }]
998
+ : []),
999
+ ];
1000
+
1001
+ let angles;
1002
+ try {
1003
+ angles = await Promise.all(angleSpecs.map((spec) => fetchSearchAngle(fetchImpl, spec)));
1004
+ } catch (err) {
1005
+ // Retrieval is remote and there is NO disk fallback ([[no-fallbacks-by-default]]):
1006
+ // a grounding report built from a stale local walk is exactly the failure this
1007
+ // task removed. Say the console is unreachable and stop.
1008
+ return {
1009
+ error: err instanceof ConsoleRetrievalError ? err.code : 'CONSOLE_UNREACHABLE',
1010
+ message:
1011
+ `Pre-plan grounding retrieves through the console search endpoint (GET /api/search) and it did not answer (${err?.message ?? err}). `
1012
+ + 'Call heyspex_dev_server_status before concluding the console is down, then re-run heyspex_ground_plan. '
1013
+ + 'Do NOT hand-walk the vault to substitute for grounding.',
1014
+ topic,
1015
+ };
1016
+ }
1090
1017
 
1091
- const anchors = ANCHOR_SPECS.map((a) => {
1092
- const path = resolveSpecPath(vaultRoot, a);
1018
+ // ── Rerank: two passes over the SAME bounded window ────────────────────────
1019
+ // Pass 1 ranks on what every row carries (rank/position/status/title) so the
1020
+ // enrichment cap lands on the rows most likely to matter; pass 2 re-scores with
1021
+ // the tags and `updated` stamps the bounded metadata read added.
1022
+ const candidates = mergeAngles(angles);
1023
+ const rank = (list) => {
1024
+ const now = Date.now();
1025
+ for (const c of list) c._scored = scoreCandidate(tokens, explicitTags, areas, c, now);
1026
+ list.sort((a, b) => b._scored.score - a._scored.score || a.id.localeCompare(b.id));
1027
+ return list;
1028
+ };
1029
+ rank(candidates);
1030
+ await enrichCandidates(fetchImpl, candidates, ENRICH_CAP);
1031
+ rank(candidates);
1032
+
1033
+ // Anchors: the small static constitution list. One bounded resolve per anchor
1034
+ // (5 calls, never a folder scan) so `found` still tells the truth per vault.
1035
+ const anchors = await mapWithConcurrency(ANCHOR_SPECS, async (a) => {
1036
+ const resolved = await resolveSpecRef(fetchImpl, a.basename);
1093
1037
  return {
1094
- basename: a.basename,
1095
- path: path || `specs/${a.subdir ? `${a.subdir}/` : ''}${a.basename}.md`,
1038
+ basename: resolved?.basename || a.basename,
1039
+ path: resolved?.relPath || `specs/${a.subdir ? `${a.subdir}/` : ''}${a.basename}.md`,
1096
1040
  why: a.why,
1097
1041
  tier: 'anchor',
1098
- found: Boolean(path),
1042
+ found: Boolean(resolved),
1099
1043
  };
1100
1044
  });
1101
1045
 
@@ -1113,54 +1057,29 @@ export function executeGroundPlan(vaultRoot, args = {}) {
1113
1057
  future_considerations: [],
1114
1058
  };
1115
1059
 
1116
- const scored = [];
1117
-
1118
- for (const { rel, bucket } of SCAN_DIRS) {
1119
- const dir = join(vaultRoot, rel);
1120
- const files = walkMdFiles(dir);
1121
- for (const filePath of files) {
1122
- try {
1123
- const content = readFileSync(filePath, 'utf8');
1124
- const { score, reason, frontmatter, basename: fileBase, status } = scoreSpec(
1125
- filePath,
1126
- content,
1127
- tokens,
1128
- explicitTags,
1129
- areas,
1130
- index, // Pass index for TF-IDF scoring
1131
- );
1132
- if (score <= 0 && bucket !== 'memories') continue;
1133
-
1134
- const entry = {
1135
- basename: fileBase,
1136
- id: frontmatter.id || fileBase,
1137
- name: frontmatter.name || fileBase,
1138
- path: relative(vaultRoot, filePath).replace(/\\/g, '/'),
1139
- status,
1140
- relevance: score,
1141
- reason,
1142
- type: frontmatter.type || bucket,
1143
- // Authored frontmatter.summary, carried so the candidate card can
1144
- // prefer it over the body-derived extractSummaryLine() fallback
1145
- // (same precedence as the index/brief consumers: authored wins).
1146
- summary: typeof frontmatter.summary === 'string' ? frontmatter.summary : null,
1147
- };
1148
-
1149
- scored.push({ bucket, entry });
1150
-
1151
- if (discovered[bucket]) {
1152
- discovered[bucket].push(entry);
1153
- }
1154
- } catch {
1155
- /* skip unreadable */
1156
- }
1157
- }
1158
- }
1159
-
1160
- scored.sort((a, b) => b.entry.relevance - a.entry.relevance);
1060
+ const scored = candidates.map((c) => {
1061
+ const bucket = TYPE_BUCKETS[c.type] || null;
1062
+ const entry = {
1063
+ basename: c.basename,
1064
+ id: c.id,
1065
+ name: c.name,
1066
+ // Known only for rows the bounded metadata read covered; `null` elsewhere —
1067
+ // a candidate is addressed by id, never by a path this module guessed.
1068
+ path: c.relPath || null,
1069
+ status: c.status,
1070
+ relevance: c._scored.score,
1071
+ reason: c._scored.reasons.slice(0, 4).join(', ') || 'low relevance',
1072
+ type: c.type,
1073
+ tags: c.tags,
1074
+ // Authored `summary:` when the metadata read returned one; the endpoint's
1075
+ // `ts_headline` snippet otherwise (same job — a body-free triage line).
1076
+ summary: c.summary || c.snippet || null,
1077
+ };
1078
+ if (bucket && discovered[bucket]) discovered[bucket].push(entry);
1079
+ return { bucket, entry };
1080
+ });
1161
1081
 
1162
1082
  for (const key of Object.keys(discovered)) {
1163
- discovered[key].sort((a, b) => b.relevance - a.relevance);
1164
1083
  discovered[key] = discovered[key].slice(0, limit);
1165
1084
  }
1166
1085
 
@@ -1183,18 +1102,12 @@ export function executeGroundPlan(vaultRoot, args = {}) {
1183
1102
  // one-line summary, and the why-matched lexical signals. Bodies are NOT
1184
1103
  // included — the agent deep-reads only the shortlist it selects (two-stage).
1185
1104
  const candidateCards = topEntries.map((e) => {
1186
- // Precedence: authored frontmatter.summary wins over the body-derived
1187
- // extractSummaryLine() fallback, matching the index (U2) and /hs-next brief
1188
- // consumers so the same spec reads identically across surfaces. The body
1189
- // fallback is preserved byte-for-byte for specs with no authored summary.
1190
- let summary = e.summary && String(e.summary).trim() ? String(e.summary).trim() : '';
1191
- if (!summary) {
1192
- try {
1193
- summary = extractSummaryLine(readFileSync(join(vaultRoot, e.path), 'utf8'));
1194
- } catch {
1195
- /* unreadable — leave summary blank */
1196
- }
1197
- }
1105
+ // Precedence: authored frontmatter.summary (from the bounded metadata read)
1106
+ // wins over the endpoint's `ts_headline` snippet, matching the index (U2) and
1107
+ // /hs-next brief consumers so the same spec reads identically across surfaces.
1108
+ // The old body-derived fallback is gone with the file walk — the snippet is
1109
+ // the endpoint's own answer to the same question, computed over the page only.
1110
+ const summary = e.summary && String(e.summary).trim() ? String(e.summary).trim() : '';
1198
1111
  return {
1199
1112
  id: e.id,
1200
1113
  basename: e.basename,
@@ -1231,8 +1144,13 @@ export function executeGroundPlan(vaultRoot, args = {}) {
1231
1144
  const possibleDuplicates = openWork.filter((e) => e.relevance >= 4).slice(0, 5);
1232
1145
 
1233
1146
  const protocols = discovered.protocols.slice(0, 8);
1147
+ // Decisions and lessons are the memories that CONSTRAIN a plan (references and
1148
+ // ui-patterns inform it). Selected on the row's `type:` — the folder-path test
1149
+ // this replaces needed a walked path, and `path` is now null for any row the
1150
+ // bounded metadata read did not cover.
1234
1151
  const decisionsToHeed = discovered.memories
1235
- .filter((m) => m.path.includes('/decisions/') || m.path.includes('/lessons/'))
1152
+ .filter((m) => m.type === 'memory-decision' || m.type === 'memory-lesson'
1153
+ || (m.path || '').includes('/decisions/') || (m.path || '').includes('/lessons/'))
1236
1154
  .slice(0, 8);
1237
1155
 
1238
1156
  const horizonItems = discovered.future_considerations.slice(0, 5);
@@ -1262,7 +1180,11 @@ export function executeGroundPlan(vaultRoot, args = {}) {
1262
1180
  const architecturePlacement = {
1263
1181
  surfaces,
1264
1182
  dependency_specs: dependencySpecs,
1265
- manifest_phase_hint: guessManifestPhase(vaultRoot, tokens),
1183
+ // Retired with the file walk ([[HS-YIR1-8KK5-TASK-planning-grounding-retrieves-through]]):
1184
+ // the hint was a substring scan of specs/roadmaps/HS-ROADMAP-product-master.md
1185
+ // read from disk on every call, and it only ever said "go read the manifest".
1186
+ // The roadmap is one bounded read away for an agent that wants it.
1187
+ manifest_phase_hint: null,
1266
1188
  };
1267
1189
 
1268
1190
  const northStarPillarHooks = {
@@ -1343,12 +1265,13 @@ export function executeGroundPlan(vaultRoot, args = {}) {
1343
1265
  }
1344
1266
  enhancementSuggestions.push('Add child tasks for each implementation phase after stress-test.');
1345
1267
 
1268
+ // `large_markdown_files` is GONE, not emptied-in-place: it walked every .md in
1269
+ // the workspace to measure line counts, and the field itself was the
1270
+ // read-everything anti-pattern the bounded report exists to retire
1271
+ // ([[hs-ground-as-built-mcp-failure-modes]]).
1346
1272
  const codebaseHints = includeCodebaseHints
1347
- ? {
1348
- ...buildCodebaseHints(topic, summary, areas, tokens),
1349
- large_markdown_files: findLargeMarkdownFiles(vaultRoot),
1350
- }
1351
- : { grep_queries: [], path_globs: [], large_markdown_files: [] };
1273
+ ? buildCodebaseHints(topic, summary, areas, tokens)
1274
+ : { grep_queries: [], path_globs: [] };
1352
1275
 
1353
1276
  const landscapeSummary = {
1354
1277
  open_work: openWork.map((e) => ({ basename: e.basename, id: e.id, reason: e.reason })),
@@ -1497,7 +1420,6 @@ export function executeGroundPlan(vaultRoot, args = {}) {
1497
1420
  ? {
1498
1421
  grep_queries: codebaseHints.grep_queries?.slice(0, 5) || [],
1499
1422
  path_globs: codebaseHints.path_globs?.slice(0, 4) || [],
1500
- large_markdown_files: (codebaseHints.large_markdown_files || []).slice(0, 5),
1501
1423
  }
1502
1424
  : null,
1503
1425
 
@@ -1562,18 +1484,30 @@ export function executeGroundPlan(vaultRoot, args = {}) {
1562
1484
 
1563
1485
  /**
1564
1486
  * `heyspex_resolve_focus_intent` lexical resolver — [[HS-TASK-JZEJ]] /
1565
- * [[HS-EPIC-OOPW]] Phase B. Given a free-text focus utterance, run lexical
1566
- * wide-recall over the vault (titles/bodies/tags) and return a WIDE candidate
1567
- * window for the calling agent's LLM to rerank. HeySpex hosts no reranker — the
1568
- * metric is Recall@N, not nDCG@10 ordering
1487
+ * [[HS-EPIC-OOPW]] Phase B. Given a free-text focus utterance, retrieve a WIDE
1488
+ * candidate window through the console search endpoint and hand it to the
1489
+ * calling agent's LLM to rerank. HeySpex hosts no reranker — the metric is
1490
+ * Recall@N, not nDCG@10 ordering
1569
1491
  * ([[llm-as-reranker-lexical-recall-companion-architecture]]). No embeddings /
1570
1492
  * vector store (Gate B = NO,
1571
1493
  * [[retrieval-substrate-lexical-not-embeddings-gate-b-no]]); consumes the same
1572
- * scoring substrate as hs-ground ([[HS-EPIC-G7R2-hs-ground-three-tier-grounding-report]]).
1494
+ * bounded retrieval substrate as hs-ground
1495
+ * ([[HS-EPIC-G7R2-hs-ground-three-tier-grounding-report]]).
1496
+ *
1497
+ * Retrieval is TWO bounded angle families, never a vault walk
1498
+ * ([[HS-YIR1-8KK5-TASK-planning-grounding-retrieves-through]]):
1499
+ * 1. `GET /api/search?q=<utterance>` — the lexical window, `?limit=`-bounded
1500
+ * and continued with the endpoint's cursor up to `top_k` rows.
1501
+ * 2. For each tag of the closed vocabulary the utterance names EXACTLY
1502
+ * (`GET /api/scheduler/known-tags`, one page), one bounded
1503
+ * `GET /api/specs?tag=…` page. Those rows are the strict-tag matches, and
1504
+ * they arrive carrying their own tags — so the strict/lexical split is now
1505
+ * an indexed filter instead of a scan over every spec's frontmatter.
1573
1506
  *
1574
1507
  * The utterance need not contain a spec's exact tag word: utterance tokens match
1575
- * tags via computeTagScore's substring pass and match titles/bodies via BM25, so
1576
- * tag-relevant specs still surface — the core Phase-B behavior.
1508
+ * titles/bodies through the tsvector and tags through computeTagScore's
1509
+ * substring pass, so tag-relevant specs still surface — the core Phase-B
1510
+ * behavior.
1577
1511
  *
1578
1512
  * Returns an empty window (NOT an error) for an empty/stopword-only utterance,
1579
1513
  * an empty vault, or no matches — the caller's documented fallback is
@@ -1585,11 +1519,12 @@ export function executeGroundPlan(vaultRoot, args = {}) {
1585
1519
  * counts (plus a ready-to-read `beat`) the confirmation copy reads back
1586
1520
  * ([[HS-TASK-5DB2]] / focus-intent-routing §5–6).
1587
1521
  *
1588
- * @param {string} vaultRoot - Root of the vault (contains specs/).
1522
+ * @param {string} vaultRoot - Retained for signature parity; nothing here reads it.
1589
1523
  * @param {{ utterance?: string, top_k?: number }} args
1590
- * @returns {{ utterance: string, candidates: Array<{ spec: string, tags: string[], match_kind: 'strict-tag' | 'lexical', why: string }>, total: number, justification: { strict_tag_matches: number, lexically_adjacent: number, beat: string } }}
1524
+ * @param {{ fetchImpl?: Function }} [deps] - Console transport (stubbed in tests).
1525
+ * @returns {Promise<{ utterance: string, candidates: Array<{ spec: string, tags: string[], match_kind: 'strict-tag' | 'lexical', why: string }>, total: number, justification: { strict_tag_matches: number, lexically_adjacent: number, beat: string } }>}
1591
1526
  */
1592
- export function resolveFocusIntentLexical(vaultRoot, args = {}) {
1527
+ export async function resolveFocusIntentLexical(vaultRoot, args = {}, { fetchImpl = defaultConsoleFetch } = {}) {
1593
1528
  const utterance = String(args.utterance || '').trim();
1594
1529
  // Wide-recall window: default 30, capped at 50 — same Recall@N bounds as
1595
1530
  // executeGroundPlan's limit. top_k narrows the window when the caller asks.
@@ -1600,64 +1535,97 @@ export function resolveFocusIntentLexical(vaultRoot, args = {}) {
1600
1535
  return { utterance, candidates: [], total: 0, justification: buildFocusJustification(0, 0) };
1601
1536
  }
1602
1537
 
1603
- // Graceful degradation ([[HS-TASK-09I3]] / focus-intent-routing §6). The
1604
- // lexical recall primitive can be unreachable in a constrained environment —
1605
- // no vault index, an IO error while walking specs/, a corpus that fails to
1606
- // load. When the index build or scoring throws, the resolver must NOT raise:
1607
- // it degrades to an empty window flagged `degraded`, routing the caller to the
1608
- // Phase-A exact-tag NLU floor (heyspex_list_known_tags + closed-vocab mapping)
1609
- // instead of erroring out. An empty *result* (no matches, empty vault) is a
1610
- // success and is handled by the normal path below — only a thrown primitive
1611
- // trips degradation.
1612
- let index;
1613
- try {
1614
- index = getOrBuildIndex(vaultRoot);
1615
- } catch (err) {
1616
- return buildDegradedFocusResult(utterance, err);
1617
- }
1618
-
1619
1538
  // A candidate is a STRICT-TAG match when the user named one of its tags
1620
1539
  // exactly — every token of some tag is present in the utterance (so multi-word
1621
1540
  // tags like `focus-areas` match "focus areas"). Everything else that still
1622
1541
  // surfaced matched on title/body/substring only — "lexically adjacent". The
1623
1542
  // split feeds the confirmation copy ([[HS-TASK-5DB2]]).
1624
1543
  const utteranceSet = new Set(tokens);
1625
- const isStrictTagHit = (specTags) =>
1626
- (specTags || []).some((tag) => {
1627
- const t = String(tag).toLowerCase();
1628
- if (utteranceSet.has(t)) return true;
1629
- const parts = tokenize(t);
1630
- return parts.length > 0 && parts.every((p) => utteranceSet.has(p));
1631
- });
1544
+ const namesTagExactly = (tag) => {
1545
+ const t = String(tag || '').toLowerCase();
1546
+ if (!t) return false;
1547
+ if (utteranceSet.has(t)) return true;
1548
+ const parts = tokenize(t);
1549
+ return parts.length > 0 && parts.every((p) => utteranceSet.has(p));
1550
+ };
1551
+ const isStrictTagHit = (specTags) => (specTags || []).some(namesTagExactly);
1632
1552
 
1553
+ // Graceful degradation ([[HS-TASK-09I3]] / focus-intent-routing §6). The
1554
+ // recall primitive is now REMOTE, so the unreachable case is a dead console,
1555
+ // a 401, or a 5xx rather than an IO error walking specs/. The resolver must
1556
+ // NOT raise: it degrades to an empty window flagged `degraded`, routing the
1557
+ // caller to the Phase-A exact-tag NLU floor (heyspex_list_known_tags +
1558
+ // closed-vocab mapping). An empty *result* (no matches, empty vault) is a
1559
+ // success and is handled by the normal path below.
1633
1560
  let candidates;
1634
1561
  try {
1635
- const scored = [];
1636
- for (const spec of index.specs.values()) {
1637
- // No explicit tags / areas from a free-text utterance — the utterance
1638
- // tokens carry the tag matching (computeTagScore substring pass) and the
1639
- // lexical hit.
1640
- const result = scoreSpecWithIndex(tokens, [], [], spec, index.corpus);
1641
- // Gate on the LEXICAL signal (BM25 + title + tag), not the full weighted
1642
- // score: status/backlink/recency boosts are unconditional, so the full
1643
- // score is > 0 for every open spec even with no term match. A candidate
1644
- // must actually match the utterance; the boosts only tie-break among
1645
- // matches.
1646
- const lexical = result.bm25 + result.titleBoost + result.tagScore;
1647
- if (lexical <= 0) continue;
1648
- scored.push({ spec, result });
1649
- }
1562
+ // Angle 1 — the lexical window, bounded per call and cursor-paged to top_k.
1563
+ const lexicalAngle = await fetchSearchAngle(fetchImpl, {
1564
+ label: 'utterance',
1565
+ q: utterance,
1566
+ want: topK,
1567
+ });
1650
1568
 
1651
- // Deterministic order: score desc, basename asc as a stable tiebreak.
1652
- scored.sort(
1653
- (a, b) => b.result.score - a.result.score || a.spec.basename.localeCompare(b.spec.basename),
1569
+ // Angle 2 — strict-tag lookup over the closed vocabulary. Best-effort: a
1570
+ // known-tags failure costs the strict/lexical distinction, not the window.
1571
+ const knownTags = await fetchKnownTags(fetchImpl);
1572
+ const namedTags = knownTags.filter(namesTagExactly).slice(0, 3);
1573
+ const tagPages = await Promise.all(
1574
+ namedTags.map((tag) => fetchTagPage(fetchImpl, tag, Math.min(topK, SEARCH_ANGLE_LIMIT))),
1654
1575
  );
1655
1576
 
1656
- candidates = scored.slice(0, topK).map(({ spec, result }) => ({
1657
- spec: spec.id,
1658
- tags: spec.tags,
1659
- match_kind: isStrictTagHit(spec.tags) ? 'strict-tag' : 'lexical',
1660
- why: result.reasons.slice(0, 4).join(', ') || 'low relevance',
1577
+ const byId = new Map(mergeAngles([lexicalAngle]).map((c) => [c.id, c]));
1578
+ // Fold the tag rows in: they carry tags/basename/updated already, so they
1579
+ // both enrich a row the search angle found and add rows it did not.
1580
+ tagPages.forEach((rows, tagIndex) => {
1581
+ rows.forEach((row, position) => {
1582
+ const id = String((row && row.id) || '').trim();
1583
+ if (!id) return;
1584
+ let candidate = byId.get(id);
1585
+ if (!candidate) {
1586
+ candidate = {
1587
+ id,
1588
+ basename: id,
1589
+ name: String(row.name || id),
1590
+ type: String(row.type || 'unknown'),
1591
+ status: String(row.status || 'open').toLowerCase(),
1592
+ rank: 0,
1593
+ snippet: '',
1594
+ bestPosition: position,
1595
+ angles: [],
1596
+ tags: [],
1597
+ relPath: '',
1598
+ updatedAt: 0,
1599
+ summary: null,
1600
+ enriched: false,
1601
+ };
1602
+ byId.set(id, candidate);
1603
+ }
1604
+ const label = `tag:${namedTags[tagIndex]}`;
1605
+ if (!candidate.angles.includes(label)) candidate.angles.push(label);
1606
+ candidate.bestPosition = Math.min(candidate.bestPosition, position);
1607
+ applySpecMetadata(candidate, row);
1608
+ });
1609
+ });
1610
+
1611
+ const window = [...byId.values()];
1612
+ const now = Date.now();
1613
+ const rescore = () => {
1614
+ for (const c of window) c._scored = scoreCandidate(tokens, [], [], c, now);
1615
+ window.sort((a, b) => b._scored.score - a._scored.score || a.basename.localeCompare(b.basename));
1616
+ };
1617
+ // Enrich the top of the window so `tags` are real for the rows the caller
1618
+ // will actually read back, bounded by ENRICH_CAP (constant, never N).
1619
+ rescore();
1620
+ await enrichCandidates(fetchImpl, window.filter((c) => !c.enriched), ENRICH_CAP);
1621
+ // Deterministic order: score desc, basename asc as a stable tiebreak.
1622
+ rescore();
1623
+
1624
+ candidates = window.slice(0, topK).map((c) => ({
1625
+ spec: c.id,
1626
+ tags: c.tags,
1627
+ match_kind: isStrictTagHit(c.tags) ? 'strict-tag' : 'lexical',
1628
+ why: c._scored.reasons.slice(0, 4).join(', ') || 'low relevance',
1661
1629
  }));
1662
1630
  } catch (err) {
1663
1631
  return buildDegradedFocusResult(utterance, err);
@@ -1877,22 +1845,23 @@ function detectTagDrift(tags, options = {}) {
1877
1845
 
1878
1846
  // Export scoring functions for unit testing (HS-TASK-9HIW)
1879
1847
  export {
1880
- computeBM25,
1848
+ computeRetrievalScore,
1881
1849
  computeTitleBoost,
1882
- computeBacklinkScore,
1883
1850
  computeRecencyScore,
1884
1851
  computeStatusBoost,
1885
1852
  computeTagScore,
1886
1853
  computeAreaScore,
1887
- scoreSpecWithIndex,
1854
+ scoreCandidate,
1888
1855
  tokenize,
1889
1856
  detectTagDrift,
1890
1857
  extractWikilinks,
1891
1858
  normalizeWikilinkTarget,
1892
1859
  parseIsoDate,
1860
+ ConsoleRetrievalError,
1893
1861
  SCORE_WEIGHTS,
1894
- BM25_K1,
1895
- BM25_B,
1862
+ SEARCH_ANGLE_LIMIT,
1863
+ MAX_PAGES_PER_ANGLE,
1864
+ ENRICH_CAP,
1896
1865
  STOPWORDS,
1897
1866
  AREA_BOOSTS,
1898
1867
  };
@@ -1913,7 +1882,7 @@ export function buildGroundWorkflowMarkdown(options = {}) {
1913
1882
  '',
1914
1883
  '1. Call MCP tool **`heyspex_ground_plan`** with `topic`, `summary`, `tags`, `areas`.',
1915
1884
  '2. Call **`heyspex_recall_memories`** (`limit: 10`, tags from tool `recall_memories_hint`).',
1916
- '3. Read **all anchors** + top 5–8 discovered specs; any `.md` ≥500 lines in `large_markdown_files` → read fully.',
1885
+ '3. Read **all anchors** + only the shortlist you rerank out of `candidate_cards` — never every discovered spec.',
1917
1886
  '4. Explore codebase via `codebase_hints` (`rg`, Glob) — not README-only ([[audit-read-plan-files-not-just-readmes]]).',
1918
1887
  '5. **Deliverable:** `## Landscape scan` — table of specs/code with relevance; duplicates and surprises.',
1919
1888
  '',