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
@@ -25,11 +25,60 @@
25
25
  * it to every outbound request — `consoleFetch` AND `postClaimEndpoint` share
26
26
  * one transport, so both get it for free. No token resolvable → no header,
27
27
  * exactly today's (unauthenticated) behavior against a local dev console.
28
+ *
29
+ * SIGN-IN vs OUTAGE ([[HS-9R6S-N8VP-DIAG-capture-writes-report-the-console-as]]):
30
+ * a refused connection is NOT proof the console is down — it is equally the
31
+ * symptom of talking to the wrong console. After recovery has failed, a
32
+ * status-0 result is re-classified against the resolved reliance
33
+ * (scripts/lib/dev-mode.mjs) + the resolved bearer token:
34
+ *
35
+ * - cloud reliance + loopback URL → `CONSOLE_LOCAL_URL_IN_CLOUD_MODE`
36
+ * - cloud reliance + no token → `CONSOLE_NOT_SIGNED_IN`
37
+ * - anything else → `CONSOLE_UNREACHABLE` (unchanged)
38
+ * - an answered HTTP 401 → `CONSOLE_NOT_SIGNED_IN`
39
+ *
40
+ * Both new classes carry `authProblem` on the result. Callers MUST NOT treat
41
+ * an `authProblem` result as bufferable: nothing becomes reachable until the
42
+ * user runs `hs login`, so stashing to the offline buffer would hide the real
43
+ * cause behind a false "your words will sync" promise. The 4xx →
44
+ * CAPTURE_CONFLICT / 5xx → PATCH_FAILED contract is untouched — both still
45
+ * mean "the server ANSWERED", and neither buffers; 401 only gets a more
46
+ * specific code and an actionable message inside that same never-buffer family.
47
+ *
48
+ * EXPLICIT SCOPE ([[HS-MVG4-WW9R-TASK-mcp-and-hs-name-account-vault-on-every]],
49
+ * invariant 2 of [[HS-BIWR-WW9R-EPIC-explicit-vault-scope-on-every-console]]):
50
+ * every vault-scoped request names `{ account, vault }` on the wire —
51
+ * `x-heyspex-vault-id` from the resolver (ANY source, the registry pointer
52
+ * included) and `x-heyspex-account-id` from the one account reader
53
+ * (session-owner.mjs). A cwd that resolves to an UNREGISTERED root, or a
54
+ * machine with no configured account, is refused LOCALLY before any fetch:
55
+ * `consoleFetch` / `postClaimEndpoint` return a status-0 result carrying
56
+ * `refusal: VAULT_NOT_REGISTERED | ACCOUNT_NOT_CONFIGURED` (also stamped as
57
+ * `authProblem`, so every existing never-buffer path treats it the same way a
58
+ * sign-in problem is treated) and a message naming `hs connect` / `hs login`.
59
+ * The endpoints in {@link VAULT_LESS_ENDPOINTS} (pairing, session identity,
60
+ * creating/listing vaults and workspaces, dev-mode) are exempt: headers are
61
+ * attached when they resolve, and nothing refuses when they do not.
28
62
  */
29
63
  import { readFileSync } from 'node:fs';
30
64
  import { homedir } from 'node:os';
31
65
  import { join } from 'node:path';
32
66
 
67
+ import { readBearer } from './credentials.mjs';
68
+ import { resolveSessionOwnerUncached } from './session-owner.mjs';
69
+ import { isVaultLessEndpoint, VAULT_LESS_ENDPOINTS } from './vault-less-endpoints.mjs';
70
+
71
+ export { isVaultLessEndpoint, VAULT_LESS_ENDPOINTS };
72
+
73
+ // The runnable form of a HeySpex command on THIS machine — `hs login` when the
74
+ // bin is on PATH, else an absolute-path `node "<install>/bin/hs.mjs" login`.
75
+ // Never hard-code the bare `hs …` spelling in text a human reads, and never an
76
+ // `npx` form: npx resolves from the READER's working directory, and outside a
77
+ // checkout `npx hs` installs the unrelated public package hs@2.0.1
78
+ // ([[HS-I6HT-EKYU-DIAG-every-message-tells-the-user-to-run-hs]]).
79
+ import { hsCommand } from '../../scripts/lib/hs-command.mjs';
80
+ import { getActiveConfig, UNREGISTERED_VAULT_ID } from './vault-resolve.mjs';
81
+
33
82
  export const DEFAULT_CONSOLE_TIMEOUT_MS = 30000;
34
83
 
35
84
  /** Backoff when Next dev restarts (next.config change, etc.) — ECONNREFUSED is transient. */
@@ -39,6 +88,26 @@ function sleep(ms) {
39
88
  return new Promise((resolve) => setTimeout(resolve, ms));
40
89
  }
41
90
 
