heyspex 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (352) hide show
  1. package/.claude-plugin/marketplace.json +14 -0
  2. package/README.md +21 -0
  3. package/bin/hs.mjs +1089 -104
  4. package/bin/spexcode.mjs +262 -0
  5. package/dev-factory/engine/worktree.mjs +740 -0
  6. package/dev-factory/lib/code-repo-root.mjs +67 -0
  7. package/dev-factory/lib/context-budget.mjs +84 -0
  8. package/dev-factory/lib/epic-detect.mjs +190 -0
  9. package/dev-factory/lib/fleet-queue-file.mjs +846 -0
  10. package/dev-factory/lib/fleet-queue.mjs +426 -0
  11. package/dev-factory/lib/fleet-state.mjs +14 -18
  12. package/dev-factory/lib/model-catalog.mjs +338 -0
  13. package/dev-factory/lib/seat-context.mjs +199 -0
  14. package/dev-factory/lib/spec-io.mjs +93 -0
  15. package/dev-factory/lib/sufficiency-score.mjs +8 -2
  16. package/dev-factory/lib/worktree-allocator.mjs +744 -0
  17. package/dev-factory/model-catalog.json +222 -0
  18. package/dist/cli/hs-check.mjs +1127 -103
  19. package/dist/cli/hs-claim.mjs +981 -63
  20. package/dist/cli/hs-connect.mjs +10059 -1729
  21. package/dist/cli/hs-defer.mjs +5507 -3072
  22. package/dist/cli/hs-dev.mjs +576 -0
  23. package/dist/cli/hs-env.mjs +416 -302
  24. package/dist/cli/hs-index-rebuild.mjs +7334 -0
  25. package/dist/cli/hs-index-reconcile.mjs +8138 -0
  26. package/dist/cli/hs-init.mjs +2840 -149
  27. package/dist/cli/hs-install.mjs +7214 -594
  28. package/dist/cli/hs-join.mjs +10640 -2421
  29. package/dist/cli/hs-vault-pull.mjs +8157 -6370
  30. package/dist/cli/hs-vault-push.mjs +6433 -0
  31. package/dist/cli/hs-vault.mjs +4029 -1555
  32. package/dist/cli/hs-workspace.mjs +2702 -538
  33. package/dist/cli-templates/customer-vault/specs/entity-model.md +594 -601
  34. package/dist/cli-templates/customer-vault/specs/protocols/canonical-frontmatter-templates.md +110 -177
  35. package/dist/cli-templates/customer-vault/specs/protocols/destructive-action-checklist.md +122 -0
  36. package/dist/cli-templates/customer-vault/specs/protocols/memory-protocol.md +13 -15
  37. package/dist/cli-templates/customer-vault/specs/protocols/session-close-protocol.md +91 -0
  38. package/dist/cli-templates/customer-vault/specs/protocols/spec-hierarchy.md +150 -168
  39. package/dist/cli-templates/customer-vault/specs/protocols/spec-summary-authoring.md +83 -0
  40. package/dist/cli-templates/harness-commands/claude/hs-auto.md +36 -0
  41. package/dist/cli-templates/harness-commands/claude/hs-backfill.md +28 -0
  42. package/dist/cli-templates/harness-commands/claude/hs-capture.md +62 -0
  43. package/dist/cli-templates/harness-commands/claude/hs-defer.md +29 -0
  44. package/dist/cli-templates/harness-commands/claude/hs-dev.md +20 -0
  45. package/dist/cli-templates/harness-commands/claude/hs-doctor.md +32 -0
  46. package/dist/cli-templates/harness-commands/claude/hs-ground.md +30 -0
  47. package/dist/cli-templates/harness-commands/claude/hs-handoff.md +33 -0
  48. package/dist/cli-templates/harness-commands/claude/hs-log.md +27 -0
  49. package/dist/cli-templates/harness-commands/claude/hs-next.md +29 -0
  50. package/dist/cli-templates/harness-commands/claude/hs-promote.md +70 -0
  51. package/dist/cli-templates/harness-commands/claude/hs-push.md +35 -0
  52. package/dist/cli-templates/harness-commands/claude/hs-qc.md +27 -0
  53. package/dist/cli-templates/harness-commands/claude/hs-res.md +63 -0
  54. package/dist/cli-templates/harness-commands/claude/hs-resolve.md +23 -0
  55. package/dist/cli-templates/harness-commands/claude/hs-review.md +23 -0
  56. package/dist/cli-templates/harness-commands/claude/hs-view.md +28 -0
  57. package/dist/cli-templates/harness-commands/claude/kill-ports.md +32 -0
  58. package/dist/cli-templates/harness-commands/claude/prototype.md +91 -0
  59. package/dist/cli-templates/harness-commands/cursor/hs-auto.md +40 -0
  60. package/dist/cli-templates/harness-commands/cursor/hs-backfill.md +31 -0
  61. package/dist/cli-templates/harness-commands/cursor/hs-capture.md +65 -0
  62. package/dist/cli-templates/harness-commands/cursor/hs-defer.md +32 -0
  63. package/dist/cli-templates/harness-commands/cursor/hs-dev.md +24 -0
  64. package/dist/cli-templates/harness-commands/cursor/hs-doctor.md +36 -0
  65. package/dist/cli-templates/harness-commands/cursor/hs-ground.md +33 -0
  66. package/dist/cli-templates/harness-commands/cursor/hs-handoff.md +37 -0
  67. package/dist/cli-templates/harness-commands/cursor/hs-log.md +31 -0
  68. package/dist/cli-templates/harness-commands/cursor/hs-next.md +32 -0
  69. package/dist/cli-templates/harness-commands/cursor/hs-promote.md +73 -0
  70. package/dist/cli-templates/harness-commands/cursor/hs-push.md +38 -0
  71. package/dist/cli-templates/harness-commands/cursor/hs-qc.md +30 -0
  72. package/dist/cli-templates/harness-commands/cursor/hs-res.md +67 -0
  73. package/dist/cli-templates/harness-commands/cursor/hs-resolve.md +26 -0
  74. package/dist/cli-templates/harness-commands/cursor/hs-review.md +26 -0
  75. package/dist/cli-templates/harness-commands/cursor/hs-view.md +32 -0
  76. package/dist/cli-templates/harness-commands/cursor/kill-ports.md +35 -0
  77. package/dist/cli-templates/harness-commands/cursor/prototype.md +95 -0
  78. package/docs/copilot-plugin-install.md +162 -0
  79. package/docs/driving-heyspex-from-copilot.md +165 -0
  80. package/docs/mcp-client-config.md +259 -6
  81. package/package.json +249 -124
  82. package/plugins/heyspex/.claude-plugin/plugin.json +12 -0
  83. package/plugins/heyspex/.cursor-plugin/plugin.json +15 -0
  84. package/plugins/heyspex/.mcp.json +12 -0
  85. package/plugins/heyspex/README.md +309 -0
  86. package/plugins/heyspex/bin/launch-mcp.mjs +95 -0
  87. package/plugins/heyspex/bin/run-hook.mjs +80 -0
  88. package/plugins/heyspex/hooks/cursor-hooks.json +308 -0
  89. package/plugins/heyspex/hooks/hooks.json +418 -0
  90. package/plugins/heyspex/mcp.json +13 -0
  91. package/scripts/check-mcp-freshness.mjs +17 -9
  92. package/scripts/claude-hooks/_env-vault-events.mjs +68 -0
  93. package/scripts/claude-hooks/_guardrail-events.mjs +96 -0
  94. package/scripts/claude-hooks/_guardrail-loader.mjs +494 -0
  95. package/scripts/claude-hooks/_intent-classifier-router.mjs +456 -0
  96. package/scripts/claude-hooks/_intent-classifier-semantic.mjs +356 -0
  97. package/scripts/claude-hooks/_run.mjs +413 -0
  98. package/scripts/claude-hooks/_transcript-text.mjs +60 -0
  99. package/scripts/claude-hooks/_vault-retrieval.mjs +322 -0
  100. package/scripts/claude-hooks/agent-spawn-delegation-gate.mjs +133 -0
  101. package/scripts/claude-hooks/capture-execution-gate.mjs +51 -0
  102. package/scripts/claude-hooks/capture-workflow-gate.mjs +11 -0
  103. package/scripts/claude-hooks/check-auto-memory-budget.mjs +396 -0
  104. package/scripts/claude-hooks/check-claude-md-budget.mjs +327 -0
  105. package/scripts/claude-hooks/check-mcp-freshness-hook.mjs +14 -0
  106. package/scripts/claude-hooks/claim-before-code-gate.mjs +11 -0
  107. package/scripts/claude-hooks/claim-liveness-shared.mjs +577 -0
  108. package/scripts/claude-hooks/close-terminal-on-ship.mjs +64 -0
  109. package/scripts/claude-hooks/coo-denylist-gate.mjs +11 -0
  110. package/scripts/claude-hooks/coo-guard-check.mjs +11 -0
  111. package/scripts/claude-hooks/deep-research-cost-gate.mjs +11 -0
  112. package/scripts/claude-hooks/dev-server-task-reminder.mjs +15 -0
  113. package/scripts/claude-hooks/diagnose-capture-reminder.mjs +11 -0
  114. package/scripts/claude-hooks/directive-obligation-gate.mjs +11 -0
  115. package/scripts/claude-hooks/emit-claim-status.mjs +41 -0
  116. package/scripts/claude-hooks/enforce-session-sequence.mjs +11 -0
  117. package/scripts/claude-hooks/enforce-vault-naming.mjs +11 -0
  118. package/scripts/claude-hooks/env-vault-guard.mjs +11 -0
  119. package/scripts/claude-hooks/epic-milestone-phrasing-gate.mjs +11 -0
  120. package/scripts/claude-hooks/git-identity-guard.mjs +11 -0
  121. package/scripts/claude-hooks/git-reflog-audit.mjs +15 -0
  122. package/scripts/claude-hooks/git-stash-gate.mjs +11 -0
  123. package/scripts/claude-hooks/guard-auto-memory-writes.mjs +137 -0
  124. package/scripts/claude-hooks/id-display-format-gate.mjs +11 -0
  125. package/scripts/claude-hooks/inject-communication-style.mjs +86 -0
  126. package/scripts/claude-hooks/inject-coo-seat.mjs +78 -0
  127. package/scripts/claude-hooks/inject-dev-server-awareness.mjs +11 -0
  128. package/scripts/claude-hooks/inject-doctrine.mjs +13 -0
  129. package/scripts/claude-hooks/inject-vault-context.mjs +14 -0
  130. package/scripts/claude-hooks/intent-classifier-trigger.mjs +279 -0
  131. package/scripts/claude-hooks/log-session-activity.mjs +341 -0
  132. package/scripts/claude-hooks/naughty-list-gate.mjs +11 -0
  133. package/scripts/claude-hooks/notify-status-flip.mjs +72 -0
  134. package/scripts/claude-hooks/plan-exit-ingest-gate.mjs +170 -0
  135. package/scripts/claude-hooks/plan-ingest-close-reminder.mjs +66 -0
  136. package/scripts/claude-hooks/plan-review-gate.mjs +11 -0
  137. package/scripts/claude-hooks/preflight-reminder.mjs +262 -0
  138. package/scripts/claude-hooks/protected-action-intent.mjs +221 -0
  139. package/scripts/claude-hooks/research-method-guard.mjs +398 -0
  140. package/scripts/claude-hooks/research-protocol-gate.mjs +11 -0
  141. package/scripts/claude-hooks/session-end.mjs +451 -0
  142. package/scripts/claude-hooks/settings-write-gate.mjs +11 -0
  143. package/scripts/claude-hooks/settlement-check-gate.mjs +39 -0
  144. package/scripts/claude-hooks/status-flip-detect.mjs +17 -0
  145. package/scripts/claude-hooks/terminal-close-shared.mjs +426 -0
  146. package/scripts/claude-hooks/terminal-close-watcher.mjs +122 -0
  147. package/scripts/claude-hooks/vault-mirror-catchup.mjs +11 -0
  148. package/scripts/claude-hooks/vault-write-tier-guard.mjs +11 -0
  149. package/scripts/claude-hooks/wip-checkpoint.mjs +302 -0
  150. package/scripts/cursor-hooks/manifest.mjs +281 -0
  151. package/scripts/dev-server-status.mjs +34 -13
  152. package/scripts/dev-server-stop.mjs +313 -0
  153. package/scripts/heyspex-mode.mjs +288 -0
  154. package/scripts/hs-auto-resolve.mjs +240 -0
  155. package/scripts/hs-auto-source.mjs +398 -0
  156. package/scripts/hs-auto.mjs +134 -0
  157. package/scripts/hs-capture-cli.mjs +326 -0
  158. package/scripts/hs-classifier.mjs +136 -0
  159. package/scripts/hs-companion.mjs +88 -0
  160. package/scripts/hs-gateway.mjs +115 -0
  161. package/scripts/hs-mode.mjs +125 -0
  162. package/scripts/hs-next.mjs +441 -0
  163. package/scripts/hs-settings.mjs +338 -0
  164. package/scripts/hs-view.mjs +496 -0
  165. package/scripts/hs-worktree.mjs +201 -0
  166. package/scripts/install-claude-code-hooks.mjs +272 -0
  167. package/scripts/install-cursor-hooks.mjs +471 -0
  168. package/scripts/lib/agent-env-markers.mjs +38 -0
  169. package/scripts/lib/console-url-resolver.mjs +199 -61
  170. package/scripts/lib/customer-kit.mjs +1203 -0
  171. package/scripts/lib/dev-mode.mjs +249 -32
  172. package/scripts/lib/directive-obligations.mjs +349 -0
  173. package/scripts/lib/heyspex-home.mjs +72 -0
  174. package/scripts/lib/hook-dispositions.mjs +210 -0
  175. package/scripts/lib/hook-vault-header.mjs +30 -0
  176. package/scripts/lib/hs-command.mjs +277 -0
  177. package/scripts/lib/hs-companion-settings.mjs +472 -0
  178. package/scripts/lib/hs-source-branch.mjs +196 -0
  179. package/scripts/lib/hs-source-commit.mjs +59 -5
  180. package/scripts/lib/hs-source-identity.mjs +526 -0
  181. package/scripts/lib/hs-source-log.mjs +13 -22
  182. package/scripts/lib/hs-source-preflight.mjs +18 -4
  183. package/scripts/lib/hs-source-push.mjs +386 -307
  184. package/scripts/lib/hs-worktree-args.mjs +115 -0
  185. package/scripts/lib/shared-path-helpers.mjs +134 -0
  186. package/scripts/lib/standalone-doctor.mjs +540 -3
  187. package/scripts/lib/subagent-git-lock.mjs +109 -0
  188. package/scripts/lib/template-pin.mjs +34 -0
  189. package/scripts/lib/window-placement.mjs +109 -0
  190. package/scripts/provenance-lib.mjs +91 -0
  191. package/scripts/stamp-provenance-on-edit.mjs +222 -0
  192. package/scripts/wip-ledger-health.mjs +653 -0
  193. package/spexharness/doctrine/baseline/SPEXCODE-DOCTRINE.baseline.json +29 -0
  194. package/spexharness/src/adapters/audit.mjs +197 -0
  195. package/spexharness/src/adapters/claude-code.mjs +449 -0
  196. package/spexharness/src/adapters/copilot.mjs +236 -0
  197. package/spexharness/src/adapters/detect-harness.mjs +34 -0
  198. package/spexharness/src/chat/tui/ansi.mjs +448 -0
  199. package/spexharness/src/chat/tui/palette.mjs +22 -0
  200. package/spexharness/src/chat/tui/select.mjs +208 -0
  201. package/spexharness/src/config-root.mjs +54 -0
  202. package/spexharness/src/context-budget.mjs +141 -0
  203. package/spexharness/src/dev-server-control.mjs +389 -0
  204. package/spexharness/src/doctrine/behaviour.mjs +371 -0
  205. package/spexharness/src/doctrine/corpus.mjs +733 -0
  206. package/spexharness/src/doctrine/inject.mjs +338 -0
  207. package/spexharness/src/doctrine/merge.mjs +184 -0
  208. package/spexharness/src/doctrine/sources.mjs +252 -0
  209. package/spexharness/src/doctrine/synthesize.mjs +401 -0
  210. package/spexharness/src/doctrine/vault-source.mjs +574 -0
  211. package/spexharness/src/doctrine/writer.mjs +176 -0
  212. package/spexharness/src/events.mjs +84 -0
  213. package/spexharness/src/gates/_shell-write-targets.mjs +77 -0
  214. package/spexharness/src/gates/capture-workflow-gate.mjs +202 -0
  215. package/spexharness/src/gates/claim-before-code-gate.decide.mjs +33 -0
  216. package/spexharness/src/gates/claim-before-code-gate.mjs +129 -0
  217. package/spexharness/src/gates/deep-research-cost-gate.mjs +275 -0
  218. package/spexharness/src/gates/diagnose-capture-reminder.mjs +112 -0
  219. package/spexharness/src/gates/directive-obligation-gate.decide.mjs +132 -0
  220. package/spexharness/src/gates/directive-obligation-gate.mjs +183 -0
  221. package/spexharness/src/gates/enforce-session-sequence.mjs +895 -0
  222. package/spexharness/src/gates/enforce-vault-naming.mjs +215 -0
  223. package/spexharness/src/gates/epic-milestone-phrasing-gate.mjs +233 -0
  224. package/spexharness/src/gates/git-identity-guard.decide.mjs +436 -0
  225. package/spexharness/src/gates/git-identity-guard.mjs +61 -0
  226. package/spexharness/src/gates/git-reflog-audit.mjs +151 -0
  227. package/spexharness/src/gates/git-stash-gate.decide.mjs +361 -0
  228. package/spexharness/src/gates/git-stash-gate.mjs +159 -0
  229. package/spexharness/src/gates/id-display-format-gate.mjs +190 -0
  230. package/spexharness/src/gates/inject-doctrine-gate.mjs +89 -0
  231. package/spexharness/src/gates/inject-vault-context.mjs +239 -0
  232. package/spexharness/src/gates/naughty-list-gate.mjs +213 -0
  233. package/spexharness/src/gates/plan-review-gate.mjs +233 -0
  234. package/spexharness/src/gates/preflight-reminder-gate.mjs +265 -0
  235. package/spexharness/src/gates/research-protocol-gate.mjs +175 -0
  236. package/spexharness/src/gates/settings-write-gate.decide.mjs +96 -0
  237. package/spexharness/src/gates/settings-write-gate.mjs +104 -0
  238. package/spexharness/src/gates/vault-mirror-catchup.mjs +124 -0
  239. package/spexharness/src/gates/vault-write-tier-guard.decide.mjs +125 -0
  240. package/spexharness/src/gates/vault-write-tier-guard.mjs +42 -0
  241. package/spexharness/src/git-reflog-audit.mjs +294 -0
  242. package/spexharness/src/intent-classifier.mjs +196 -0
  243. package/spexharness/src/intent-directive.mjs +626 -0
  244. package/spexharness/src/profile/learn.mjs +551 -0
  245. package/spexharness/src/profile/proposals.mjs +357 -0
  246. package/spexharness/src/profile/vault-inclusion.mjs +386 -0
  247. package/spexharness/src/redact.mjs +151 -0
  248. package/spexharness/src/rule-table.mjs +849 -0
  249. package/spexharness/src/seat-context.mjs +122 -0
  250. package/spexharness/src/session-bootstrap.mjs +512 -0
  251. package/spexharness/src/session-record.mjs +815 -0
  252. package/spexharness/src/token-inventory.mjs +207 -0
  253. package/vendor/heyspex-mcp/account-switch.mjs +96 -0
  254. package/vendor/heyspex-mcp/alignment-gate.mjs +315 -1
  255. package/vendor/heyspex-mcp/blocker-resolver.mjs +37 -2
  256. package/vendor/heyspex-mcp/breakdown-ladder.mjs +119 -4
  257. package/vendor/heyspex-mcp/breakdown-source.mjs +271 -0
  258. package/vendor/heyspex-mcp/breakdown-writer.mjs +23 -3
  259. package/vendor/heyspex-mcp/capture-buffer-patches.mjs +77 -8
  260. package/vendor/heyspex-mcp/capture-channel.mjs +55 -5
  261. package/vendor/heyspex-mcp/capture-console-client.mjs +117 -3
  262. package/vendor/heyspex-mcp/capture-focus.mjs +1061 -31
  263. package/vendor/heyspex-mcp/capture-outbox.mjs +15 -5
  264. package/vendor/heyspex-mcp/capture-sources.mjs +195 -0
  265. package/vendor/heyspex-mcp/capture-tools.mjs +1264 -0
  266. package/vendor/heyspex-mcp/claim-tools.mjs +1147 -0
  267. package/vendor/heyspex-mcp/cli-verbs.mjs +2074 -0
  268. package/vendor/heyspex-mcp/cli.mjs +207 -15524
  269. package/vendor/heyspex-mcp/close-branch-report.mjs +102 -0
  270. package/vendor/heyspex-mcp/cloud-tool-surface.mjs +128 -0
  271. package/vendor/heyspex-mcp/cloud-vault-writes.mjs +602 -0
  272. package/vendor/heyspex-mcp/config-seed.yaml +15 -0
  273. package/vendor/heyspex-mcp/config-settings.mjs +290 -7
  274. package/vendor/heyspex-mcp/console-fetch.mjs +822 -17
  275. package/vendor/heyspex-mcp/console-transport.mjs +172 -0
  276. package/vendor/heyspex-mcp/credentials.mjs +585 -0
  277. package/vendor/heyspex-mcp/defer-spec.mjs +44 -4
  278. package/vendor/heyspex-mcp/deferred-reclaim.mjs +29 -3
  279. package/vendor/heyspex-mcp/design-handlers.mjs +31 -0
  280. package/vendor/heyspex-mcp/design-tools.mjs +4 -1
  281. package/vendor/heyspex-mcp/dev-factory-bridge.mjs +4 -4
  282. package/vendor/heyspex-mcp/dev-mode-core.mjs +45 -0
  283. package/vendor/heyspex-mcp/dev-tools.mjs +131 -0
  284. package/vendor/heyspex-mcp/diagnose-handlers.mjs +54 -0
  285. package/vendor/heyspex-mcp/events-tools.mjs +207 -0
  286. package/vendor/heyspex-mcp/filing-tools.mjs +723 -0
  287. package/vendor/heyspex-mcp/fleet-handlers.mjs +37 -0
  288. package/vendor/heyspex-mcp/fleet-queue-tools.mjs +71 -15
  289. package/vendor/heyspex-mcp/focus-tools.mjs +190 -0
  290. package/vendor/heyspex-mcp/folder-trust-store.mjs +60 -0
  291. package/vendor/heyspex-mcp/http-transport.mjs +302 -0
  292. package/vendor/heyspex-mcp/list-tools.mjs +257 -0
  293. package/vendor/heyspex-mcp/login-handoff.mjs +15 -1
  294. package/vendor/heyspex-mcp/mcp-config.mjs +107 -0
  295. package/vendor/heyspex-mcp/memory-tools.mjs +523 -0
  296. package/vendor/heyspex-mcp/mvp-mode.mjs +53 -6
  297. package/vendor/heyspex-mcp/obligation-backstop.mjs +103 -0
  298. package/vendor/heyspex-mcp/occupied-keys.mjs +142 -0
  299. package/vendor/heyspex-mcp/on-deck-brief.mjs +3377 -0
  300. package/vendor/heyspex-mcp/on-deck-scheduler.mjs +1708 -0
  301. package/vendor/heyspex-mcp/package-root.mjs +32 -0
  302. package/vendor/heyspex-mcp/peer-presence.mjs +8 -1
  303. package/vendor/heyspex-mcp/plan-ground.mjs +621 -652
  304. package/vendor/heyspex-mcp/plan-push.mjs +908 -22
  305. package/vendor/heyspex-mcp/plan-review.mjs +124 -30
  306. package/vendor/heyspex-mcp/preflight-gate.mjs +0 -0
  307. package/vendor/heyspex-mcp/procedure-handlers.mjs +327 -0
  308. package/vendor/heyspex-mcp/procedure-tools.mjs +1181 -970
  309. package/vendor/heyspex-mcp/promote-capture.mjs +388 -69
  310. package/vendor/heyspex-mcp/promote-diagnose.mjs +74 -0
  311. package/vendor/heyspex-mcp/promote-research-to-diagnose.mjs +9 -2
  312. package/vendor/heyspex-mcp/promote-research.mjs +666 -53
  313. package/vendor/heyspex-mcp/promotion-helpers.mjs +251 -0
  314. package/vendor/heyspex-mcp/prompts.mjs +1460 -0
  315. package/vendor/heyspex-mcp/read-path.mjs +191 -15
  316. package/vendor/heyspex-mcp/receipt-shape.mjs +146 -0
  317. package/vendor/heyspex-mcp/recipe-handlers.mjs +117 -0
  318. package/vendor/heyspex-mcp/regen-tasks.mjs +210 -3
  319. package/vendor/heyspex-mcp/register-spec.mjs +266 -54
  320. package/vendor/heyspex-mcp/request-context.mjs +83 -0
  321. package/vendor/heyspex-mcp/research-focus.mjs +207 -4
  322. package/vendor/heyspex-mcp/research-handlers.mjs +625 -0
  323. package/vendor/heyspex-mcp/research-tools.mjs +4 -4
  324. package/vendor/heyspex-mcp/response-budget.mjs +348 -0
  325. package/vendor/heyspex-mcp/review-focus.mjs +25 -1
  326. package/vendor/heyspex-mcp/roadmap-default.mjs +70 -0
  327. package/vendor/heyspex-mcp/search-specs.mjs +280 -0
  328. package/vendor/heyspex-mcp/seat-block-ledger.mjs +113 -0
  329. package/vendor/heyspex-mcp/seat-identity.mjs +2 -6
  330. package/vendor/heyspex-mcp/session-owner.mjs +109 -0
  331. package/vendor/heyspex-mcp/session-tools.mjs +162 -0
  332. package/vendor/heyspex-mcp/settings-registry.mjs +232 -0
  333. package/vendor/heyspex-mcp/settings-tools.mjs +85 -12
  334. package/vendor/heyspex-mcp/spec-edit-tools.mjs +453 -0
  335. package/vendor/heyspex-mcp/spec-id-lib.mjs +93 -17
  336. package/vendor/heyspex-mcp/split-research.mjs +304 -56
  337. package/vendor/heyspex-mcp/stdio-rpc.mjs +22 -0
  338. package/vendor/heyspex-mcp/submit-for-review.mjs +150 -7
  339. package/vendor/heyspex-mcp/summary-contract.mjs +105 -0
  340. package/vendor/heyspex-mcp/summary-write-tools.mjs +228 -0
  341. package/vendor/heyspex-mcp/symptom-query.mjs +146 -0
  342. package/vendor/heyspex-mcp/test-helpers.mjs +123 -1
  343. package/vendor/heyspex-mcp/tool-catalog.mjs +2819 -0
  344. package/vendor/heyspex-mcp/tool-dispatch.mjs +657 -0
  345. package/vendor/heyspex-mcp/tool-helpers.mjs +325 -0
  346. package/vendor/heyspex-mcp/validate-linkage.mjs +414 -27
  347. package/vendor/heyspex-mcp/vault-io.mjs +720 -0
  348. package/vendor/heyspex-mcp/vault-less-endpoints.mjs +52 -0
  349. package/vendor/heyspex-mcp/vault-resolve.mjs +554 -68
  350. package/vendor/heyspex-mcp/verify-reproduction.mjs +270 -30
  351. package/vendor/heyspex-mcp/whoami.mjs +92 -0
  352. package/vendor/heyspex-mcp/write-outbox.mjs +512 -0
