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
@@ -25,11 +25,82 @@
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.
62
+ *
63
+ * NO PIN, NO POINTER (diagnose "unpinned repo silently follows the
64
+ * account-wide active-vault pointer"; client half of
65
+ * [[HS-W3JF-PEGM-DIAG-one-account-wide-active-vault-pointer]] fix 2, "fail
66
+ * closed on a missing binding"): a repo that names no vault — no heyspex.yaml,
67
+ * no specs/, no satellite pin in its MCP manifest, no HEYSPEX_VAULT_ROOT/DIR —
68
+ * resolves at tier 4 to the registry's `active_vault_id` (`source:
69
+ * 'account-pointer'`), which mirrors whatever the owner last clicked in the
70
+ * console's vault switcher. On 2026-10-10 a session in the code repo resolved
71
+ * that way to a throwaway test vault, and every claim/file/submit would have
72
+ * landed there silently. So a pointer-sourced result is a THIRD local
73
+ * refusal, `VAULT_NOT_PINNED` ({@link VaultNotPinnedError}): the message names
74
+ * the vault the pointer would have used and the runnable pin (`hs install
75
+ * --attach-vault <id> --target <repo>` for a code repo, `hs connect` for a
76
+ * vault). The resolver's tier ORDER is untouched (decision memory
77
+ * `vault-resolver-precedence`) — only what the transport does with the
78
+ * result changes. Deliberate pointer use is the env hatch
79
+ * `HEYSPEX_ALLOW_POINTER_VAULT=1` ({@link pointerVaultAllowed}), which
80
+ * restores the old behaviour and announces the vault once per process on
81
+ * stderr ({@link announcePointerVaultOnce}).
28
82
  */
83
+ import { heyspexHome } from '../../scripts/lib/heyspex-home.mjs';
29
84
  import { readFileSync } from 'node:fs';
30
85
  import { homedir } from 'node:os';
31
86
  import { join } from 'node:path';
32
87
 
88
+ import { readBearer } from './credentials.mjs';
89
+ import { resolveSessionOwnerUncached } from './session-owner.mjs';
90
+ import { isVaultLessEndpoint, VAULT_LESS_ENDPOINTS } from './vault-less-endpoints.mjs';
91
+
92
+ export { isVaultLessEndpoint, VAULT_LESS_ENDPOINTS };
93
+
94
+ // The runnable form of a HeySpex command on THIS machine — `hs login` when the
95
+ // bin is on PATH, else an absolute-path `node "<install>/bin/hs.mjs" login`.
96
+ // Never hard-code the bare `hs …` spelling in text a human reads, and never an
97
+ // `npx` form: npx resolves from the READER's working directory, and outside a
98
+ // checkout `npx hs` installs the unrelated public package hs@2.0.1
99
+ // ([[HS-I6HT-EKYU-DIAG-every-message-tells-the-user-to-run-hs]]).
100
+ import { hsCommand } from '../../scripts/lib/hs-command.mjs';
101
+ import { maybeDrainOnFirstSuccess } from './write-outbox.mjs';
102
+ import { getActiveConfig, registryAccountIdForRoot, UNREGISTERED_VAULT_ID } from './vault-resolve.mjs';
103
+
33
104
  export const DEFAULT_CONSOLE_TIMEOUT_MS = 30000;
34
105
 
35
106
  /** Backoff when Next dev restarts (next.config change, etc.) — ECONNREFUSED is transient. */
@@ -39,6 +110,26 @@ function sleep(ms) {
39
110
  return new Promise((resolve) => setTimeout(resolve, ms));
40
111
  }
41
112
 
113
+ /**
114
+ * Cold cloud lambda ([[HS-2FQY]] read side): the console answers 503
115
+ * `vault_scope_unresolved` (+ `Retry-After`) while it has not resolved its
116
+ * active vault yet, instead of a 200 empty bucket that reads as "the vault is
117
+ * empty". Transient by contract — the console kicks its own self-heal — so
118
+ * the client waits the advertised window (capped) and tries once more.
119
+ */
120
+ export const VAULT_SCOPE_RETRY_CAP_MS = 5000;
121
+ export const VAULT_SCOPE_RETRY_DEFAULT_MS = 3000;
122
+
123
+ export function isVaultScopeUnresolved(result) {
124
+ return result?.status === 503 && result?.body?.error === 'vault_scope_unresolved';
125
+ }
126
+
127
+ export function retryAfterMs(result) {
128
+ const raw = Number(result?.retryAfterSeconds);
129
+ if (!Number.isFinite(raw) || raw <= 0) return VAULT_SCOPE_RETRY_DEFAULT_MS;
130
+ return Math.min(raw * 1000, VAULT_SCOPE_RETRY_CAP_MS);
131
+ }
132
+
42
133
  /**
43
134
  * Detect AbortError from a thrown fetch error. Covers the standard
44
135
  * `AbortError` name and the substring fallback some runtimes use
@@ -98,26 +189,553 @@ export function rewriteUrlOrigin(urlString, originUrl) {
98
189
  return target.href;
99
190
  }
100
191
 
192
+ /** Local refusal: the repo records an account this machine holds no token for. */
193
+ export const ACCOUNT_TOKEN_MISSING = 'account_token_missing';
194
+
195
+ /**
196
+ * Thrown by {@link getAuthHeader} when the repo's heyspex.yaml records
197
+ * `account: <A>` and this machine stores no token for A
198
+ * ([[HS-VWUD-WW9R-TASK-record-the-connecting-account-in]], invariant 4). The
199
+ * transport refuses every console call with it rather than sending the
200
+ * CURRENT account's token — that fallback is exactly how a repo connected as
201
+ * A wrote as B after `hs login --switch-account B`. Names `hs login --account A`.
202
+ */
203
+ export class AccountTokenMissingError extends Error {
204
+ /** @param {string} accountId the account the repo records */
205
+ constructor(accountId) {
206
+ super(
207
+ `This repo is connected under HeySpex account ${accountId} (its heyspex.yaml \`account:\`), `
208
+ + 'but this machine holds no sign-in for that account, so the console call was refused before it was sent. '
209
+ + `Run \`${hsCommand(`login --account ${accountId}`)}\` to sign in as it, then try again. `
210
+ + 'Nothing was sent, and the current account was NOT used in its place.',
211
+ );
212
+ this.name = 'AccountTokenMissingError';
213
+ this.code = ACCOUNT_TOKEN_MISSING;
214
+ this.accountId = accountId;
215
+ }
216
+ }
217
+
218
+ // `account:` line of a repo's heyspex.yaml — the account `hs connect` recorded
219
+ // the repo under. Same line-anchored shape as {@link MARKER_VAULT_ID_RE} and
220
+ // as init.ts `parseAccountId` / install-identity.ts `parseMarkerAccount`.
221
+ const MARKER_ACCOUNT_RE = /^account:[ \t]*["']?([^"'\r\n#]+)/m;
222
+
223
+ /**
224
+ * The `account` a repo's `heyspex.yaml` records, or `null` (no marker, no
225
+ * key, unreadable). Never throws. `readFile` is a test seam.
226
+ * @param {string | null | undefined} repoRootAbs
227
+ * @param {{ readFile?: (p: string) => string }} [opts]
228
+ */
229
+ export function readMarkerAccount(repoRootAbs, { readFile } = {}) {
230
+ if (typeof repoRootAbs !== 'string' || !repoRootAbs.trim()) return null;
231
+ const read = readFile ?? ((p) => readFileSync(p, 'utf8'));
232
+ try {
233
+ const raw = read(join(repoRootAbs, 'heyspex.yaml'));
234
+ const value = String(raw ?? '').match(MARKER_ACCOUNT_RE)?.[1]?.trim() ?? '';
235
+ return value || null;
236
+ } catch {
237
+ return null;
238
+ }
239
+ }
240
+
241
+ /**
242
+ * The account THIS process's repo records: the resolver's root (memoized
243
+ * `getActiveConfig`) → its heyspex.yaml `account:`. Read per call, so an `hs
244
+ * connect` backfill is seen without a restart. `null` when the resolver
245
+ * fails or the marker records none (a legacy marker: the current account
246
+ * applies, unchanged).
247
+ * @returns {string | null}
248
+ */
249
+ export function getRepoAccountId({ homeDir } = {}) {
250
+ let resolved;
251
+ try {
252
+ resolved = getActiveConfig();
253
+ } catch {
254
+ return null;
255
+ }
256
+ // Machine-local registry row, NOT the committed marker: a legacy
257
+ // `account:` line in heyspex.yaml is ignored (owner ruling 2026-10-16).
258
+ return registryAccountIdForRoot(
259
+ resolved?.repoRootAbs,
260
+ homeDir ? { registryDir: join(homeDir, '.heyspex') } : {},
261
+ );
262
+ }
263
+
101
264
  /**
102
265
  * Resolve the outbound `Authorization` header, or `{}` when no token
103
266
  * resolves (no header at all — matches today's unauthenticated behavior
104
267
  * 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").
268
+ * `~/.heyspex/credentials.yaml` — read by the ONE shared bearer reader
269
+ * (`./credentials.mjs`, [[HS-HL0D-WW9R-TASK-share-one-bearer-reader-and-one-console]]),
270
+ * never re-implemented here.
271
+ *
272
+ * Which stored token: the REPO's account when its heyspex.yaml records one
273
+ * (`repoAccountId`, default {@link getRepoAccountId}), else the current
274
+ * account ([[HS-VWUD-WW9R-TASK-record-the-connecting-account-in]]). A recorded
275
+ * account with no stored token throws {@link AccountTokenMissingError} — never
276
+ * a silent fall back to the current account. Injectable `env`/`homeDir`/
277
+ * `repoAccountId` for tests (`null` = "the repo records none"); an unreadable
278
+ * credentials file is just "no token".
279
+ * @param {{ env?: NodeJS.ProcessEnv | Record<string, string | undefined>, homeDir?: string, repoAccountId?: string | null }} [opts]
280
+ */
281
+ export function getAuthHeader({ env = process.env, homeDir = heyspexHome(), repoAccountId } = {}) {
282
+ const accountId = repoAccountId === undefined ? getRepoAccountId({ homeDir }) : repoAccountId;
283
+ const { token } = readBearer({ env, homeDir, accountId });
284
+ if (!token && accountId) throw new AccountTokenMissingError(accountId);
285
+ return token ? { Authorization: `Bearer ${token}` } : {};
286
+ }
287
+
288
+ /**
289
+ * VAULT SCOPING ([[HS-M95C]]): a cloud console scopes every request to the
290
+ * account's active-vault POINTER unless the request names a vault. The MCP
291
+ * server resolves its vault locally (vault-resolve.mjs, five tiers) but never
292
+ * put that id on the wire, so a session started in repo A read/wrote vault B.
293
+ *
294
+ * Contract (agreed with the console side, revised by
295
+ * [[HS-MVG4-WW9R-TASK-mcp-and-hs-name-account-vault-on-every]]): send
296
+ * `x-heyspex-vault-id: <id>` for EVERY registered id a REPO-DERIVED tier
297
+ * produced (`explicit`, `env-vault-root`, `env-vault-dir`, `satellite-pin`,
298
+ * `cwd-marker`, `marker-registry-mismatch`, `cwd-heuristic`). The registry's
299
+ * active pointer (`account-pointer`) used to send nothing so the cloud console
300
+ * could fall back to ITS pointer; HS-MVG4 then put the pointer's id on the
301
+ * wire too, because the two pointers drift and a silent fallback is exactly
302
+ * what served repo A's session from vault B. Both shapes share the same
303
+ * defect — the repo never said which vault it meant — so a pointer-sourced
304
+ * result is now REFUSED locally ({@link VaultNotPinnedError}) unless the
305
+ * `HEYSPEX_ALLOW_POINTER_VAULT=1` hatch is set, in which case the HS-MVG4
306
+ * behaviour (pointer id on the wire) applies. An UNREGISTERED root is not a
307
+ * "no header" case either: it throws {@link VaultNotRegisteredError} so the
308
+ * transport refuses before any fetch.
309
+ */
310
+ export const VAULT_ID_HEADER = 'x-heyspex-vault-id';
311
+ /** The account the client believes it acts as; the console refuses a mismatch with 409 `ACCOUNT_MISMATCH`. */
312
+ export const ACCOUNT_ID_HEADER = 'x-heyspex-account-id';
313
+
314
+ /** Local refusal: the cwd resolves to a root no registry row names. */
315
+ export const VAULT_NOT_REGISTERED = 'VAULT_NOT_REGISTERED';
316
+ /** Local refusal: this machine has no configured account (`hs login` never ran). */
317
+ export const ACCOUNT_NOT_CONFIGURED = 'ACCOUNT_NOT_CONFIGURED';
318
+ /** Local refusal: the repo names no vault, so only the account-wide pointer answered. */
319
+ export const VAULT_NOT_PINNED = 'VAULT_NOT_PINNED';
320
+
321
+ /** The resolver tier that reads the registry's account-wide `active_vault_id` (vault-resolve.mjs tier 4). */
322
+ export const POINTER_VAULT_SOURCE = 'account-pointer';
323
+ /** Env hatch: `1`/`true` lets a pointer-sourced vault go on the wire (old behaviour), announced once per process. */
324
+ export const ALLOW_POINTER_VAULT_ENV = 'HEYSPEX_ALLOW_POINTER_VAULT';
325
+
326
+ /**
327
+ * Is the deliberate-pointer hatch set? Pure over `env`.
328
+ * @param {NodeJS.ProcessEnv | Record<string, string | undefined>} [env]
329
+ */
330
+ export function pointerVaultAllowed(env = process.env) {
331
+ const raw = env?.[ALLOW_POINTER_VAULT_ENV];
332
+ if (typeof raw !== 'string') return false;
333
+ const v = raw.trim().toLowerCase();
334
+ return v === '1' || v === 'true' || v === 'yes';
335
+ }
336
+
337
+ /**
338
+ * Thrown by {@link vaultIdHeaderFor} / {@link getVaultIdHeader} when the
339
+ * resolver answered from the account-wide pointer (`source:
340
+ * 'account-pointer'`) and the hatch is off: the repo the session runs in
341
+ * names no vault, so the only thing that chose one was whatever the owner
342
+ * last selected in the console. `code` is stable for machine matching; the
343
+ * message names the vault the pointer WOULD have used (id + root), the repo
344
+ * that needs a pin, and the runnable pin for each repo kind.
345
+ */
346
+ export class VaultNotPinnedError extends Error {
347
+ /**
348
+ * @param {{ vaultId?: string | null, repoRootAbs?: string | null, source?: string | null } | null | undefined} resolved
349
+ * the pointer-sourced resolver result (its `repoRootAbs` is the POINTER vault's root)
350
+ * @param {{ cwd?: string | null }} [detail] `cwd`: the unpinned repo the session runs in
351
+ */
352
+ constructor(resolved, { cwd = null } = {}) {
353
+ const clean = (v) => (typeof v === 'string' && v.trim() ? v.trim() : null);
354
+ const vaultId = clean(resolved?.vaultId);
355
+ const vaultRoot = clean(resolved?.repoRootAbs);
356
+ const repo = clean(cwd);
357
+ const here = repo ? ` (${repo})` : '';
358
+ const wouldUse = vaultId
359
+ ? `vault ${vaultId}${vaultRoot ? ` (${vaultRoot})` : ''}`
360
+ : 'an unknown vault';
361
+ // Both commands render through `hsCommand` so they run on the machine
362
+ // reading them ([[HS-I6HT]]); `<repo>` stands in when the cwd is unknown.
363
+ const attach = hsCommand(`install --attach-vault ${vaultId ?? '<vault-id>'} --target ${repo ?? '<repo>'}`);
364
+ const connect = hsCommand('connect');
365
+ super(
366
+ `This repo${here} names no HeySpex vault — no heyspex.yaml, no specs/ dir, and no HEYSPEX_VAULT_ROOT pin in its .mcp.json — `
367
+ + 'so the console call was refused before it was sent. Without a pin the session would silently follow the '
368
+ + 'account-wide active-vault pointer (whatever vault was last selected in the console or by `hs vault use`), '
369
+ + `which right now names ${wouldUse}. `
370
+ + 'Pin a vault, then restart the MCP server: '
371
+ + `for a code repo run \`${attach}\`; for a vault repo run \`${connect}\` inside it. `
372
+ + `To follow the pointer on purpose, start the server with ${ALLOW_POINTER_VAULT_ENV}=1. Nothing was sent.`,
373
+ );
374
+ this.name = 'VaultNotPinnedError';
375
+ this.code = VAULT_NOT_PINNED;
376
+ this.vaultId = vaultId;
377
+ this.vaultRootAbs = vaultRoot;
378
+ this.repoRootAbs = repo;
379
+ this.source = clean(resolved?.source);
380
+ }
381
+ }
382
+
383
+ /** One line for stderr naming the pointer vault the hatch admitted. Pure. */
384
+ export function pointerVaultNotice(resolved) {
385
+ const id = typeof resolved?.vaultId === 'string' && resolved.vaultId.trim() ? resolved.vaultId.trim() : 'unknown';
386
+ const root = typeof resolved?.repoRootAbs === 'string' && resolved.repoRootAbs.trim() ? ` (${resolved.repoRootAbs.trim()})` : '';
387
+ return `[heyspex-mcp] ${ALLOW_POINTER_VAULT_ENV} is set: this repo names no vault, so console calls follow the account-wide active-vault pointer — vault ${id}${root}. Every claim/file/submit this session lands there.\n`;
388
+ }
389
+
390
+ let _pointerVaultAnnounced = false;
391
+
392
+ /**
393
+ * Write {@link pointerVaultNotice} to stderr ONCE per process, and only when
394
+ * `resolved` is pointer-sourced AND the hatch is on. Called at stdio-server
395
+ * start (cli.mjs) and lazily by {@link vaultIdHeaderFor} when the hatch
396
+ * admits a pointer header (covers the `cli.mjs call …` subprocesses), so
397
+ * every process that acts on the pointer says so exactly once. Returns
398
+ * `true` iff it wrote. Never throws.
399
+ * @param {{ vaultId?: string, repoRootAbs?: string, source?: string } | null | undefined} resolved
400
+ * @param {{ env?: NodeJS.ProcessEnv | Record<string, string | undefined>, write?: (line: string) => unknown }} [opts]
401
+ */
402
+ export function announcePointerVaultOnce(resolved, { env = process.env, write } = {}) {
403
+ if (_pointerVaultAnnounced) return false;
404
+ if (resolved?.source !== POINTER_VAULT_SOURCE || !pointerVaultAllowed(env)) return false;
405
+ _pointerVaultAnnounced = true;
406
+ try {
407
+ (write ?? ((line) => process.stderr.write(line)))(pointerVaultNotice(resolved));
408
+ } catch {
409
+ /* a stderr failure never becomes a transport failure */
410
+ }
411
+ return true;
412
+ }
413
+
414
+ /** Test-only: forget that the pointer notice was written, so the next process-start path writes it again. */
415
+ export function resetPointerVaultNoticeForTests() {
416
+ _pointerVaultAnnounced = false;
417
+ }
418
+
419
+ /**
420
+ * Thrown by {@link vaultIdHeaderFor} / {@link getVaultIdHeader} when the
421
+ * resolver landed on the `unregistered` sentinel (`reason: 'unregistered'`),
422
+ * or on a registered row whose id is a LEGACY slug with no cloud UUID to
423
+ * stand in for it (`reason: 'legacy-id'`, see {@link cloudVaultIdFor}).
424
+ * `code` is stable for machine matching; the message names the resolved root
425
+ * and the runnable `hs connect` form for this machine.
426
+ */
427
+ export class VaultNotRegisteredError extends Error {
428
+ /**
429
+ * @param {string | null | undefined} repoRootAbs the root the resolver settled on
430
+ * @param {string | null | undefined} source the resolver tier that produced it
431
+ * @param {{ reason?: 'unregistered' | 'legacy-id', legacyId?: string | null }} [detail]
432
+ */
433
+ constructor(repoRootAbs, source, { reason = 'unregistered', legacyId = null } = {}) {
434
+ const root = typeof repoRootAbs === 'string' && repoRootAbs.trim() ? repoRootAbs.trim() : null;
435
+ const where = root ? ` (${root})` : '';
436
+ // Names the verb (`hs connect`) AND the form that runs on this machine
437
+ // (`hsCommand`, [[HS-I6HT]]) — identical when the bin is on PATH.
438
+ const why =
439
+ reason === 'legacy-id'
440
+ ? `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, `
441
+ : `This repo${where} is not connected to a HeySpex vault on this machine (\`hs connect\` has not registered it), `;
442
+ super(
443
+ why
444
+ + 'so the console call was refused before it was sent. '
445
+ + `Run \`${hsCommand('connect')}\`${root ? ` in ${root}` : ''} to ${reason === 'legacy-id' ? 're-register' : 'connect'} it, then try again. Nothing was sent.`,
446
+ );
447
+ this.name = 'VaultNotRegisteredError';
448
+ this.code = VAULT_NOT_REGISTERED;
449
+ this.reason = reason;
450
+ this.repoRootAbs = root;
451
+ this.source = typeof source === 'string' ? source : null;
452
+ this.legacyId = legacyId;
453
+ }
454
+ }
455
+
456
+ /** The shape of every vault id the console mints (`randomUUID()`); a registry row that is not one is a legacy slug. */
457
+ 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;
458
+
459
+ export function isCloudVaultId(value) {
460
+ return typeof value === 'string' && CLOUD_VAULT_ID_RE.test(value.trim());
461
+ }
462
+
463
+ // `vault_id:` line of a repo's heyspex.yaml — the id the console minted and
464
+ // `hs init` / `hs connect` wrote back ([[HS-6CFU-TASK-write-minted-vault-id-back-into-heyspex]]).
465
+ // Same minimal line-anchored shape as session-owner.mjs's `account_id:` reader.
466
+ const MARKER_VAULT_ID_RE = /^vault_id:[ \t]*["']?([^"'\r\n#]+)/m;
467
+
468
+ /**
469
+ * The `vault_id` a repo's `heyspex.yaml` carries, or `null` (no marker, no
470
+ * key, unreadable). Never throws. `readFile` is a test seam.
471
+ * @param {string | null | undefined} repoRootAbs
472
+ * @param {{ readFile?: (p: string) => string }} [opts]
473
+ */
474
+ export function readMarkerVaultId(repoRootAbs, { readFile } = {}) {
475
+ if (typeof repoRootAbs !== 'string' || !repoRootAbs.trim()) return null;
476
+ const read = readFile ?? ((p) => readFileSync(p, 'utf8'));
477
+ try {
478
+ const raw = read(join(repoRootAbs, 'heyspex.yaml'));
479
+ const value = String(raw ?? '').match(MARKER_VAULT_ID_RE)?.[1]?.trim() ?? '';
480
+ return value || null;
481
+ } catch {
482
+ return null;
483
+ }
484
+ }
485
+
486
+ /**
487
+ * The id to put on the wire for a REGISTERED resolver result: the registry
488
+ * row's id when it is a cloud UUID; otherwise (a legacy slug row such as the
489
+ * pre-UUID `heyspex-specs` registration) the UUID the repo's `heyspex.yaml`
490
+ * `vault_id` names. The committed marker is a first-class routing fact now:
491
+ * the resolver's cwd tier already returns a marker's `vault_id` itself
492
+ * (`cwd-marker`, and `marker-registry-mismatch` when a registry row
493
+ * disagrees — the marker wins, [[HS-VVNQ]]), and its `account:` picks the
494
+ * token ({@link getAuthHeader}, [[HS-VWUD-WW9R-TASK-record-the-connecting-account-in]]);
495
+ * this fallback covers the remaining tiers that land on a slug row. Neither a UUID →
496
+ * throws {@link VaultNotRegisteredError} (`reason: 'legacy-id'`): the console
497
+ * can only scope by the id it minted, and a slug on the wire is a guaranteed
498
+ * 409, so refusing locally is the honest answer.
499
+ * @param {{ vaultId: string, repoRootAbs?: string, source?: string }} resolved
500
+ * @param {{ readMarkerVaultId?: (repoRootAbs: string) => string | null }} [opts]
501
+ */
502
+ export function cloudVaultIdFor(resolved, { readMarkerVaultId: readMarker = readMarkerVaultId } = {}) {
503
+ const rowId = resolved.vaultId.trim();
504
+ if (isCloudVaultId(rowId)) return rowId;
505
+ const marker = readMarker(resolved.repoRootAbs);
506
+ if (isCloudVaultId(marker)) return marker.trim();
507
+ throw new VaultNotRegisteredError(resolved.repoRootAbs, resolved.source, { reason: 'legacy-id', legacyId: rowId });
508
+ }
509
+
510
+ /**
511
+ * Thrown by {@link accountIdHeaderFor} / {@link getAccountIdHeader} when no
512
+ * account resolves (no `HS_ACCOUNT_ID`, no `account_id:` in
513
+ * `~/.heyspex/config.yaml`). Names `hs login`, the only thing that fixes it.
514
+ */
515
+ export class AccountNotConfiguredError extends Error {
516
+ constructor() {
517
+ super(
518
+ 'This machine has no HeySpex account configured (`hs login` has not been run), '
519
+ + 'so the console call was refused before it was sent. '
520
+ + `Run \`${hsCommand('login')}\` on this machine, then try again. Nothing was sent.`,
521
+ );
522
+ this.name = 'AccountNotConfiguredError';
523
+ this.code = ACCOUNT_NOT_CONFIGURED;
524
+ }
525
+ }
526
+
527
+ /** True for the three local scope refusals this module throws. */
528
+ export function isLocalScopeRefusal(err) {
529
+ return (
530
+ err instanceof VaultNotRegisteredError
531
+ || err instanceof AccountNotConfiguredError
532
+ || err instanceof VaultNotPinnedError
533
+ );
534
+ }
535
+
536
+ /**
537
+ * Gate: the vault header for a resolver result. A registered id from a
538
+ * REPO-DERIVED tier → the header naming the CLOUD id ({@link cloudVaultIdFor});
539
+ * the `unregistered` sentinel → throws {@link VaultNotRegisteredError}; a
540
+ * pointer-sourced result (`source: 'account-pointer'`) → throws
541
+ * {@link VaultNotPinnedError} unless the hatch is on ({@link pointerVaultAllowed}
542
+ * over `opts.env`), in which case the pointer's id goes on the wire and the
543
+ * process announces it once ({@link announcePointerVaultOnce}); no result /
544
+ * blank id → `{}` (nothing to name — the resolver itself failed, which is not
545
+ * the same as "unregistered"). Pure given `opts.readMarkerVaultId`, `opts.env`
546
+ * and `opts.write`.
547
+ * @param {{ vaultId?: string, source?: string, repoRootAbs?: string } | null | undefined} resolved
548
+ * @param {{ readMarkerVaultId?: (repoRootAbs: string) => string | null, env?: NodeJS.ProcessEnv | Record<string, string | undefined>, cwd?: string | null, write?: (line: string) => unknown }} [opts]
549
+ * `cwd`: the unpinned repo named in the refusal (default: the resolver's own
550
+ * cwd base, `CLAUDE_PROJECT_DIR` then `process.cwd()`).
551
+ */
552
+ export function vaultIdHeaderFor(resolved, opts = {}) {
553
+ const vaultId = typeof resolved?.vaultId === 'string' ? resolved.vaultId.trim() : '';
554
+ if (!vaultId) return {};
555
+ if (vaultId === UNREGISTERED_VAULT_ID) {
556
+ throw new VaultNotRegisteredError(resolved?.repoRootAbs, resolved?.source);
557
+ }
558
+ if (resolved?.source === POINTER_VAULT_SOURCE) {
559
+ const env = opts.env ?? process.env;
560
+ if (!pointerVaultAllowed(env)) {
561
+ throw new VaultNotPinnedError(resolved, { cwd: opts.cwd === undefined ? resolverCwdBase() : opts.cwd });
562
+ }
563
+ announcePointerVaultOnce(resolved, { env, write: opts.write });
564
+ }
565
+ return { [VAULT_ID_HEADER]: cloudVaultIdFor({ ...resolved, vaultId }, opts) };
566
+ }
567
+
568
+ /**
569
+ * The folder the resolver's walk started from — the repo that needs a pin.
570
+ * Mirrors vault-resolve.mjs `cwdBase` (`CLAUDE_PROJECT_DIR`, then `process.cwd()`).
571
+ */
572
+ function resolverCwdBase() {
573
+ const fromEnv = process.env.CLAUDE_PROJECT_DIR;
574
+ return typeof fromEnv === 'string' && fromEnv.trim() ? fromEnv.trim() : process.cwd();
575
+ }
576
+
577
+ /**
578
+ * The vault-id header for THIS process, from the memoized resolver
579
+ * (`getActiveConfig`, resolved once per process; tests reset it via
580
+ * `resetActiveConfigCache`). A resolver FAILURE is "no header" (`{}`); an
581
+ * `unregistered` RESULT throws {@link VaultNotRegisteredError} and a
582
+ * pointer-sourced one throws {@link VaultNotPinnedError} (hatch off) so the
583
+ * caller refuses before any fetch.
584
+ */
585
+ export function getVaultIdHeader() {
586
+ let resolved;
587
+ try {
588
+ resolved = getActiveConfig();
589
+ } catch {
590
+ return {};
591
+ }
592
+ return vaultIdHeaderFor(resolved);
593
+ }
594
+
595
+ /**
596
+ * Pure gate: the account header for a configured account id, or throws
597
+ * {@link AccountNotConfiguredError} when none is configured.
598
+ * @param {string | null | undefined} accountId
599
+ */
600
+ export function accountIdHeaderFor(accountId) {
601
+ const id = typeof accountId === 'string' ? accountId.trim() : '';
602
+ if (!id) throw new AccountNotConfiguredError();
603
+ return { [ACCOUNT_ID_HEADER]: id };
604
+ }
605
+
606
+ /**
607
+ * The account-id header for THIS process: the repo's recorded `account:`
608
+ * when its heyspex.yaml names one — the account whose token
609
+ * {@link getAuthHeader} sends, so the two headers can never name different
610
+ * accounts ([[HS-VWUD-WW9R-TASK-record-the-connecting-account-in]]) — else
611
+ * the one account reader (session-owner.mjs: `HS_ACCOUNT_ID` env, then the
612
+ * credentials store's current account). Read per call, like the bearer —
613
+ * `hs login` can re-pair the machine while a server is up. Injectable
614
+ * `env`/`homeDir`/`repoAccountId` for tests (`null` = "the repo records none").
615
+ * @param {{ env?: NodeJS.ProcessEnv | Record<string, string | undefined>, homeDir?: string, repoAccountId?: string | null }} [opts]
616
+ */
617
+ export function getAccountIdHeader({ repoAccountId, ...opts } = {}) {
618
+ const recorded = repoAccountId === undefined ? getRepoAccountId({ homeDir: opts.homeDir }) : repoAccountId;
619
+ return accountIdHeaderFor(recorded || resolveSessionOwnerUncached(opts));
620
+ }
621
+
622
+ /**
623
+ * The transport result for a local scope refusal — the shape every caller of
624
+ * `consoleFetch` already handles (`ok: false`, `status: 0`), with the code on
625
+ * `refusal` AND on `authProblem` so `consoleErrorCode` / the never-buffer
626
+ * contract pick it up with no per-caller change. Pure.
627
+ * @param {VaultNotRegisteredError | AccountNotConfiguredError | VaultNotPinnedError | AccountTokenMissingError} err
628
+ * @param {string} url the request that was NOT sent
107
629
  */
108
- export function getAuthHeader({ env = process.env, homeDir = homedir() } = {}) {
109
- const envToken = env.HEYSPEX_MCP_TOKEN?.trim();
110
- if (envToken) return { Authorization: `Bearer ${envToken}` };
630
+ export function scopeRefusalResult(err, url) {
631
+ return {
632
+ ok: false,
633
+ status: 0,
634
+ body: null,
635
+ url,
636
+ error: err.message,
637
+ errorKind: 'refused',
638
+ refusal: err.code,
639
+ authProblem: err.code,
640
+ authMessage: err.message,
641
+ };
642
+ }
643
+
644
+ /** Cloud mode, but the resolved console URL points at this machine. */
645
+ export const CONSOLE_LOCAL_URL_IN_CLOUD_MODE = 'CONSOLE_LOCAL_URL_IN_CLOUD_MODE';
646
+ /** Cloud mode, but no bearer token is stored on this machine (or a 401 came back). */
647
+ export const CONSOLE_NOT_SIGNED_IN = 'CONSOLE_NOT_SIGNED_IN';
111
648
 
649
+ /**
650
+ * Plain-language, action-naming text for the two sign-in classes. Written for
651
+ * the repo owner to read directly: short sentences, no jargon, the exact
652
+ * command to run, and an explicit "do not start a local server" so an agent
653
+ * does not fall back to `npm run dev` — a customer has no local server.
654
+ *
655
+ * The command is rendered by `hsCommand`, never typed out, so the message
656
+ * names a form that actually runs on the machine reading it
657
+ * ([[HS-I6HT-EKYU-DIAG-every-message-tells-the-user-to-run-hs]]).
658
+ */
659
+ export function consoleAuthErrorMessage(code, url) {
660
+ const where = url ? ` (${url})` : '';
661
+ const login = hsCommand('login');
662
+ if (code === CONSOLE_LOCAL_URL_IN_CLOUD_MODE) {
663
+ return (
664
+ `HeySpex is set to cloud mode, but this machine is pointed at a local console${where}. `
665
+ + `Nothing was saved. Run \`${login}\` to point this machine at the HeySpex cloud, then try again. `
666
+ + 'Do not start a local dev server — cloud is the normal mode.'
667
+ );
668
+ }
669
+ if (code === CONSOLE_NOT_SIGNED_IN) {
670
+ return (
671
+ `This machine is not signed in to HeySpex, so the call${url ? ` to ${url}` : ''} could not go through. `
672
+ + `Nothing was saved. Run \`${login}\` on this machine, then try again. `
673
+ + 'The server is not down — do not start a local dev server.'
674
+ );
675
+ }
676
+ return null;
677
+ }
678
+
679
+ /** Message for an answered HTTP 401 (the console replied; it rejected the token). */
680
+ export function consoleUnauthorizedMessage(url) {
681
+ const where = url ? ` at ${url}` : '';
682
+ return (
683
+ `HeySpex${where} rejected this request because this machine is not signed in (HTTP 401). `
684
+ + `Nothing was saved. Run \`${hsCommand('login')}\` on this machine, then try again.`
685
+ );
686
+ }
687
+
688
+ /**
689
+ * Lazy, cached, never-throwing load of the shared dev/prod mode core. Kept
690
+ * dynamic (and optional) so a vendored copy of this directory without the
691
+ * repo's `scripts/` tree simply falls back to today's behavior instead of
692
+ * failing to import. Mirrors cli.mjs's handleDevMode self-containment note.
693
+ */
694
+ let devModeCorePromise;
695
+ async function loadDevModeCore() {
696
+ if (!devModeCorePromise) {
697
+ devModeCorePromise = import('../../scripts/lib/dev-mode.mjs').catch(() => null);
698
+ }
699
+ return devModeCorePromise;
700
+ }
701
+
702
+ /**
703
+ * Resolve the effective reliance ("cloud" | "local"), or `null` when it cannot
704
+ * be determined (no dev-mode core available). Never reimplements the contract:
705
+ * `resolveDevMode` (env → machine-local record → default prod/cloud) when the
706
+ * core exports it, else `readLocalDevModeRecord` + `deriveSemantics`.
707
+ */
708
+ export async function resolveConsoleReliance({ env = process.env, homeDir = heyspexHome() } = {}) {
709
+ const core = await loadDevModeCore();
710
+ if (!core) return null;
112
711
  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}` };
712
+ if (typeof core.resolveDevMode === 'function') {
713
+ return core.resolveDevMode({ env, homeDir }).reliance ?? null;
714
+ }
715
+ const { record } = core.readLocalDevModeRecord({ homeDir });
716
+ const mode = record?.mode ?? 'prod';
717
+ return core.deriveSemantics(mode, record?.reliance ?? null).reliance ?? null;
117
718
  } catch {
118
- // Missing file, missing key, or unreadable — no credentials-file token.
719
+ return null;
119
720
  }
120
- return {};
721
+ }
722
+
723
+ /**
724
+ * Decide whether a transport failure is really a sign-in problem. Pure.
725
+ *
726
+ * @param {{ url: string, reliance: string|null, hasToken: boolean }} state
727
+ * @returns {string|null} one of the two auth codes, or null (= genuine outage)
728
+ */
729
+ export function classifyConsoleAuthProblem({ url, reliance, hasToken }) {
730
+ if (reliance !== 'cloud') return null; // local dev / unknown → today's behavior
731
+ if (isLoopbackUrl(url)) return CONSOLE_LOCAL_URL_IN_CLOUD_MODE;
732
+ if (!hasToken) return CONSOLE_NOT_SIGNED_IN;
733
+ return null;
734
+ }
735
+
736
+ function headerHasToken(header) {
737
+ const value = header?.Authorization;
738
+ return typeof value === 'string' && value.trim().length > 0;
121
739
  }
122
740
 
123
741
  /**
@@ -129,6 +747,9 @@ export function getAuthHeader({ env = process.env, homeDir = homedir() } = {}) {
129
747
  * @param {number} [deps.defaultTimeoutMs]
130
748
  * @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
749
  * @param {() => object} [deps.getAuthHeader] Resolves the outbound Authorization header (default: {@link getAuthHeader}).
750
+ * @param {() => object} [deps.getVaultIdHeader] Resolves the outbound `x-heyspex-vault-id` header (default: {@link getVaultIdHeader}); throws {@link VaultNotRegisteredError} for an unregistered root, {@link VaultNotPinnedError} for a pointer-sourced one.
751
+ * @param {() => object} [deps.getAccountIdHeader] Resolves the outbound `x-heyspex-account-id` header (default: {@link getAccountIdHeader}); throws {@link AccountNotConfiguredError} when no account is configured.
752
+ * @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
753
  */
133
754
  export function makeConsoleFetch({
134
755
  getConsoleUrl,
@@ -136,7 +757,72 @@ export function makeConsoleFetch({
136
757
  defaultTimeoutMs = DEFAULT_CONSOLE_TIMEOUT_MS,
137
758
  recoverFromTransportFailure,
138
759
  getAuthHeader: getAuthHeaderDep = getAuthHeader,
760
+ getVaultIdHeader: getVaultIdHeaderDep = getVaultIdHeader,
761
+ getAccountIdHeader: getAccountIdHeaderDep = getAccountIdHeader,
762
+ getConsoleAuthState,
139
763
  }) {
764
+ function safeAuthHeader() {
765
+ try {
766
+ return getAuthHeaderDep() || {};
767
+ } catch {
768
+ return {};
769
+ }
770
+ }
771
+
772
+ /**
773
+ * One header resolver, two policies. A resolver that throws a local scope
774
+ * refusal ({@link isLocalScopeRefusal}) is surfaced as `{ refusal }` for a
775
+ * vault-scoped endpoint and swallowed to `{}` for a vault-less one; any
776
+ * OTHER throw is "no header" either way (a broken resolver must not become
777
+ * a new failure mode — unchanged from before).
778
+ */
779
+ function headerOrRefusal(resolveHeader, vaultLess) {
780
+ try {
781
+ return { headers: resolveHeader() || {} };
782
+ } catch (err) {
783
+ if (!vaultLess && isLocalScopeRefusal(err)) return { refusal: err };
784
+ return { headers: {} };
785
+ }
786
+ }
787
+
788
+ /**
789
+ * The `{ account, vault }` scope headers for one request, or the local
790
+ * refusal that stops it before any fetch. Vault-less endpoints never refuse.
791
+ * @returns {{ headers: object } | { refusal: Error }}
792
+ */
793
+ function scopeHeadersFor(method, path) {
794
+ const vaultLess = isVaultLessEndpoint(method, path);
795
+ const vault = headerOrRefusal(getVaultIdHeaderDep, vaultLess);
796
+ if (vault.refusal) return vault;
797
+ const account = headerOrRefusal(getAccountIdHeaderDep, vaultLess);
798
+ if (account.refusal) return account;
799
+ return { headers: { ...vault.headers, ...account.headers } };
800
+ }
801
+
802
+ /**
803
+ * The Authorization header for one request, or the local refusal that stops
804
+ * it: a repo that records an account this machine holds no token for
805
+ * ({@link AccountTokenMissingError}) refuses EVERY console call, vault-less
806
+ * ones included — sending no token, or the current account's, would both be
807
+ * wrong. Any other throw is "no header" (unchanged).
808
+ * @returns {{ headers: object } | { refusal: Error }}
809
+ */
810
+ function authOrRefusal() {
811
+ try {
812
+ return { headers: getAuthHeaderDep() || {} };
813
+ } catch (err) {
814
+ if (err instanceof AccountTokenMissingError) return { refusal: err };
815
+ return { headers: {} };
816
+ }
817
+ }
818
+
819
+ const resolveAuthState =
820
+ getConsoleAuthState
821
+ || (async () => ({
822
+ reliance: await resolveConsoleReliance(),
823
+ hasToken: headerHasToken(safeAuthHeader()),
824
+ }));
825
+
140
826
  async function attempt(url, init, timeoutMs) {
141
827
  const controller = new AbortController();
142
828
  const timer = setTimeout(() => controller.abort(), timeoutMs);
@@ -149,7 +835,14 @@ export function makeConsoleFetch({
149
835
  } catch {
150
836
  parsed = { raw: text };
151
837
  }
152
- return { ok: response.ok, status: response.status, body: parsed, url };
838
+ const retryAfterRaw = response.headers?.get?.('retry-after');
839
+ return {
840
+ ok: response.ok,
841
+ status: response.status,
842
+ body: parsed,
843
+ url,
844
+ ...(retryAfterRaw != null && retryAfterRaw !== '' ? { retryAfterSeconds: retryAfterRaw } : {}),
845
+ };
153
846
  } catch (err) {
154
847
  const errorKind = isAbortError(err) ? 'timeout' : 'network';
155
848
  return {
@@ -244,15 +937,52 @@ export function makeConsoleFetch({
244
937
  return result;
245
938
  }
246
939
 
940
+ /**
941
+ * Last word on a status-0 failure: was the console really down, or were we
942
+ * talking to the wrong console / with no sign-in? Runs only AFTER every
943
+ * recovery path has failed, so a genuinely recoverable local dev server
944
+ * still heals exactly as before ([[HS-9R6S-N8VP-DIAG]]).
945
+ */
946
+ async function stampAuthProblem(result, url) {
947
+ let state;
948
+ try {
949
+ state = await resolveAuthState({ url });
950
+ } catch {
951
+ return result; // never let classification turn into a new failure mode
952
+ }
953
+ const authProblem = classifyConsoleAuthProblem({
954
+ url: result.url || url,
955
+ reliance: state?.reliance ?? null,
956
+ hasToken: state?.hasToken === true,
957
+ });
958
+ if (!authProblem) return result;
959
+ return {
960
+ ...result,
961
+ authProblem,
962
+ reliance: state?.reliance ?? null,
963
+ authMessage: consoleAuthErrorMessage(authProblem, result.url || url),
964
+ };
965
+ }
966
+
247
967
  async function withRetry(url, init, timeoutMs, { retryOnTimeout = true } = {}) {
248
968
  let result = await attempt(url, init, timeoutMs);
249
969
  if (!result.ok && result.errorKind === 'timeout' && retryOnTimeout) {
250
970
  result = await attempt(url, init, timeoutMs);
251
971
  }
972
+ // Cold cloud lambda: wait the advertised window, then the same URL once more.
973
+ if (!result.ok && isVaultScopeUnresolved(result)) {
974
+ await sleep(retryAfterMs(result));
975
+ const retry = await attempt(url, init, timeoutMs);
976
+ if (retry.ok) return { ...retry, recoveredVia: 'vault_scope_unresolved', attemptedUrl: url };
977
+ result = retry;
978
+ }
252
979
  if (result.ok) return result;
253
980
  if (result.errorKind === 'network') {
254
981
  result = await recoverNetworkFailure(result, { url, init, timeoutMs });
255
982
  }
983
+ if (!result.ok && result.status === 0) {
984
+ result = await stampAuthProblem(result, url);
985
+ }
256
986
  return result;
257
987
  }
258
988
 
@@ -261,10 +991,15 @@ export function makeConsoleFetch({
261
991
  */
262
992
  async function postClaimEndpoint(method, specId, bodyJson) {
263
993
  const consoleUrl = getConsoleUrl();
264
- const url = `${consoleUrl}/api/specs/${encodeURIComponent(specId)}/claim`;
994
+ const path = `/api/specs/${encodeURIComponent(specId)}/claim`;
995
+ const url = `${consoleUrl}${path}`;
996
+ const auth = authOrRefusal();
997
+ if (auth.refusal) return scopeRefusalResult(auth.refusal, url);
998
+ const scope = scopeHeadersFor(method, path);
999
+ if (scope.refusal) return scopeRefusalResult(scope.refusal, url);
265
1000
  const init = {
266
1001
  method,
267
- headers: { 'content-type': 'application/json', ...getAuthHeaderDep() },
1002
+ headers: { 'content-type': 'application/json', ...auth.headers, ...scope.headers },
268
1003
  };
269
1004
  if (bodyJson !== undefined) {
270
1005
  init.body = JSON.stringify(bodyJson);
@@ -287,16 +1022,30 @@ export function makeConsoleFetch({
287
1022
  async function consoleFetch(path, options = {}) {
288
1023
  const consoleUrl = getConsoleUrl();
289
1024
  const url = `${consoleUrl}${path}`;
1025
+ const method = options.method || 'GET';
1026
+ const auth = authOrRefusal();
1027
+ if (auth.refusal) return scopeRefusalResult(auth.refusal, url);
1028
+ const scope = scopeHeadersFor(method, path);
1029
+ if (scope.refusal) return scopeRefusalResult(scope.refusal, url);
290
1030
  const init = {
291
- method: options.method || 'GET',
292
- headers: { 'content-type': 'application/json', ...getAuthHeaderDep(), ...options.headers },
1031
+ method,
1032
+ headers: {
1033
+ 'content-type': 'application/json',
1034
+ ...auth.headers,
1035
+ ...scope.headers,
1036
+ ...options.headers,
1037
+ },
293
1038
  };
294
1039
  if (options.body !== undefined) {
295
1040
  init.body = typeof options.body === 'string' ? options.body : JSON.stringify(options.body);
296
1041
  }
297
1042
  const timeoutMs = options.timeout || defaultTimeoutMs;
298
1043
  const retryOnTimeout = options.retryOnTimeout === false ? isLoopbackUrl(url) : true;
299
- return withRetry(url, init, timeoutMs, { retryOnTimeout });
1044
+ const outcome = await withRetry(url, init, timeoutMs, { retryOnTimeout });
1045
+ // Degrade-don't-die: the first successful console call of the process kicks
1046
+ // a background replay of any queued writes (non-blocking, failure-safe).
1047
+ if (outcome?.ok) maybeDrainOnFirstSuccess(consoleFetch);
1048
+ return outcome;
300
1049
  }
301
1050
 
302
1051
  return { postClaimEndpoint, consoleFetch };
@@ -308,7 +1057,51 @@ export function makeConsoleFetch({
308
1057
  * actually down). Caller supplies the HTTP-error fallback used when the
309
1058
  * transport succeeded but the console returned a 4xx/5xx.
310
1059
  */
1060
+ /**
1061
+ * The console's three TYPED vault-scope refusals
1062
+ * ([[HS-T5J5-WW9R-TASK-typed-vault-refusals-vault-not]]), one envelope
1063
+ * `{ error, reason, requested, hint }`. They name WHICH vault was refused and
1064
+ * why; the `hint` names the account's active vault when it is known. The
1065
+ * transport renders them by name so an agent reads two vault ids, not a bare
1066
+ * status ([[HS-N0VS-WW9R-TASK-hs-doctor-and-the-mcp-transport-name]] for
1067
+ * [[HS-W3JF-PEGM-DIAG-one-account-wide-active-vault-pointer]] fix 3).
1068
+ */
1069
+ export const SCOPE_REFUSAL_CODES = Object.freeze(['VAULT_SCOPE_MISMATCH', 'ACCOUNT_MISMATCH', 'VAULT_NOT_REGISTERED']);
1070
+
1071
+ /** The typed refusal envelope on a failed transport result, or `null`. Pure. */
1072
+ export function scopeRefusalEnvelope(result) {
1073
+ const body = result?.body;
1074
+ if (!body || typeof body !== 'object' || !SCOPE_REFUSAL_CODES.includes(body.error)) return null;
1075
+ return body;
1076
+ }
1077
+
1078
+ /**
1079
+ * Agent-facing text for a typed refusal: code, reason, the vault the request
1080
+ * named, and the console's hint (which carries the account's active vault when
1081
+ * known) — the "both vaults side by side" the incident lacked. Pure.
1082
+ */
1083
+ export function scopeRefusalMessage(envelope, url) {
1084
+ const where = url ? ` from ${url}` : '';
1085
+ const reason = typeof envelope.reason === 'string' && envelope.reason ? envelope.reason : 'no reason given';
1086
+ const requested =
1087
+ typeof envelope.requested === 'string' && envelope.requested
1088
+ ? `vault ${envelope.requested}`
1089
+ : `no vault (no \`${VAULT_ID_HEADER}\` header)`;
1090
+ const hint = typeof envelope.hint === 'string' && envelope.hint ? ` ${envelope.hint}` : '';
1091
+ return `${envelope.error} (${reason})${where}: this request named ${requested}.${hint}`.trim();
1092
+ }
1093
+
311
1094
  export function consoleErrorCode(result, httpFailureCode) {
1095
+ // A local scope refusal never reached the wire: the code IS the answer.
1096
+ if (result.refusal) return result.refusal;
1097
+ // A sign-in problem outranks every transport story: no amount of retrying,
1098
+ // port discovery or waiting fixes it, and "the server is down" sends the
1099
+ // agent off to start a local dev server the customer does not have.
1100
+ if (result.authProblem) return result.authProblem;
1101
+ // A typed vault-scope refusal IS the code — never the caller's generic one.
1102
+ const scoped = scopeRefusalEnvelope(result);
1103
+ if (scoped) return scoped.error;
1104
+ if (result.status === 401) return CONSOLE_NOT_SIGNED_IN;
312
1105
  if (result.urlMismatch) return 'CONSOLE_URL_MISMATCH';
313
1106
  if (result.errorKind === 'timeout') return 'CONSOLE_TIMEOUT';
314
1107
  if (result.status === 0) return 'CONSOLE_UNREACHABLE';
@@ -321,6 +1114,18 @@ export function consoleErrorCode(result, httpFailureCode) {
321
1114
  * server on network error.
322
1115
  */
323
1116
  export function consoleErrorMessage(result, httpFailureMessage) {
1117
+ if (result.refusal) return result.error || result.authMessage || httpFailureMessage;
1118
+ if (result.authProblem) {
1119
+ return (
1120
+ result.authMessage
1121
+ || consoleAuthErrorMessage(result.authProblem, result.url)
1122
+ || httpFailureMessage
1123
+ );
1124
+ }
1125
+ // A typed vault-scope refusal names both vaults; the generic text names none.
1126
+ const scoped = scopeRefusalEnvelope(result);
1127
+ if (scoped) return scopeRefusalMessage(scoped, result.url);
1128
+ if (result.status === 401) return consoleUnauthorizedMessage(result.url);
324
1129
  if (result.urlMismatch) {
325
1130
  const live =
326
1131
  result.suggestedUrl