heyspex 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (322) hide show
  1. package/README.md +21 -0
  2. package/bin/hs.mjs +931 -98
  3. package/bin/spexcode.mjs +262 -0
  4. package/dev-factory/engine/worktree.mjs +740 -0
  5. package/dev-factory/lib/code-repo-root.mjs +67 -0
  6. package/dev-factory/lib/context-budget.mjs +84 -0
  7. package/dev-factory/lib/epic-detect.mjs +190 -0
  8. package/dev-factory/lib/fleet-queue-file.mjs +846 -0
  9. package/dev-factory/lib/fleet-queue.mjs +426 -0
  10. package/dev-factory/lib/fleet-state.mjs +14 -18
  11. package/dev-factory/lib/model-catalog.mjs +338 -0
  12. package/dev-factory/lib/seat-context.mjs +199 -0
  13. package/dev-factory/lib/spec-io.mjs +93 -0
  14. package/dev-factory/lib/sufficiency-score.mjs +8 -2
  15. package/dev-factory/lib/worktree-allocator.mjs +744 -0
  16. package/dev-factory/model-catalog.json +222 -0
  17. package/dist/cli/hs-check.mjs +1085 -103
  18. package/dist/cli/hs-claim.mjs +936 -63
  19. package/dist/cli/hs-connect.mjs +9786 -1910
  20. package/dist/cli/hs-defer.mjs +5267 -3053
  21. package/dist/cli/hs-dev.mjs +564 -0
  22. package/dist/cli/hs-env.mjs +385 -299
  23. package/dist/cli/hs-index-rebuild.mjs +7129 -0
  24. package/dist/cli/hs-index-reconcile.mjs +7852 -0
  25. package/dist/cli/hs-init.mjs +777 -138
  26. package/dist/cli/hs-install.mjs +6583 -485
  27. package/dist/cli/hs-join.mjs +10137 -2368
  28. package/dist/cli/hs-vault-pull.mjs +4659 -5882
  29. package/dist/cli/hs-vault.mjs +3793 -1537
  30. package/dist/cli/hs-workspace.mjs +2550 -524
  31. package/dist/cli-templates/customer-vault/specs/entity-model.md +594 -601
  32. package/dist/cli-templates/customer-vault/specs/protocols/canonical-frontmatter-templates.md +110 -177
  33. package/dist/cli-templates/customer-vault/specs/protocols/destructive-action-checklist.md +122 -0
  34. package/dist/cli-templates/customer-vault/specs/protocols/memory-protocol.md +13 -15
  35. package/dist/cli-templates/customer-vault/specs/protocols/session-close-protocol.md +91 -0
  36. package/dist/cli-templates/customer-vault/specs/protocols/spec-hierarchy.md +150 -168
  37. package/dist/cli-templates/customer-vault/specs/protocols/spec-summary-authoring.md +83 -0
  38. package/dist/cli-templates/harness-commands/claude/hs-auto.md +36 -0
  39. package/dist/cli-templates/harness-commands/claude/hs-backfill.md +28 -0
  40. package/dist/cli-templates/harness-commands/claude/hs-capture.md +62 -0
  41. package/dist/cli-templates/harness-commands/claude/hs-defer.md +29 -0
  42. package/dist/cli-templates/harness-commands/claude/hs-dev.md +20 -0
  43. package/dist/cli-templates/harness-commands/claude/hs-doctor.md +32 -0
  44. package/dist/cli-templates/harness-commands/claude/hs-ground.md +28 -0
  45. package/dist/cli-templates/harness-commands/claude/hs-handoff.md +33 -0
  46. package/dist/cli-templates/harness-commands/claude/hs-log.md +27 -0
  47. package/dist/cli-templates/harness-commands/claude/hs-next.md +29 -0
  48. package/dist/cli-templates/harness-commands/claude/hs-promote.md +70 -0
  49. package/dist/cli-templates/harness-commands/claude/hs-push.md +35 -0
  50. package/dist/cli-templates/harness-commands/claude/hs-qc.md +27 -0
  51. package/dist/cli-templates/harness-commands/claude/hs-res.md +63 -0
  52. package/dist/cli-templates/harness-commands/claude/hs-resolve.md +23 -0
  53. package/dist/cli-templates/harness-commands/claude/hs-review.md +23 -0
  54. package/dist/cli-templates/harness-commands/claude/hs-view.md +26 -0
  55. package/dist/cli-templates/harness-commands/claude/kill-ports.md +32 -0
  56. package/dist/cli-templates/harness-commands/claude/prototype.md +91 -0
  57. package/dist/cli-templates/harness-commands/cursor/hs-auto.md +40 -0
  58. package/dist/cli-templates/harness-commands/cursor/hs-backfill.md +31 -0
  59. package/dist/cli-templates/harness-commands/cursor/hs-capture.md +65 -0
  60. package/dist/cli-templates/harness-commands/cursor/hs-defer.md +32 -0
  61. package/dist/cli-templates/harness-commands/cursor/hs-dev.md +24 -0
  62. package/dist/cli-templates/harness-commands/cursor/hs-doctor.md +36 -0
  63. package/dist/cli-templates/harness-commands/cursor/hs-ground.md +31 -0
  64. package/dist/cli-templates/harness-commands/cursor/hs-handoff.md +37 -0
  65. package/dist/cli-templates/harness-commands/cursor/hs-log.md +31 -0
  66. package/dist/cli-templates/harness-commands/cursor/hs-next.md +32 -0
  67. package/dist/cli-templates/harness-commands/cursor/hs-promote.md +73 -0
  68. package/dist/cli-templates/harness-commands/cursor/hs-push.md +38 -0
  69. package/dist/cli-templates/harness-commands/cursor/hs-qc.md +30 -0
  70. package/dist/cli-templates/harness-commands/cursor/hs-res.md +67 -0
  71. package/dist/cli-templates/harness-commands/cursor/hs-resolve.md +26 -0
  72. package/dist/cli-templates/harness-commands/cursor/hs-review.md +26 -0
  73. package/dist/cli-templates/harness-commands/cursor/hs-view.md +30 -0
  74. package/dist/cli-templates/harness-commands/cursor/kill-ports.md +35 -0
  75. package/dist/cli-templates/harness-commands/cursor/prototype.md +95 -0
  76. package/docs/mcp-client-config.md +259 -6
  77. package/package.json +231 -124
  78. package/plugins/heyspex/.claude-plugin/plugin.json +12 -0
  79. package/plugins/heyspex/.cursor-plugin/plugin.json +15 -0
  80. package/plugins/heyspex/.mcp.json +12 -0
  81. package/plugins/heyspex/bin/launch-mcp.mjs +95 -0
  82. package/plugins/heyspex/bin/run-hook.mjs +62 -0
  83. package/plugins/heyspex/hooks/cursor-hooks.json +227 -0
  84. package/plugins/heyspex/hooks/hooks.json +361 -0
  85. package/plugins/heyspex/mcp.json +13 -0
  86. package/scripts/check-mcp-freshness.mjs +17 -9
  87. package/scripts/claude-hooks/_env-vault-events.mjs +68 -0
  88. package/scripts/claude-hooks/_guardrail-events.mjs +96 -0
  89. package/scripts/claude-hooks/_guardrail-loader.mjs +494 -0
  90. package/scripts/claude-hooks/_intent-classifier-router.mjs +410 -0
  91. package/scripts/claude-hooks/_intent-classifier-semantic.mjs +351 -0
  92. package/scripts/claude-hooks/_run.mjs +120 -0
  93. package/scripts/claude-hooks/_transcript-text.mjs +60 -0
  94. package/scripts/claude-hooks/_vault-retrieval.mjs +322 -0
  95. package/scripts/claude-hooks/agent-spawn-delegation-gate.mjs +133 -0
  96. package/scripts/claude-hooks/capture-workflow-gate.mjs +11 -0
  97. package/scripts/claude-hooks/check-auto-memory-budget.mjs +396 -0
  98. package/scripts/claude-hooks/check-claude-md-budget.mjs +327 -0
  99. package/scripts/claude-hooks/check-mcp-freshness-hook.mjs +14 -0
  100. package/scripts/claude-hooks/claim-before-code-gate.mjs +11 -0
  101. package/scripts/claude-hooks/claim-liveness-shared.mjs +575 -0
  102. package/scripts/claude-hooks/close-terminal-on-ship.mjs +64 -0
  103. package/scripts/claude-hooks/coo-denylist-gate.mjs +11 -0
  104. package/scripts/claude-hooks/coo-guard-check.mjs +11 -0
  105. package/scripts/claude-hooks/deep-research-cost-gate.mjs +11 -0
  106. package/scripts/claude-hooks/dev-server-task-reminder.mjs +15 -0
  107. package/scripts/claude-hooks/emit-claim-status.mjs +41 -0
  108. package/scripts/claude-hooks/enforce-session-sequence.mjs +11 -0
  109. package/scripts/claude-hooks/enforce-vault-naming.mjs +11 -0
  110. package/scripts/claude-hooks/env-vault-guard.mjs +11 -0
  111. package/scripts/claude-hooks/epic-milestone-phrasing-gate.mjs +11 -0
  112. package/scripts/claude-hooks/git-identity-guard.mjs +11 -0
  113. package/scripts/claude-hooks/git-reflog-audit.mjs +15 -0
  114. package/scripts/claude-hooks/git-stash-gate.mjs +11 -0
  115. package/scripts/claude-hooks/guard-auto-memory-writes.mjs +137 -0
  116. package/scripts/claude-hooks/id-display-format-gate.mjs +11 -0
  117. package/scripts/claude-hooks/inject-communication-style.mjs +66 -0
  118. package/scripts/claude-hooks/inject-coo-seat.mjs +78 -0
  119. package/scripts/claude-hooks/inject-dev-server-awareness.mjs +11 -0
  120. package/scripts/claude-hooks/inject-doctrine.mjs +13 -0
  121. package/scripts/claude-hooks/inject-vault-context.mjs +14 -0
  122. package/scripts/claude-hooks/intent-classifier-trigger.mjs +247 -0
  123. package/scripts/claude-hooks/log-session-activity.mjs +341 -0
  124. package/scripts/claude-hooks/naughty-list-gate.mjs +11 -0
  125. package/scripts/claude-hooks/notify-status-flip.mjs +72 -0
  126. package/scripts/claude-hooks/plan-exit-ingest-gate.mjs +170 -0
  127. package/scripts/claude-hooks/plan-ingest-close-reminder.mjs +66 -0
  128. package/scripts/claude-hooks/plan-review-gate.mjs +11 -0
  129. package/scripts/claude-hooks/preflight-reminder.mjs +262 -0
  130. package/scripts/claude-hooks/protected-action-intent.mjs +221 -0
  131. package/scripts/claude-hooks/research-method-guard.mjs +398 -0
  132. package/scripts/claude-hooks/research-protocol-gate.mjs +11 -0
  133. package/scripts/claude-hooks/session-end.mjs +449 -0
  134. package/scripts/claude-hooks/settings-write-gate.mjs +11 -0
  135. package/scripts/claude-hooks/settlement-check-gate.mjs +39 -0
  136. package/scripts/claude-hooks/status-flip-detect.mjs +17 -0
  137. package/scripts/claude-hooks/terminal-close-shared.mjs +426 -0
  138. package/scripts/claude-hooks/terminal-close-watcher.mjs +122 -0
  139. package/scripts/claude-hooks/wip-checkpoint.mjs +298 -0
  140. package/scripts/cursor-hooks/manifest.mjs +235 -0
  141. package/scripts/dev-server-status.mjs +34 -13
  142. package/scripts/dev-server-stop.mjs +313 -0
  143. package/scripts/heyspex-mode.mjs +268 -0
  144. package/scripts/hs-auto-resolve.mjs +240 -0
  145. package/scripts/hs-auto-source.mjs +398 -0
  146. package/scripts/hs-auto.mjs +134 -0
  147. package/scripts/hs-capture-cli.mjs +326 -0
  148. package/scripts/hs-classifier.mjs +136 -0
  149. package/scripts/hs-companion.mjs +88 -0
  150. package/scripts/hs-gateway.mjs +115 -0
  151. package/scripts/hs-mode.mjs +125 -0
  152. package/scripts/hs-next.mjs +441 -0
  153. package/scripts/hs-settings.mjs +338 -0
  154. package/scripts/hs-view.mjs +333 -0
  155. package/scripts/hs-worktree.mjs +201 -0
  156. package/scripts/install-claude-code-hooks.mjs +270 -0
  157. package/scripts/install-cursor-hooks.mjs +471 -0
  158. package/scripts/lib/agent-env-markers.mjs +38 -0
  159. package/scripts/lib/console-url-resolver.mjs +197 -60
  160. package/scripts/lib/customer-kit.mjs +886 -0
  161. package/scripts/lib/dev-mode.mjs +244 -28
  162. package/scripts/lib/heyspex-home.mjs +72 -0
  163. package/scripts/lib/hs-command.mjs +277 -0
  164. package/scripts/lib/hs-companion-settings.mjs +472 -0
  165. package/scripts/lib/hs-source-commit.mjs +59 -5
  166. package/scripts/lib/hs-source-identity.mjs +526 -0
  167. package/scripts/lib/hs-source-log.mjs +11 -21
  168. package/scripts/lib/hs-source-push.mjs +107 -58
  169. package/scripts/lib/hs-worktree-args.mjs +115 -0
  170. package/scripts/lib/shared-path-helpers.mjs +134 -0
  171. package/scripts/lib/standalone-doctor.mjs +540 -3
  172. package/scripts/lib/subagent-git-lock.mjs +109 -0
  173. package/scripts/lib/template-pin.mjs +34 -0
  174. package/scripts/provenance-lib.mjs +91 -0
  175. package/scripts/stamp-provenance-on-edit.mjs +222 -0
  176. package/scripts/wip-ledger-health.mjs +653 -0
  177. package/spexharness/doctrine/baseline/SPEXCODE-DOCTRINE.baseline.json +29 -0
  178. package/spexharness/src/adapters/audit.mjs +197 -0
  179. package/spexharness/src/adapters/claude-code.mjs +441 -0
  180. package/spexharness/src/chat/tui/ansi.mjs +448 -0
  181. package/spexharness/src/chat/tui/palette.mjs +22 -0
  182. package/spexharness/src/chat/tui/select.mjs +208 -0
  183. package/spexharness/src/config-root.mjs +54 -0
  184. package/spexharness/src/context-budget.mjs +141 -0
  185. package/spexharness/src/dev-server-control.mjs +389 -0
  186. package/spexharness/src/doctrine/behaviour.mjs +371 -0
  187. package/spexharness/src/doctrine/corpus.mjs +733 -0
  188. package/spexharness/src/doctrine/inject.mjs +338 -0
  189. package/spexharness/src/doctrine/merge.mjs +184 -0
  190. package/spexharness/src/doctrine/sources.mjs +252 -0
  191. package/spexharness/src/doctrine/synthesize.mjs +401 -0
  192. package/spexharness/src/doctrine/vault-source.mjs +574 -0
  193. package/spexharness/src/doctrine/writer.mjs +176 -0
  194. package/spexharness/src/events.mjs +84 -0
  195. package/spexharness/src/gates/capture-workflow-gate.mjs +202 -0
  196. package/spexharness/src/gates/claim-before-code-gate.decide.mjs +33 -0
  197. package/spexharness/src/gates/claim-before-code-gate.mjs +129 -0
  198. package/spexharness/src/gates/deep-research-cost-gate.mjs +274 -0
  199. package/spexharness/src/gates/enforce-session-sequence.mjs +895 -0
  200. package/spexharness/src/gates/enforce-vault-naming.mjs +215 -0
  201. package/spexharness/src/gates/epic-milestone-phrasing-gate.mjs +233 -0
  202. package/spexharness/src/gates/git-identity-guard.decide.mjs +436 -0
  203. package/spexharness/src/gates/git-identity-guard.mjs +61 -0
  204. package/spexharness/src/gates/git-reflog-audit.mjs +151 -0
  205. package/spexharness/src/gates/git-stash-gate.decide.mjs +361 -0
  206. package/spexharness/src/gates/git-stash-gate.mjs +159 -0
  207. package/spexharness/src/gates/id-display-format-gate.mjs +190 -0
  208. package/spexharness/src/gates/inject-doctrine-gate.mjs +89 -0
  209. package/spexharness/src/gates/inject-vault-context.mjs +239 -0
  210. package/spexharness/src/gates/naughty-list-gate.mjs +213 -0
  211. package/spexharness/src/gates/plan-review-gate.mjs +233 -0
  212. package/spexharness/src/gates/preflight-reminder-gate.mjs +265 -0
  213. package/spexharness/src/gates/research-protocol-gate.mjs +175 -0
  214. package/spexharness/src/gates/settings-write-gate.decide.mjs +96 -0
  215. package/spexharness/src/gates/settings-write-gate.mjs +104 -0
  216. package/spexharness/src/git-reflog-audit.mjs +294 -0
  217. package/spexharness/src/intent-classifier.mjs +194 -0
  218. package/spexharness/src/intent-directive.mjs +541 -0
  219. package/spexharness/src/profile/learn.mjs +551 -0
  220. package/spexharness/src/profile/proposals.mjs +357 -0
  221. package/spexharness/src/profile/vault-inclusion.mjs +386 -0
  222. package/spexharness/src/redact.mjs +151 -0
  223. package/spexharness/src/rule-table.mjs +819 -0
  224. package/spexharness/src/seat-context.mjs +122 -0
  225. package/spexharness/src/session-bootstrap.mjs +512 -0
  226. package/spexharness/src/session-record.mjs +815 -0
  227. package/spexharness/src/token-inventory.mjs +207 -0
  228. package/vendor/heyspex-mcp/account-switch.mjs +95 -0
  229. package/vendor/heyspex-mcp/alignment-gate.mjs +315 -1
  230. package/vendor/heyspex-mcp/blocker-resolver.mjs +37 -2
  231. package/vendor/heyspex-mcp/breakdown-ladder.mjs +119 -4
  232. package/vendor/heyspex-mcp/breakdown-source.mjs +271 -0
  233. package/vendor/heyspex-mcp/breakdown-writer.mjs +23 -3
  234. package/vendor/heyspex-mcp/capture-buffer-patches.mjs +46 -5
  235. package/vendor/heyspex-mcp/capture-channel.mjs +55 -5
  236. package/vendor/heyspex-mcp/capture-console-client.mjs +80 -1
  237. package/vendor/heyspex-mcp/capture-focus.mjs +1035 -29
  238. package/vendor/heyspex-mcp/capture-outbox.mjs +5 -1
  239. package/vendor/heyspex-mcp/capture-tools.mjs +1219 -0
  240. package/vendor/heyspex-mcp/claim-tools.mjs +1124 -0
  241. package/vendor/heyspex-mcp/cli-verbs.mjs +2076 -0
  242. package/vendor/heyspex-mcp/cli.mjs +201 -15524
  243. package/vendor/heyspex-mcp/cloud-tool-surface.mjs +128 -0
  244. package/vendor/heyspex-mcp/cloud-vault-writes.mjs +412 -0
  245. package/vendor/heyspex-mcp/config-seed.yaml +15 -0
  246. package/vendor/heyspex-mcp/config-settings.mjs +290 -7
  247. package/vendor/heyspex-mcp/console-fetch.mjs +657 -16
  248. package/vendor/heyspex-mcp/console-transport.mjs +166 -0
  249. package/vendor/heyspex-mcp/credentials.mjs +584 -0
  250. package/vendor/heyspex-mcp/defer-spec.mjs +44 -4
  251. package/vendor/heyspex-mcp/deferred-reclaim.mjs +29 -3
  252. package/vendor/heyspex-mcp/design-handlers.mjs +31 -0
  253. package/vendor/heyspex-mcp/design-tools.mjs +4 -1
  254. package/vendor/heyspex-mcp/dev-factory-bridge.mjs +4 -4
  255. package/vendor/heyspex-mcp/dev-mode-core.mjs +45 -0
  256. package/vendor/heyspex-mcp/dev-tools.mjs +124 -0
  257. package/vendor/heyspex-mcp/diagnose-handlers.mjs +54 -0
  258. package/vendor/heyspex-mcp/events-tools.mjs +207 -0
  259. package/vendor/heyspex-mcp/filing-tools.mjs +688 -0
  260. package/vendor/heyspex-mcp/fleet-handlers.mjs +37 -0
  261. package/vendor/heyspex-mcp/fleet-queue-tools.mjs +69 -14
  262. package/vendor/heyspex-mcp/focus-tools.mjs +190 -0
  263. package/vendor/heyspex-mcp/folder-trust-store.mjs +59 -0
  264. package/vendor/heyspex-mcp/http-transport.mjs +300 -0
  265. package/vendor/heyspex-mcp/list-tools.mjs +257 -0
  266. package/vendor/heyspex-mcp/login-handoff.mjs +15 -1
  267. package/vendor/heyspex-mcp/mcp-config.mjs +94 -0
  268. package/vendor/heyspex-mcp/memory-tools.mjs +523 -0
  269. package/vendor/heyspex-mcp/mvp-mode.mjs +53 -6
  270. package/vendor/heyspex-mcp/occupied-keys.mjs +141 -0
  271. package/vendor/heyspex-mcp/on-deck-brief.mjs +3360 -0
  272. package/vendor/heyspex-mcp/on-deck-scheduler.mjs +1708 -0
  273. package/vendor/heyspex-mcp/package-root.mjs +32 -0
  274. package/vendor/heyspex-mcp/peer-presence.mjs +8 -1
  275. package/vendor/heyspex-mcp/plan-ground.mjs +621 -652
  276. package/vendor/heyspex-mcp/plan-push.mjs +906 -21
  277. package/vendor/heyspex-mcp/plan-review.mjs +124 -30
  278. package/vendor/heyspex-mcp/preflight-gate.mjs +0 -0
  279. package/vendor/heyspex-mcp/procedure-handlers.mjs +325 -0
  280. package/vendor/heyspex-mcp/procedure-tools.mjs +540 -351
  281. package/vendor/heyspex-mcp/promote-capture.mjs +386 -69
  282. package/vendor/heyspex-mcp/promote-diagnose.mjs +74 -0
  283. package/vendor/heyspex-mcp/promote-research-to-diagnose.mjs +9 -2
  284. package/vendor/heyspex-mcp/promote-research.mjs +660 -53
  285. package/vendor/heyspex-mcp/promotion-helpers.mjs +251 -0
  286. package/vendor/heyspex-mcp/prompts.mjs +1460 -0
  287. package/vendor/heyspex-mcp/read-path.mjs +29 -9
  288. package/vendor/heyspex-mcp/receipt-shape.mjs +146 -0
  289. package/vendor/heyspex-mcp/recipe-handlers.mjs +117 -0
  290. package/vendor/heyspex-mcp/regen-tasks.mjs +210 -3
  291. package/vendor/heyspex-mcp/register-spec.mjs +266 -54
  292. package/vendor/heyspex-mcp/request-context.mjs +59 -0
  293. package/vendor/heyspex-mcp/research-focus.mjs +207 -4
  294. package/vendor/heyspex-mcp/research-handlers.mjs +623 -0
  295. package/vendor/heyspex-mcp/research-tools.mjs +4 -4
  296. package/vendor/heyspex-mcp/response-budget.mjs +348 -0
  297. package/vendor/heyspex-mcp/review-focus.mjs +25 -1
  298. package/vendor/heyspex-mcp/search-specs.mjs +280 -0
  299. package/vendor/heyspex-mcp/seat-block-ledger.mjs +113 -0
  300. package/vendor/heyspex-mcp/seat-identity.mjs +2 -6
  301. package/vendor/heyspex-mcp/session-owner.mjs +108 -0
  302. package/vendor/heyspex-mcp/session-tools.mjs +152 -0
  303. package/vendor/heyspex-mcp/settings-registry.mjs +232 -0
  304. package/vendor/heyspex-mcp/settings-tools.mjs +85 -12
  305. package/vendor/heyspex-mcp/spec-edit-tools.mjs +446 -0
  306. package/vendor/heyspex-mcp/spec-id-lib.mjs +93 -17
  307. package/vendor/heyspex-mcp/split-research.mjs +301 -56
  308. package/vendor/heyspex-mcp/stdio-rpc.mjs +22 -0
  309. package/vendor/heyspex-mcp/submit-for-review.mjs +132 -6
  310. package/vendor/heyspex-mcp/summary-contract.mjs +105 -0
  311. package/vendor/heyspex-mcp/summary-write-tools.mjs +224 -0
  312. package/vendor/heyspex-mcp/symptom-query.mjs +146 -0
  313. package/vendor/heyspex-mcp/test-helpers.mjs +108 -1
  314. package/vendor/heyspex-mcp/tool-catalog.mjs +2813 -0
  315. package/vendor/heyspex-mcp/tool-dispatch.mjs +639 -0
  316. package/vendor/heyspex-mcp/tool-helpers.mjs +325 -0
  317. package/vendor/heyspex-mcp/validate-linkage.mjs +414 -27
  318. package/vendor/heyspex-mcp/vault-io.mjs +724 -0
  319. package/vendor/heyspex-mcp/vault-less-endpoints.mjs +52 -0
  320. package/vendor/heyspex-mcp/vault-resolve.mjs +366 -68
  321. package/vendor/heyspex-mcp/verify-reproduction.mjs +270 -30
  322. package/vendor/heyspex-mcp/whoami.mjs +91 -0