@@ -0,0 +1,720 @@
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 { consoleReadErrorFrom, 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 consoleReadErrorFrom(res, `GET /api/specs/resolve returned ${res.status}: ${res.error || JSON.stringify(res.body ?? {})}`, null);
506
+ },
507
+ routeLabel: 'GET /api/specs/resolve',
508
+ consoleUrl,
509
+ });
510
+ }
511
+
512
+ /**
513
+ * Cloud-reliant "load this spec by id" primitive ([[HS-DJWP-MCPC-TASK]] round
514
+ * 2, AC1 "general spec load"). Disk tier calls `findSpecFile` + `loadSpec`
515
+ * (both untouched, verbatim) and returns `{ specPath, frontmatter, body }` —
516
+ * `null` when not found. Neon tier makes ONE `GET /api/specs/[id]` call (the
517
+ * route resolves AND loads together, per its own docstring: "`:id` accepts
518
+ * the spec `id` or a wikilink basename").
519
+ *
520
+ * Used by `handleNextOnDeck` to pre-fetch the direct `spec_id` lookup branch
521
+ * of `resolveNextOnDeckPayload` — see the `__cloud_direct_spec` injection
522
+ * there for why that branch itself could not be made async directly (14+
523
+ * synchronous call sites in `cli.test.mjs`).
524
+ */
525
+ export async function loadSpecByIdViaTier(vaultRoot, specId, { tier, consoleUrl }) {
526
+ return readViaSelector({
527
+ tier,
528
+ diskRead: () => {
529
+ const specPath = findSpecFile(vaultRoot, specId);
530
+ if (!specPath) return null;
531
+ const spec = loadSpec(vaultRoot, specPath);
532
+ if (!spec) return null;
533
+ return { specPath, frontmatter: spec.frontmatter, body: spec.body };
534
+ },
535
+ neonRead: async () => {
536
+ const res = await consoleFetch(`/api/specs/${encodeURIComponent(specId)}`);
537
+ if (res.ok) {
538
+ const spec = res.body?.spec;
539
+ if (!spec) return null;
540
+ return { specPath: spec.relPath ?? null, frontmatter: spec.frontmatter || {}, body: spec.body || '' };
541
+ }
542
+ if (res.status === 404) return null; // not_found / ambiguous_id — a real answer, not a failure.
543
+ throw consoleReadErrorFrom(res, `GET /api/specs/${specId} returned ${res.status}: ${res.error || JSON.stringify(res.body ?? {})}`, null);
544
+ },
545
+ routeLabel: 'GET /api/specs/[id]',
546
+ consoleUrl,
547
+ });
548
+ }
549
+
550
+ /**
551
+ * [[HS-H227-MCPC-TASK]] Pre-fetch, over HTTP, the bounded closure of specs
552
+ * that on-deck brief composition's `loadRef` calls can reach for a given
553
+ * root spec's frontmatter — `extractDecompositionContext`'s `related:` links
554
+ * and its milestone-chain climb (walked by `enrichOnDeckWithClaimTarget` too,
555
+ * via `resolveHierarchy`/`computeClaimTarget` in blocker-resolver.mjs), plus
556
+ * each hop's OWN `related:` links (parent-contract tethering checks the
557
+ * task's related AND its parent's related) and a milestone's sibling `epics:`
558
+ * (decomposition context's milestone-siblings list). Real chains are shallow
559
+ * (task -> epic -> milestone), but the climb is bounded (8 hops) as a
560
+ * belt-and-braces guard against a malformed cycle.
561
+ *
562
+ * Returns a `Map` keyed by every spelling a `loadRef(ref)` call might use for
563
+ * an entry (the ref token it was fetched by, its resolved frontmatter `id`,
564
+ * and its specPath basename) — `loadTaskViaGraph` below does the lookup. The
565
+ * root spec itself (already fetched by the caller as `__cloud_direct_spec`,
566
+ * no second HTTP call here) is seeded under `rootRef` so
567
+ * `enrichOnDeckWithClaimTarget`'s OWN `loadTask(vaultRoot, result.spec_id)`
568
+ * re-load of the on-deck spec also answers from this same cache.
569
+ * `loadSpecByIdViaTier`'s own no-silent-disk-fallback holds here too: a
570
+ * genuine console failure mid-walk throws (propagated to the caller's
571
+ * existing CONSOLE_UNREACHABLE_CLOUD_MODE handling); a 404/not-found ref is
572
+ * just absent from the map — every consumer already treats a missing
573
+ * `loadRef` result as "skip this piece of context", never a hard error.
574
+ */
575
+ export async function prefetchCloudTaskGraph(vaultRoot, rootRef, rootSpec, { tier, consoleUrl }) {
576
+ const cache = new Map();
577
+
578
+ const register = (key, loaded) => {
579
+ if (!loaded) return null;
580
+ const fm = loaded.frontmatter || {};
581
+ const shaped = {
582
+ frontmatter: fm,
583
+ body: loaded.body || '',
584
+ specPath: loaded.specPath ?? null,
585
+ id: fm.id || key,
586
+ status: fm.status,
587
+ name: fm.name,
588
+ type: fm.type,
589
+ tags: fm.tags || [],
590
+ };
591
+ if (key) cache.set(String(key), shaped);
592
+ if (fm.id) cache.set(String(fm.id), shaped);
593
+ if (loaded.specPath) {
594
+ const base = String(loaded.specPath).split(/[\\/]/).pop().replace(/\.md$/i, '');
595
+ cache.set(base, shaped);
596
+ }
597
+ return shaped;
598
+ };
599
+
600
+ const fetchRef = async (ref) => {
601
+ const key = wikilinkTarget(ref);
602
+ if (!key) return null;
603
+ if (cache.has(key)) return cache.get(key);
604
+ const loaded = await loadSpecByIdViaTier(vaultRoot, key, { tier, consoleUrl });
605
+ return register(key, loaded);
606
+ };
607
+
608
+ const relatedOf = (fm) => (Array.isArray(fm?.related) ? fm.related : []);
609
+
610
+ const rootShaped = register(rootRef, rootSpec);
611
+ await Promise.all(relatedOf(rootShaped?.frontmatter).map(fetchRef));
612
+
613
+ let currentFm = rootShaped?.frontmatter || {};
614
+ for (let hops = 0; hops < 8; hops++) {
615
+ const parentRef = currentFm.parent || currentFm.parent_epic || currentFm.milestone;
616
+ if (!parentRef) break;
617
+ const parentShaped = await fetchRef(parentRef);
618
+ if (!parentShaped) break;
619
+ const parentFm = parentShaped.frontmatter || {};
620
+ await Promise.all(relatedOf(parentFm).map(fetchRef));
621
+ if (Array.isArray(parentFm.epics)) {
622
+ await Promise.all(parentFm.epics.map(fetchRef));
623
+ }
624
+ currentFm = parentFm;
625
+ }
626
+
627
+ return cache;
628
+ }
629
+
630
+ // [[HS-68XO-DIAG]] residual — pick a UNIQUELY-resolvable handle for a claim target.
631
+ //
632
+ // computeClaimTarget reports a leaf's bare frontmatter `id` as `spec_id`, but a
633
+ // shared-suffix family (>1 sibling carrying the same id, e.g. the six
634
+ // `HS-W6AD-TASK` children of HS-W6AD-EPIC) makes that bare token AMBIGUOUS. The
635
+ // hardened resolver ([[vault-id-assignment-protocol]] §Shared-suffix families)
636
+ // correctly REFUSES a bare ambiguous token — which would 409 the very
637
+ // `heyspex_claim_spec` the on-deck brief tees up (the claim/release/alignment
638
+ // actions are all built off `claim_target.spec_id`). When the bare id no longer
639
+ // resolves to exactly one spec, return the leaf's unique basename instead (it
640
+ // resolves via the resolver's exact-path check), so the claim/status/progress
641
+ // handshake targets exactly this sibling. Unique families still resolve from the
642
+ // bare token → it is returned unchanged (the convention + every blocker-resolver
643
+ // test asserting bare ids is preserved).
644
+ export function disambiguateClaimSpecId(vaultRoot, specId, relPath) {
645
+ if (!specId || !relPath) return specId;
646
+ let resolvesUniquely = false;
647
+ try {
648
+ resolvesUniquely = !!findSpecFile(vaultRoot, specId);
649
+ } catch {
650
+ resolvesUniquely = false;
651
+ }
652
+ if (resolvesUniquely) return specId;
653
+ const base = String(relPath)
654
+ .split(/[\\/]/)
655
+ .pop()
656
+ .replace(/\.md$/i, '');
657
+ return base && base !== specId ? base : specId;
658
+ }
659
+
660
+ export function loadRoadmap(vaultRoot, roadmapId) {
661
+ const specPath = findSpecFile(vaultRoot, roadmapId);
662
+ if (!specPath) return null;
663
+
664
+ const spec = loadSpec(vaultRoot, specPath);
665
+ if (!spec) return null;
666
+
667
+ return {
668
+ ...spec,
669
+ specPath,
670
+ id: spec.frontmatter.id || roadmapId,
671
+ };
672
+ }
673
+
674
+ export function loadTask(vaultRoot, taskId) {
675
+ const specPath = findSpecFile(vaultRoot, taskId);
676
+ if (!specPath) return null;
677
+
678
+ const spec = loadSpec(vaultRoot, specPath);
679
+ if (!spec) return null;
680
+
681
+ return {
682
+ ...spec,
683
+ specPath,
684
+ id: spec.frontmatter.id || taskId,
685
+ status: spec.frontmatter.status,
686
+ name: spec.frontmatter.name,
687
+ type: spec.frontmatter.type,
688
+ tags: spec.frontmatter.tags || [],
689
+ };
690
+ }
691
+
692
+ /**
693
+ * [[HS-H227-MCPC-TASK]] Cloud-aware `loadTask` for on-deck brief composition.
694
+ * `cloudTaskGraph`, when supplied, is the `Map` `prefetchCloudTaskGraph` built
695
+ * over HTTP in neon tier — a miss there returns null, never a disk fallback
696
+ * (no-silent-disk-fallback rule; a genuine console failure already surfaced
697
+ * during the pre-fetch, before this synchronous lookup ever runs). Every
698
+ * OTHER `loadTask` call site (disk-only on-deck flows this task does not
699
+ * touch — blocker/prerequisite resolution, the scheduler cohort path, etc.)
700
+ * never passes a graph and this falls through to the original disk read,
701
+ * unchanged.
702
+ */
703
+ export function loadTaskViaGraph(vaultRoot, ref, cloudTaskGraph) {
704
+ if (cloudTaskGraph) {
705
+ const key = wikilinkTarget(ref);
706
+ return (key && cloudTaskGraph.get(key)) || null;
707
+ }
708
+ try {
709
+ return loadTask(vaultRoot, ref);
710
+ } catch {
711
+ return null;
712
+ }
713
+ }
714
+
715
+ // Extract a basename or id from a `[[wikilink]]` (with or without surrounding quotes).
716
+ export function wikilinkTarget(raw) {
717
+ if (typeof raw !== 'string') return null;
718
+ const m = raw.match(/\[\[([^\]]+)\]\]/);
719
+ return m ? m[1].trim() : raw.trim();
720
+ }