91
+ /**
92
+ * Cold cloud lambda ([[HS-2FQY]] read side): the console answers 503
93
+ * `vault_scope_unresolved` (+ `Retry-After`) while it has not resolved its
94
+ * active vault yet, instead of a 200 empty bucket that reads as "the vault is
95
+ * empty". Transient by contract — the console kicks its own self-heal — so
96
+ * the client waits the advertised window (capped) and tries once more.
97
+ */
98
+ export const VAULT_SCOPE_RETRY_CAP_MS = 5000;
99
+ export const VAULT_SCOPE_RETRY_DEFAULT_MS = 3000;
100
+
101
+ export function isVaultScopeUnresolved(result) {
102
+ return result?.status === 503 && result?.body?.error === 'vault_scope_unresolved';
103
+ }
104
+
105
+ export function retryAfterMs(result) {
106
+ const raw = Number(result?.retryAfterSeconds);
107
+ if (!Number.isFinite(raw) || raw <= 0) return VAULT_SCOPE_RETRY_DEFAULT_MS;
108
+ return Math.min(raw * 1000, VAULT_SCOPE_RETRY_CAP_MS);
109
+ }
110
+
42
111
  /**
43
112
  * Detect AbortError from a thrown fetch error. Covers the standard
44
113
  * `AbortError` name and the substring fallback some runtimes use
@@ -98,26 +167,415 @@ export function rewriteUrlOrigin(urlString, originUrl) {
98
167
  return target.href;
99
168
  }
100
169
 
170
+ /** Local refusal: the repo records an account this machine holds no token for. */
171
+ export const ACCOUNT_TOKEN_MISSING = 'account_token_missing';
172
+
173
+ /**
174
+ * Thrown by {@link getAuthHeader} when the repo's heyspex.yaml records
175
+ * `account: <A>` and this machine stores no token for A
176
+ * ([[HS-VWUD-WW9R-TASK-record-the-connecting-account-in]], invariant 4). The
177
+ * transport refuses every console call with it rather than sending the
178
+ * CURRENT account's token — that fallback is exactly how a repo connected as
179
+ * A wrote as B after `hs login --switch-account B`. Names `hs login --account A`.
180
+ */
181
+ export class AccountTokenMissingError extends Error {
182
+ /** @param {string} accountId the account the repo records */
183
+ constructor(accountId) {
184
+ super(
185
+ `This repo is connected under HeySpex account ${accountId} (its heyspex.yaml \`account:\`), `
186
+ + 'but this machine holds no sign-in for that account, so the console call was refused before it was sent. '
187
+ + `Run \`${hsCommand(`login --account ${accountId}`)}\` to sign in as it, then try again. `
188
+ + 'Nothing was sent, and the current account was NOT used in its place.',
189
+ );
190
+ this.name = 'AccountTokenMissingError';
191
+ this.code = ACCOUNT_TOKEN_MISSING;
192
+ this.accountId = accountId;
193
+ }
194
+ }
195
+
196
+ // `account:` line of a repo's heyspex.yaml — the account `hs connect` recorded
197
+ // the repo under. Same line-anchored shape as {@link MARKER_VAULT_ID_RE} and
198
+ // as init.ts `parseAccountId` / install-identity.ts `parseMarkerAccount`.
199
+ const MARKER_ACCOUNT_RE = /^account:[ \t]*["']?([^"'\r\n#]+)/m;
200
+
201
+ /**
202
+ * The `account` a repo's `heyspex.yaml` records, or `null` (no marker, no
203
+ * key, unreadable). Never throws. `readFile` is a test seam.
204
+ * @param {string | null | undefined} repoRootAbs
205
+ * @param {{ readFile?: (p: string) => string }} [opts]
206
+ */
207
+ export function readMarkerAccount(repoRootAbs, { readFile } = {}) {
208
+ if (typeof repoRootAbs !== 'string' || !repoRootAbs.trim()) return null;
209
+ const read = readFile ?? ((p) => readFileSync(p, 'utf8'));
210
+ try {
211
+ const raw = read(join(repoRootAbs, 'heyspex.yaml'));
212
+ const value = String(raw ?? '').match(MARKER_ACCOUNT_RE)?.[1]?.trim() ?? '';
213
+ return value || null;
214
+ } catch {
215
+ return null;
216
+ }
217
+ }
218
+
219
+ /**
220
+ * The account THIS process's repo records: the resolver's root (memoized
221
+ * `getActiveConfig`) → its heyspex.yaml `account:`. Read per call, so an `hs
222
+ * connect` backfill is seen without a restart. `null` when the resolver
223
+ * fails or the marker records none (a legacy marker: the current account
224
+ * applies, unchanged).
225
+ * @returns {string | null}
226
+ */
227
+ export function getRepoAccountId() {
228
+ let resolved;
229
+ try {
230
+ resolved = getActiveConfig();
231
+ } catch {
232
+ return null;
233
+ }
234
+ return readMarkerAccount(resolved?.repoRootAbs);
235
+ }
236
+
101
237
  /**
102
238
  * Resolve the outbound `Authorization` header, or `{}` when no token
103
239
  * resolves (no header at all — matches today's unauthenticated behavior
104
240
  * against a local dev console). Precedence: `HEYSPEX_MCP_TOKEN` env →
105
- * `~/.heyspex/credentials.yaml` `mcp_token`. Injectable `env`/`homeDir` for
106
- * tests; never throws (an unreadable credentials file is just "no token").
241
+ * `~/.heyspex/credentials.yaml` — read by the ONE shared bearer reader
242
+ * (`./credentials.mjs`, [[HS-HL0D-WW9R-TASK-share-one-bearer-reader-and-one-console]]),
243
+ * never re-implemented here.
244
+ *
245
+ * Which stored token: the REPO's account when its heyspex.yaml records one
246
+ * (`repoAccountId`, default {@link getRepoAccountId}), else the current
247
+ * account ([[HS-VWUD-WW9R-TASK-record-the-connecting-account-in]]). A recorded
248
+ * account with no stored token throws {@link AccountTokenMissingError} — never
249
+ * a silent fall back to the current account. Injectable `env`/`homeDir`/
250
+ * `repoAccountId` for tests (`null` = "the repo records none"); an unreadable
251
+ * credentials file is just "no token".
252
+ * @param {{ env?: NodeJS.ProcessEnv | Record<string, string | undefined>, homeDir?: string, repoAccountId?: string | null }} [opts]
253
+ */
254
+ export function getAuthHeader({ env = process.env, homeDir = homedir(), repoAccountId } = {}) {
255
+ const accountId = repoAccountId === undefined ? getRepoAccountId() : repoAccountId;
256
+ const { token } = readBearer({ env, homeDir, accountId });
257
+ if (!token && accountId) throw new AccountTokenMissingError(accountId);
258
+ return token ? { Authorization: `Bearer ${token}` } : {};
259
+ }
260
+
261
+ /**
262
+ * VAULT SCOPING ([[HS-M95C]]): a cloud console scopes every request to the
263
+ * account's active-vault POINTER unless the request names a vault. The MCP
264
+ * server resolves its vault locally (vault-resolve.mjs, five tiers) but never
265
+ * put that id on the wire, so a session started in repo A read/wrote vault B.
266
+ *
267
+ * Contract (agreed with the console side, revised by
268
+ * [[HS-MVG4-WW9R-TASK-mcp-and-hs-name-account-vault-on-every]]): send
269
+ * `x-heyspex-vault-id: <id>` for EVERY registered id the resolver produced,
270
+ * whatever its source — a repo-derived tier (`explicit`, `env-vault-root`,
271
+ * `env-vault-dir`, `satellite-pin`, `cwd-heuristic`) AND the registry's active
272
+ * pointer (`account-pointer`). The pointer used to send nothing so the cloud
273
+ * console could fall back to ITS pointer; the two pointers drift, and a silent
274
+ * fallback is exactly what served repo A's session from vault B. An
275
+ * UNREGISTERED root is not a "no header" case either: it throws
276
+ * {@link VaultNotRegisteredError} so the transport refuses before any fetch.
107
277
  */
108
- export function getAuthHeader({ env = process.env, homeDir = homedir() } = {}) {
109
- const envToken = env.HEYSPEX_MCP_TOKEN?.trim();
110
- if (envToken) return { Authorization: `Bearer ${envToken}` };
278
+ export const VAULT_ID_HEADER = 'x-heyspex-vault-id';
279
+ /** The account the client believes it acts as; the console refuses a mismatch with 409 `ACCOUNT_MISMATCH`. */
280
+ export const ACCOUNT_ID_HEADER = 'x-heyspex-account-id';
111
281
 
282
+ /** Local refusal: the cwd resolves to a root no registry row names. */
283
+ export const VAULT_NOT_REGISTERED = 'VAULT_NOT_REGISTERED';
284
+ /** Local refusal: this machine has no configured account (`hs login` never ran). */
285
+ export const ACCOUNT_NOT_CONFIGURED = 'ACCOUNT_NOT_CONFIGURED';
286
+
287
+ /**
288
+ * Thrown by {@link vaultIdHeaderFor} / {@link getVaultIdHeader} when the
289
+ * resolver landed on the `unregistered` sentinel (`reason: 'unregistered'`),
290
+ * or on a registered row whose id is a LEGACY slug with no cloud UUID to
291
+ * stand in for it (`reason: 'legacy-id'`, see {@link cloudVaultIdFor}).
292
+ * `code` is stable for machine matching; the message names the resolved root
293
+ * and the runnable `hs connect` form for this machine.
294
+ */
295
+ export class VaultNotRegisteredError extends Error {
296
+ /**
297
+ * @param {string | null | undefined} repoRootAbs the root the resolver settled on
298
+ * @param {string | null | undefined} source the resolver tier that produced it
299
+ * @param {{ reason?: 'unregistered' | 'legacy-id', legacyId?: string | null }} [detail]
300
+ */
301
+ constructor(repoRootAbs, source, { reason = 'unregistered', legacyId = null } = {}) {
302
+ const root = typeof repoRootAbs === 'string' && repoRootAbs.trim() ? repoRootAbs.trim() : null;
303
+ const where = root ? ` (${root})` : '';
304
+ // Names the verb (`hs connect`) AND the form that runs on this machine
305
+ // (`hsCommand`, [[HS-I6HT]]) — identical when the bin is on PATH.
306
+ const why =
307
+ reason === 'legacy-id'
308
+ ? `This repo${where} is registered on this machine under a legacy id (\`${legacyId}\`), not the vault id the console knows, and its heyspex.yaml carries no \`vault_id\` to stand in for it, `
309
+ : `This repo${where} is not connected to a HeySpex vault on this machine (\`hs connect\` has not registered it), `;
310
+ super(
311
+ why
312
+ + 'so the console call was refused before it was sent. '
313
+ + `Run \`${hsCommand('connect')}\`${root ? ` in ${root}` : ''} to ${reason === 'legacy-id' ? 're-register' : 'connect'} it, then try again. Nothing was sent.`,
314
+ );
315
+ this.name = 'VaultNotRegisteredError';
316
+ this.code = VAULT_NOT_REGISTERED;
317
+ this.reason = reason;
318
+ this.repoRootAbs = root;
319
+ this.source = typeof source === 'string' ? source : null;
320
+ this.legacyId = legacyId;
321
+ }
322
+ }
323
+
324
+ /** The shape of every vault id the console mints (`randomUUID()`); a registry row that is not one is a legacy slug. */
325
+ const CLOUD_VAULT_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
326
+
327
+ export function isCloudVaultId(value) {
328
+ return typeof value === 'string' && CLOUD_VAULT_ID_RE.test(value.trim());
329
+ }
330
+
331
+ // `vault_id:` line of a repo's heyspex.yaml — the id the console minted and
332
+ // `hs init` / `hs connect` wrote back ([[HS-6CFU-TASK-write-minted-vault-id-back-into-heyspex]]).
333
+ // Same minimal line-anchored shape as session-owner.mjs's `account_id:` reader.
334
+ const MARKER_VAULT_ID_RE = /^vault_id:[ \t]*["']?([^"'\r\n#]+)/m;
335
+
336
+ /**
337
+ * The `vault_id` a repo's `heyspex.yaml` carries, or `null` (no marker, no
338
+ * key, unreadable). Never throws. `readFile` is a test seam.
339
+ * @param {string | null | undefined} repoRootAbs
340
+ * @param {{ readFile?: (p: string) => string }} [opts]
341
+ */
342
+ export function readMarkerVaultId(repoRootAbs, { readFile } = {}) {
343
+ if (typeof repoRootAbs !== 'string' || !repoRootAbs.trim()) return null;
344
+ const read = readFile ?? ((p) => readFileSync(p, 'utf8'));
345
+ try {
346
+ const raw = read(join(repoRootAbs, 'heyspex.yaml'));
347
+ const value = String(raw ?? '').match(MARKER_VAULT_ID_RE)?.[1]?.trim() ?? '';
348
+ return value || null;
349
+ } catch {
350
+ return null;
351
+ }
352
+ }
353
+
354
+ /**
355
+ * The id to put on the wire for a REGISTERED resolver result: the registry
356
+ * row's id when it is a cloud UUID; otherwise (a legacy slug row such as the
357
+ * pre-UUID `heyspex-specs` registration) the UUID the repo's `heyspex.yaml`
358
+ * `vault_id` names. The committed marker is a first-class routing fact now:
359
+ * the resolver's cwd tier already returns a marker's `vault_id` itself
360
+ * (`cwd-marker`, and `marker-registry-mismatch` when a registry row
361
+ * disagrees — the marker wins, [[HS-VVNQ]]), and its `account:` picks the
362
+ * token ({@link getAuthHeader}, [[HS-VWUD-WW9R-TASK-record-the-connecting-account-in]]);
363
+ * this fallback covers the remaining tiers that land on a slug row. Neither a UUID →
364
+ * throws {@link VaultNotRegisteredError} (`reason: 'legacy-id'`): the console
365
+ * can only scope by the id it minted, and a slug on the wire is a guaranteed
366
+ * 409, so refusing locally is the honest answer.
367
+ * @param {{ vaultId: string, repoRootAbs?: string, source?: string }} resolved
368
+ * @param {{ readMarkerVaultId?: (repoRootAbs: string) => string | null }} [opts]
369
+ */
370
+ export function cloudVaultIdFor(resolved, { readMarkerVaultId: readMarker = readMarkerVaultId } = {}) {
371
+ const rowId = resolved.vaultId.trim();
372
+ if (isCloudVaultId(rowId)) return rowId;
373
+ const marker = readMarker(resolved.repoRootAbs);
374
+ if (isCloudVaultId(marker)) return marker.trim();
375
+ throw new VaultNotRegisteredError(resolved.repoRootAbs, resolved.source, { reason: 'legacy-id', legacyId: rowId });
376
+ }
377
+
378
+ /**
379
+ * Thrown by {@link accountIdHeaderFor} / {@link getAccountIdHeader} when no
380
+ * account resolves (no `HS_ACCOUNT_ID`, no `account_id:` in
381
+ * `~/.heyspex/config.yaml`). Names `hs login`, the only thing that fixes it.
382
+ */
383
+ export class AccountNotConfiguredError extends Error {
384
+ constructor() {
385
+ super(
386
+ 'This machine has no HeySpex account configured (`hs login` has not been run), '
387
+ + 'so the console call was refused before it was sent. '
388
+ + `Run \`${hsCommand('login')}\` on this machine, then try again. Nothing was sent.`,
389
+ );
390
+ this.name = 'AccountNotConfiguredError';
391
+ this.code = ACCOUNT_NOT_CONFIGURED;
392
+ }
393
+ }
394
+
395
+ /** True for the two local scope refusals this module throws. */
396
+ export function isLocalScopeRefusal(err) {
397
+ return err instanceof VaultNotRegisteredError || err instanceof AccountNotConfiguredError;
398
+ }
399
+
400
+ /**
401
+ * Gate: the vault header for a resolver result. A registered id → the header
402
+ * naming the CLOUD id ({@link cloudVaultIdFor}), whatever `source` says; the
403
+ * `unregistered` sentinel → throws {@link VaultNotRegisteredError}; no result
404
+ * / blank id → `{}` (nothing to name — the resolver itself failed, which is
405
+ * not the same as "unregistered"). Pure given `opts.readMarkerVaultId`.
406
+ * @param {{ vaultId?: string, source?: string, repoRootAbs?: string } | null | undefined} resolved
407
+ * @param {{ readMarkerVaultId?: (repoRootAbs: string) => string | null }} [opts]
408
+ */
409
+ export function vaultIdHeaderFor(resolved, opts = {}) {
410
+ const vaultId = typeof resolved?.vaultId === 'string' ? resolved.vaultId.trim() : '';
411
+ if (!vaultId) return {};
412
+ if (vaultId === UNREGISTERED_VAULT_ID) {
413
+ throw new VaultNotRegisteredError(resolved?.repoRootAbs, resolved?.source);
414
+ }
415
+ return { [VAULT_ID_HEADER]: cloudVaultIdFor({ ...resolved, vaultId }, opts) };
416
+ }
417
+
418
+ /**
419
+ * The vault-id header for THIS process, from the memoized resolver
420
+ * (`getActiveConfig`, resolved once per process; tests reset it via
421
+ * `resetActiveConfigCache`). A resolver FAILURE is "no header" (`{}`); an
422
+ * `unregistered` RESULT throws {@link VaultNotRegisteredError} so the caller
423
+ * refuses before any fetch.
424
+ */
425
+ export function getVaultIdHeader() {
426
+ let resolved;
112
427
  try {
113
- const raw = readFileSync(join(homeDir, '.heyspex', 'credentials.yaml'), 'utf8');
114
- const match = raw.match(/^mcp_token:\s*["']?([^"'\r\n#]+)/m);
115
- const token = match?.[1]?.trim();
116
- if (token) return { Authorization: `Bearer ${token}` };
428
+ resolved = getActiveConfig();
117
429
  } catch {
118
- // Missing file, missing key, or unreadable — no credentials-file token.
430
+ return {};
119
431
  }
120
- return {};
432
+ return vaultIdHeaderFor(resolved);
433
+ }
434
+
435
+ /**
436
+ * Pure gate: the account header for a configured account id, or throws
437
+ * {@link AccountNotConfiguredError} when none is configured.
438
+ * @param {string | null | undefined} accountId
439
+ */
440
+ export function accountIdHeaderFor(accountId) {
441
+ const id = typeof accountId === 'string' ? accountId.trim() : '';
442
+ if (!id) throw new AccountNotConfiguredError();
443
+ return { [ACCOUNT_ID_HEADER]: id };
444
+ }
445
+
446
+ /**
447
+ * The account-id header for THIS process: the repo's recorded `account:`
448
+ * when its heyspex.yaml names one — the account whose token
449
+ * {@link getAuthHeader} sends, so the two headers can never name different
450
+ * accounts ([[HS-VWUD-WW9R-TASK-record-the-connecting-account-in]]) — else
451
+ * the one account reader (session-owner.mjs: `HS_ACCOUNT_ID` env, then the
452
+ * credentials store's current account). Read per call, like the bearer —
453
+ * `hs login` can re-pair the machine while a server is up. Injectable
454
+ * `env`/`homeDir`/`repoAccountId` for tests (`null` = "the repo records none").
455
+ * @param {{ env?: NodeJS.ProcessEnv | Record<string, string | undefined>, homeDir?: string, repoAccountId?: string | null }} [opts]
456
+ */
457
+ export function getAccountIdHeader({ repoAccountId, ...opts } = {}) {
458
+ const recorded = repoAccountId === undefined ? getRepoAccountId() : repoAccountId;
459
+ return accountIdHeaderFor(recorded || resolveSessionOwnerUncached(opts));
460
+ }
461
+
462
+ /**
463
+ * The transport result for a local scope refusal — the shape every caller of
464
+ * `consoleFetch` already handles (`ok: false`, `status: 0`), with the code on
465
+ * `refusal` AND on `authProblem` so `consoleErrorCode` / the never-buffer
466
+ * contract pick it up with no per-caller change. Pure.
467
+ * @param {VaultNotRegisteredError | AccountNotConfiguredError} err
468
+ * @param {string} url the request that was NOT sent
469
+ */
470
+ export function scopeRefusalResult(err, url) {
471
+ return {
472
+ ok: false,
473
+ status: 0,
474
+ body: null,
475
+ url,
476
+ error: err.message,
477
+ errorKind: 'refused',
478
+ refusal: err.code,
479
+ authProblem: err.code,
480
+ authMessage: err.message,
481
+ };
482
+ }
483
+
484
+ /** Cloud mode, but the resolved console URL points at this machine. */
485
+ export const CONSOLE_LOCAL_URL_IN_CLOUD_MODE = 'CONSOLE_LOCAL_URL_IN_CLOUD_MODE';
486
+ /** Cloud mode, but no bearer token is stored on this machine (or a 401 came back). */
487
+ export const CONSOLE_NOT_SIGNED_IN = 'CONSOLE_NOT_SIGNED_IN';
488
+
489
+ /**
490
+ * Plain-language, action-naming text for the two sign-in classes. Written for
491
+ * the repo owner to read directly: short sentences, no jargon, the exact
492
+ * command to run, and an explicit "do not start a local server" so an agent
493
+ * does not fall back to `npm run dev` — a customer has no local server.
494
+ *
495
+ * The command is rendered by `hsCommand`, never typed out, so the message
496
+ * names a form that actually runs on the machine reading it
497
+ * ([[HS-I6HT-EKYU-DIAG-every-message-tells-the-user-to-run-hs]]).
498
+ */
499
+ export function consoleAuthErrorMessage(code, url) {
500
+ const where = url ? ` (${url})` : '';
501
+ const login = hsCommand('login');
502
+ if (code === CONSOLE_LOCAL_URL_IN_CLOUD_MODE) {
503
+ return (
504
+ `HeySpex is set to cloud mode, but this machine is pointed at a local console${where}. `
505
+ + `Nothing was saved. Run \`${login}\` to point this machine at the HeySpex cloud, then try again. `
506
+ + 'Do not start a local dev server — cloud is the normal mode.'
507
+ );
508
+ }
509
+ if (code === CONSOLE_NOT_SIGNED_IN) {
510
+ return (
511
+ `This machine is not signed in to HeySpex, so the call${url ? ` to ${url}` : ''} could not go through. `
512
+ + `Nothing was saved. Run \`${login}\` on this machine, then try again. `
513
+ + 'The server is not down — do not start a local dev server.'
514
+ );
515
+ }
516
+ return null;
517
+ }
518
+
519
+ /** Message for an answered HTTP 401 (the console replied; it rejected the token). */
520
+ export function consoleUnauthorizedMessage(url) {
521
+ const where = url ? ` at ${url}` : '';
522
+ return (
523
+ `HeySpex${where} rejected this request because this machine is not signed in (HTTP 401). `
524
+ + `Nothing was saved. Run \`${hsCommand('login')}\` on this machine, then try again.`
525
+ );
526
+ }
527
+
528
+ /**
529
+ * Lazy, cached, never-throwing load of the shared dev/prod mode core. Kept
530
+ * dynamic (and optional) so a vendored copy of this directory without the
531
+ * repo's `scripts/` tree simply falls back to today's behavior instead of
532
+ * failing to import. Mirrors cli.mjs's handleDevMode self-containment note.
533
+ */
534
+ let devModeCorePromise;
535
+ async function loadDevModeCore() {
536
+ if (!devModeCorePromise) {
537
+ devModeCorePromise = import('../../scripts/lib/dev-mode.mjs').catch(() => null);
538
+ }
539
+ return devModeCorePromise;
540
+ }
541
+
542
+ /**
543
+ * Resolve the effective reliance ("cloud" | "local"), or `null` when it cannot
544
+ * be determined (no dev-mode core available). Never reimplements the contract:
545
+ * `resolveDevMode` (env → machine-local record → default prod/cloud) when the
546
+ * core exports it, else `readLocalDevModeRecord` + `deriveSemantics`.
547
+ */
548
+ export async function resolveConsoleReliance({ env = process.env, homeDir = homedir() } = {}) {
549
+ const core = await loadDevModeCore();
550
+ if (!core) return null;
551
+ try {
552
+ if (typeof core.resolveDevMode === 'function') {
553
+ return core.resolveDevMode({ env, homeDir }).reliance ?? null;
554
+ }
555
+ const { record } = core.readLocalDevModeRecord({ homeDir });
556
+ const mode = record?.mode ?? 'prod';
557
+ return core.deriveSemantics(mode, record?.reliance ?? null).reliance ?? null;
558
+ } catch {
559
+ return null;
560
+ }
561
+ }
562
+
563
+ /**
564
+ * Decide whether a transport failure is really a sign-in problem. Pure.
565
+ *
566
+ * @param {{ url: string, reliance: string|null, hasToken: boolean }} state
567
+ * @returns {string|null} one of the two auth codes, or null (= genuine outage)
568
+ */
569
+ export function classifyConsoleAuthProblem({ url, reliance, hasToken }) {
570
+ if (reliance !== 'cloud') return null; // local dev / unknown → today's behavior
571
+ if (isLoopbackUrl(url)) return CONSOLE_LOCAL_URL_IN_CLOUD_MODE;
572
+ if (!hasToken) return CONSOLE_NOT_SIGNED_IN;
573
+ return null;
574
+ }
575
+
576
+ function headerHasToken(header) {
577
+ const value = header?.Authorization;
578
+ return typeof value === 'string' && value.trim().length > 0;
121
579
  }
122
580
 
123
581
  /**
@@ -129,6 +587,9 @@ export function getAuthHeader({ env = process.env, homeDir = homedir() } = {}) {
129
587
  * @param {number} [deps.defaultTimeoutMs]
130
588
  * @param {(failedResult: object, ctx: { url: string, init: object, timeoutMs: number }) => Promise<{ retryUrl?: string, reason?: string, healedUrl?: string, discoveryHealed?: boolean, devServerStatus?: object } | null | undefined>} [deps.recoverFromTransportFailure]
131
589
  * @param {() => object} [deps.getAuthHeader] Resolves the outbound Authorization header (default: {@link getAuthHeader}).
590
+ * @param {() => object} [deps.getVaultIdHeader] Resolves the outbound `x-heyspex-vault-id` header (default: {@link getVaultIdHeader}); throws {@link VaultNotRegisteredError} for an unregistered root.
591
+ * @param {() => object} [deps.getAccountIdHeader] Resolves the outbound `x-heyspex-account-id` header (default: {@link getAccountIdHeader}); throws {@link AccountNotConfiguredError} when no account is configured.
592
+ * @param {() => Promise<{ reliance: string|null, hasToken: boolean }>} [deps.getConsoleAuthState] Resolves the reliance + token state used to tell "not signed in" from "server down" (default: dev-mode core + {@link getAuthHeader}).
132
593
  */
133
594
  export function makeConsoleFetch({
134
595
  getConsoleUrl,
@@ -136,7 +597,72 @@ export function makeConsoleFetch({
136
597
  defaultTimeoutMs = DEFAULT_CONSOLE_TIMEOUT_MS,
137
598
  recoverFromTransportFailure,
138
599
  getAuthHeader: getAuthHeaderDep = getAuthHeader,
600
+ getVaultIdHeader: getVaultIdHeaderDep = getVaultIdHeader,
601
+ getAccountIdHeader: getAccountIdHeaderDep = getAccountIdHeader,
602
+ getConsoleAuthState,
139
603
  }) {
604
+ function safeAuthHeader() {
605
+ try {
606
+ return getAuthHeaderDep() || {};
607
+ } catch {
608
+ return {};
609
+ }
610
+ }
611
+
612
+ /**
613
+ * One header resolver, two policies. A resolver that throws a local scope
614
+ * refusal ({@link isLocalScopeRefusal}) is surfaced as `{ refusal }` for a
615
+ * vault-scoped endpoint and swallowed to `{}` for a vault-less one; any
616
+ * OTHER throw is "no header" either way (a broken resolver must not become
617
+ * a new failure mode — unchanged from before).
618
+ */
619
+ function headerOrRefusal(resolveHeader, vaultLess) {
620
+ try {
621
+ return { headers: resolveHeader() || {} };
622
+ } catch (err) {
623
+ if (!vaultLess && isLocalScopeRefusal(err)) return { refusal: err };
624
+ return { headers: {} };
625
+ }
626
+ }
627
+
628
+ /**
629
+ * The `{ account, vault }` scope headers for one request, or the local
630
+ * refusal that stops it before any fetch. Vault-less endpoints never refuse.
631
+ * @returns {{ headers: object } | { refusal: Error }}
632
+ */
633
+ function scopeHeadersFor(method, path) {
634
+ const vaultLess = isVaultLessEndpoint(method, path);
635
+ const vault = headerOrRefusal(getVaultIdHeaderDep, vaultLess);
636
+ if (vault.refusal) return vault;
637
+ const account = headerOrRefusal(getAccountIdHeaderDep, vaultLess);
638
+ if (account.refusal) return account;
639
+ return { headers: { ...vault.headers, ...account.headers } };
640
+ }
641
+
642
+ /**
643
+ * The Authorization header for one request, or the local refusal that stops
644
+ * it: a repo that records an account this machine holds no token for
645
+ * ({@link AccountTokenMissingError}) refuses EVERY console call, vault-less
646
+ * ones included — sending no token, or the current account's, would both be
647
+ * wrong. Any other throw is "no header" (unchanged).
648
+ * @returns {{ headers: object } | { refusal: Error }}
649
+ */
650
+ function authOrRefusal() {
651
+ try {
652
+ return { headers: getAuthHeaderDep() || {} };
653
+ } catch (err) {
654
+ if (err instanceof AccountTokenMissingError) return { refusal: err };
655
+ return { headers: {} };
656
+ }
657
+ }
658
+
659
+ const resolveAuthState =
660
+ getConsoleAuthState
661
+ || (async () => ({
662
+ reliance: await resolveConsoleReliance(),
663
+ hasToken: headerHasToken(safeAuthHeader()),
664
+ }));
665
+
140
666
  async function attempt(url, init, timeoutMs) {
141
667
  const controller = new AbortController();
142
668
  const timer = setTimeout(() => controller.abort(), timeoutMs);
@@ -149,7 +675,14 @@ export function makeConsoleFetch({
149
675
  } catch {
150
676
  parsed = { raw: text };
151
677
  }
152
- return { ok: response.ok, status: response.status, body: parsed, url };
678
+ const retryAfterRaw = response.headers?.get?.('retry-after');
679
+ return {
680
+ ok: response.ok,
681
+ status: response.status,
682
+ body: parsed,
683
+ url,
684
+ ...(retryAfterRaw != null && retryAfterRaw !== '' ? { retryAfterSeconds: retryAfterRaw } : {}),
685
+ };
153
686
  } catch (err) {
154
687
  const errorKind = isAbortError(err) ? 'timeout' : 'network';
155
688
  return {
@@ -244,15 +777,52 @@ export function makeConsoleFetch({
244
777
  return result;
245
778
  }
246
779
 
780
+ /**
781
+ * Last word on a status-0 failure: was the console really down, or were we
782
+ * talking to the wrong console / with no sign-in? Runs only AFTER every
783
+ * recovery path has failed, so a genuinely recoverable local dev server
784
+ * still heals exactly as before ([[HS-9R6S-N8VP-DIAG]]).
785
+ */
786
+ async function stampAuthProblem(result, url) {
787
+ let state;
788
+ try {
789
+ state = await resolveAuthState({ url });
790
+ } catch {
791
+ return result; // never let classification turn into a new failure mode
792
+ }
793
+ const authProblem = classifyConsoleAuthProblem({
794
+ url: result.url || url,
795
+ reliance: state?.reliance ?? null,
796
+ hasToken: state?.hasToken === true,
797
+ });
798
+ if (!authProblem) return result;
799
+ return {
800
+ ...result,
801
+ authProblem,
802
+ reliance: state?.reliance ?? null,
803
+ authMessage: consoleAuthErrorMessage(authProblem, result.url || url),
804
+ };
805
+ }
806
+
247
807
  async function withRetry(url, init, timeoutMs, { retryOnTimeout = true } = {}) {
248
808
  let result = await attempt(url, init, timeoutMs);
249
809
  if (!result.ok && result.errorKind === 'timeout' && retryOnTimeout) {
250
810
  result = await attempt(url, init, timeoutMs);
251
811
  }
812
+ // Cold cloud lambda: wait the advertised window, then the same URL once more.
813
+ if (!result.ok && isVaultScopeUnresolved(result)) {
814
+ await sleep(retryAfterMs(result));
815
+ const retry = await attempt(url, init, timeoutMs);
816
+ if (retry.ok) return { ...retry, recoveredVia: 'vault_scope_unresolved', attemptedUrl: url };
817
+ result = retry;
818
+ }
252
819
  if (result.ok) return result;
253
820
  if (result.errorKind === 'network') {
254
821
  result = await recoverNetworkFailure(result, { url, init, timeoutMs });
255
822
  }
823
+ if (!result.ok && result.status === 0) {
824
+ result = await stampAuthProblem(result, url);
825
+ }
256
826
  return result;
257
827
  }
258
828
 
@@ -261,10 +831,15 @@ export function makeConsoleFetch({
261
831
  */
262
832
  async function postClaimEndpoint(method, specId, bodyJson) {
263
833
  const consoleUrl = getConsoleUrl();
264
- const url = `${consoleUrl}/api/specs/${encodeURIComponent(specId)}/claim`;
834
+ const path = `/api/specs/${encodeURIComponent(specId)}/claim`;
835
+ const url = `${consoleUrl}${path}`;
836
+ const auth = authOrRefusal();
837
+ if (auth.refusal) return scopeRefusalResult(auth.refusal, url);
838
+ const scope = scopeHeadersFor(method, path);
839
+ if (scope.refusal) return scopeRefusalResult(scope.refusal, url);
265
840
  const init = {
266
841
  method,
267
- headers: { 'content-type': 'application/json', ...getAuthHeaderDep() },
842
+ headers: { 'content-type': 'application/json', ...auth.headers, ...scope.headers },
268
843
  };
269
844
  if (bodyJson !== undefined) {
270
845
  init.body = JSON.stringify(bodyJson);
@@ -287,9 +862,19 @@ export function makeConsoleFetch({
287
862
  async function consoleFetch(path, options = {}) {
288
863
  const consoleUrl = getConsoleUrl();
289
864
  const url = `${consoleUrl}${path}`;
865
+ const method = options.method || 'GET';
866
+ const auth = authOrRefusal();
867
+ if (auth.refusal) return scopeRefusalResult(auth.refusal, url);
868
+ const scope = scopeHeadersFor(method, path);
869
+ if (scope.refusal) return scopeRefusalResult(scope.refusal, url);
290
870
  const init = {
291
- method: options.method || 'GET',
292
- headers: { 'content-type': 'application/json', ...getAuthHeaderDep(), ...options.headers },
871
+ method,
872
+ headers: {
873
+ 'content-type': 'application/json',
874
+ ...auth.headers,
875
+ ...scope.headers,
876
+ ...options.headers,
877
+ },
293
878
  };
294
879
  if (options.body !== undefined) {
295
880
  init.body = typeof options.body === 'string' ? options.body : JSON.stringify(options.body);
@@ -308,7 +893,51 @@ export function makeConsoleFetch({
308
893
  * actually down). Caller supplies the HTTP-error fallback used when the
309
894
  * transport succeeded but the console returned a 4xx/5xx.
310
895
  */
896
+ /**
897
+ * The console's three TYPED vault-scope refusals
898
+ * ([[HS-T5J5-WW9R-TASK-typed-vault-refusals-vault-not]]), one envelope
899
+ * `{ error, reason, requested, hint }`. They name WHICH vault was refused and
900
+ * why; the `hint` names the account's active vault when it is known. The
901
+ * transport renders them by name so an agent reads two vault ids, not a bare
902
+ * status ([[HS-N0VS-WW9R-TASK-hs-doctor-and-the-mcp-transport-name]] for
903
+ * [[HS-W3JF-PEGM-DIAG-one-account-wide-active-vault-pointer]] fix 3).
904
+ */
905
+ export const SCOPE_REFUSAL_CODES = Object.freeze(['VAULT_SCOPE_MISMATCH', 'ACCOUNT_MISMATCH', 'VAULT_NOT_REGISTERED']);
906
+
907
+ /** The typed refusal envelope on a failed transport result, or `null`. Pure. */
908
+ export function scopeRefusalEnvelope(result) {
909
+ const body = result?.body;
910
+ if (!body || typeof body !== 'object' || !SCOPE_REFUSAL_CODES.includes(body.error)) return null;
911
+ return body;
912
+ }
913
+
914
+ /**
915
+ * Agent-facing text for a typed refusal: code, reason, the vault the request
916
+ * named, and the console's hint (which carries the account's active vault when
917
+ * known) — the "both vaults side by side" the incident lacked. Pure.
918
+ */
919
+ export function scopeRefusalMessage(envelope, url) {
920
+ const where = url ? ` from ${url}` : '';
921
+ const reason = typeof envelope.reason === 'string' && envelope.reason ? envelope.reason : 'no reason given';
922
+ const requested =
923
+ typeof envelope.requested === 'string' && envelope.requested
924
+ ? `vault ${envelope.requested}`
925
+ : `no vault (no \`${VAULT_ID_HEADER}\` header)`;
926
+ const hint = typeof envelope.hint === 'string' && envelope.hint ? ` ${envelope.hint}` : '';
927
+ return `${envelope.error} (${reason})${where}: this request named ${requested}.${hint}`.trim();
928
+ }
929
+
311
930
  export function consoleErrorCode(result, httpFailureCode) {
931
+ // A local scope refusal never reached the wire: the code IS the answer.
932
+ if (result.refusal) return result.refusal;
933
+ // A sign-in problem outranks every transport story: no amount of retrying,
934
+ // port discovery or waiting fixes it, and "the server is down" sends the
935
+ // agent off to start a local dev server the customer does not have.
936
+ if (result.authProblem) return result.authProblem;
937
+ // A typed vault-scope refusal IS the code — never the caller's generic one.
938
+ const scoped = scopeRefusalEnvelope(result);
939
+ if (scoped) return scoped.error;
940
+ if (result.status === 401) return CONSOLE_NOT_SIGNED_IN;
312
941
  if (result.urlMismatch) return 'CONSOLE_URL_MISMATCH';
313
942
  if (result.errorKind === 'timeout') return 'CONSOLE_TIMEOUT';
314
943
  if (result.status === 0) return 'CONSOLE_UNREACHABLE';
@@ -321,6 +950,18 @@ export function consoleErrorCode(result, httpFailureCode) {
321
950
  * server on network error.
322
951
  */
323
952
  export function consoleErrorMessage(result, httpFailureMessage) {
953
+ if (result.refusal) return result.error || result.authMessage || httpFailureMessage;
954
+ if (result.authProblem) {
955
+ return (
956
+ result.authMessage
957
+ || consoleAuthErrorMessage(result.authProblem, result.url)
958
+ || httpFailureMessage
959
+ );
960
+ }
961
+ // A typed vault-scope refusal names both vaults; the generic text names none.
962
+ const scoped = scopeRefusalEnvelope(result);
963
+ if (scoped) return scopeRefusalMessage(scoped, result.url);
964
+ if (result.status === 401) return consoleUnauthorizedMessage(result.url);
324
965
  if (result.urlMismatch) {
325
966
  const live =
326
967
  result.suggestedUrl