@@ -0,0 +1,724 @@
1
+ // Vault file IO shared by every tool: frontmatter/YAML parsing, spec-file
2
+ // resolution by id / file key / crosswalk, and the tier-aware spec loaders.
3
+ // Extracted from cli.mjs under
4
+ // [[HS-BLNF-6W61-DIAG-cli-mjs-has-grown-into-a-13-665-line]] (no behavior change).
5
+ //
6
+ // Read-path selector ([[HS-DJWP-MCPC-TASK-wire-the-mcp-server-s-vault-reads]] of
7
+ // [[HS-T6GS-MCPC-EPIC-mcp-server-reads-the-vault-through-the]]): the ONE tier
8
+ // decision point every wired vault read below goes through. Tier is resolved
9
+ // fresh per tool invocation (never cached at module scope) so a dev-mode file
10
+ // edited between calls takes effect on the very next call.
11
+
12
+ import { join } from 'node:path';
13
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
14
+ import { alternateOrderingBasename, fileKeyFromBasename, parseSpecId } from './spec-id-lib.mjs';
15
+ import { readViaSelector } from './read-path.mjs';
16
+ import { consoleFetch } from './console-transport.mjs';
17
+
18
+ export function parseFrontmatter(content) {
19
+ const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
20
+ if (!match) return { frontmatter: {}, body: content };
21
+
22
+ const yamlBlock = match[1];
23
+ const body = content.slice(match[0].length).trim();
24
+ const frontmatter = parseSimpleYaml(yamlBlock);
25
+
26
+ return { frontmatter, body };
27
+ }
28
+
29
+ export function parseSimpleYaml(yaml) {
30
+ const result = {};
31
+ const lines = yaml.split(/\r?\n/);
32
+ let currentKey = null;
33
+ let currentArray = null;
34
+ let inNestedObject = false;
35
+ let nestedKey = null;
36
+ let nestedArray = null;
37
+ // Block-scalar collection state (`key: |` / `key: >`). The vast majority of
38
+ // authored `summary:` values in this vault are literal block scalars, so the
39
+ // claim brief must read them as strings — not silently drop them to `[]`,
40
+ // which made the /hs-next card fall into the "author a summary" path for any
41
+ // spec whose summary was a block scalar. [[HS-TASK-ECOI]]
42
+ let blockKey = null;
43
+ let blockLines = null;
44
+ let blockFold = false;
45
+
46
+ const flushBlock = () => {
47
+ if (blockKey === null) return;
48
+ const contentful = blockLines.filter((l) => l.trim() !== '');
49
+ const minIndent = contentful.length
50
+ ? Math.min(...contentful.map((l) => l.match(/^(\s*)/)[1].length))
51
+ : 0;
52
+ const stripped = blockLines.map((l) => l.slice(minIndent));
53
+ result[blockKey] = blockFold
54
+ ? stripped.map((l) => l.trim()).filter(Boolean).join(' ')
55
+ : stripped.join('\n').replace(/\n+$/, '');
56
+ blockKey = null;
57
+ blockLines = null;
58
+ blockFold = false;
59
+ };
60
+
61
+ for (const line of lines) {
62
+ // Collect an active block scalar: blank or indented lines belong to it; a
63
+ // line at the key column ends it and is parsed normally below.
64
+ if (blockKey !== null) {
65
+ if (line.trim() === '' || /^\s/.test(line)) {
66
+ blockLines.push(line);
67
+ continue;
68
+ }
69
+ flushBlock();
70
+ }
71
+
72
+ // Skip empty lines and comments
73
+ if (!line.trim() || line.trim().startsWith('#')) continue;
74
+
75
+ // Detect array item under a key
76
+ const arrayItemMatch = line.match(/^(\s+)-\s*(.*)$/);
77
+ if (arrayItemMatch && currentArray !== null) {
78
+ const indent = arrayItemMatch[1].length;
79
+ const value = arrayItemMatch[2].trim().replace(/^["']|["']$/g, '');
80
+ if (indent <= 2 && currentArray) {
81
+ currentArray.push(value);
82
+ } else if (nestedArray) {
83
+ nestedArray.push(value);
84
+ }
85
+ continue;
86
+ }
87
+
88
+ // Detect nested object key (e.g., roadmap_manifest:)
89
+ const nestedKeyMatch = line.match(/^(\s{2,})(\w+):\s*(.*)$/);
90
+ if (nestedKeyMatch && inNestedObject) {
91
+ const key = nestedKeyMatch[2];
92
+ const value = nestedKeyMatch[3].trim();
93
+ if (value) {
94
+ if (!result[nestedKey]) result[nestedKey] = {};
95
+ result[nestedKey][key] = parseYamlValue(value);
96
+ }
97
+ continue;
98
+ }
99
+
100
+ // Detect top-level key
101
+ const keyMatch = line.match(/^(\w[\w_-]*):\s*(.*)$/);
102
+ if (keyMatch) {
103
+ const key = keyMatch[1];
104
+ const value = keyMatch[2].trim();
105
+
106
+ currentArray = null;
107
+ inNestedObject = false;
108
+ nestedKey = null;
109
+ nestedArray = null;
110
+
111
+ if (/^[|>][-+\d]*$/.test(value)) {
112
+ // Block scalar (`|`, `>`, or a header variant like `|-`/`>+`/`|2`):
113
+ // collect the indented continuation lines into a string value.
114
+ currentKey = key;
115
+ blockKey = key;
116
+ blockLines = [];
117
+ blockFold = value.startsWith('>');
118
+ result[key] = '';
119
+ } else if (value === '') {
120
+ // Could be array, nested object, or multiline
121
+ currentKey = key;
122
+ result[key] = [];
123
+ currentArray = result[key];
124
+ } else if (value.startsWith('[') && value.endsWith(']')) {
125
+ // Inline array
126
+ result[key] = value.slice(1, -1).split(',').map(s => s.trim().replace(/^["']|["']$/g, ''));
127
+ } else {
128
+ result[key] = parseYamlValue(value);
129
+ }
130
+
131
+ // Check if this is roadmap_manifest (needs special handling)
132
+ if (key === 'roadmap_manifest') {
133
+ inNestedObject = true;
134
+ nestedKey = key;
135
+ result[key] = {};
136
+ }
137
+
138
+ // `pin:` is a nested object ({ spec_id, pinned_at, expires_at, pinned_by })
139
+ // written by the console pin writer (`POST /api/pin`). Without
140
+ // this, parseSimpleYaml only descends into roadmap_manifest, so an empty
141
+ // `pin:` value parses to an array and the modern pin block is invisible to
142
+ // /hs-next — every focus reader downstream (getPinnedSpecIdFromFocus, the
143
+ // milestone/epic drill) sees no pin and falls through to scheduler_default.
144
+ // Guarded on empty value so a scalar `pin:` elsewhere is unaffected.
145
+ if (key === 'pin' && value === '') {
146
+ inNestedObject = true;
147
+ nestedKey = key;
148
+ result[key] = {};
149
+ currentArray = null;
150
+ }
151
+ }
152
+ }
153
+
154
+ // Flush a block scalar that ran to the end of the frontmatter.
155
+ flushBlock();
156
+
157
+ // Handle roadmap_manifest specially - re-parse with full YAML support
158
+ if (result.roadmap_manifest !== undefined) {
159
+ result.roadmap_manifest = parseRoadmapManifest(yaml);
160
+ }
161
+
162
+ return result;
163
+ }
164
+
165
+ export function parseRoadmapManifest(yaml) {
166
+ // Find roadmap_manifest block and parse it more carefully
167
+ const manifestMatch = yaml.match(/roadmap_manifest:\s*\r?\n([\s\S]*?)(?=\r?\n\w|\r?\n$|$)/);
168
+ if (!manifestMatch) return {};
169
+
170
+ const manifestBlock = manifestMatch[1];
171
+ const manifest = { phases: [], initiatives: [], horizon: [] };
172
+
173
+ // Parse version and default
174
+ const versionMatch = manifestBlock.match(/version:\s*(\d+)/);
175
+ if (versionMatch) manifest.version = parseInt(versionMatch[1], 10);
176
+
177
+ const defaultMatch = manifestBlock.match(/default:\s*(true|false)/);
178
+ if (defaultMatch) manifest.default = defaultMatch[1] === 'true';
179
+
180
+ // Parse phases
181
+ const phasesMatch = manifestBlock.match(/phases:\s*\r?\n([\s\S]*?)(?=\r?\n\s{2}\w|\r?\n\s{2}-\s*id:.*initiatives|\r?\n\s{2}initiatives:|\r?\n\s{2}horizon:|$)/);
182
+ if (phasesMatch) {
183
+ const phasesBlock = phasesMatch[1];
184
+ const phaseBlocks = phasesBlock.split(/\r?\n\s{4}- id:/);
185
+
186
+ for (let i = 0; i < phaseBlocks.length; i++) {
187
+ const block = (i === 0 ? phaseBlocks[i].replace(/^\s*- id:/, '') : phaseBlocks[i]).trim();
188
+ if (!block) continue;
189
+
190
+ const phase = { tasks: [] };
191
+
192
+ const idMatch = block.match(/^(\S+)/);
193
+ if (idMatch) phase.id = idMatch[1];
194
+
195
+ const nameMatch = block.match(/name:\s*(.+)/);
196
+ if (nameMatch) phase.name = nameMatch[1].trim();
197
+
198
+ const summaryMatch = block.match(/summary:\s*(.+)/);
199
+ if (summaryMatch) phase.summary = summaryMatch[1].trim();
200
+
201
+ // Parse tasks array
202
+ const tasksMatch = block.match(/tasks:\s*\r?\n([\s\S]*?)(?=\r?\n\s{4}- id:|$)/);
203
+ if (tasksMatch) {
204
+ const taskLines = tasksMatch[1].match(/^\s*- (\S+)/gm);
205
+ if (taskLines) {
206
+ phase.tasks = taskLines.map(t => t.replace(/^\s*- /, '').trim());
207
+ }
208
+ }
209
+
210
+ if (phase.id) manifest.phases.push(phase);
211
+ }
212
+ }
213
+
214
+ // Parse initiatives
215
+ const initiativesMatch = manifestBlock.match(/initiatives:\s*\r?\n([\s\S]*?)(?=\r?\n\s{2}\w|$)/);
216
+ if (initiativesMatch) {
217
+ const initLines = initiativesMatch[1].match(/^\s*- (\S+)/gm);
218
+ if (initLines) {
219
+ manifest.initiatives = initLines.map(t => t.replace(/^\s*- /, '').trim());
220
+ }
221
+ }
222
+
223
+ // Parse horizon
224
+ const horizonMatch = manifestBlock.match(/horizon:\s*\r?\n([\s\S]*?)(?=\r?\n\w|$)/);
225
+ if (horizonMatch) {
226
+ const horizonLines = horizonMatch[1].match(/^\s*- (\S+)/gm);
227
+ if (horizonLines) {
228
+ manifest.horizon = horizonLines.map(t => t.replace(/^\s*- /, '').trim());
229
+ }
230
+ }
231
+
232
+ return manifest;
233
+ }
234
+
235
+ export function parseYamlValue(value) {
236
+ if (value === 'true') return true;
237
+ if (value === 'false') return false;
238
+ if (value === 'null' || value === '~') return null;
239
+ if (/^-?\d+$/.test(value)) return parseInt(value, 10);
240
+ if (/^-?\d+\.\d+$/.test(value)) return parseFloat(value);
241
+ return value.replace(/^["']|["']$/g, '');
242
+ }
243
+
244
+ export function loadSpec(vaultRoot, specPath) {
245
+ const fullPath = join(vaultRoot, specPath);
246
+ if (!existsSync(fullPath)) return null;
247
+
248
+ const content = readFileSync(fullPath, 'utf-8');
249
+ return parseFrontmatter(content);
250
+ }
251
+
252
+ // Raw, single-ordering resolver: locate a spec file whose basename (or
253
+ // frontmatter id) matches `specId` exactly as given. The dual-read wrapper
254
+ // `findSpecFile` calls this once per candidate ordering.
255
+ export function resolveSpecFileRaw(vaultRoot, specId) {
256
+ // Check exact filename matches first
257
+ const locations = [
258
+ `specs/tasks/${specId}.md`,
259
+ `specs/epics/${specId}.md`,
260
+ `specs/milestones/${specId}.md`,
261
+ `specs/roadmaps/${specId}.md`,
262
+ `specs/research/${specId}.md`,
263
+ `specs/reviews/${specId}.md`,
264
+ `specs/adr/${specId}.md`,
265
+ `specs/${specId}.md`,
266
+ ];
267
+ // CAP is family-keyed (dual-read via parseSpecId); IDEA is not reordered, so it
268
+ // stays a type-first prefix check ([[HS-EPIC-DLE3]]).
269
+ if (parseSpecId(specId)?.typeLabel === 'CAP' || /^HS-IDEA-/i.test(specId)) {
270
+ for (const bucket of ['captures', 'ideas']) {
271
+ const bucketDir = join(vaultRoot, 'specs', bucket);
272
+ if (!existsSync(bucketDir)) continue;
273
+ try {
274
+ for (const file of readdirSync(bucketDir)) {
275
+ if (file.startsWith(specId) && file.endsWith('.md')) {
276
+ return join('specs', bucket, file);
277
+ }
278
+ }
279
+ } catch {
280
+ /* ignore */
281
+ }
282
+ }
283
+ }
284
+
285
+ for (const loc of locations) {
286
+ const fullPath = join(vaultRoot, loc);
287
+ if (existsSync(fullPath)) return loc;
288
+ }
289
+
290
+ // Search in specs directories by filename prefix. Vault root (`specs/`) is
291
+ // scanned via a sentinel that the helpers treat as a flat .md walk (no
292
+ // subdir recursion) so foundational specs (heyspex-north-star, etc.) and
293
+ // adr/ entries resolve from [[wikilinks]]. [[HS-TASK-NWLR]]
294
+ //
295
+ // Ambiguity guard ([[HS-68XO-DIAG]] / vault-id-assignment-protocol §Shared-suffix
296
+ // families, same contract as [[HS-QEBP-TASK]]): a bare shared-family token
297
+ // (e.g. `HS-W6AD-TASK` when six sibling tasks all carry that id) matches MORE
298
+ // THAN ONE basename at a token boundary. Picking the first in readdir order
299
+ // silently targets the wrong sibling (the diagnosed claim/scheduler bug), so we
300
+ // collect ALL matches and resolve only when exactly one exists — >1 returns null
301
+ // (ambiguous → surfaces upstream rather than guessing). A full basename already
302
+ // returned via the exact-path check above, so it never reaches this scan.
303
+ const specDirs = ['specs/tasks', 'specs/epics', 'specs/milestones', 'specs/diagnoses', 'specs/roadmaps', 'specs/research', 'specs/reviews', 'specs/captures', 'specs/ideas', 'specs/adr', 'specs'];
304
+ const matchesAtBoundary = (file) =>
305
+ file.endsWith('.md') && (file === `${specId}.md` || file.startsWith(`${specId}-`));
306
+
307
+ const prefixMatches = new Set();
308
+ for (const dir of specDirs) {
309
+ const dirPath = join(vaultRoot, dir);
310
+ if (!existsSync(dirPath)) continue;
311
+ try {
312
+ for (const entry of readdirSync(dirPath, { withFileTypes: true })) {
313
+ if (entry.isFile() && matchesAtBoundary(entry.name)) {
314
+ prefixMatches.add(join(dir, entry.name));
315
+ }
316
+ }
317
+ } catch {
318
+ // Directory doesn't exist or isn't readable
319
+ }
320
+ }
321
+ if (prefixMatches.size === 1) return [...prefixMatches][0];
322
+ if (prefixMatches.size > 1) return null; // ambiguous bare family token — do not guess
323
+
324
+ // Search by frontmatter id field (handles filename != id cases). Same
325
+ // exactly-one rule: a shared id carried by >1 sibling is ambiguous → null.
326
+ const idMatches = new Set();
327
+ for (const dir of specDirs) {
328
+ const dirPath = join(vaultRoot, dir);
329
+ if (!existsSync(dirPath)) continue;
330
+ try {
331
+ for (const entry of readdirSync(dirPath, { withFileTypes: true })) {
332
+ if (!entry.isFile()) continue;
333
+ const file = entry.name;
334
+ if (!file.endsWith('.md')) continue;
335
+ const filePath = join(dir, file);
336
+ const content = readFileSync(join(vaultRoot, filePath), 'utf-8');
337
+ const { frontmatter } = parseFrontmatter(content);
338
+ if (frontmatter.id === specId) idMatches.add(filePath);
339
+ }
340
+ } catch {
341
+ // Skip files that can't be read
342
+ }
343
+ }
344
+ if (idMatches.size === 1) return [...idMatches][0];
345
+ return null; // 0 → not found; >1 → ambiguous shared-family id
346
+ }
347
+
348
+ // Dual-read spec resolver ([[HS-EPIC-6SNQ-dual-read-resolver-grace-period-for-pipe]],
349
+ // Phase 1 of [[HS-MIL-KY1F-pipeline-first-spec-identifier-format-mi]]).
350
+ //
351
+ // During the format migration grace window the on-disk basenames are in ONE
352
+ // ordering at a time (type-first today, family-first after the bulk rename), but
353
+ // callers may pass either. Resolve the input as-given first; on a miss, flip the
354
+ // ordering once and retry, logging the legacy/cross-ordering hit to stderr so the
355
+ // sunset gate can count residual legacy references. Family-first input that hits
356
+ // directly never touches the fallback (no log).
357
+ export function findSpecFile(vaultRoot, specId) {
358
+ const direct = resolveSpecFileRaw(vaultRoot, specId);
359
+ if (direct) return direct;
360
+
361
+ const alt = alternateOrderingBasename(specId);
362
+ if (alt) {
363
+ const hit = resolveSpecFileRaw(vaultRoot, alt);
364
+ if (hit) {
365
+ process.stderr.write?.(
366
+ `[heyspex-mcp dual-read] resolved "${specId}" via alternate ordering "${alt}" → ${hit}\n`,
367
+ );
368
+ return hit;
369
+ }
370
+ }
371
+
372
+ // File-key tier ([[HS-ANWD-RHIB-TASK]], parity with the console resolver's
373
+ // buildSpecLookup priority: file key above basename fallbacks). Extract a
374
+ // candidate key from the query — a bare `HS-<key>` citation or the era-aware
375
+ // key of a full basename — and match it against every spec's frontmatter
376
+ // `file_id:` / basename-derived key, in ANY of the three era orderings
377
+ // (era-1/2 keys sit mid-basename, so the prefix scan above never finds them).
378
+ const byKey = resolveSpecFileByFileKey(vaultRoot, specId);
379
+ if (byKey) return byKey;
380
+
381
+ // Crosswalk tier: after the step-4 vault-wide rename, retired basenames/ids
382
+ // live in .heyspex/id-crosswalk.json → re-resolve to the surviving name.
383
+ const viaCrosswalk = resolveSpecFileViaCrosswalk(vaultRoot, specId);
384
+ if (viaCrosswalk) return viaCrosswalk;
385
+
386
+ return null;
387
+ }
388
+
389
+ // Directories the file-key tier scans — superset mirror of the specDirs list in
390
+ // resolveSpecFileRaw (kept separate so that function stays untouched).
391
+ export const FILE_KEY_SCAN_DIRS = ['specs/tasks', 'specs/epics', 'specs/milestones', 'specs/diagnoses', 'specs/roadmaps', 'specs/research', 'specs/reviews', 'specs/captures', 'specs/ideas', 'specs/adr', 'specs'];
392
+
393
+ /** Bare `HS-<fileKey>` citation → the key; else the era-aware key of a full basename; else null. */
394
+ export function fileKeyCandidateFromQuery(specId) {
395
+ const trimmed = String(specId ?? '').trim().replace(/\.md$/i, '');
396
+ const bare = /^HS-((?=[0-9]*[A-Z])[A-Z0-9]{4,})$/.exec(trimmed);
397
+ if (bare) return bare[1];
398
+ return fileKeyFromBasename(trimmed);
399
+ }
400
+
401
+ /**
402
+ * Resolve a spec by its per-file FILE KEY across all three era orderings.
403
+ * Frontmatter `file_id:` is the SSOT (a lazily-minted key may exist only
404
+ * there), with the basename-derived key as the cheap first check. Fail-closed
405
+ * on ambiguity: >1 match returns null and names the colliding candidates on
406
+ * stderr — never guess ([[vault-id-assignment-protocol]] ambiguity contract).
407
+ */
408
+ export function resolveSpecFileByFileKey(vaultRoot, specId) {
409
+ const candidate = fileKeyCandidateFromQuery(specId);
410
+ if (!candidate) return null;
411
+
412
+ const matches = new Set();
413
+ for (const dir of FILE_KEY_SCAN_DIRS) {
414
+ const dirPath = join(vaultRoot, dir);
415
+ if (!existsSync(dirPath)) continue;
416
+ try {
417
+ for (const entry of readdirSync(dirPath, { withFileTypes: true })) {
418
+ if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
419
+ const base = entry.name.replace(/\.md$/i, '');
420
+ if (fileKeyFromBasename(base) === candidate) {
421
+ matches.add(join(dir, entry.name));
422
+ continue;
423
+ }
424
+ try {
425
+ const { frontmatter } = parseFrontmatter(readFileSync(join(dirPath, entry.name), 'utf-8'));
426
+ if (frontmatter.file_id === candidate) matches.add(join(dir, entry.name));
427
+ } catch {
428
+ // Unreadable file — skip.
429
+ }
430
+ }
431
+ } catch {
432
+ // Directory unreadable — skip.
433
+ }
434
+ }
435
+
436
+ if (matches.size === 1) {
437
+ const hit = [...matches][0];
438
+ process.stderr.write?.(
439
+ `[heyspex-mcp dual-read] resolved "${specId}" via file key "${candidate}" → ${hit}\n`,
440
+ );
441
+ return hit;
442
+ }
443
+ if (matches.size > 1) {
444
+ process.stderr.write?.(
445
+ `[heyspex-mcp dual-read] AMBIGUOUS file key "${candidate}" for "${specId}" — candidates: ${[...matches].join(', ')} (fail-closed, not guessing)\n`,
446
+ );
447
+ }
448
+ return null;
449
+ }
450
+
451
+ /**
452
+ * Crosswalk fallback ([[HS-ANWD-RHIB-TASK]]): read the retired-id redirect
453
+ * table the step-4 rename writes (`.heyspex/id-crosswalk.json`, rows
454
+ * `{oldBasename, oldId, newBasename, newId}`) and re-resolve a retired
455
+ * basename/id to the surviving spec. Tolerates an absent or malformed file
456
+ * silently (pre-rename vaults have none) — mirror of the indexer's
457
+ * loadIdCrosswalk contract.
458
+ */
459
+ export function resolveSpecFileViaCrosswalk(vaultRoot, specId) {
460
+ let rows;
461
+ try {
462
+ rows = JSON.parse(readFileSync(join(vaultRoot, '.heyspex', 'id-crosswalk.json'), 'utf-8'));
463
+ } catch {
464
+ return null;
465
+ }
466
+ if (!Array.isArray(rows)) return null;
467
+
468
+ const q = String(specId ?? '').trim().replace(/\.md$/i, '');
469
+ const hit = rows.find((r) => r && (r.oldBasename === q || r.oldId === q));
470
+ if (!hit) return null;
471
+ const target = hit.newBasename ?? hit.newId;
472
+ if (!target) return null;
473
+ const resolved = resolveSpecFileRaw(vaultRoot, target);
474
+ if (resolved) {
475
+ process.stderr.write?.(
476
+ `[heyspex-mcp dual-read] resolved retired "${specId}" via crosswalk → ${resolved}\n`,
477
+ );
478
+ }
479
+ return resolved;
480
+ }
481
+
482
+ /**
483
+ * Cloud-reliant sibling of `findSpecFile` ([[HS-DJWP-MCPC-TASK]] "spec-id
484
+ * resolution" family). `findSpecFile` itself (and the multi-tier
485
+ * `resolveSpecFileRaw` / `resolveSpecFileByFileKey` / `resolveSpecFileViaCrosswalk`
486
+ * chain it drives) stays disk-only and UNTOUCHED — it is the `diskRead` branch
487
+ * here, verbatim. In `neon` tier this never walks `specs/` directories; it asks
488
+ * `GET /api/specs/resolve?q=` for the `{basename, id, relPath}` triple. A 404
489
+ * (not_found OR ambiguous_id) is a real, reachable-console answer — "no such
490
+ * spec" — and returns `null` exactly like `findSpecFile` does today; only a
491
+ * genuine transport/5xx failure throws (wrapped by `readViaSelector` into
492
+ * `CONSOLE_UNREACHABLE_CLOUD_MODE`).
493
+ */
494
+ export async function resolveSpecIdViaTier(vaultRoot, specId, { tier, consoleUrl }) {
495
+ return readViaSelector({
496
+ tier,
497
+ // Same contract as findSpecFile: a bare relPath string, or null.
498
+ diskRead: () => findSpecFile(vaultRoot, specId),
499
+ neonRead: async () => {
500
+ const res = await consoleFetch(`/api/specs/resolve?q=${encodeURIComponent(specId)}`);
501
+ if (res.ok) {
502
+ return res.body?.resolved?.relPath ?? null;
503
+ }
504
+ if (res.status === 404) return null; // not_found / ambiguous_id — a real answer, not a failure.
505
+ throw new Error(
506
+ `GET /api/specs/resolve returned ${res.status}: ${res.error || JSON.stringify(res.body ?? {})}`,
507
+ );
508
+ },
509
+ routeLabel: 'GET /api/specs/resolve',
510
+ consoleUrl,
511
+ });
512
+ }
513
+
514
+ /**
515
+ * Cloud-reliant "load this spec by id" primitive ([[HS-DJWP-MCPC-TASK]] round
516
+ * 2, AC1 "general spec load"). Disk tier calls `findSpecFile` + `loadSpec`
517
+ * (both untouched, verbatim) and returns `{ specPath, frontmatter, body }` —
518
+ * `null` when not found. Neon tier makes ONE `GET /api/specs/[id]` call (the
519
+ * route resolves AND loads together, per its own docstring: "`:id` accepts
520
+ * the spec `id` or a wikilink basename").
521
+ *
522
+ * Used by `handleNextOnDeck` to pre-fetch the direct `spec_id` lookup branch
523
+ * of `resolveNextOnDeckPayload` — see the `__cloud_direct_spec` injection
524
+ * there for why that branch itself could not be made async directly (14+
525
+ * synchronous call sites in `cli.test.mjs`).
526
+ */
527
+ export async function loadSpecByIdViaTier(vaultRoot, specId, { tier, consoleUrl }) {
528
+ return readViaSelector({
529
+ tier,
530
+ diskRead: () => {
531
+ const specPath = findSpecFile(vaultRoot, specId);
532
+ if (!specPath) return null;
533
+ const spec = loadSpec(vaultRoot, specPath);
534
+ if (!spec) return null;
535
+ return { specPath, frontmatter: spec.frontmatter, body: spec.body };
536
+ },
537
+ neonRead: async () => {
538
+ const res = await consoleFetch(`/api/specs/${encodeURIComponent(specId)}`);
539
+ if (res.ok) {
540
+ const spec = res.body?.spec;
541
+ if (!spec) return null;
542
+ return { specPath: spec.relPath ?? null, frontmatter: spec.frontmatter || {}, body: spec.body || '' };
543
+ }
544
+ if (res.status === 404) return null; // not_found / ambiguous_id — a real answer, not a failure.
545
+ throw new Error(
546
+ `GET /api/specs/${specId} returned ${res.status}: ${res.error || JSON.stringify(res.body ?? {})}`,
547
+ );
548
+ },
549
+ routeLabel: 'GET /api/specs/[id]',
550
+ consoleUrl,
551
+ });
552
+ }
553
+
554
+ /**
555
+ * [[HS-H227-MCPC-TASK]] Pre-fetch, over HTTP, the bounded closure of specs
556
+ * that on-deck brief composition's `loadRef` calls can reach for a given
557
+ * root spec's frontmatter — `extractDecompositionContext`'s `related:` links
558
+ * and its milestone-chain climb (walked by `enrichOnDeckWithClaimTarget` too,
559
+ * via `resolveHierarchy`/`computeClaimTarget` in blocker-resolver.mjs), plus
560
+ * each hop's OWN `related:` links (parent-contract tethering checks the
561
+ * task's related AND its parent's related) and a milestone's sibling `epics:`
562
+ * (decomposition context's milestone-siblings list). Real chains are shallow
563
+ * (task -> epic -> milestone), but the climb is bounded (8 hops) as a
564
+ * belt-and-braces guard against a malformed cycle.
565
+ *
566
+ * Returns a `Map` keyed by every spelling a `loadRef(ref)` call might use for
567
+ * an entry (the ref token it was fetched by, its resolved frontmatter `id`,
568
+ * and its specPath basename) — `loadTaskViaGraph` below does the lookup. The
569
+ * root spec itself (already fetched by the caller as `__cloud_direct_spec`,
570
+ * no second HTTP call here) is seeded under `rootRef` so
571
+ * `enrichOnDeckWithClaimTarget`'s OWN `loadTask(vaultRoot, result.spec_id)`
572
+ * re-load of the on-deck spec also answers from this same cache.
573
+ * `loadSpecByIdViaTier`'s own no-silent-disk-fallback holds here too: a
574
+ * genuine console failure mid-walk throws (propagated to the caller's
575
+ * existing CONSOLE_UNREACHABLE_CLOUD_MODE handling); a 404/not-found ref is
576
+ * just absent from the map — every consumer already treats a missing
577
+ * `loadRef` result as "skip this piece of context", never a hard error.
578
+ */
579
+ export async function prefetchCloudTaskGraph(vaultRoot, rootRef, rootSpec, { tier, consoleUrl }) {
580
+ const cache = new Map();
581
+
582
+ const register = (key, loaded) => {
583
+ if (!loaded) return null;
584
+ const fm = loaded.frontmatter || {};
585
+ const shaped = {
586
+ frontmatter: fm,
587
+ body: loaded.body || '',
588
+ specPath: loaded.specPath ?? null,
589
+ id: fm.id || key,
590
+ status: fm.status,
591
+ name: fm.name,
592
+ type: fm.type,
593
+ tags: fm.tags || [],
594
+ };
595
+ if (key) cache.set(String(key), shaped);
596
+ if (fm.id) cache.set(String(fm.id), shaped);
597
+ if (loaded.specPath) {
598
+ const base = String(loaded.specPath).split(/[\\/]/).pop().replace(/\.md$/i, '');
599
+ cache.set(base, shaped);
600
+ }
601
+ return shaped;
602
+ };
603
+
604
+ const fetchRef = async (ref) => {
605
+ const key = wikilinkTarget(ref);
606
+ if (!key) return null;
607
+ if (cache.has(key)) return cache.get(key);
608
+ const loaded = await loadSpecByIdViaTier(vaultRoot, key, { tier, consoleUrl });
609
+ return register(key, loaded);
610
+ };
611
+
612
+ const relatedOf = (fm) => (Array.isArray(fm?.related) ? fm.related : []);
613
+
614
+ const rootShaped = register(rootRef, rootSpec);
615
+ await Promise.all(relatedOf(rootShaped?.frontmatter).map(fetchRef));
616
+
617
+ let currentFm = rootShaped?.frontmatter || {};
618
+ for (let hops = 0; hops < 8; hops++) {
619
+ const parentRef = currentFm.parent || currentFm.parent_epic || currentFm.milestone;
620
+ if (!parentRef) break;
621
+ const parentShaped = await fetchRef(parentRef);
622
+ if (!parentShaped) break;
623
+ const parentFm = parentShaped.frontmatter || {};
624
+ await Promise.all(relatedOf(parentFm).map(fetchRef));
625
+ if (Array.isArray(parentFm.epics)) {
626
+ await Promise.all(parentFm.epics.map(fetchRef));
627
+ }
628
+ currentFm = parentFm;
629
+ }
630
+
631
+ return cache;
632
+ }
633
+
634
+ // [[HS-68XO-DIAG]] residual — pick a UNIQUELY-resolvable handle for a claim target.
635
+ //
636
+ // computeClaimTarget reports a leaf's bare frontmatter `id` as `spec_id`, but a
637
+ // shared-suffix family (>1 sibling carrying the same id, e.g. the six
638
+ // `HS-W6AD-TASK` children of HS-W6AD-EPIC) makes that bare token AMBIGUOUS. The
639
+ // hardened resolver ([[vault-id-assignment-protocol]] §Shared-suffix families)
640
+ // correctly REFUSES a bare ambiguous token — which would 409 the very
641
+ // `heyspex_claim_spec` the on-deck brief tees up (the claim/release/alignment
642
+ // actions are all built off `claim_target.spec_id`). When the bare id no longer
643
+ // resolves to exactly one spec, return the leaf's unique basename instead (it
644
+ // resolves via the resolver's exact-path check), so the claim/status/progress
645
+ // handshake targets exactly this sibling. Unique families still resolve from the
646
+ // bare token → it is returned unchanged (the convention + every blocker-resolver
647
+ // test asserting bare ids is preserved).
648
+ export function disambiguateClaimSpecId(vaultRoot, specId, relPath) {
649
+ if (!specId || !relPath) return specId;
650
+ let resolvesUniquely = false;
651
+ try {
652
+ resolvesUniquely = !!findSpecFile(vaultRoot, specId);
653
+ } catch {
654
+ resolvesUniquely = false;
655
+ }
656
+ if (resolvesUniquely) return specId;
657
+ const base = String(relPath)
658
+ .split(/[\\/]/)
659
+ .pop()
660
+ .replace(/\.md$/i, '');
661
+ return base && base !== specId ? base : specId;
662
+ }
663
+
664
+ export function loadRoadmap(vaultRoot, roadmapId) {
665
+ const specPath = findSpecFile(vaultRoot, roadmapId);
666
+ if (!specPath) return null;
667
+
668
+ const spec = loadSpec(vaultRoot, specPath);
669
+ if (!spec) return null;
670
+
671
+ return {
672
+ ...spec,
673
+ specPath,
674
+ id: spec.frontmatter.id || roadmapId,
675
+ };
676
+ }
677
+
678
+ export function loadTask(vaultRoot, taskId) {
679
+ const specPath = findSpecFile(vaultRoot, taskId);
680
+ if (!specPath) return null;
681
+
682
+ const spec = loadSpec(vaultRoot, specPath);
683
+ if (!spec) return null;
684
+
685
+ return {
686
+ ...spec,
687
+ specPath,
688
+ id: spec.frontmatter.id || taskId,
689
+ status: spec.frontmatter.status,
690
+ name: spec.frontmatter.name,
691
+ type: spec.frontmatter.type,
692
+ tags: spec.frontmatter.tags || [],
693
+ };
694
+ }
695
+
696
+ /**
697
+ * [[HS-H227-MCPC-TASK]] Cloud-aware `loadTask` for on-deck brief composition.
698
+ * `cloudTaskGraph`, when supplied, is the `Map` `prefetchCloudTaskGraph` built
699
+ * over HTTP in neon tier — a miss there returns null, never a disk fallback
700
+ * (no-silent-disk-fallback rule; a genuine console failure already surfaced
701
+ * during the pre-fetch, before this synchronous lookup ever runs). Every
702
+ * OTHER `loadTask` call site (disk-only on-deck flows this task does not
703
+ * touch — blocker/prerequisite resolution, the scheduler cohort path, etc.)
704
+ * never passes a graph and this falls through to the original disk read,
705
+ * unchanged.
706
+ */
707
+ export function loadTaskViaGraph(vaultRoot, ref, cloudTaskGraph) {
708
+ if (cloudTaskGraph) {
709
+ const key = wikilinkTarget(ref);
710
+ return (key && cloudTaskGraph.get(key)) || null;
711
+ }
712
+ try {
713
+ return loadTask(vaultRoot, ref);
714
+ } catch {
715
+ return null;
716
+ }
717
+ }
718
+
719
+ // Extract a basename or id from a `[[wikilink]]` (with or without surrounding quotes).
720
+ export function wikilinkTarget(raw) {
721
+ if (typeof raw !== 'string') return null;
722
+ const m = raw.match(/\[\[([^\]]+)\]\]/);
723
+ return m ? m[1].trim() : raw.trim();
724
+ }