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
@@ -5,8 +5,290 @@ var __esm = (fn, res) => function __init() {
5
5
  return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
6
6
  };
7
7
 
8
+ // vendor/heyspex-mcp/credentials.mjs
9
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
10
+ import os, { homedir } from "node:os";
11
+ import { dirname as dirname2, join as join2, resolve } from "node:path";
12
+ function credentialsFilePath(homeDir = homedir()) {
13
+ return join2(homeDir, ".heyspex", "credentials.yaml");
14
+ }
15
+ function parseMcpToken(raw) {
16
+ if (typeof raw !== "string" || raw.length === 0) return null;
17
+ const token = raw.match(MCP_TOKEN_LINE_RE)?.[1]?.trim();
18
+ return token ? token : null;
19
+ }
20
+ function isValidAccountId(id) {
21
+ return typeof id === "string" && ACCOUNT_ID_RE.test(id);
22
+ }
23
+ function topLevelScalar(raw, key) {
24
+ const re = new RegExp(`^${key}:[ \\t]*["']?([^"'\\r\\n#]+)`, "m");
25
+ const value = raw.match(re)?.[1]?.trim();
26
+ return value ? value : null;
27
+ }
28
+ function decodeScalar(text2) {
29
+ const t = text2.trim();
30
+ if (t === "" || t === "null" || t === "~") return null;
31
+ if (t.startsWith('"')) {
32
+ try {
33
+ const v = JSON.parse(t);
34
+ return typeof v === "string" && v ? v : null;
35
+ } catch {
36
+ return t.replace(/^"|"$/g, "") || null;
37
+ }
38
+ }
39
+ if (t.startsWith("'")) return t.replace(/^'|'$/g, "") || null;
40
+ const bare = t.replace(/\s+#.*$/, "").trim();
41
+ return bare || null;
42
+ }
43
+ function emptyAccount() {
44
+ return { mcp_token: null, console_url: null, email: null, paired_at: null };
45
+ }
46
+ function findAccountsBlock(lines) {
47
+ const start = lines.findIndex((l) => /^accounts:[ \t]*(#.*)?$/.test(l));
48
+ if (start < 0) return null;
49
+ let end = start + 1;
50
+ while (end < lines.length && (lines[end].trim() === "" || /^[ \t]/.test(lines[end]))) end += 1;
51
+ while (end > start + 1 && lines[end - 1].trim() === "") end -= 1;
52
+ return { start, end };
53
+ }
54
+ function parseAccountsBlock(lines) {
55
+ const accounts = {};
56
+ const block = findAccountsBlock(lines);
57
+ if (!block) return accounts;
58
+ let current = null;
59
+ for (const line of lines.slice(block.start + 1, block.end)) {
60
+ const header = line.match(/^ {2}([^\s:#][^:]*):[ \t]*(#.*)?$/);
61
+ if (header) {
62
+ const id = decodeScalar(header[1]);
63
+ current = isValidAccountId(id) ? id : null;
64
+ if (current) accounts[current] = emptyAccount();
65
+ continue;
66
+ }
67
+ const field = line.match(/^ {4}([a-z_]+):(.*)$/);
68
+ if (field && current && ACCOUNT_FIELDS.includes(field[1])) {
69
+ accounts[current][field[1]] = decodeScalar(field[2]);
70
+ }
71
+ }
72
+ return accounts;
73
+ }
74
+ function parseCredentialsStore(raw) {
75
+ const text2 = typeof raw === "string" ? raw : "";
76
+ if (topLevelScalar(text2, "schema_version") !== String(CREDENTIALS_SCHEMA_VERSION)) {
77
+ const token = parseMcpToken(text2);
78
+ if (!token) return { schemaVersion: 1, currentAccountId: null, accounts: {} };
79
+ return {
80
+ schemaVersion: 1,
81
+ currentAccountId: LEGACY_ACCOUNT_ID,
82
+ accounts: {
83
+ [LEGACY_ACCOUNT_ID]: { ...emptyAccount(), mcp_token: token, console_url: topLevelScalar(text2, "console_url") }
84
+ }
85
+ };
86
+ }
87
+ const accounts = parseAccountsBlock(text2.split(/\r?\n/));
88
+ const current = topLevelScalar(text2, "current_account_id");
89
+ return {
90
+ schemaVersion: 2,
91
+ currentAccountId: current && accounts[current] ? current : null,
92
+ accounts
93
+ };
94
+ }
95
+ function resolveBearer({ envToken, credentialsText, accountId: accountId2, legacyAccountId } = {}) {
96
+ const fromEnv = typeof envToken === "string" ? envToken.trim() : "";
97
+ if (fromEnv) return { token: fromEnv, source: "env" };
98
+ const store = parseCredentialsStore(credentialsText);
99
+ let entry;
100
+ if (typeof accountId2 === "string" && accountId2.trim()) {
101
+ const wanted = accountId2.trim();
102
+ entry = store.accounts[wanted];
103
+ if (!entry && store.schemaVersion === 1 && legacyAccountId && wanted === legacyAccountId) {
104
+ entry = store.accounts[LEGACY_ACCOUNT_ID];
105
+ }
106
+ } else {
107
+ entry = store.currentAccountId ? store.accounts[store.currentAccountId] : void 0;
108
+ }
109
+ if (entry?.mcp_token) return { token: entry.mcp_token, source: "credentials-file" };
110
+ return { token: null, source: null };
111
+ }
112
+ function readTextOrNull(read, path4) {
113
+ try {
114
+ const raw = read(path4);
115
+ return typeof raw === "string" ? raw : null;
116
+ } catch {
117
+ return null;
118
+ }
119
+ }
120
+ function readLegacyConfigAccountId(read, homeDir) {
121
+ const raw = readTextOrNull(read, join2(homeDir, ".heyspex", "config.yaml"));
122
+ const value = raw?.match(CONFIG_ACCOUNT_ID_RE)?.[1]?.trim() ?? "";
123
+ return value || null;
124
+ }
125
+ function readBearer({ env = process.env, homeDir = homedir(), readFile: readFile2, accountId: accountId2 } = {}) {
126
+ const read = readFile2 ?? ((p) => readFileSync(p, "utf8"));
127
+ const credentialsText = readTextOrNull(read, credentialsFilePath(homeDir));
128
+ const named = typeof accountId2 === "string" && accountId2.trim() ? accountId2.trim() : null;
129
+ const legacyAccountId = named && parseCredentialsStore(credentialsText).schemaVersion === 1 ? readLegacyConfigAccountId(read, homeDir) : null;
130
+ return resolveBearer({ envToken: env?.[MCP_TOKEN_ENV], credentialsText, accountId: named, legacyAccountId });
131
+ }
132
+ function readCurrentAccountId({ homeDir = homedir(), readFile: readFile2 } = {}) {
133
+ const read = readFile2 ?? ((p) => readFileSync(p, "utf8"));
134
+ const store = parseCredentialsStore(readTextOrNull(read, credentialsFilePath(homeDir)));
135
+ if (store.schemaVersion === 2 && store.currentAccountId && store.currentAccountId !== LEGACY_ACCOUNT_ID) {
136
+ return store.currentAccountId;
137
+ }
138
+ if (store.schemaVersion === 2 && store.currentAccountId === null) return null;
139
+ return readLegacyConfigAccountId(read, homeDir);
140
+ }
141
+ function describeBearerSource(source) {
142
+ if (source === "env") return `${MCP_TOKEN_ENV} (env)`;
143
+ if (source === "credentials-file") return "~/.heyspex/credentials.yaml (mcp_token)";
144
+ return "none";
145
+ }
146
+ var MCP_TOKEN_ENV, MCP_TOKEN_LINE_RE, CREDENTIALS_SCHEMA_VERSION, LEGACY_ACCOUNT_ID, ACCOUNT_FIELDS, ACCOUNT_ID_RE, CONFIG_ACCOUNT_ID_RE;
147
+ var init_credentials = __esm({
148
+ "vendor/heyspex-mcp/credentials.mjs"() {
149
+ "use strict";
150
+ MCP_TOKEN_ENV = "HEYSPEX_MCP_TOKEN";
151
+ MCP_TOKEN_LINE_RE = /^mcp_token:[ \t]*["']?([^"'\r\n#]+)/m;
152
+ CREDENTIALS_SCHEMA_VERSION = 2;
153
+ LEGACY_ACCOUNT_ID = "legacy";
154
+ ACCOUNT_FIELDS = ["mcp_token", "console_url", "email", "paired_at"];
155
+ ACCOUNT_ID_RE = /^[A-Za-z0-9][A-Za-z0-9_.@+-]*$/;
156
+ CONFIG_ACCOUNT_ID_RE = /^account_id:\s*["']?([^"'\r\n#]+)/m;
157
+ }
158
+ });
159
+
160
+ // scripts/lib/dev-mode.mjs
161
+ import { mkdirSync as mkdirSync3, readFileSync as readFileSync6, writeFileSync as writeFileSync3 } from "node:fs";
162
+ import { homedir as homedir6 } from "node:os";
163
+ import { join as join7 } from "node:path";
164
+ function devModeFilePath(homeDir = homedir6()) {
165
+ return join7(homeDir, ".heyspex", DEV_MODE_FILENAME);
166
+ }
167
+ function normalizeMode(raw) {
168
+ const v = String(raw ?? "").trim().toLowerCase();
169
+ if (v === "prod" || v === "production") return { mode: "prod", reliance: "cloud" };
170
+ if (v === "dev") return { mode: "dev", reliance: null };
171
+ if (v === "local") return { mode: "dev", reliance: "local" };
172
+ if (v === "cloud") return { mode: "dev", reliance: "cloud" };
173
+ return null;
174
+ }
175
+ function normalizeReliance(raw) {
176
+ const v = String(raw ?? "").trim().toLowerCase();
177
+ return v === "local" || v === "cloud" ? v : null;
178
+ }
179
+ function deriveSemantics(mode, reliance) {
180
+ const effectiveReliance = mode === "prod" ? "cloud" : reliance ?? "local";
181
+ const local = mode === "dev" && effectiveReliance === "local";
182
+ return {
183
+ reliance: effectiveReliance,
184
+ consoleUrl: local ? LOCAL_CONSOLE_URL : CLOUD_CONSOLE_URL,
185
+ readTier: local ? "disk" : "neon",
186
+ vaultWriteMode: local ? "local" : "cloud"
187
+ };
188
+ }
189
+ function readLocalDevModeRecord({ homeDir = homedir6(), readFile: readFile2 = defaultReadFile } = {}) {
190
+ let raw;
191
+ try {
192
+ raw = readFile2(devModeFilePath(homeDir));
193
+ } catch {
194
+ return { record: null, parseError: null };
195
+ }
196
+ try {
197
+ const parsed = JSON.parse(raw);
198
+ const mode = parsed?.mode === "prod" || parsed?.mode === "dev" ? parsed.mode : null;
199
+ const reliance = normalizeReliance(parsed?.reliance);
200
+ if (!mode || mode === "dev" && !reliance) {
201
+ return { record: null, parseError: `invalid dev-mode record: ${JSON.stringify(parsed)}` };
202
+ }
203
+ return {
204
+ record: {
205
+ mode,
206
+ reliance: mode === "prod" ? "cloud" : reliance,
207
+ console_url: typeof parsed.console_url === "string" ? parsed.console_url : null,
208
+ updated_at: typeof parsed.updated_at === "string" ? parsed.updated_at : null,
209
+ source: typeof parsed.source === "string" ? parsed.source : null
210
+ },
211
+ parseError: null
212
+ };
213
+ } catch (err) {
214
+ return { record: null, parseError: `unparseable dev-mode.json: ${String(err?.message ?? err)}` };
215
+ }
216
+ }
217
+ function resolveDevMode({
218
+ env = process.env,
219
+ homeDir = homedir6(),
220
+ readFile: readFile2 = defaultReadFile,
221
+ cloudSetting = void 0
222
+ } = {}) {
223
+ const local = readLocalDevModeRecord({ homeDir, readFile: readFile2 });
224
+ const cloudKnown = cloudSetting !== void 0;
225
+ const cloudValue = cloudKnown && cloudSetting ? {
226
+ mode: cloudSetting.mode === "dev" ? "dev" : "prod",
227
+ reliance: normalizeReliance(cloudSetting.reliance) ?? "cloud"
228
+ } : null;
229
+ let mode;
230
+ let reliance;
231
+ let source;
232
+ let relianceSource;
233
+ const envParsed = normalizeMode(env.HEYSPEX_DEV_MODE);
234
+ if (envParsed) {
235
+ mode = envParsed.mode;
236
+ const envReliance = envParsed.reliance ?? normalizeReliance(env.HEYSPEX_DEV_RELIANCE);
237
+ reliance = envReliance ?? "local";
238
+ source = "env";
239
+ relianceSource = envReliance ? "env" : "assumed-local";
240
+ } else if (local.record) {
241
+ mode = local.record.mode;
242
+ reliance = local.record.reliance;
243
+ source = "local-override";
244
+ } else if (cloudValue) {
245
+ mode = cloudValue.mode;
246
+ reliance = cloudValue.reliance;
247
+ source = "cloud-setting";
248
+ } else {
249
+ mode = "prod";
250
+ reliance = "cloud";
251
+ source = "default";
252
+ }
253
+ const semantics = deriveSemantics(mode, reliance);
254
+ const consoleUrl = source === "local-override" && local.record?.console_url ? local.record.console_url.replace(/\/+$/, "") : semantics.consoleUrl;
255
+ const staleAgainstCloud = source === "local-override" && cloudValue !== null && (cloudValue.mode !== mode || mode === "dev" && cloudValue.reliance !== semantics.reliance);
256
+ return {
257
+ mode,
258
+ reliance: semantics.reliance,
259
+ consoleUrl,
260
+ readTier: semantics.readTier,
261
+ vaultWriteMode: semantics.vaultWriteMode,
262
+ sources: {
263
+ mode: source,
264
+ reliance: relianceSource ?? source,
265
+ consoleUrl: source === "local-override" && local.record?.console_url ? "local-override" : "derived",
266
+ readTier: "derived",
267
+ vaultWriteMode: "derived"
268
+ },
269
+ localOverride: {
270
+ present: local.record !== null,
271
+ record: local.record,
272
+ parseError: local.parseError,
273
+ staleAgainstCloud
274
+ },
275
+ cloudSetting: { known: cloudKnown, value: cloudValue }
276
+ };
277
+ }
278
+ var CLOUD_CONSOLE_URL, LOCAL_CONSOLE_URL, DEV_MODE_FILENAME, defaultReadFile;
279
+ var init_dev_mode = __esm({
280
+ "scripts/lib/dev-mode.mjs"() {
281
+ "use strict";
282
+ init_credentials();
283
+ CLOUD_CONSOLE_URL = "https://app.heyspex.com";
284
+ LOCAL_CONSOLE_URL = "http://localhost:3000";
285
+ DEV_MODE_FILENAME = "dev-mode.json";
286
+ defaultReadFile = (p) => readFileSync6(p, "utf8");
287
+ }
288
+ });
289
+
8
290
  // dist/cli/._sql-orm-inert.cli-shim.mjs
9
- var inert, sql, and, eq, bigint, bigserial, boolean, foreignKey, index, integer, jsonb, pgEnum, pgTable, primaryKey, text, timestamp, uniqueIndex;
291
+ var inert, customType, sql, and, eq, bigint, bigserial, boolean, foreignKey, index, integer, jsonb, pgEnum, pgTable, primaryKey, text, timestamp, uniqueIndex;
10
292
  var init_sql_orm_inert_cli_shim = __esm({
11
293
  "dist/cli/._sql-orm-inert.cli-shim.mjs"() {
12
294
  "use strict";
@@ -24,6 +306,7 @@ var init_sql_orm_inert_cli_shim = __esm({
24
306
  return inert;
25
307
  }
26
308
  });
309
+ customType = inert;
27
310
  sql = inert;
28
311
  and = inert;
29
312
  eq = inert;
@@ -69,7 +352,7 @@ var init_interfaces = __esm({
69
352
  });
70
353
 
71
354
  // spex-console/src/lib/db/schema/postgres.ts
72
- var edgeTypeEnum, validationSeverityEnum, outboxOpEnum, specs, tags, edges, wikilinks, aliases, validationIssues, indexMeta, frontmatterFields, eventOutbox, captureSessions, handoffContext, claimEventKindEnum, activeClaimStatusEnum, pendingReasonEnum, focusEventKindEnum, specEventKindEnum, focusPin, captureFocusPin, captureFocusSelection, focusSelection, focusSkips, activeClaims, claimWaiters, claimEvents, focusEvents, focusAreas, focusAreasEvents, specEvents, rateLimitBuckets, userVaults, milestoneSnapshots, modelRegistryEntries, launchIntentStatusEnum, launchIntents, companionRelayPresence, companionSettingsStore, wipLedger, gatherResults, agentSessions, usageEvents, streamEvents, jobStreams, resolutionStatusEnum, resolutionFocusPin, resolutionLedger, workspaces, vaults, accountSettings, vaultGrants, githubInstallations, fleetMachines, fleetTasks, fleetResults, userApiKeys, apiKeys, mcpSessions, pairingCodes, companionSessions, inviteRequests, notifications, webhookIngestions, specSettlements;
355
+ var edgeTypeEnum, validationSeverityEnum, outboxOpEnum, tsvector, specs, tags, edges, wikilinks, specLinks, aliases, validationIssues, indexMeta, frontmatterFields, eventOutbox, captureSessions, handoffContext, claimEventKindEnum, activeClaimStatusEnum, pendingReasonEnum, focusEventKindEnum, specEventKindEnum, focusPin, captureFocusPin, captureFocusSelection, focusSelection, focusSkips, activeClaims, claimWaiters, fileClaims, claimEvents, focusEvents, focusAreas, focusAreasEvents, specEvents, rateLimitBuckets, userVaults, milestoneSnapshots, modelRegistryEntries, launchIntentStatusEnum, launchIntents, companionRelayPresence, companionSettingsStore, consoleSettingsStore, wipLedger, wipLedgerArchive, gatherResults, agentSessions, usageEvents, streamEvents, jobStreams, resolutionStatusEnum, resolutionFocusPin, resolutionLedger, workspaces, vaults, accountSettings, vaultGrants, pendingVaultGrants, githubInstallations, fleetMachines, fleetTasks, fleetResults, userApiKeys, apiKeys, mcpSessions, pairingCodes, companionSessions, inviteRequests, notifications, webhookIngestions, specSettlements;
73
356
  var init_postgres = __esm({
74
357
  "spex-console/src/lib/db/schema/postgres.ts"() {
75
358
  "use strict";
@@ -84,6 +367,11 @@ var init_postgres = __esm({
84
367
  ]);
85
368
  validationSeverityEnum = pgEnum("validation_severity", ["error", "warning"]);
86
369
  outboxOpEnum = pgEnum("outbox_op", ["upsert", "delete"]);
370
+ tsvector = customType({
371
+ dataType() {
372
+ return "tsvector";
373
+ }
374
+ });
87
375
  specs = pgTable(
88
376
  "specs",
89
377
  {
@@ -124,11 +412,47 @@ var init_postgres = __esm({
124
412
  // `family:` field when present, else derives it via `familyOf` (parse
125
413
  // fallback from `id`/basename). Nothing should string-parse family from `id`
126
414
  // once this column is populated — see `dev-factory/handoffs/NAMING-V3-MIGRATION-BRIEF.md`.
127
- family: text("family")
415
+ family: text("family"),
416
+ // Lexical search vector ([[HS-0E1G-8KK5-TASK-generated-search-vector-column-gin]]
417
+ // of [[HS-FI33-8KK5-EPIC-lexical-retrieval-in-postgres-generated]]; research:
418
+ // [[HS-HPEH-8KK5-RES-infinitely-scaling-spec-vault-nothing]] §Lexical retrieval).
419
+ // GENERATED ALWAYS … STORED: Postgres rewrites it on every INSERT/UPDATE of
420
+ // `name` / `body_text`, so the write path never touches it and it can never
421
+ // drift from the row. `coalesce` is mandatory — a NULL operand poisons the
422
+ // whole vector (NULL || tsvector = NULL). Title is weight A, body weight B so
423
+ // `ts_rank_cd` ranks a title hit above a body hit. Two-argument
424
+ // `to_tsvector('english', …)` is required inside a generated column (the
425
+ // one-argument form depends on a session setting and is not IMMUTABLE).
426
+ // Still lexical-only: no embeddings ([[retrieval-substrate-lexical-not-embeddings-gate-b-no]]).
427
+ searchVector: tsvector("search_vector").generatedAlwaysAs(
428
+ sql`setweight(to_tsvector('english', coalesce("name", '')), 'A') || setweight(to_tsvector('english', coalesce("body_text", '')), 'B')`
429
+ ),
430
+ // Single-column tenant-qualified key ([[HS-ZUZF-8KK5-TASK-per-vault-database-routing-switch-and]]
431
+ // of [[HS-91S9-8KK5-EPIC-large-tenant-tier-bm25-search]]). The specs PRIMARY
432
+ // KEY is COMPOSITE (vault_id, id) — but ParadeDB `pg_search` (the BM25 task,
433
+ // [[HS-KZJ1-8KK5-TASK-bm25-pg-search-index-and-query-path]]) requires a
434
+ // SINGLE-column unique key for its index. `vault_id || ':' || id` is that
435
+ // key: both operands are NOT NULL so no `coalesce` is needed (unlike
436
+ // `search_vector` above), `||` on text is IMMUTABLE so it is legal in a
437
+ // GENERATED … STORED expression, and STORED means the write path never sets
438
+ // it and it can never drift from the row. The unique index below is the
439
+ // constraint pg_search will point at.
440
+ specKey: text("spec_key").generatedAlwaysAs(sql`"vault_id" || ':' || "id"`)
128
441
  },
129
442
  (table) => [
130
443
  // Composite PK (vault_id, id): the same HS-XXXX id may exist once PER VAULT.
131
444
  primaryKey({ columns: [table.vaultId, table.id] }),
445
+ // Full-text search: `search_vector @@ websearch_to_tsquery('english', $q)`
446
+ // answers from this GIN index instead of scanning bodies. Built plainly in
447
+ // the migration (scripts/migrate.mjs wraps each migration in a transaction,
448
+ // and CREATE INDEX CONCURRENTLY cannot run inside one); for a large live
449
+ // table use scripts/build-search-indexes-concurrently.mjs
450
+ // (docs/search-index-runbook.md).
451
+ index("specs_search_idx").using("gin", table.searchVector),
452
+ // Near-miss title lookups (`name % $q` / `similarity(name, $q)` / `ILIKE
453
+ // '%term%'`) via pg_trgm. Needs `CREATE EXTENSION pg_trgm` — hand-added to
454
+ // the same migration because drizzle-kit cannot express extensions.
455
+ index("specs_name_trgm_idx").using("gin", table.name.op("gin_trgm_ops")),
132
456
  // SpexConsole main list views filter by type + status
133
457
  index("specs_type_status_idx").on(table.type, table.status),
134
458
  // Family-graph lookups (Naming V3 step 1) — group specs by family key.
@@ -141,7 +465,22 @@ var init_postgres = __esm({
141
465
  // (deleted_at IS NULL) so soft-deleted orphans don't block re-indexing. The
142
466
  // upsert path soft-deletes any conflicting row first. Scoped by vault so the
143
467
  // same rel_path can be indexed independently under two vaults.
144
- uniqueIndex("specs_relpath_user_uq").on(table.relPath, table.userId, table.vaultId).where(sql`${table.deletedAt} IS NULL`)
468
+ uniqueIndex("specs_relpath_user_uq").on(table.relPath, table.userId, table.vaultId).where(sql`${table.deletedAt} IS NULL`),
469
+ // Keyset paging ([[HS-C6FO-8KK5-TASK-keyset-spec-list-endpoint-query-layer]]):
470
+ // the immutable sort key is (created, id) — never `updated_at`, which moves
471
+ // edited rows between pages. Partial (deleted_at IS NULL) so soft-deleted
472
+ // rows never cost the index a page. Unfiltered list-query root.
473
+ index("specs_vault_created_id_idx").on(table.vaultId, table.created, table.id).where(sql`${table.deletedAt} IS NULL`),
474
+ // Same keyset shape, pre-filtered by type — the Docs list's most common
475
+ // filter (e.g. `type=task`).
476
+ index("specs_vault_type_created_id_idx").on(table.vaultId, table.type, table.created, table.id).where(sql`${table.deletedAt} IS NULL`),
477
+ // Same keyset shape, pre-filtered by status — the Docs list's other common
478
+ // filter (e.g. `status=open`).
479
+ index("specs_vault_status_created_id_idx").on(table.vaultId, table.status, table.created, table.id).where(sql`${table.deletedAt} IS NULL`),
480
+ // The single-column unique constraint `pg_search` needs (see `specKey`).
481
+ // NOT partial: pg_search indexes the whole table, and a partial unique
482
+ // index would not qualify as its key.
483
+ uniqueIndex("specs_spec_key_uq").on(table.specKey)
145
484
  ]
146
485
  );
147
486
  tags = pgTable(
@@ -221,6 +560,30 @@ var init_postgres = __esm({
221
560
  index("wikilinks_target_idx").on(table.targetBasename)
222
561
  ]
223
562
  );
563
+ specLinks = pgTable(
564
+ "spec_links",
565
+ {
566
+ id: bigserial("id", { mode: "number" }).primaryKey(),
567
+ vaultId: text("vault_id").notNull(),
568
+ subjectId: text("subject_id").notNull(),
569
+ objectId: text("object_id"),
570
+ objectKey: text("object_key").notNull(),
571
+ kind: text("kind").notNull().default("wikilink").$type(),
572
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow()
573
+ },
574
+ (table) => [
575
+ foreignKey({
576
+ columns: [table.vaultId, table.subjectId],
577
+ foreignColumns: [specs.vaultId, specs.id]
578
+ }).onDelete("cascade"),
579
+ // Forward refs: what does this spec point at?
580
+ index("spec_links_subject_idx").on(table.vaultId, table.subjectId, table.id),
581
+ // Backlinks: what points at this spec?
582
+ index("spec_links_object_idx").on(table.vaultId, table.objectId, table.id),
583
+ // Backlinks by basename (unresolved forward references / rename survival).
584
+ index("spec_links_object_key_idx").on(table.vaultId, table.objectKey, table.id)
585
+ ]
586
+ );
224
587
  aliases = pgTable(
225
588
  "aliases",
226
589
  {
@@ -624,6 +987,38 @@ var init_postgres = __esm({
624
987
  )
625
988
  ]
626
989
  );
990
+ fileClaims = pgTable(
991
+ "file_claims",
992
+ {
993
+ // Tenant-isolation scope ([[HS-ZP5H-TASK]]) — mirrors `active_claims.vault_id`.
994
+ vaultId: text("vault_id").notNull().default(DEFAULT_VAULT_ID),
995
+ filePath: text("file_path").notNull(),
996
+ // Persistent human accountable for this lease ([[HS-LLBO-ALE0-RES-multi-user-teams-on-a-shared-spec-vault]]).
997
+ // Resolved from the caller's vault scope at the call site — never a
998
+ // literal `SINGLE_USER_ID` baked in here. Distinct column from `agent_id`
999
+ // below: the agent is the ephemeral worker, never the owner.
1000
+ ownerUserId: text("owner_user_id").notNull().default(SINGLE_USER_ID),
1001
+ agentId: text("agent_id").notNull(),
1002
+ sessionId: text("session_id").notNull(),
1003
+ // Spec id the agent is working on when claiming this file, if any.
1004
+ // Informational only — mirrors `FileClaim.workingSpecId`.
1005
+ workingSpecId: text("working_spec_id"),
1006
+ claimedAt: timestamp("claimed_at", { withTimezone: true }).notNull().default(sql`now()`),
1007
+ // Reuses `active_claim_status` — same value domain as the file-side
1008
+ // `LeaseStatus` union ("working" | "pending" | "blocked").
1009
+ status: activeClaimStatusEnum("status").notNull().default("working"),
1010
+ lastActivityAt: timestamp("last_activity_at", { withTimezone: true }).notNull().default(sql`now()`),
1011
+ heartbeatAt: timestamp("heartbeat_at", { withTimezone: true }),
1012
+ expiresAt: timestamp("expires_at", { withTimezone: true }),
1013
+ // Shared global order: DEFAULT nextval on the SAME sequence `active_claims`
1014
+ // and `claim_waiters` mint from — not a per-table bigserial, which would
1015
+ // mint a second, unrelated counter and defeat the shared fencing contract.
1016
+ sequence: bigint("sequence", { mode: "number" }).notNull().unique().default(sql`nextval('active_claims_sequence_seq')`),
1017
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().default(sql`now()`),
1018
+ updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().default(sql`now()`)
1019
+ },
1020
+ (table) => [primaryKey({ columns: [table.vaultId, table.filePath] })]
1021
+ );
627
1022
  claimEvents = pgTable(
628
1023
  "claim_events",
629
1024
  {
@@ -750,6 +1145,7 @@ var init_postgres = __esm({
750
1145
  "rate_limit_buckets",
751
1146
  {
752
1147
  userId: text("user_id").notNull(),
1148
+ vaultId: text("vault_id").notNull().default(""),
753
1149
  bucket: text("bucket").notNull(),
754
1150
  windowStart: timestamp("window_start", { withTimezone: true }).notNull(),
755
1151
  count: integer("count").notNull().default(0),
@@ -757,7 +1153,7 @@ var init_postgres = __esm({
757
1153
  updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().default(sql`now()`)
758
1154
  },
759
1155
  (table) => [
760
- primaryKey({ columns: [table.userId, table.bucket, table.windowStart] }),
1156
+ primaryKey({ columns: [table.userId, table.vaultId, table.bucket, table.windowStart] }),
761
1157
  // Background reaper / dashboards: enumerate stale rows by `window_start`
762
1158
  // older than the longest live window. The hot path uses the composite PK.
763
1159
  index("rate_limit_buckets_window_idx").on(table.windowStart)
@@ -889,6 +1285,17 @@ var init_postgres = __esm({
889
1285
  },
890
1286
  (table) => [primaryKey({ columns: [table.userId, table.vaultId] })]
891
1287
  );
1288
+ consoleSettingsStore = pgTable(
1289
+ "console_settings",
1290
+ {
1291
+ userId: text("user_id").notNull().default(SINGLE_USER_ID),
1292
+ vaultId: text("vault_id").notNull().default(DEFAULT_VAULT_ID),
1293
+ namespace: text("namespace").notNull(),
1294
+ payload: jsonb("payload").notNull(),
1295
+ updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().default(sql`now()`)
1296
+ },
1297
+ (table) => [primaryKey({ columns: [table.userId, table.vaultId, table.namespace] })]
1298
+ );
892
1299
  wipLedger = pgTable(
893
1300
  "wip_ledger",
894
1301
  {
@@ -901,6 +1308,27 @@ var init_postgres = __esm({
901
1308
  },
902
1309
  (table) => [primaryKey({ columns: [table.vaultId, table.specId] })]
903
1310
  );
1311
+ wipLedgerArchive = pgTable(
1312
+ "wip_ledger_archive",
1313
+ {
1314
+ id: bigserial("id", { mode: "number" }).primaryKey(),
1315
+ vaultId: text("vault_id").notNull().default(DEFAULT_VAULT_ID),
1316
+ specId: text("spec_id").notNull(),
1317
+ /** The full WipEntry blob at the moment it was archived — see `WipEntry` in `src/lib/wip-state.ts`. */
1318
+ payload: jsonb("payload").notNull(),
1319
+ archivedAt: timestamp("archived_at", { withTimezone: true }).notNull().default(sql`now()`)
1320
+ },
1321
+ (table) => [
1322
+ // "newest archived record for this (vault, spec)" — the restore lookup.
1323
+ index("wip_ledger_archive_vault_spec_idx").on(
1324
+ table.vaultId,
1325
+ table.specId,
1326
+ table.archivedAt.desc()
1327
+ ),
1328
+ // The purge cron's own scan: "every row older than a cutoff, any vault".
1329
+ index("wip_ledger_archive_archived_at_idx").on(table.archivedAt)
1330
+ ]
1331
+ );
904
1332
  gatherResults = pgTable(
905
1333
  "gather_results",
906
1334
  {
@@ -918,6 +1346,16 @@ var init_postgres = __esm({
918
1346
  * ([[HS-RIZN-DIL5-DIAG-spexcrawl-search-returns-zero-pages-on]]).
919
1347
  */
920
1348
  search: jsonb("search"),
1349
+ /**
1350
+ * A completed site walk's duplicate/orphan audit — which pages say
1351
+ * exactly the same thing as each other, and which pages nothing else
1352
+ * links to ([[HS-4TNX-ABF9-TASK-carry-the-site-audit-report-from-a]]).
1353
+ * The second job-level report beside `pages`, carried exactly as
1354
+ * `search` is: open shape (the Companion owns the fields), null for a
1355
+ * single-page gather, a research job, and uploads from Companions that
1356
+ * predate the report.
1357
+ */
1358
+ siteAudit: jsonb("site_audit"),
921
1359
  createdAt: timestamp("created_at", { withTimezone: true }).default(sql`now()`)
922
1360
  },
923
1361
  (table) => [primaryKey({ columns: [table.jobId, table.idempotencyKey] })]
@@ -1100,6 +1538,44 @@ var init_postgres = __esm({
1100
1538
  repoRootAbs: text("repo_root_abs"),
1101
1539
  githubRemote: text("github_remote"),
1102
1540
  schemaVersion: integer("schema_version").notNull().default(1),
1541
+ /**
1542
+ * Price plan the vault is on — `free` | `pro` | `enterprise`, the same
1543
+ * vocabulary as `ApiKeyPlan` in `api-key-rate-limit.ts`
1544
+ * ([[HS-JRER-8KK5-TASK-per-vault-plan-tier-tier-limits-table]]). Drives the
1545
+ * per-tier statement timeout / work_mem / rate-limit budgets in
1546
+ * `src/lib/plan-tier.ts` (`PLAN_TIER_LIMITS`, `resolveVaultPlanTier`). How a
1547
+ * vault GETS a tier (billing) is out of scope — the column defaults every
1548
+ * vault to `free` so the most conservative limits apply until a tier is set.
1549
+ * Readers must treat an unknown value as `free`.
1550
+ */
1551
+ planTier: text("plan_tier").notNull().default("free"),
1552
+ /**
1553
+ * Dedicated-database routing key ([[HS-ZUZF-8KK5-TASK-per-vault-database-routing-switch-and]]).
1554
+ *
1555
+ * NULL (the default, and every vault today) = this vault lives in the SHARED
1556
+ * database, i.e. the one `DATABASE_URL` points at. A non-null value is a
1557
+ * short opaque REF — never a connection string and never a secret. The ref
1558
+ * NAMES where the credential lives: environment variable
1559
+ * `HEYSPEX_DEDICATED_DB_<REF>` (uppercased, non-alphanumerics → `_`), or
1560
+ * `dedicated_databases.<ref>` in `~/.heyspex/credentials.yaml`. Resolution
1561
+ * and the per-ref pool memo live in `src/lib/db/clients/vault-router.ts`;
1562
+ * `scripts/migrate.mjs` migrates every ref listed in `DEDICATED_DATABASE_REFS`
1563
+ * alongside the shared database.
1564
+ *
1565
+ * The `vaults` table itself ALWAYS stays on the shared control plane — the
1566
+ * router reads this column through the admin connection, so the row that says
1567
+ * "vault X is elsewhere" is never itself elsewhere.
1568
+ */
1569
+ databaseRef: text("database_ref"),
1570
+ /**
1571
+ * Which lexical-search substrate this vault's queries use — `gin` (the
1572
+ * built-in `search_vector` GIN index shipped in migration 0068) or `bm25`
1573
+ * (ParadeDB `pg_search`, evaluated by
1574
+ * [[HS-KZJ1-8KK5-TASK-bm25-pg-search-index-and-query-path]]). Declared here
1575
+ * so the BM25 task is a per-vault flag flip rather than a global cutover;
1576
+ * nothing reads it yet. Readers must treat an unknown value as `gin`.
1577
+ */
1578
+ searchEngine: text("search_engine").notNull().default("gin"),
1103
1579
  createdAt: timestamp("created_at", { withTimezone: true }).notNull().default(sql`now()`),
1104
1580
  updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().default(sql`now()`),
1105
1581
  /** Vault equivalent of `workspaces.archived` — see that column's doc comment. */
@@ -1131,6 +1607,22 @@ var init_postgres = __esm({
1131
1607
  },
1132
1608
  (table) => [primaryKey({ columns: [table.vaultId, table.accountId] })]
1133
1609
  );
1610
+ pendingVaultGrants = pgTable(
1611
+ "pending_vault_grants",
1612
+ {
1613
+ id: text("id").primaryKey(),
1614
+ vaultId: text("vault_id").notNull().references(() => vaults.id, { onDelete: "cascade" }),
1615
+ email: text("email").notNull(),
1616
+ access: text("access").notNull().default("read"),
1617
+ invitedBy: text("invited_by").notNull(),
1618
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().default(sql`now()`),
1619
+ fulfilledAt: timestamp("fulfilled_at", { withTimezone: true }),
1620
+ fulfilledAccountId: text("fulfilled_account_id")
1621
+ },
1622
+ (table) => [
1623
+ uniqueIndex("pending_vault_grants_vault_id_email_idx").on(table.vaultId, table.email)
1624
+ ]
1625
+ );
1134
1626
  githubInstallations = pgTable(
1135
1627
  "github_installations",
1136
1628
  {
@@ -1361,6 +1853,31 @@ var init_postgres = __esm({
1361
1853
  // new | invited | declined
1362
1854
  /** Clerk invitation id, set when an approval actually issues one. */
1363
1855
  clerkInvitationId: text("clerk_invitation_id"),
1856
+ /**
1857
+ * The Clerk invitation's accept URL, stored alongside the id at approve
1858
+ * time so the admin surface can show/copy/resend it without a live Clerk
1859
+ * round trip for every render
1860
+ * ([[HS-7DKO-JQ64-TASK-approve-sends-the-customer-a-resend]]).
1861
+ */
1862
+ clerkInvitationUrl: text("clerk_invitation_url"),
1863
+ /** When the first-party approval email (Resend) was confirmed sent. Null until it succeeds. */
1864
+ approvalEmailSentAt: timestamp("approval_email_sent_at", { withTimezone: true }),
1865
+ /** Why the approval email failed or was skipped — never blocks approval, just recorded. */
1866
+ approvalEmailError: text("approval_email_error"),
1867
+ /**
1868
+ * Resend's message id for the approval email currently on the row — the
1869
+ * key a later `POST /api/webhooks/resend` delivery matches back to this
1870
+ * row ([[HS-HUK3-JQ64-TASK-approval-email-delivery-proof-store-the]]).
1871
+ * Reset (and re-set) on every approve/resend, since each send mints a new
1872
+ * Resend message id.
1873
+ */
1874
+ approvalEmailId: text("approval_email_id"),
1875
+ /** Resend delivery-webhook outcome: sent | delayed | delivered | bounced | complained. Null until a webhook arrives. */
1876
+ approvalEmailDeliveryStatus: text("approval_email_delivery_status"),
1877
+ /** When the most recently applied delivery-webhook event was recorded. */
1878
+ approvalEmailDeliveryAt: timestamp("approval_email_delivery_at", { withTimezone: true }),
1879
+ /** Bounce/complaint detail from the webhook payload (e.g. the bounce message). */
1880
+ approvalEmailDeliveryDetail: text("approval_email_delivery_detail"),
1364
1881
  /** Account id of the admin who approved/declined — audit trail for the decision. */
1365
1882
  reviewedBy: text("reviewed_by"),
1366
1883
  reviewedAt: timestamp("reviewed_at", { withTimezone: true }),
@@ -1480,130 +1997,116 @@ var init_postgres = __esm({
1480
1997
  }
1481
1998
  });
1482
1999
 
1483
- // spex-console/src/lib/vault-registry/neon-probe.ts
1484
- function withTimeout(promise, ms, label) {
1485
- return new Promise((resolve, reject) => {
1486
- const timer = setTimeout(() => reject(new NeonProbeTimeoutError(label, ms)), ms);
1487
- promise.then(
1488
- (value) => {
1489
- clearTimeout(timer);
1490
- resolve(value);
1491
- },
1492
- (err) => {
1493
- clearTimeout(timer);
1494
- reject(err instanceof Error ? err : new Error(String(err)));
1495
- }
1496
- );
1497
- });
1498
- }
1499
- function sleep(ms) {
1500
- return new Promise((resolve) => setTimeout(resolve, ms));
1501
- }
1502
- function emitProbeFailure(options, failure) {
2000
+ // spex-console/bin/hs-workspace.ts
2001
+ import { readFileSync as readFileSync9 } from "node:fs";
2002
+ import path3 from "node:path";
2003
+ import process3 from "node:process";
2004
+ import { createInterface as createInterface2 } from "node:readline/promises";
2005
+
2006
+ // spex-console/src/lib/cli/control-shared.ts
2007
+ import path from "node:path";
2008
+
2009
+ // scripts/lib/hs-command.mjs
2010
+ import { existsSync } from "node:fs";
2011
+ import { delimiter, dirname, join } from "node:path";
2012
+ import { fileURLToPath } from "node:url";
2013
+ var HS_BIN_NAME = "hs";
2014
+ var HS_DISPATCHER_PATH = (() => {
1503
2015
  try {
1504
- options?.onFailure?.(failure);
2016
+ const here = dirname(fileURLToPath(import.meta.url));
2017
+ return join(dirname(dirname(here)), "bin", "hs.mjs");
1505
2018
  } catch {
2019
+ return null;
1506
2020
  }
2021
+ })();
2022
+ function shellQuote(p) {
2023
+ return /[\\\s]/.test(p) ? `"${p}"` : p;
1507
2024
  }
1508
- async function runNeonReachabilityProbe(attempt, options = {}, label = "Neon reachability probe") {
1509
- const timeoutMs = options.timeoutMs ?? NEON_PROBE_TIMEOUT_MS;
1510
- const backoffMs = options.backoffMs ?? [];
1511
- const deadlineMs = options.deadlineMs ?? Number.POSITIVE_INFINITY;
1512
- const attempts = backoffMs.length + 1;
1513
- let startedAt = Date.now();
1514
- let suspensions = 0;
1515
- for (let i = 0; i < attempts; i++) {
1516
- const attemptStartedAt = Date.now();
1517
- try {
1518
- await withTimeout(Promise.resolve().then(attempt), timeoutMs, label);
1519
- return true;
1520
- } catch (err) {
1521
- const elapsedMs = Date.now() - attemptStartedAt;
1522
- const suspended = elapsedMs >= timeoutMs * SUSPENSION_ELAPSED_FACTOR && elapsedMs - timeoutMs >= SUSPENSION_MIN_OVERSHOOT_MS;
1523
- if (suspended && suspensions < MAX_SUSPENSION_RETRIES) {
1524
- suspensions += 1;
1525
- emitProbeFailure(options, {
1526
- reason: "suspended",
1527
- attempt: i + 1,
1528
- attempts,
1529
- timeoutMs,
1530
- elapsedMs,
1531
- willRetry: true,
1532
- error: err
1533
- });
1534
- startedAt = Date.now();
1535
- i -= 1;
1536
- continue;
1537
- }
1538
- const isLast = i === attempts - 1;
1539
- const pastDeadline = Date.now() - startedAt >= deadlineMs;
1540
- const willRetry = !isLast && !pastDeadline;
1541
- emitProbeFailure(options, {
1542
- reason: err instanceof NeonProbeTimeoutError ? "timeout" : "error",
1543
- attempt: i + 1,
1544
- attempts,
1545
- timeoutMs,
1546
- elapsedMs,
1547
- willRetry,
1548
- error: err
1549
- });
1550
- if (!willRetry) return false;
1551
- await sleep(backoffMs[i]);
1552
- }
1553
- }
1554
- return false;
2025
+ var HS_DISPATCHER_PREFIX = HS_DISPATCHER_PATH ? `node ${shellQuote(HS_DISPATCHER_PATH)}` : HS_BIN_NAME;
2026
+
2027
+ // vendor/heyspex-mcp/console-fetch.mjs
2028
+ init_credentials();
2029
+ import { readFileSync as readFileSync4 } from "node:fs";
2030
+ import { homedir as homedir4 } from "node:os";
2031
+ import { join as join5 } from "node:path";
2032
+
2033
+ // vendor/heyspex-mcp/session-owner.mjs
2034
+ init_credentials();
2035
+ import { readFileSync as readFileSync2 } from "node:fs";
2036
+ import { homedir as homedir2 } from "node:os";
2037
+ import { join as join3 } from "node:path";
2038
+ function resolveSessionOwnerUncached({ env = process.env, homeDir = homedir2(), readFile: readFile2 } = {}) {
2039
+ const fromEnv = typeof env?.HS_ACCOUNT_ID === "string" ? env.HS_ACCOUNT_ID.trim() : "";
2040
+ if (fromEnv) return fromEnv;
2041
+ return readCurrentAccountId({ homeDir, readFile: readFile2 });
1555
2042
  }
1556
- var NEON_PROBE_TIMEOUT_MS, SUSPENSION_ELAPSED_FACTOR, SUSPENSION_MIN_OVERSHOOT_MS, MAX_SUSPENSION_RETRIES, NeonProbeTimeoutError;
1557
- var init_neon_probe = __esm({
1558
- "spex-console/src/lib/vault-registry/neon-probe.ts"() {
1559
- "use strict";
1560
- NEON_PROBE_TIMEOUT_MS = 1500;
1561
- SUSPENSION_ELAPSED_FACTOR = 3;
1562
- SUSPENSION_MIN_OVERSHOOT_MS = 5e3;
1563
- MAX_SUSPENSION_RETRIES = 2;
1564
- NeonProbeTimeoutError = class _NeonProbeTimeoutError extends Error {
1565
- timeoutMs;
1566
- constructor(label, timeoutMs) {
1567
- super(`${label} timed out after ${timeoutMs}ms`);
1568
- this.name = "NeonProbeTimeoutError";
1569
- this.timeoutMs = timeoutMs;
1570
- Object.setPrototypeOf(this, _NeonProbeTimeoutError.prototype);
1571
- }
1572
- };
1573
- }
1574
- });
2043
+
2044
+ // vendor/heyspex-mcp/vault-less-endpoints.mjs
2045
+ var VAULT_LESS_ENDPOINTS = Object.freeze([
2046
+ "* /api/mcp/session",
2047
+ "* /api/mcp/pairing-codes",
2048
+ "POST /api/vaults",
2049
+ "GET /api/vaults",
2050
+ "POST /api/workspaces",
2051
+ "GET /api/workspaces",
2052
+ "* /api/admin/dev-mode"
2053
+ ]);
2054
+ function pathnameOf(path4) {
2055
+ const raw = String(path4 ?? "").split("?")[0].split("#")[0];
2056
+ const noTrailing = raw.length > 1 ? raw.replace(/\/+$/, "") : raw;
2057
+ return noTrailing || "/";
2058
+ }
2059
+ function isVaultLessEndpoint(method, path4) {
2060
+ const m = String(method || "GET").toUpperCase();
2061
+ const pathname = pathnameOf(path4);
2062
+ return VAULT_LESS_ENDPOINTS.some((row) => {
2063
+ const [rowMethod, rowPath] = row.split(" ");
2064
+ return rowPath === pathname && (rowMethod === "*" || rowMethod === m);
2065
+ });
2066
+ }
2067
+
2068
+ // vendor/heyspex-mcp/vault-resolve.mjs
2069
+ import { existsSync as existsSync2, readFileSync as readFileSync3 } from "node:fs";
2070
+ import { homedir as homedir3, tmpdir } from "node:os";
2071
+ import { dirname as dirname3, join as join4, resolve as resolve2 } from "node:path";
2072
+
2073
+ // vendor/heyspex-mcp/console-fetch.mjs
2074
+ var SCOPE_REFUSAL_CODES = Object.freeze(["VAULT_SCOPE_MISMATCH", "ACCOUNT_MISMATCH", "VAULT_NOT_REGISTERED"]);
2075
+
2076
+ // spex-console/src/lib/cli/control-shared.ts
2077
+ init_credentials();
2078
+
2079
+ // spex-console/src/lib/vault-registry/registry-file.ts
2080
+ import { existsSync as existsSync3, mkdirSync as mkdirSync2, readFileSync as readFileSync5, renameSync as renameSync2, writeFileSync as writeFileSync2 } from "node:fs";
2081
+ import { homedir as homedir5 } from "node:os";
2082
+ import { dirname as dirname4, join as join6, resolve as resolve3 } from "node:path";
1575
2083
 
1576
2084
  // spex-console/src/lib/vault-registry/types.ts
2085
+ var LEGACY_REGISTRY_ACCOUNT_ID = "legacy";
2086
+ var VAULT_REGISTRY_SCHEMA_VERSION = 2;
1577
2087
  function emptyVaultRegistry() {
1578
2088
  return {
1579
2089
  schema_version: VAULT_REGISTRY_SCHEMA_VERSION,
2090
+ current_account_id: null,
2091
+ accounts: {},
1580
2092
  workspaces: [],
1581
- vaults: [],
1582
- active_workspace_id: null,
1583
- active_vault_id: null
2093
+ vaults: []
1584
2094
  };
1585
2095
  }
1586
- var VAULT_REGISTRY_SCHEMA_VERSION;
1587
- var init_types = __esm({
1588
- "spex-console/src/lib/vault-registry/types.ts"() {
1589
- "use strict";
1590
- VAULT_REGISTRY_SCHEMA_VERSION = 1;
1591
- }
1592
- });
1593
2096
 
1594
2097
  // spex-console/src/lib/vault-registry/registry-file.ts
1595
- import { existsSync, mkdirSync, readFileSync as readFileSync4, renameSync, writeFileSync } from "node:fs";
1596
- import { homedir as homedir3 } from "node:os";
1597
- import { dirname, join as join3 } from "node:path";
1598
2098
  function vaultRegistryDir(opts = {}) {
1599
- return opts.registryDir ?? join3(homedir3(), ".heyspex");
2099
+ return opts.registryDir ?? join6(homedir5(), ".heyspex");
1600
2100
  }
1601
2101
  function vaultRegistryFilePath(opts = {}) {
1602
- return join3(vaultRegistryDir(opts), "registry.json");
2102
+ return join6(vaultRegistryDir(opts), "registry.json");
1603
2103
  }
1604
2104
  function isNonEmptyString(v) {
1605
2105
  return typeof v === "string" && v.trim().length > 0;
1606
2106
  }
2107
+ function accountOf(o) {
2108
+ return isNonEmptyString(o.account_id) ? o.account_id : LEGACY_REGISTRY_ACCOUNT_ID;
2109
+ }
1607
2110
  function normalizeWorkspace(raw) {
1608
2111
  if (!raw || typeof raw !== "object") return null;
1609
2112
  const o = raw;
@@ -1614,7 +2117,8 @@ function normalizeWorkspace(raw) {
1614
2117
  id: o.id,
1615
2118
  display_name: o.display_name,
1616
2119
  created_at: o.created_at,
1617
- archived: o.archived === true
2120
+ archived: o.archived === true,
2121
+ account_id: accountOf(o)
1618
2122
  };
1619
2123
  if (isNonEmptyString(o.github_org)) record.github_org = o.github_org;
1620
2124
  return record;
@@ -1635,57 +2139,161 @@ function normalizeVault(raw) {
1635
2139
  repo_root_abs: o.repo_root_abs,
1636
2140
  schema_version: o.schema_version,
1637
2141
  registered_at: o.registered_at,
1638
- archived: o.archived === true
2142
+ archived: o.archived === true,
2143
+ account_id: accountOf(o)
1639
2144
  };
1640
2145
  if (isNonEmptyString(o.github_remote)) record.github_remote = o.github_remote;
1641
2146
  return record;
1642
2147
  }
2148
+ function normalizeStoredPointers(raw) {
2149
+ const o = raw && typeof raw === "object" ? raw : {};
2150
+ const out = {};
2151
+ for (const field of ["active_vault_id", "active_workspace_id"]) {
2152
+ const v = o[field];
2153
+ if (v === null || isNonEmptyString(v)) out[field] = v;
2154
+ }
2155
+ return out;
2156
+ }
2157
+ function normalizePointers(raw) {
2158
+ const o = raw && typeof raw === "object" ? raw : {};
2159
+ return {
2160
+ active_vault_id: isNonEmptyString(o.active_vault_id) ? o.active_vault_id : null,
2161
+ active_workspace_id: isNonEmptyString(o.active_workspace_id) ? o.active_workspace_id : null
2162
+ };
2163
+ }
2164
+ function registryFileSchemaOf(json) {
2165
+ if (!json || typeof json !== "object" || Array.isArray(json)) return null;
2166
+ return json.schema_version === 2 ? 2 : 1;
2167
+ }
1643
2168
  function parseVaultRegistryFile(json) {
1644
- const fallback = emptyVaultRegistry();
1645
- if (!json || typeof json !== "object") return fallback;
2169
+ const schema = registryFileSchemaOf(json);
2170
+ if (schema === null) return emptyVaultRegistry();
1646
2171
  const o = json;
1647
2172
  const workspacesRaw = Array.isArray(o.workspaces) ? o.workspaces : [];
1648
2173
  const workspaces2 = workspacesRaw.map(normalizeWorkspace).filter((w) => w !== null);
1649
2174
  const vaultsRaw = Array.isArray(o.vaults) ? o.vaults : [];
1650
2175
  const vaults2 = vaultsRaw.map(normalizeVault).filter((v) => v !== null);
1651
- const active_workspace_id = isNonEmptyString(o.active_workspace_id) ? o.active_workspace_id : null;
1652
- const active_vault_id = isNonEmptyString(o.active_vault_id) ? o.active_vault_id : null;
2176
+ if (schema === 1) {
2177
+ const legacy = normalizePointers(o);
2178
+ const hasLegacyState = legacy.active_vault_id !== null || legacy.active_workspace_id !== null || workspaces2.length > 0 || vaults2.length > 0;
2179
+ return {
2180
+ schema_version: VAULT_REGISTRY_SCHEMA_VERSION,
2181
+ current_account_id: hasLegacyState ? LEGACY_REGISTRY_ACCOUNT_ID : null,
2182
+ accounts: hasLegacyState ? { [LEGACY_REGISTRY_ACCOUNT_ID]: legacy } : {},
2183
+ workspaces: workspaces2,
2184
+ vaults: vaults2
2185
+ };
2186
+ }
2187
+ const accounts = {};
2188
+ if (o.accounts && typeof o.accounts === "object" && !Array.isArray(o.accounts)) {
2189
+ for (const [id, pointers] of Object.entries(o.accounts)) {
2190
+ if (isNonEmptyString(id)) accounts[id] = normalizeStoredPointers(pointers);
2191
+ }
2192
+ }
1653
2193
  return {
1654
2194
  schema_version: VAULT_REGISTRY_SCHEMA_VERSION,
2195
+ current_account_id: isNonEmptyString(o.current_account_id) ? o.current_account_id : null,
2196
+ accounts,
1655
2197
  workspaces: workspaces2,
1656
- vaults: vaults2,
1657
- active_workspace_id,
1658
- active_vault_id
2198
+ vaults: vaults2
1659
2199
  };
1660
2200
  }
1661
- function loadVaultRegistry(opts = {}) {
2201
+ function readRegistryJson(opts) {
1662
2202
  const filePath = vaultRegistryFilePath(opts);
1663
- if (!existsSync(filePath)) return emptyVaultRegistry();
2203
+ if (!existsSync3(filePath)) return { kind: "missing" };
1664
2204
  try {
1665
- const raw = readFileSync4(filePath, "utf8");
1666
- return parseVaultRegistryFile(JSON.parse(raw));
2205
+ const raw = readFileSync5(filePath, "utf8");
2206
+ const json = JSON.parse(raw);
2207
+ if (registryFileSchemaOf(json) === null) return { kind: "unreadable" };
2208
+ return { kind: "ok", raw, json };
1667
2209
  } catch {
1668
- return emptyVaultRegistry();
2210
+ return { kind: "unreadable" };
1669
2211
  }
1670
2212
  }
1671
- function writeVaultRegistry(registry, opts = {}) {
1672
- const filePath = vaultRegistryFilePath(opts);
1673
- mkdirSync(dirname(filePath), { recursive: true });
1674
- const tmpPath = `${filePath}.tmp`;
1675
- writeFileSync(tmpPath, `${JSON.stringify(registry, null, 2)}
1676
- `, "utf8");
1677
- renameSync(tmpPath, filePath);
2213
+ function inspectVaultRegistry(opts = {}) {
2214
+ const read = readRegistryJson(opts);
2215
+ if (read.kind !== "ok") {
2216
+ return {
2217
+ registry: emptyVaultRegistry(),
2218
+ file_schema_version: null,
2219
+ registry_unreadable: read.kind === "unreadable"
2220
+ };
2221
+ }
2222
+ return {
2223
+ registry: parseVaultRegistryFile(read.json),
2224
+ file_schema_version: registryFileSchemaOf(read.json),
2225
+ registry_unreadable: false
2226
+ };
1678
2227
  }
1679
- function isValidId(id) {
1680
- if (!isNonEmptyString(id)) return false;
1681
- if (id.includes("/") || id.includes("\\")) return false;
1682
- if (id === "." || id === "..") return false;
2228
+ function loadVaultRegistry(opts = {}) {
2229
+ return inspectVaultRegistry(opts).registry;
2230
+ }
2231
+ function ownsRegistryRows(registry, accountId2) {
2232
+ return registry.vaults.some((v) => v.account_id === accountId2) || registry.workspaces.some((w) => w.account_id === accountId2);
2233
+ }
2234
+ function accountPointersOf(registry, accountId2) {
2235
+ const own = accountId2 ? registry.accounts[accountId2] : void 0;
2236
+ const legacy = registry.accounts[LEGACY_REGISTRY_ACCOUNT_ID];
2237
+ const clearsCount = Boolean(accountId2) && ownsRegistryRows(registry, accountId2);
2238
+ const pick = (field) => {
2239
+ const mine = own?.[field];
2240
+ if (isNonEmptyString(mine)) return mine;
2241
+ if (mine === null && clearsCount && accountId2 !== LEGACY_REGISTRY_ACCOUNT_ID) return null;
2242
+ const fallback = legacy?.[field];
2243
+ return isNonEmptyString(fallback) ? fallback : null;
2244
+ };
2245
+ return {
2246
+ active_vault_id: pick("active_vault_id"),
2247
+ active_workspace_id: pick("active_workspace_id")
2248
+ };
2249
+ }
2250
+ function serializeVaultRegistry(registry) {
2251
+ return `${JSON.stringify(registry, null, 2)}
2252
+ `;
2253
+ }
2254
+ function underTestRunner() {
2255
+ return Boolean(process.env.VITEST) || Boolean(process.env.NODE_TEST_CONTEXT);
2256
+ }
2257
+ function samePathLoose(a, b) {
2258
+ const norm = (p) => {
2259
+ const r = resolve3(p).replace(/[\\/]+$/, "");
2260
+ return process.platform === "win32" ? r.toLowerCase() : r;
2261
+ };
2262
+ return norm(a) === norm(b);
2263
+ }
2264
+ function assertNotRealHomeRegistryUnderTest(filePath) {
2265
+ if (!underTestRunner()) return;
2266
+ if (samePathLoose(dirname4(filePath), join6(homedir5(), ".heyspex"))) {
2267
+ throw new Error(
2268
+ `registry-file: refusing to write ${filePath} under a test runner \u2014 that is the real ~/.heyspex registry. Pass opts.registryDir (a temp dir).`
2269
+ );
2270
+ }
2271
+ }
2272
+ function writeVaultRegistry(registry, opts = {}) {
2273
+ const filePath = vaultRegistryFilePath(opts);
2274
+ assertNotRealHomeRegistryUnderTest(filePath);
2275
+ mkdirSync2(dirname4(filePath), { recursive: true });
2276
+ const tmpPath = `${filePath}.tmp`;
2277
+ writeFileSync2(tmpPath, serializeVaultRegistry(registry), "utf8");
2278
+ renameSync2(tmpPath, filePath);
2279
+ }
2280
+ function isValidId(id) {
2281
+ if (!isNonEmptyString(id)) return false;
2282
+ if (id.includes("/") || id.includes("\\")) return false;
2283
+ if (id === "." || id === "..") return false;
1683
2284
  return true;
1684
2285
  }
2286
+ function requireAccountId(fn, accountId2) {
2287
+ if (!isNonEmptyString(accountId2)) {
2288
+ throw new Error(`${fn}: account_id is required`);
2289
+ }
2290
+ return accountId2;
2291
+ }
1685
2292
  function registerWorkspace(record, opts = {}) {
1686
2293
  if (!isValidId(record.id)) {
1687
2294
  throw new Error(`registerWorkspace: invalid workspace id "${record.id}"`);
1688
2295
  }
2296
+ requireAccountId("registerWorkspace", record.account_id);
1689
2297
  const registry = loadVaultRegistry(opts);
1690
2298
  const idx = registry.workspaces.findIndex((w) => w.id === record.id);
1691
2299
  if (idx >= 0) {
@@ -1696,30 +2304,927 @@ function registerWorkspace(record, opts = {}) {
1696
2304
  writeVaultRegistry(registry, opts);
1697
2305
  return registry;
1698
2306
  }
1699
- function setActiveWorkspaceId(workspaceId, opts = {}) {
1700
- const registry = loadVaultRegistry(opts);
1701
- registry.active_workspace_id = workspaceId;
1702
- writeVaultRegistry(registry, opts);
1703
- return registry;
2307
+ function writeAccountPointer(fn, accountId2, field, value, opts) {
2308
+ requireAccountId(fn, accountId2);
2309
+ if (value !== null && !isNonEmptyString(value)) {
2310
+ throw new Error(`${fn}: ${field} must be a non-empty string or null`);
2311
+ }
2312
+ if (opts === null || typeof opts !== "object") {
2313
+ throw new Error(`${fn}: opts must be an object`);
2314
+ }
2315
+ const registry = loadVaultRegistry(opts);
2316
+ const row = { ...registry.accounts[accountId2] ?? {}, [field]: value };
2317
+ registry.accounts = { ...registry.accounts, [accountId2]: row };
2318
+ if (registry.current_account_id === null || registry.current_account_id === LEGACY_REGISTRY_ACCOUNT_ID && accountId2 !== LEGACY_REGISTRY_ACCOUNT_ID) {
2319
+ registry.current_account_id = accountId2;
2320
+ }
2321
+ writeVaultRegistry(registry, opts);
2322
+ return registry;
2323
+ }
2324
+ function setActiveWorkspaceId(accountId2, workspaceId, opts = {}) {
2325
+ return writeAccountPointer(
2326
+ "setActiveWorkspaceId",
2327
+ accountId2,
2328
+ "active_workspace_id",
2329
+ workspaceId,
2330
+ opts
2331
+ );
2332
+ }
2333
+ function clearPointerEverywhere(registry, field, id) {
2334
+ for (const [accountId2, pointers] of Object.entries(registry.accounts)) {
2335
+ if (pointers[field] === id) registry.accounts[accountId2] = { ...pointers, [field]: null };
2336
+ }
2337
+ }
2338
+ function removeWorkspace(id, opts = {}) {
2339
+ const registry = loadVaultRegistry(opts);
2340
+ registry.workspaces = registry.workspaces.filter((w) => w.id !== id);
2341
+ clearPointerEverywhere(registry, "active_workspace_id", id);
2342
+ writeVaultRegistry(registry, opts);
2343
+ return registry;
2344
+ }
2345
+
2346
+ // spex-console/src/lib/cli/control-shared.ts
2347
+ var EXIT = {
2348
+ /** Success. */
2349
+ OK: 0,
2350
+ /**
2351
+ * A read-only CHECK ran correctly and its answer is "no" — `hs connect
2352
+ * --check` on an unconnected repo. Distinct from {@link EXIT.USAGE} so a
2353
+ * script can tell "you typed the command wrong" from "the command worked,
2354
+ * the repo is not connected". Both were `1` until 2026-09-18, which made
2355
+ * `--check` unusable in a conditional: every caller had to parse stdout to
2356
+ * find out which of the two had happened.
2357
+ */
2358
+ CHECK_FAILED: 2,
2359
+ /** Usage error — unknown subcommand/flag, or a missing required argument. */
2360
+ USAGE: 1,
2361
+ /** Claim race lost — the spec is already claimed by another agent (409). */
2362
+ CONFLICT: 9,
2363
+ /** Target not found — release of an unheld claim, etc. (404). */
2364
+ NOT_FOUND: 10,
2365
+ /** Invalid argument — bad `--ttl`, unknown `--kind`, unparseable date. */
2366
+ INVALID_ARG: 11,
2367
+ /** SpexConsole unreachable (network error reaching the dev server). */
2368
+ CONSOLE_UNREACHABLE: 12,
2369
+ /** Server failure — a non-OK response that isn't 400/402/404/409. */
2370
+ WRITE_FAILED: 13,
2371
+ /** Neon control plane absent — the endpoint returned 402 (upgrade required). */
2372
+ NEON_REQUIRED: 14,
2373
+ /**
2374
+ * A cross-account `--account <id>` override was refused — the caller is
2375
+ * not a builder/admin (`resolveScopedAccountId`'s `forbidden_account_override`,
2376
+ * [[HS-X3GB-EKLJ-TASK-hs-cli-account-flag-for-cross-account]]). Distinct
2377
+ * from `USAGE`/`INVALID_ARG` — this is an authorization refusal, not a
2378
+ * malformed command.
2379
+ */
2380
+ FORBIDDEN: 15,
2381
+ /**
2382
+ * A CLOUD write refused because no account id could be resolved (no
2383
+ * `HS_ACCOUNT_ID` env var, no `account_id:` in `~/.heyspex/config.yaml`) --
2384
+ * `resolveCloudAccountIdOrRefuse` in `vault-workspace-shared.ts`
2385
+ * ([[HS-YU1S-UAGY-TASK-cloud-writes-refuse-without-a]]). Distinct from
2386
+ * `FORBIDDEN` -- this is "not signed in at all", not "signed in but not
2387
+ * authorized for this override".
2388
+ */
2389
+ SIGN_IN_REQUIRED: 16,
2390
+ /**
2391
+ * Signed in, but the account a repo would be attached to was not confirmed:
2392
+ * a non-interactive install/connect with no matching `--account` / `--yes`,
2393
+ * an `account_id` that disagrees with the bearer's user, or the human
2394
+ * aborting the identity prompt (`install-identity.ts`,
2395
+ * [[HS-HAQI-KODY-DIAG-hs-install-and-hs-connect-bind-a-new]]).
2396
+ */
2397
+ IDENTITY_UNCONFIRMED: 17,
2398
+ /**
2399
+ * The repo this verb runs in is not registered on this machine (no
2400
+ * `~/.heyspex/registry.json` row for its root, or a legacy slug row with no
2401
+ * cloud `vault_id` in its heyspex.yaml), so the call was refused LOCALLY —
2402
+ * nothing was sent. Names `hs connect`
2403
+ * ([[HS-MVG4-WW9R-TASK-mcp-and-hs-name-account-vault-on-every]]).
2404
+ */
2405
+ VAULT_NOT_REGISTERED: 18,
2406
+ /**
2407
+ * The local half of a setup verb (`hs connect`, `hs vault *`, `hs workspace
2408
+ * *`) was written but the CLOUD half did not land -- the console refused
2409
+ * it or was never reached. The verb's receipt names the failed half and the
2410
+ * exact command to re-run
2411
+ * ([[HS-STCY-WW9R-TASK-add-a-shared-receipt-that-reads-back]], invariant 6).
2412
+ * Distinct from {@link EXIT.WRITE_FAILED} so a script can tell "re-run to
2413
+ * sync the cloud" from a generic server failure.
2414
+ */
2415
+ CLOUD_WRITE_FAILED: 19,
2416
+ /**
2417
+ * The repo's committed `heyspex.yaml` records `account: <A>` but the machine
2418
+ * is signed in as another account, so `hs connect` refused and wrote
2419
+ * nothing (marker, registry, cloud). Repair: `hs login --switch-account <A>`
2420
+ * ([[HS-VWUD-WW9R-TASK-record-the-connecting-account-in]], invariant 4).
2421
+ */
2422
+ ACCOUNT_MISMATCH: 20,
2423
+ /**
2424
+ * Both sides of a setup verb's receipt were read back and still DISAGREE
2425
+ * after a short bounded re-read of the cloud side (a briefly stale read is
2426
+ * absorbed first). The write may well have landed; what the verb cannot
2427
+ * claim is that this machine and the console now say the same thing. The
2428
+ * receipt prints the diffs and the command to re-run
2429
+ * ([[HS-FKSR-WW9R-TASK-make-one-hs-connect-run-create-a-cloud]], invariant 6).
2430
+ * Distinct from {@link EXIT.CLOUD_WRITE_FAILED} (a write that did not land).
2431
+ */
2432
+ RECEIPT_DISAGREE: 21
2433
+ };
2434
+ var VAULT_ID_HEADER = "x-heyspex-vault-id";
2435
+ var ACCOUNT_ID_HEADER = "x-heyspex-account-id";
2436
+ function readPairedBearer(opts = {}) {
2437
+ return readBearer(opts);
2438
+ }
2439
+ function readPairedToken(opts = {}) {
2440
+ return readPairedBearer(opts).token ?? void 0;
2441
+ }
2442
+ function scopeHeadersFor(scope, method, path4) {
2443
+ if (scope === "none") {
2444
+ if (!isVaultLessEndpoint(method, path4)) {
2445
+ throw new Error(
2446
+ `controlRequest: scope "none" is only allowed for the vault-less endpoints (${VAULT_LESS_ENDPOINTS.join(", ")}); ${method.toUpperCase()} ${path4} is vault-scoped and must name { accountId, vaultId }.`
2447
+ );
2448
+ }
2449
+ return {};
2450
+ }
2451
+ return { [VAULT_ID_HEADER]: scope.vaultId, [ACCOUNT_ID_HEADER]: scope.accountId };
2452
+ }
2453
+ function cloudRequestScope(scope) {
2454
+ const named = { accountId: scope.accountId, vaultId: scope.vaultId };
2455
+ return {
2456
+ ...named,
2457
+ headersFor: (method, path4) => isVaultLessEndpoint(method, path4) ? {} : scopeHeadersFor(named, method, path4)
2458
+ };
2459
+ }
2460
+ function withCloudScope(deps, accountId2, vaultId) {
2461
+ if (!deps?.http || !vaultId || !accountId2) return deps;
2462
+ return { ...deps, http: { ...deps.http, scope: cloudRequestScope({ accountId: accountId2, vaultId }) } };
2463
+ }
2464
+
2465
+ // scripts/lib/console-url-resolver.mjs
2466
+ init_dev_mode();
2467
+ import { readFileSync as readFileSync7 } from "node:fs";
2468
+ import { homedir as homedir7 } from "node:os";
2469
+ import { join as join8 } from "node:path";
2470
+ var DEFAULT_CONSOLE_URL = LOCAL_CONSOLE_URL;
2471
+ function trimTrailingSlash(url) {
2472
+ return url.replace(/\/+$/, "");
2473
+ }
2474
+ var LOOPBACK_HOSTS = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "::1", "[::1]", "0.0.0.0"]);
2475
+ function isLoopbackConsoleUrl(url) {
2476
+ if (typeof url !== "string" || !url) return false;
2477
+ try {
2478
+ return LOOPBACK_HOSTS.has(new URL(url).hostname.toLowerCase());
2479
+ } catch {
2480
+ return false;
2481
+ }
2482
+ }
2483
+ var staleLoopbackCredentialsWarned = false;
2484
+ function warnStaleLoopbackCredentialsOnce(credentialsUrl, derivedUrl, { log }) {
2485
+ if (staleLoopbackCredentialsWarned) return;
2486
+ staleLoopbackCredentialsWarned = true;
2487
+ try {
2488
+ log(
2489
+ `[heyspex] ignoring console_url ${credentialsUrl} from ~/.heyspex/credentials.yaml: the resolved reliance is cloud, and a loopback console cannot serve it \u2014 using ${derivedUrl} (set HEYSPEX_CONSOLE_URL to override explicitly, or run \`hs dev mode:local\` to go back to a local console).
2490
+ `
2491
+ );
2492
+ } catch {
2493
+ }
2494
+ }
2495
+ function readCredentialsConsoleUrl({ homeDir = homedir7(), readFile: readFile2 = defaultReadFile2 } = {}) {
2496
+ try {
2497
+ const raw = readFile2(join8(homeDir, ".heyspex", "credentials.yaml"));
2498
+ const match = String(raw ?? "").match(/^console_url:\s*["']?([^"'\r\n#]+)/m);
2499
+ const value = match?.[1]?.trim();
2500
+ return value && value.length > 0 ? trimTrailingSlash(value) : null;
2501
+ } catch {
2502
+ return null;
2503
+ }
2504
+ }
2505
+ function defaultReadFile2(p) {
2506
+ return readFileSync7(p, "utf8");
2507
+ }
2508
+ function readDiscoveryFileConsoleUrl(repoRoot, { readDiscovery = defaultReadFile2 } = {}) {
2509
+ if (!repoRoot) return null;
2510
+ try {
2511
+ const discovery = String(readDiscovery(join8(repoRoot, ".heyspex", "console-url")) ?? "").trim();
2512
+ if (discovery.length > 0) return trimTrailingSlash(discovery);
2513
+ } catch {
2514
+ }
2515
+ return null;
2516
+ }
2517
+ var ConsoleUrlUnresolvedError = class extends Error {
2518
+ constructor() {
2519
+ super(
2520
+ "resolveConsoleUrl: local reliance with no HEYSPEX_CONSOLE_URL env, no console_url: in ~/.heyspex/credentials.yaml, and no .heyspex/console-url discovery file \u2014 refusing to silently fall back to the localhost:3000 default in strict mode."
2521
+ );
2522
+ this.name = "ConsoleUrlUnresolvedError";
2523
+ }
2524
+ };
2525
+ function resolveModeEnvelope({ env, homeDir }) {
2526
+ try {
2527
+ const resolved = resolveDevMode({ env, homeDir });
2528
+ return {
2529
+ reliance: resolved.reliance === "cloud" ? "cloud" : "local",
2530
+ consoleUrl: resolved.consoleUrl || DEFAULT_CONSOLE_URL
2531
+ };
2532
+ } catch {
2533
+ return { reliance: "local", consoleUrl: DEFAULT_CONSOLE_URL };
2534
+ }
2535
+ }
2536
+ function resolveConsoleUrl({
2537
+ repoRoot,
2538
+ env = process.env,
2539
+ homeDir = homedir7(),
2540
+ strict = false,
2541
+ devMode,
2542
+ log = (line) => process.stderr.write(line),
2543
+ readFile: readFile2 = defaultReadFile2,
2544
+ readDiscovery = defaultReadFile2
2545
+ } = {}) {
2546
+ const envUrl = env.HEYSPEX_CONSOLE_URL?.trim();
2547
+ if (envUrl) return trimTrailingSlash(envUrl);
2548
+ const mode = devMode ?? resolveModeEnvelope({ env, homeDir });
2549
+ const cloud = mode.reliance === "cloud";
2550
+ const derivedUrl = trimTrailingSlash(
2551
+ cloud && isLoopbackConsoleUrl(mode.consoleUrl) ? CLOUD_CONSOLE_URL : mode.consoleUrl || DEFAULT_CONSOLE_URL
2552
+ );
2553
+ const credentialsUrl = readCredentialsConsoleUrl({ homeDir, readFile: readFile2 });
2554
+ if (credentialsUrl) {
2555
+ if (cloud && isLoopbackConsoleUrl(credentialsUrl)) {
2556
+ warnStaleLoopbackCredentialsOnce(credentialsUrl, derivedUrl, { log });
2557
+ } else {
2558
+ return credentialsUrl;
2559
+ }
2560
+ }
2561
+ if (!cloud) {
2562
+ const discoveryUrl = readDiscoveryFileConsoleUrl(repoRoot, { readDiscovery });
2563
+ if (discoveryUrl) return discoveryUrl;
2564
+ if (strict) throw new ConsoleUrlUnresolvedError();
2565
+ }
2566
+ return derivedUrl;
2567
+ }
2568
+
2569
+ // spex-console/src/lib/cli/defer.ts
2570
+ function resolveSpexConsoleUrl(vaultRoot, readDiscovery, opts = {}) {
2571
+ return resolveConsoleUrl({
2572
+ repoRoot: vaultRoot,
2573
+ env: opts.env,
2574
+ homeDir: opts.homeDir,
2575
+ readFile: opts.readFile,
2576
+ readDiscovery,
2577
+ log: opts.log
2578
+ });
2579
+ }
2580
+ var HELP = [
2581
+ "Usage:",
2582
+ " hs defer <spec-id> [--until <when>] [--json] [--no-daemon]",
2583
+ " hs undefer <spec-id> [--json] [--no-daemon]",
2584
+ "",
2585
+ "Time-shift a spec via its `defer_until` frontmatter field. A deferred spec is",
2586
+ "scheduler-invisible until the date passes, then auto-revives.",
2587
+ "",
2588
+ "Arguments:",
2589
+ " <spec-id> The spec to defer/undefer (e.g. HS-TASK-EWL5).",
2590
+ "",
2591
+ "Flags:",
2592
+ " --until <when> ISO date, or relative offset +Nd / +Nw / +Nm / +Ny.",
2593
+ " Defaults to +1y (matches the console Defer button).",
2594
+ " Ignored by `undefer` (which always clears the field).",
2595
+ " --json Emit a machine-readable JSON result.",
2596
+ " --no-daemon Write straight to the vault (no console needed; CI path).",
2597
+ " --help, -h Show this help.",
2598
+ "",
2599
+ "Exit codes: 0 ok \xB7 1 usage \xB7 10 not-found \xB7 11 bad-arg \xB7 12 console-unreachable \xB7 13 write-failed"
2600
+ ].join("\n");
2601
+
2602
+ // spex-console/src/lib/cli/install-identity.ts
2603
+ import { spawnSync } from "node:child_process";
2604
+ import { existsSync as existsSync4 } from "node:fs";
2605
+ import { homedir as homedir9 } from "node:os";
2606
+ import { join as join9 } from "node:path";
2607
+ import { createInterface } from "node:readline/promises";
2608
+
2609
+ // scripts/lib/shared-path-helpers.mjs
2610
+ import { isAbsolute, resolve as resolve4, sep } from "node:path";
2611
+ function resolveCodeRoot({
2612
+ explicit,
2613
+ packageRoot,
2614
+ vaultRoot,
2615
+ env = process.env
2616
+ } = {}) {
2617
+ const fromExplicit = typeof explicit === "string" ? explicit.trim() : "";
2618
+ if (fromExplicit) return fromExplicit;
2619
+ const fromEnv = typeof env?.HS_PKG_ROOT === "string" ? env.HS_PKG_ROOT.trim() : "";
2620
+ if (fromEnv) return fromEnv;
2621
+ const fromPackage = typeof packageRoot === "string" ? packageRoot.trim() : "";
2622
+ if (fromPackage) return fromPackage;
2623
+ const fromVault = typeof vaultRoot === "string" ? vaultRoot.trim() : "";
2624
+ return fromVault;
2625
+ }
2626
+
2627
+ // spex-console/src/lib/cli/install-identity.ts
2628
+ init_credentials();
2629
+
2630
+ // vendor/heyspex-mcp/whoami.mjs
2631
+ init_credentials();
2632
+ import { homedir as homedir8 } from "node:os";
2633
+ var SESSION_PATH = "/api/mcp/session";
2634
+ async function probeCredentialIdentity({ consoleUrl, token, fetchImpl = fetch } = {}) {
2635
+ if (!token) return { kind: "no_credential" };
2636
+ const base = String(consoleUrl ?? "").trim().replace(/\/+$/, "");
2637
+ let res;
2638
+ try {
2639
+ res = await fetchImpl(`${base}${SESSION_PATH}`, {
2640
+ method: "GET",
2641
+ headers: { authorization: `Bearer ${token}` },
2642
+ cache: "no-store"
2643
+ });
2644
+ } catch (err) {
2645
+ return { kind: "unreachable", message: err?.message ?? String(err) };
2646
+ }
2647
+ if (res.status === 401 || res.status === 403) return { kind: "rejected", status: res.status };
2648
+ if (res.status === 404 || res.status === 405) return { kind: "unsupported", status: res.status };
2649
+ let body = null;
2650
+ try {
2651
+ body = await res.json();
2652
+ } catch {
2653
+ body = null;
2654
+ }
2655
+ if (!res.ok || !body || typeof body.userId !== "string" || !body.userId.trim()) {
2656
+ return { kind: "unreachable", message: `console returned ${res.status}` };
2657
+ }
2658
+ return {
2659
+ kind: "identified",
2660
+ accountId: body.userId.trim(),
2661
+ email: typeof body.email === "string" && body.email.trim() ? body.email.trim() : null,
2662
+ displayName: typeof body.displayName === "string" && body.displayName.trim() ? body.displayName.trim() : null,
2663
+ // How the console authenticated this caller (the route's `mode`, e.g. a
2664
+ // bearer); the `hs login` receipt shows it ([[HS-CRNE-WW9R-TASK-give-hs-install-and-hs-login-the]]).
2665
+ mode: typeof body.mode === "string" && body.mode.trim() ? body.mode.trim() : null,
2666
+ consoleUrl: base
2667
+ };
2668
+ }
2669
+ function formatIdentity({ email, accountId: accountId2 }) {
2670
+ return email ? `${email} (${accountId2})` : accountId2;
2671
+ }
2672
+
2673
+ // spex-console/src/lib/cli/init.ts
2674
+ import { randomUUID } from "node:crypto";
2675
+ var HELP2 = [
2676
+ "Usage:",
2677
+ " hs init [--console-url <url>] [--vault-root <path>] [--force] [--json]",
2678
+ " hs init --customer-vault <dir> [--console-url <url>] [--principles <all|none|a,b>]",
2679
+ " [--vault-name <name>] [--git] [--json]",
2680
+ "",
2681
+ "Write the top-level heyspex.yaml vault marker (schema_version, console_url,",
2682
+ "vault_root, vault_id; `hs connect` also records `account:`, the account id the",
2683
+ "repo was connected under) \u2014 the committed file the console / web companion read",
2684
+ "to bootstrap a vault. Idempotent: an existing marker is left untouched unless",
2685
+ "--force, EXCEPT a missing vault_id is backfilled in place either way (backward",
2686
+ "compatible \u2014 no schema_version bump). vault_id is reused exactly when already",
2687
+ "present; a fresh one is minted only when absent.",
2688
+ "Also scaffolds the vault_root folder and seeds .heyspex/config.yaml (the",
2689
+ "HeySpex workflow settings, ADR-0009) from the shipped template",
2690
+ "(vendor/heyspex-mcp/config-seed.yaml) when that file is absent. An existing",
2691
+ ".heyspex/config.yaml is never rewritten \u2014 not even with --force; change its",
2692
+ "values with `hs settings set <key> <value>`.",
2693
+ "",
2694
+ "--customer-vault <dir> scaffolds a REAL customer vault at <dir> instead: the",
2695
+ "specs/ folder tree, heyspex.yaml, the seeded .heyspex/config.yaml, and every",
2696
+ "row of the template shipping manifest",
2697
+ "(spex-console/src/lib/cli/template-manifest.ts) \u2014 universal docs",
2698
+ "copied verbatim, template-shape docs written as author-prompt skeletons \u2014",
2699
+ "plus a generated CLAUDE.md, the kept-principles index, and the template_version",
2700
+ "pin; with --git, one initial `git init` + commit. Rerunnable: a <dir> that already",
2701
+ "holds heyspex.yaml and an empty/placeholder-only specs/ is accepted, existing",
2702
+ "files are never overwritten (an existing CLAUDE.md gets a CLAUDE.md.template",
2703
+ "beside it), and the result lists each manifest row as created or kept. Refuses a",
2704
+ "non-empty <dir> without heyspex.yaml, or a specs/ holding managed specs.",
2705
+ "See init.customer-vault.ts.",
2706
+ "",
2707
+ "Flags:",
2708
+ " --console-url <url> console_url value (default: the mode-aware console --",
2709
+ " $HEYSPEX_CONSOLE_URL, else the signed-in console, else",
2710
+ " https://app.heyspex.com in prod mode).",
2711
+ " --vault-root <path> vault_root value (default: specs/).",
2712
+ " --customer-vault <dir> Scaffold a new customer vault at <dir> (see above).",
2713
+ " --principles <all|none|a,b> Which universal principle docs to ship",
2714
+ " (--customer-vault only). Omitted + a TTY: interactive",
2715
+ " keep/skip prompt per principle. Omitted + no TTY: all.",
2716
+ " --vault-name <name> Display name for the scaffolded vault (--customer-vault",
2717
+ " only; default: <dir>'s basename).",
2718
+ " --git Also run git init + one initial commit (--customer-vault only;",
2719
+ " default: no git). --no-git is accepted as a no-op.",
2720
+ " --force Overwrite an existing heyspex.yaml (never .heyspex/config.yaml).",
2721
+ " --json Emit a machine-readable JSON result.",
2722
+ " --help, -h Show this help.",
2723
+ "",
2724
+ "Exit codes: 0 ok \xB7 1 usage \xB7 11 bad-arg \xB7 13 write-failed"
2725
+ ].join("\n");
2726
+
2727
+ // spex-console/src/lib/cli/install-identity.ts
2728
+ var SWITCH_HINT = "Sign in as another account with `hs login --switch-account` (or create one with `hs login --create-account`).";
2729
+ var ATTACH_WORDING = {
2730
+ willBe: "This repo will be attached to",
2731
+ wouldBe: "It would be attached to",
2732
+ act: "attach this repo",
2733
+ choose: "Choose the account this repo should belong to.",
2734
+ noAccount: "there is no account to attach this repo to",
2735
+ whichAccount: "which account this repo would be attached to",
2736
+ accountFlag: "--account",
2737
+ yesFlag: "--yes"
2738
+ };
2739
+ var WRITE_WORDING = {
2740
+ willBe: "This change will be made as",
2741
+ wouldBe: "It would be made as",
2742
+ act: "make this change",
2743
+ choose: "Choose the account this change should be made as.",
2744
+ noAccount: "there is no account to make this change as",
2745
+ whichAccount: "which account this change would be made as",
2746
+ accountFlag: "--as",
2747
+ yesFlag: "--confirm-account"
2748
+ };
2749
+ function extractWriteIdentityFlags(argv) {
2750
+ const flags = { yes: false };
2751
+ const rest = [];
2752
+ for (let i = 0; i < argv.length; i++) {
2753
+ const arg = argv[i];
2754
+ if (arg === "--as" || arg.startsWith("--as=")) {
2755
+ const v = arg === "--as" ? argv[++i] : arg.slice("--as=".length);
2756
+ if (v === void 0 || v.trim() === "" || v.trim().startsWith("-")) {
2757
+ return { flags, rest, error: "--as requires the signed-in account's email or account id" };
2758
+ }
2759
+ flags.account = v.trim();
2760
+ } else if (arg === "--confirm-account") {
2761
+ flags.yes = true;
2762
+ } else {
2763
+ rest.push(arg);
2764
+ }
2765
+ }
2766
+ return { flags, rest };
2767
+ }
2768
+ function matchesAccount(identity, wanted) {
2769
+ const w = wanted.trim().toLowerCase();
2770
+ return identity.accountId.toLowerCase() === w || (identity.email?.toLowerCase() ?? "") === w;
2771
+ }
2772
+ function formatShownIdentity(identity) {
2773
+ return formatIdentity(identity) + (identity.unverified ? " [unverified - console not yet updated]" : "") + (identity.tokenSource ? ` [token: ${describeBearerSource(identity.tokenSource)}]` : "");
2774
+ }
2775
+ async function probeBearerIdentity(opts) {
2776
+ const probe = await probeCredentialIdentity({
2777
+ consoleUrl: opts.consoleUrl,
2778
+ token: opts.bearer.token,
2779
+ fetchImpl: opts.fetchImpl ?? fetch
2780
+ });
2781
+ if (probe.kind === "identified" && opts.bearer.source) {
2782
+ return { ...probe, tokenSource: opts.bearer.source };
2783
+ }
2784
+ return probe;
2785
+ }
2786
+ function decideIdentity(input) {
2787
+ const { probe, configAccountId, flags, interactive, consoleUrl, verb } = input;
2788
+ const w = input.wording ?? ATTACH_WORDING;
2789
+ if (isLoopbackConsoleUrl(consoleUrl)) {
2790
+ return {
2791
+ action: "proceed",
2792
+ identity: null,
2793
+ note: `Local console (${consoleUrl}): no cloud account is bound by this run.`
2794
+ };
2795
+ }
2796
+ if (probe.kind === "no_credential" || probe.kind === "rejected") {
2797
+ const why = probe.kind === "no_credential" ? "This machine is not signed in to HeySpex" : `The stored sign-in was rejected by ${consoleUrl} (HTTP ${probe.status}) - revoked, or from another console`;
2798
+ if (interactive) {
2799
+ return {
2800
+ action: "prompt",
2801
+ identity: null,
2802
+ message: `${why}. ${w.choose}`,
2803
+ choices: ["switch", "create", "quit"]
2804
+ };
2805
+ }
2806
+ return {
2807
+ action: "refuse",
2808
+ code: EXIT.SIGN_IN_REQUIRED,
2809
+ message: `${verb}: refusing -- ${why.charAt(0).toLowerCase()}${why.slice(1)}, so ${w.noAccount}. Run \`hs login\` (or \`hs login --create-account\`) first; nothing was written.`
2810
+ };
2811
+ }
2812
+ if (probe.kind === "unreachable") {
2813
+ if (flags.yes) {
2814
+ return {
2815
+ action: "proceed",
2816
+ identity: null,
2817
+ note: `Could not reach ${consoleUrl} to confirm the account (${probe.message}); continuing because ${w.yesFlag} was given.`
2818
+ };
2819
+ }
2820
+ return {
2821
+ action: "refuse",
2822
+ code: EXIT.CONSOLE_UNREACHABLE,
2823
+ message: `${verb}: refusing -- could not reach ${consoleUrl} to confirm ${w.whichAccount} (${probe.message}). Retry when online, or pass ${w.yesFlag} to continue without confirming. Nothing was written.`
2824
+ };
2825
+ }
2826
+ let identity;
2827
+ if (probe.kind === "unsupported") {
2828
+ if (!configAccountId) {
2829
+ return {
2830
+ action: "refuse",
2831
+ code: EXIT.SIGN_IN_REQUIRED,
2832
+ message: `${verb}: refusing -- ${consoleUrl} cannot confirm whose sign-in this is yet, and no current account is recorded in ~/.heyspex/credentials.yaml.Run \`hs login\` first; nothing was written.`
2833
+ };
2834
+ }
2835
+ identity = { accountId: configAccountId, email: null, unverified: true };
2836
+ } else {
2837
+ identity = {
2838
+ accountId: probe.accountId,
2839
+ email: probe.email,
2840
+ ...probe.tokenSource ? { tokenSource: probe.tokenSource } : {}
2841
+ };
2842
+ if (configAccountId && configAccountId !== probe.accountId) {
2843
+ const message2 = `The sign-in on this machine belongs to ${formatIdentity(identity)}, but this machine's current account (~/.heyspex/credentials.yaml, or HS_ACCOUNT_ID) is ${configAccountId}. A repo bound now would be registered under one and written under the other.`;
2844
+ if (interactive) {
2845
+ return { action: "prompt", identity, message: message2, choices: ["switch", "create", "quit"] };
2846
+ }
2847
+ return {
2848
+ action: "refuse",
2849
+ code: EXIT.IDENTITY_UNCONFIRMED,
2850
+ message: `${verb}: refusing -- ${message2} ${SWITCH_HINT} Nothing was written.`
2851
+ };
2852
+ }
2853
+ }
2854
+ const shown = formatShownIdentity(identity);
2855
+ if (flags.account !== void 0) {
2856
+ if (matchesAccount(identity, flags.account)) {
2857
+ return { action: "proceed", identity, note: `${w.willBe} ${shown}.` };
2858
+ }
2859
+ return {
2860
+ action: "refuse",
2861
+ code: EXIT.IDENTITY_UNCONFIRMED,
2862
+ message: `${verb}: refusing -- ${w.accountFlag} ${flags.account} does not match the signed-in account ${shown}. ${SWITCH_HINT} Nothing was written.`
2863
+ };
2864
+ }
2865
+ if (flags.yes) {
2866
+ return { action: "proceed", identity, note: `${w.willBe} ${shown} (${w.yesFlag}).` };
2867
+ }
2868
+ if (interactive) {
2869
+ return {
2870
+ action: "prompt",
2871
+ identity,
2872
+ message: `${w.willBe} ${shown} on ${consoleUrl}.`,
2873
+ choices: ["continue", "switch", "create", "quit"]
2874
+ };
2875
+ }
2876
+ return {
2877
+ action: "refuse",
2878
+ code: EXIT.IDENTITY_UNCONFIRMED,
2879
+ message: `${verb}: refusing to ${w.act} without confirming the account. ${w.wouldBe} ${shown} on ${consoleUrl}. Confirm with ${w.accountFlag} ${identity.email ?? identity.accountId} (or ${w.yesFlag}), or ${SWITCH_HINT.charAt(0).toLowerCase()}${SWITCH_HINT.slice(1)} An agent should ask the user which account to use. Nothing was written.`
2880
+ };
2881
+ }
2882
+ function renderChoices(choices, signedIn) {
2883
+ const label = {
2884
+ continue: "[Enter] continue",
2885
+ switch: signedIn ? "[s] sign in with a different account" : "[s] sign in",
2886
+ create: "[n] create a new account",
2887
+ quit: "[q] abort"
2888
+ };
2889
+ return choices.map((c) => label[c]).join(" - ");
2890
+ }
2891
+ function parseChoice(answer, choices) {
2892
+ const a = answer.trim().toLowerCase();
2893
+ const picked = a === "" || a === "c" || a === "continue" || a === "y" || a === "yes" ? "continue" : a === "s" || a === "switch" || a === "sign in" ? "switch" : a === "n" || a === "new" || a === "create" ? "create" : a === "q" || a === "quit" || a === "abort" ? "quit" : null;
2894
+ return picked && choices.includes(picked) ? picked : null;
2895
+ }
2896
+ function refusal(code, message2, reason) {
2897
+ return {
2898
+ ok: false,
2899
+ result: { code, message: message2, payload: { ok: false, error: reason, message: message2 }, isError: true }
2900
+ };
2901
+ }
2902
+ var MAX_ROUNDS = 4;
2903
+ async function runIdentityGate(io, flags, verb, wording = ATTACH_WORDING) {
2904
+ for (let round = 0; round < MAX_ROUNDS; round++) {
2905
+ const probe = await io.probe();
2906
+ const decision = decideIdentity({
2907
+ probe,
2908
+ configAccountId: io.readConfigAccountId(),
2909
+ flags,
2910
+ interactive: io.interactive,
2911
+ consoleUrl: io.consoleUrl,
2912
+ verb,
2913
+ wording
2914
+ });
2915
+ if (decision.action === "proceed") {
2916
+ io.print(decision.note);
2917
+ return { ok: true, identity: decision.identity, note: decision.note };
2918
+ }
2919
+ if (decision.action === "refuse") {
2920
+ return refusal(decision.code, decision.message, "identity_unconfirmed");
2921
+ }
2922
+ io.print(decision.message);
2923
+ const menu = renderChoices(decision.choices, decision.identity !== null);
2924
+ let choice = null;
2925
+ while (choice === null) {
2926
+ choice = parseChoice(await io.ask(`${menu}: `), decision.choices);
2927
+ }
2928
+ if (choice === "continue") {
2929
+ const note = `${wording.willBe} ${formatShownIdentity(decision.identity)}.`;
2930
+ return { ok: true, identity: decision.identity, note };
2931
+ }
2932
+ if (choice === "quit") {
2933
+ return refusal(
2934
+ EXIT.IDENTITY_UNCONFIRMED,
2935
+ `${verb}: aborted - nothing was written.`,
2936
+ "aborted"
2937
+ );
2938
+ }
2939
+ const code = await io.runLogin(choice === "create" ? "create" : "select_account");
2940
+ if (code !== 0) {
2941
+ io.print(`hs login exited ${code}; the account was not changed.`);
2942
+ }
2943
+ }
2944
+ return refusal(
2945
+ EXIT.IDENTITY_UNCONFIRMED,
2946
+ `${verb}: refusing -- the account was still not confirmed after several sign-in attempts. Nothing was written.`,
2947
+ "identity_unconfirmed"
2948
+ );
2949
+ }
2950
+ var WORKSPACE_WRITE_VERBS = /* @__PURE__ */ new Set([
2951
+ "create",
2952
+ "rename",
2953
+ "unregister",
2954
+ "archive",
2955
+ "restore"
2956
+ ]);
2957
+ async function gateWriteVerb(opts) {
2958
+ if (!opts.writeVerbs.has(opts.sub)) return null;
2959
+ return runIdentityGate(opts.io, opts.flags, `${opts.namespace} ${opts.sub}`, WRITE_WORDING);
2960
+ }
2961
+ function readConfiguredAccountId(env = process.env, homeDir = homedir9()) {
2962
+ return resolveSessionOwnerUncached({ env, homeDir });
2963
+ }
2964
+ function isInteractiveTerminal() {
2965
+ return Boolean(process.stdin.isTTY && process.stderr.isTTY) && process.env.CI !== "true";
2966
+ }
2967
+ function defaultIdentityGateIo(opts) {
2968
+ const consoleUrl = opts.consoleUrl.trim().replace(/\/+$/, "");
2969
+ const loginCli = [opts.platformRoot, resolveCodeRoot({})].filter((r) => Boolean(r && r.trim())).map((r) => join9(r, "vendor", "heyspex-mcp", "cli.mjs")).find((p) => existsSync4(p));
2970
+ return {
2971
+ consoleUrl,
2972
+ interactive: opts.interactive ?? isInteractiveTerminal(),
2973
+ probe: () => probeBearerIdentity({ consoleUrl, bearer: readPairedBearer(), fetchImpl: fetch }),
2974
+ readConfigAccountId: () => readConfiguredAccountId(),
2975
+ ask: async (question) => {
2976
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
2977
+ try {
2978
+ return await rl.question(question);
2979
+ } finally {
2980
+ rl.close();
2981
+ }
2982
+ },
2983
+ runLogin: async (prompt) => {
2984
+ const flag = prompt === "create" ? "--create-account" : "--switch-account";
2985
+ if (!loginCli) {
2986
+ process.stderr.write(
2987
+ `Run \`hs login ${flag}\` in another terminal, then come back here.
2988
+ `
2989
+ );
2990
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
2991
+ try {
2992
+ await rl.question("Press Enter when the sign-in has finished: ");
2993
+ } finally {
2994
+ rl.close();
2995
+ }
2996
+ return 0;
2997
+ }
2998
+ const res = spawnSync(
2999
+ process.execPath,
3000
+ [loginCli, "login", flag, "--console-url", consoleUrl],
3001
+ {
3002
+ stdio: "inherit",
3003
+ env: { ...process.env, HEYSPEX_DISABLE_STDIO_BOOTSTRAP: "1" }
3004
+ }
3005
+ );
3006
+ return res.status ?? 1;
3007
+ },
3008
+ print: (line) => process.stderr.write(`${line}
3009
+ `)
3010
+ };
3011
+ }
3012
+
3013
+ // spex-console/src/lib/vault-registry/account-pointer.ts
3014
+ init_sql_orm_inert_cli_shim();
3015
+
3016
+ // spex-console/src/lib/cli/vault-workspace-shared.ts
3017
+ import { readFileSync as readFileSync8 } from "node:fs";
3018
+ import os2 from "node:os";
3019
+
3020
+ // spex-console/src/lib/cli/default-workspace.ts
3021
+ function defaultWorkspaceId(accountId2) {
3022
+ const sanitized = accountId2.replace(/[/\\.\s]+/g, "-").replace(/^-+|-+$/g, "");
3023
+ return `ws-${sanitized || "account"}`;
3024
+ }
3025
+
3026
+ // spex-console/src/lib/cli/vault-workspace-shared.ts
3027
+ function resolveConfiguredAccountId(opts = {}) {
3028
+ return resolveSessionOwnerUncached({
3029
+ env: opts.env ?? process.env,
3030
+ homeDir: opts.homeDir ?? os2.homedir(),
3031
+ readFile: opts.readFile ?? ((p) => readFileSync8(p, "utf-8"))
3032
+ });
3033
+ }
3034
+ var CLOUD_ACCOUNT_ID_REQUIRED_MESSAGE = "refusing -- no signed-in account (no HS_ACCOUNT_ID env var, and no account_id: in ~/.heyspex/config.yaml). Run `hs login` first; nothing was written.";
3035
+ function resolveCloudAccountIdOrRefuse(opts = {}) {
3036
+ const accountId2 = resolveConfiguredAccountId(opts);
3037
+ return accountId2 ? { ok: true, accountId: accountId2 } : { ok: false, message: CLOUD_ACCOUNT_ID_REQUIRED_MESSAGE };
3038
+ }
3039
+ var WORKSPACE_OWNED_BY_OTHER_ACCOUNT = "workspace_owned_by_other_account";
3040
+ function workspaceOwnedByOtherAccountMessage(id) {
3041
+ return `workspace "${id}" belongs to another HeySpex account, so nothing was changed. Pick a different workspace id (e.g. \`--workspace <new-id>\`), or sign in as the account that owns it (\`hs login --switch-account\`). Transferring a workspace between accounts is not supported.`;
3042
+ }
3043
+ function classifyHttpFailureReason(res) {
3044
+ if (res.status === 0) return "network-error";
3045
+ if (res.status === 401) return "unauthorized";
3046
+ return "server-error";
3047
+ }
3048
+ async function cloudHttpRequest(http, method, path4, body) {
3049
+ const scopeHeaders = http.scope ? http.scope.headersFor(method, path4) : {};
3050
+ try {
3051
+ const res = await http.fetchImpl(`${http.consoleUrl}${path4}`, {
3052
+ method,
3053
+ headers: {
3054
+ "content-type": "application/json",
3055
+ ...http.token ? { authorization: `Bearer ${http.token}` } : {},
3056
+ ...scopeHeaders
3057
+ },
3058
+ ...body !== void 0 ? { body: JSON.stringify(body) } : {}
3059
+ });
3060
+ let json = null;
3061
+ try {
3062
+ json = await res.json();
3063
+ } catch {
3064
+ }
3065
+ return { ok: res.ok, status: res.status, json };
3066
+ } catch {
3067
+ return { ok: false, status: 0, json: null };
3068
+ }
3069
+ }
3070
+ function isCloudWriteFailure(value) {
3071
+ return typeof value === "object" && value !== null && value.kind === "cloud_write_failed";
3072
+ }
3073
+ function cloudWriteFailure(http, res) {
3074
+ const where = http.consoleUrl;
3075
+ let message2;
3076
+ if (res.status === 0) {
3077
+ message2 = `could not reach the console at ${where} -- nothing was changed; check the connection and re-run the command to retry`;
3078
+ } else if (res.status === 401) {
3079
+ message2 = `the console at ${where} refused the sign-in token (HTTP 401) -- nothing was changed; run \`hs login\` and re-run the command`;
3080
+ } else {
3081
+ const code = errorCodeOf(res.json);
3082
+ message2 = `the console at ${where} answered HTTP ${res.status}${code ? ` (${code})` : ""} -- nothing was changed; re-run the command to retry`;
3083
+ }
3084
+ return { kind: "cloud_write_failed", status: res.status, message: message2 };
3085
+ }
3086
+ function errorCodeOf(json) {
3087
+ if (!json || typeof json !== "object") return null;
3088
+ const v = json.error;
3089
+ return typeof v === "string" ? v : null;
3090
+ }
3091
+ async function classifyWorkspaceConflictHttp(id, conflictJson, http) {
3092
+ if (errorCodeOf(conflictJson) === WORKSPACE_OWNED_BY_OTHER_ACCOUNT) {
3093
+ return { written: false, reason: "owned-by-other-account" };
3094
+ }
3095
+ if (conflictJson && typeof conflictJson === "object" && conflictJson.owned_by_caller === true) {
3096
+ return { written: true };
3097
+ }
3098
+ const list = await cloudHttpRequest(http, "GET", "/api/workspaces?include_archived=true");
3099
+ if (!list.ok) return { written: false, reason: classifyHttpFailureReason(list) };
3100
+ const rows = list.json?.workspaces;
3101
+ if (!Array.isArray(rows)) return { written: false, reason: "server-error" };
3102
+ return rows.some((w) => w.id === id) ? { written: true } : { written: false, reason: "owned-by-other-account" };
3103
+ }
3104
+ async function mutateHttp(http, method, path4, body, landed) {
3105
+ const res = await cloudHttpRequest(http, method, path4, body);
3106
+ if (res.ok) return landed;
3107
+ if (res.status === 404) return "not_found";
3108
+ return cloudWriteFailure(http, res);
3109
+ }
3110
+ function manageVerbPath(kind, id) {
3111
+ return `/api/${kind}/${encodeURIComponent(id)}`;
3112
+ }
3113
+
3114
+ // spex-console/src/lib/vault-registry/account-pointer.ts
3115
+ init_postgres_client_cli_shim();
3116
+ init_postgres();
3117
+
3118
+ // spex-console/src/lib/vault-registry/neon-probe.ts
3119
+ var NEON_PROBE_TIMEOUT_MS = 1500;
3120
+ var SUSPENSION_ELAPSED_FACTOR = 3;
3121
+ var SUSPENSION_MIN_OVERSHOOT_MS = 5e3;
3122
+ var MAX_SUSPENSION_RETRIES = 2;
3123
+ function withTimeout(promise, ms, label) {
3124
+ return new Promise((resolve5, reject) => {
3125
+ const timer = setTimeout(() => reject(new NeonProbeTimeoutError(label, ms)), ms);
3126
+ promise.then(
3127
+ (value) => {
3128
+ clearTimeout(timer);
3129
+ resolve5(value);
3130
+ },
3131
+ (err) => {
3132
+ clearTimeout(timer);
3133
+ reject(err instanceof Error ? err : new Error(String(err)));
3134
+ }
3135
+ );
3136
+ });
3137
+ }
3138
+ var NeonProbeTimeoutError = class _NeonProbeTimeoutError extends Error {
3139
+ timeoutMs;
3140
+ constructor(label, timeoutMs) {
3141
+ super(`${label} timed out after ${timeoutMs}ms`);
3142
+ this.name = "NeonProbeTimeoutError";
3143
+ this.timeoutMs = timeoutMs;
3144
+ Object.setPrototypeOf(this, _NeonProbeTimeoutError.prototype);
3145
+ }
3146
+ };
3147
+ function sleep(ms) {
3148
+ return new Promise((resolve5) => setTimeout(resolve5, ms));
1704
3149
  }
1705
- function removeWorkspace(id, opts = {}) {
1706
- const registry = loadVaultRegistry(opts);
1707
- registry.workspaces = registry.workspaces.filter((w) => w.id !== id);
1708
- if (registry.active_workspace_id === id) {
1709
- registry.active_workspace_id = null;
3150
+ function emitProbeFailure(options, failure) {
3151
+ try {
3152
+ options?.onFailure?.(failure);
3153
+ } catch {
1710
3154
  }
1711
- writeVaultRegistry(registry, opts);
1712
- return registry;
1713
3155
  }
1714
- var init_registry_file = __esm({
1715
- "spex-console/src/lib/vault-registry/registry-file.ts"() {
1716
- "use strict";
1717
- init_types();
1718
- init_types();
3156
+ async function runNeonReachabilityProbe(attempt, options = {}, label = "Neon reachability probe") {
3157
+ const timeoutMs = options.timeoutMs ?? NEON_PROBE_TIMEOUT_MS;
3158
+ const backoffMs = options.backoffMs ?? [];
3159
+ const deadlineMs = options.deadlineMs ?? Number.POSITIVE_INFINITY;
3160
+ const attempts = backoffMs.length + 1;
3161
+ let startedAt = Date.now();
3162
+ let suspensions = 0;
3163
+ for (let i = 0; i < attempts; i++) {
3164
+ const attemptStartedAt = Date.now();
3165
+ try {
3166
+ await withTimeout(Promise.resolve().then(attempt), timeoutMs, label);
3167
+ return true;
3168
+ } catch (err) {
3169
+ const elapsedMs = Date.now() - attemptStartedAt;
3170
+ const suspended = elapsedMs >= timeoutMs * SUSPENSION_ELAPSED_FACTOR && elapsedMs - timeoutMs >= SUSPENSION_MIN_OVERSHOOT_MS;
3171
+ if (suspended && suspensions < MAX_SUSPENSION_RETRIES) {
3172
+ suspensions += 1;
3173
+ emitProbeFailure(options, {
3174
+ reason: "suspended",
3175
+ attempt: i + 1,
3176
+ attempts,
3177
+ timeoutMs,
3178
+ elapsedMs,
3179
+ willRetry: true,
3180
+ error: err
3181
+ });
3182
+ startedAt = Date.now();
3183
+ i -= 1;
3184
+ continue;
3185
+ }
3186
+ const isLast = i === attempts - 1;
3187
+ const pastDeadline = Date.now() - startedAt >= deadlineMs;
3188
+ const willRetry = !isLast && !pastDeadline;
3189
+ emitProbeFailure(options, {
3190
+ reason: err instanceof NeonProbeTimeoutError ? "timeout" : "error",
3191
+ attempt: i + 1,
3192
+ attempts,
3193
+ timeoutMs,
3194
+ elapsedMs,
3195
+ willRetry,
3196
+ error: err
3197
+ });
3198
+ if (!willRetry) return false;
3199
+ await sleep(backoffMs[i]);
3200
+ }
1719
3201
  }
1720
- });
3202
+ return false;
3203
+ }
1721
3204
 
1722
3205
  // spex-console/src/lib/vault-registry/account-pointer.ts
3206
+ var UnknownWorkspaceIdError = class extends Error {
3207
+ workspaceId;
3208
+ constructor(workspaceId) {
3209
+ super(
3210
+ `setActiveWorkspace: unknown workspace id "${workspaceId}" \u2014 no matching row in Neon "workspaces" or registry.json's workspaces[] list. Nothing was written.`
3211
+ );
3212
+ this.name = "UnknownWorkspaceIdError";
3213
+ this.workspaceId = workspaceId;
3214
+ }
3215
+ };
3216
+ var CloudPointerWriteError = class extends Error {
3217
+ status;
3218
+ consoleUrl;
3219
+ constructor(consoleUrl, status, code) {
3220
+ super(
3221
+ status === 0 ? `could not reach the console at ${consoleUrl} to write the active pointer` : status === 401 ? `the console at ${consoleUrl} refused the sign-in token (HTTP 401) -- run \`hs login\`` : `the console at ${consoleUrl} answered HTTP ${status}${code ? ` (${code})` : ""} to the active-pointer write`
3222
+ );
3223
+ this.name = "CloudPointerWriteError";
3224
+ this.status = status;
3225
+ this.consoleUrl = consoleUrl;
3226
+ }
3227
+ };
1723
3228
  async function isNeonReachable(deps) {
1724
3229
  if (deps.neonReachable !== void 0) return deps.neonReachable;
1725
3230
  const usingInjectedDb = deps.db !== void 0;
@@ -1742,6 +3247,19 @@ async function isNeonReachable(deps) {
1742
3247
  await db.execute(sql`select 1`);
1743
3248
  }, deps.probe);
1744
3249
  }
3250
+ function shouldUseHttp(deps) {
3251
+ return deps.db === void 0 && deps.http !== void 0;
3252
+ }
3253
+ async function readPointerHttp(http) {
3254
+ const res = await cloudHttpRequest(http, "GET", "/api/vaults/active");
3255
+ if (!res.ok || !res.json || typeof res.json !== "object") return null;
3256
+ return res.json;
3257
+ }
3258
+ function errorCodeOf2(json) {
3259
+ if (!json || typeof json !== "object") return null;
3260
+ const v = json.error;
3261
+ return typeof v === "string" ? v : null;
3262
+ }
1745
3263
  async function accountSettingsRow(accountId2, db) {
1746
3264
  const rows = await db.select().from(accountSettings).where(eq(accountSettings.accountId, accountId2)).limit(1);
1747
3265
  return rows[0] ?? null;
@@ -1761,14 +3279,30 @@ function workspaceExistsInRegistry(workspaceId, opts) {
1761
3279
  return loadVaultRegistry(opts).workspaces.some((w) => w.id === workspaceId);
1762
3280
  }
1763
3281
  async function getActiveWorkspace(accountId2, deps = {}) {
1764
- if (await isNeonReachable(deps)) {
3282
+ if (shouldUseHttp(deps)) {
3283
+ const body = await readPointerHttp(deps.http);
3284
+ if (body) {
3285
+ return typeof body.workspace_id === "string" && body.workspace_id ? body.workspace_id : null;
3286
+ }
3287
+ } else if (await isNeonReachable(deps)) {
1765
3288
  const db = deps.db ?? getPostgresDb();
1766
3289
  const row = await accountSettingsRow(accountId2, db);
1767
3290
  return row?.activeWorkspaceId ?? null;
1768
3291
  }
1769
- return loadVaultRegistry(deps.registryOpts).active_workspace_id;
3292
+ return accountPointersOf(loadVaultRegistry(deps.registryOpts), accountId2).active_workspace_id;
1770
3293
  }
1771
3294
  async function setActiveWorkspace(accountId2, workspaceId, deps = {}) {
3295
+ if (shouldUseHttp(deps)) {
3296
+ const res = await cloudHttpRequest(deps.http, "POST", "/api/vaults/active", {
3297
+ workspace_id: workspaceId
3298
+ });
3299
+ if (res.ok) return;
3300
+ const code = errorCodeOf2(res.json);
3301
+ if (res.status === 404 && code === "unknown_workspace") {
3302
+ throw new UnknownWorkspaceIdError(workspaceId);
3303
+ }
3304
+ throw new CloudPointerWriteError(deps.http.consoleUrl, res.status, code);
3305
+ }
1772
3306
  if (await isNeonReachable(deps)) {
1773
3307
  const db = deps.db ?? getPostgresDb();
1774
3308
  if (!await workspaceExistsInNeon(accountId2, workspaceId, db)) {
@@ -1781,108 +3315,50 @@ async function setActiveWorkspace(accountId2, workspaceId, deps = {}) {
1781
3315
  if (!workspaceExistsInRegistry(workspaceId, registryOpts)) {
1782
3316
  throw new UnknownWorkspaceIdError(workspaceId);
1783
3317
  }
1784
- setActiveWorkspaceId(workspaceId, registryOpts);
3318
+ setActiveWorkspaceId(accountId2, workspaceId, registryOpts);
1785
3319
  }
1786
3320
  async function clearActiveWorkspace(accountId2, deps = {}) {
1787
- if (await isNeonReachable(deps)) {
3321
+ if (!shouldUseHttp(deps) && await isNeonReachable(deps)) {
1788
3322
  const db = deps.db ?? getPostgresDb();
1789
3323
  await upsertAccountSettings(accountId2, { activeWorkspaceId: null }, db);
1790
3324
  return;
1791
3325
  }
1792
- setActiveWorkspaceId(null, deps.registryOpts ?? {});
3326
+ setActiveWorkspaceId(accountId2, null, deps.registryOpts ?? {});
1793
3327
  }
1794
- var UnknownWorkspaceIdError;
1795
- var init_account_pointer = __esm({
1796
- "spex-console/src/lib/vault-registry/account-pointer.ts"() {
1797
- "use strict";
1798
- init_sql_orm_inert_cli_shim();
1799
- init_postgres_client_cli_shim();
1800
- init_postgres();
1801
- init_neon_probe();
1802
- init_registry_file();
1803
- UnknownWorkspaceIdError = class extends Error {
1804
- workspaceId;
1805
- constructor(workspaceId) {
1806
- super(
1807
- `setActiveWorkspace: unknown workspace id "${workspaceId}" \u2014 no matching row in Neon "workspaces" or registry.json's workspaces[] list. Nothing was written.`
1808
- );
1809
- this.name = "UnknownWorkspaceIdError";
1810
- this.workspaceId = workspaceId;
1811
- }
1812
- };
3328
+
3329
+ // dist/cli/._admin-scope-inert.cli-shim.mjs
3330
+ async function resolveScopedAccountId(callerAccountId, requestedAccountId) {
3331
+ const target = requestedAccountId == null ? "" : String(requestedAccountId).trim();
3332
+ if (!target || target === callerAccountId) {
3333
+ return { ok: true, accountId: callerAccountId };
1813
3334
  }
1814
- });
3335
+ return {
3336
+ ok: false,
3337
+ code: "forbidden_account_override",
3338
+ message: "Only a builder/admin may act on another account's workspaces or vaults."
3339
+ };
3340
+ }
3341
+
3342
+ // spex-console/src/lib/vault-registry/vault-workspace-store.ts
3343
+ init_sql_orm_inert_cli_shim();
3344
+ init_postgres_client_cli_shim();
3345
+ init_postgres();
1815
3346
 
1816
3347
  // spex-console/src/lib/vault-registry/repo-identity.ts
1817
3348
  import path2 from "node:path";
1818
- var init_repo_identity = __esm({
1819
- "spex-console/src/lib/vault-registry/repo-identity.ts"() {
1820
- "use strict";
1821
- }
1822
- });
1823
3349
 
1824
- // spex-console/src/lib/cli/vault-workspace-shared.ts
1825
- import { readFileSync as readFileSync5 } from "node:fs";
1826
- import os2 from "node:os";
1827
- import path3 from "node:path";
1828
- function resolveCliAccountId(opts = {}) {
1829
- const env = opts.env ?? process.env;
1830
- const fromEnv = env.HS_ACCOUNT_ID?.trim();
1831
- if (fromEnv) return fromEnv;
1832
- try {
1833
- const homeDir = opts.homeDir ?? os2.homedir();
1834
- const readFile2 = opts.readFile ?? ((p) => readFileSync5(p, "utf-8"));
1835
- const config = readFile2(path3.join(homeDir, ".heyspex", "config.yaml"));
1836
- const match = config.match(/^account_id:\s*["']?([^"'\r\n#]+)/m);
1837
- const value = match?.[1]?.trim();
1838
- if (value && value.length > 0) return value;
1839
- } catch {
1840
- }
1841
- return DEFAULT_ACCOUNT_ID;
1842
- }
1843
- async function cloudHttpRequest(http, method, path5, body) {
1844
- try {
1845
- const res = await http.fetchImpl(`${http.consoleUrl}${path5}`, {
1846
- method,
1847
- headers: {
1848
- "content-type": "application/json",
1849
- ...http.token ? { authorization: `Bearer ${http.token}` } : {}
1850
- },
1851
- ...body !== void 0 ? { body: JSON.stringify(body) } : {}
1852
- });
1853
- let json = null;
1854
- try {
1855
- json = await res.json();
1856
- } catch {
1857
- }
1858
- return { ok: res.ok, status: res.status, json };
1859
- } catch {
1860
- return { ok: false, status: 0, json: null };
1861
- }
3350
+ // spex-console/src/lib/vault-registry/vault-workspace-store.ts
3351
+ function mapProbeFailureReason(reason) {
3352
+ return reason === "not-configured" ? "not-configured" : "network-error";
1862
3353
  }
1863
3354
  function shouldUseCloudHttp(deps) {
1864
- return deps.db === void 0 && deps.http !== void 0 && !isPostgresConfigured();
3355
+ return deps.db === void 0 && deps.http !== void 0;
1865
3356
  }
1866
- async function upsertWorkspaceHttp(record, http) {
1867
- const res = await cloudHttpRequest(http, "POST", "/api/workspaces", {
1868
- id: record.id,
1869
- display_name: record.displayName,
1870
- ...record.githubOrg ? { github_org: record.githubOrg } : {}
1871
- });
1872
- return res.status === 201 || res.status === 409;
1873
- }
1874
- async function insertWorkspaceStrictHttp(record, http) {
1875
- const res = await cloudHttpRequest(http, "POST", "/api/workspaces", {
1876
- id: record.id,
1877
- display_name: record.displayName,
1878
- ...record.githubOrg ? { github_org: record.githubOrg } : {}
1879
- });
1880
- if (res.status === 201) return "inserted";
1881
- if (res.status === 409) return "conflict";
1882
- return "unreachable";
1883
- }
1884
- async function isNeonReachableForWrite(deps) {
1885
- if (deps.neonReachable !== void 0) return deps.neonReachable;
3357
+ async function isNeonReachableForWriteWithReason(deps) {
3358
+ if (shouldUseCloudHttp(deps)) return { reachable: false, reason: "not-configured" };
3359
+ if (deps.neonReachable !== void 0) {
3360
+ return deps.neonReachable ? { reachable: true } : { reachable: false, reason: "network-error" };
3361
+ }
1886
3362
  const usingInjectedDb = deps.db !== void 0;
1887
3363
  if (!usingInjectedDb && !isPostgresConfigured()) {
1888
3364
  emitProbeFailure(deps.probe, {
@@ -1896,19 +3372,46 @@ async function isNeonReachableForWrite(deps) {
1896
3372
  "isPostgresConfigured() is false \u2014 no DATABASE_URL env var and no usable ~/.heyspex/credentials.yaml. Neon was never contacted."
1897
3373
  )
1898
3374
  });
1899
- return false;
3375
+ return { reachable: false, reason: "not-configured" };
1900
3376
  }
1901
- return runNeonReachabilityProbe(async () => {
1902
- const db = deps.db ?? getPostgresDb();
1903
- await db.execute(sql`select 1`);
1904
- }, deps.probe);
3377
+ let reason = "network-error";
3378
+ const reachable = await runNeonReachabilityProbe(
3379
+ async () => {
3380
+ const db = deps.db ?? getPostgresDb();
3381
+ await db.execute(sql`select 1`);
3382
+ },
3383
+ {
3384
+ ...deps.probe,
3385
+ onFailure: (failure) => {
3386
+ reason = mapProbeFailureReason(failure.reason);
3387
+ deps.probe?.onFailure?.(failure);
3388
+ }
3389
+ }
3390
+ );
3391
+ return reachable ? { reachable: true } : { reachable: false, reason };
3392
+ }
3393
+ async function isNeonReachableForWrite(deps) {
3394
+ return (await isNeonReachableForWriteWithReason(deps)).reachable;
1905
3395
  }
1906
- async function upsertWorkspaceNeon(record, accountId2, deps = {}) {
1907
- if (shouldUseCloudHttp(deps)) return upsertWorkspaceHttp(record, deps.http);
1908
- if (!await isNeonReachableForWrite(deps)) return false;
3396
+ async function upsertWorkspaceNeonReasoned(record, accountId2, deps = {}) {
3397
+ if (isReservedForOtherAccountsDefault(record.id, accountId2)) {
3398
+ return { written: false, reason: "owned-by-other-account" };
3399
+ }
3400
+ if (shouldUseCloudHttp(deps)) {
3401
+ const res = await cloudHttpRequest(deps.http, "POST", "/api/workspaces", {
3402
+ id: record.id,
3403
+ display_name: record.displayName,
3404
+ ...record.githubOrg ? { github_org: record.githubOrg } : {}
3405
+ });
3406
+ if (res.status === 201) return { written: true };
3407
+ if (res.status === 409) return classifyWorkspaceConflictHttp(record.id, res.json, deps.http);
3408
+ return { written: false, reason: classifyHttpFailureReason(res) };
3409
+ }
3410
+ const reach = await isNeonReachableForWriteWithReason(deps);
3411
+ if (!reach.reachable) return { written: false, reason: reach.reason };
1909
3412
  try {
1910
3413
  const db = deps.db ?? getPostgresDb();
1911
- await db.insert(workspaces).values({
3414
+ const rows = await db.insert(workspaces).values({
1912
3415
  id: record.id,
1913
3416
  accountId: accountId2,
1914
3417
  displayName: record.displayName,
@@ -1918,16 +3421,27 @@ async function upsertWorkspaceNeon(record, accountId2, deps = {}) {
1918
3421
  set: {
1919
3422
  displayName: record.displayName,
1920
3423
  ...record.githubOrg ? { githubOrg: record.githubOrg } : {}
1921
- }
1922
- });
1923
- return true;
3424
+ },
3425
+ setWhere: eq(workspaces.accountId, accountId2)
3426
+ }).returning({ id: workspaces.id });
3427
+ return rows.length > 0 ? { written: true } : { written: false, reason: "owned-by-other-account" };
1924
3428
  } catch {
1925
- return false;
3429
+ return { written: false, reason: "server-error" };
1926
3430
  }
1927
3431
  }
1928
- async function insertWorkspaceNeonStrict(record, accountId2, deps = {}) {
1929
- if (shouldUseCloudHttp(deps)) return insertWorkspaceStrictHttp(record, deps.http);
1930
- if (!await isNeonReachableForWrite(deps)) return "unreachable";
3432
+ async function insertWorkspaceNeonStrictReasoned(record, accountId2, deps = {}) {
3433
+ if (shouldUseCloudHttp(deps)) {
3434
+ const res = await cloudHttpRequest(deps.http, "POST", "/api/workspaces", {
3435
+ id: record.id,
3436
+ display_name: record.displayName,
3437
+ ...record.githubOrg ? { github_org: record.githubOrg } : {}
3438
+ });
3439
+ if (res.status === 201) return { result: "inserted" };
3440
+ if (res.status === 409) return { result: "conflict" };
3441
+ return { result: "unreachable", reason: classifyHttpFailureReason(res) };
3442
+ }
3443
+ const reach = await isNeonReachableForWriteWithReason(deps);
3444
+ if (!reach.reachable) return { result: "unreachable", reason: reach.reason };
1931
3445
  try {
1932
3446
  const db = deps.db ?? getPostgresDb();
1933
3447
  const rows = await db.insert(workspaces).values({
@@ -1936,9 +3450,9 @@ async function insertWorkspaceNeonStrict(record, accountId2, deps = {}) {
1936
3450
  displayName: record.displayName,
1937
3451
  ...record.githubOrg ? { githubOrg: record.githubOrg } : {}
1938
3452
  }).onConflictDoNothing({ target: workspaces.id }).returning({ id: workspaces.id });
1939
- return rows.length > 0 ? "inserted" : "conflict";
3453
+ return { result: rows.length > 0 ? "inserted" : "conflict" };
1940
3454
  } catch {
1941
- return "unreachable";
3455
+ return { result: "unreachable", reason: "server-error" };
1942
3456
  }
1943
3457
  }
1944
3458
  async function listWorkspacesNeon(accountId2, deps = {}) {
@@ -1962,6 +3476,15 @@ async function listWorkspacesNeon(accountId2, deps = {}) {
1962
3476
  }
1963
3477
  }
1964
3478
  async function renameWorkspaceNeon(id, displayName, accountId2, deps = {}) {
3479
+ if (shouldUseCloudHttp(deps)) {
3480
+ return mutateHttp(
3481
+ deps.http,
3482
+ "PATCH",
3483
+ manageVerbPath("workspaces", id),
3484
+ { display_name: displayName },
3485
+ "updated"
3486
+ );
3487
+ }
1965
3488
  if (!await isNeonReachableForWrite(deps)) return "unreachable";
1966
3489
  try {
1967
3490
  const db = deps.db ?? getPostgresDb();
@@ -1972,6 +3495,15 @@ async function renameWorkspaceNeon(id, displayName, accountId2, deps = {}) {
1972
3495
  }
1973
3496
  }
1974
3497
  async function setWorkspaceArchivedNeon(id, archived, accountId2, deps = {}) {
3498
+ if (shouldUseCloudHttp(deps)) {
3499
+ return mutateHttp(
3500
+ deps.http,
3501
+ "PATCH",
3502
+ manageVerbPath("workspaces", id),
3503
+ { archived },
3504
+ "updated"
3505
+ );
3506
+ }
1975
3507
  if (!await isNeonReachableForWrite(deps)) return "unreachable";
1976
3508
  try {
1977
3509
  const db = deps.db ?? getPostgresDb();
@@ -1982,6 +3514,9 @@ async function setWorkspaceArchivedNeon(id, archived, accountId2, deps = {}) {
1982
3514
  }
1983
3515
  }
1984
3516
  async function deleteWorkspaceNeon(id, accountId2, deps = {}) {
3517
+ if (shouldUseCloudHttp(deps)) {
3518
+ return mutateHttp(deps.http, "DELETE", manageVerbPath("workspaces", id), void 0, "deleted");
3519
+ }
1985
3520
  if (!await isNeonReachableForWrite(deps)) return "unreachable";
1986
3521
  try {
1987
3522
  const db = deps.db ?? getPostgresDb();
@@ -1991,146 +3526,22 @@ async function deleteWorkspaceNeon(id, accountId2, deps = {}) {
1991
3526
  return "unreachable";
1992
3527
  }
1993
3528
  }
1994
- var DEFAULT_ACCOUNT_ID;
1995
- var init_vault_workspace_shared = __esm({
1996
- "spex-console/src/lib/cli/vault-workspace-shared.ts"() {
1997
- "use strict";
1998
- init_sql_orm_inert_cli_shim();
1999
- init_postgres_client_cli_shim();
2000
- init_interfaces();
2001
- init_postgres();
2002
- init_neon_probe();
2003
- init_repo_identity();
2004
- DEFAULT_ACCOUNT_ID = SINGLE_USER_ID;
2005
- }
2006
- });
2007
-
2008
- // spex-console/bin/hs-workspace.ts
2009
- import { readFileSync as readFileSync6 } from "node:fs";
2010
- import path4 from "node:path";
2011
- import process2 from "node:process";
2012
- import { createInterface } from "node:readline/promises";
2013
-
2014
- // spex-console/src/lib/cli/control-shared.ts
2015
- import { readFileSync } from "node:fs";
2016
- import { homedir } from "node:os";
2017
- import { join } from "node:path";
2018
- var EXIT = {
2019
- /** Success. */
2020
- OK: 0,
2021
- /** Usage error — unknown subcommand/flag, or a missing required argument. */
2022
- USAGE: 1,
2023
- /** Claim race lost — the spec is already claimed by another agent (409). */
2024
- CONFLICT: 9,
2025
- /** Target not found — release of an unheld claim, etc. (404). */
2026
- NOT_FOUND: 10,
2027
- /** Invalid argument — bad `--ttl`, unknown `--kind`, unparseable date. */
2028
- INVALID_ARG: 11,
2029
- /** SpexConsole unreachable (network error reaching the dev server). */
2030
- CONSOLE_UNREACHABLE: 12,
2031
- /** Server failure — a non-OK response that isn't 400/402/404/409. */
2032
- WRITE_FAILED: 13,
2033
- /** Neon control plane absent — the endpoint returned 402 (upgrade required). */
2034
- NEON_REQUIRED: 14,
2035
- /**
2036
- * A cross-account `--account <id>` override was refused — the caller is
2037
- * not a builder/admin (`resolveScopedAccountId`'s `forbidden_account_override`,
2038
- * [[HS-X3GB-EKLJ-TASK-hs-cli-account-flag-for-cross-account]]). Distinct
2039
- * from `USAGE`/`INVALID_ARG` — this is an authorization refusal, not a
2040
- * malformed command.
2041
- */
2042
- FORBIDDEN: 15
2043
- };
2044
- function readPairedToken() {
2045
- try {
2046
- const raw = readFileSync(join(homedir(), ".heyspex", "credentials.yaml"), "utf-8");
2047
- const match = raw.match(/\b(hsmcp_[A-Za-z0-9_-]+|hscmp_[A-Za-z0-9_-]+)\b/);
2048
- return match?.[1];
2049
- } catch {
2050
- return void 0;
2051
- }
2052
- }
2053
-
2054
- // spex-console/src/lib/cli/defer.ts
2055
- import { readFileSync as readFileSync3 } from "node:fs";
2056
- import os from "node:os";
2057
- import path from "node:path";
2058
-
2059
- // scripts/lib/console-url-resolver.mjs
2060
- import { readFileSync as readFileSync2 } from "node:fs";
2061
- import { homedir as homedir2 } from "node:os";
2062
- import { join as join2 } from "node:path";
2063
- var DEFAULT_CONSOLE_URL = "https://app.heyspex.com";
2064
-
2065
- // spex-console/src/lib/cli/defer.ts
2066
- function resolveSpexConsoleUrl(vaultRoot, readDiscovery, opts = {}) {
2067
- const env = opts.env ?? process.env;
2068
- const raw = (env.HEYSPEX_CONSOLE_URL ?? env.HEYSPEX_COCKPIT_URL)?.trim();
2069
- if (raw && raw.length > 0) return raw.replace(/\/+$/, "");
3529
+ async function findWorkspaceForAccountNeon(workspaceId, accountId2, deps = {}) {
3530
+ if (!await isNeonReachableForWrite(deps)) return "unreachable";
3531
+ const db = deps.db ?? getPostgresDb();
2070
3532
  try {
2071
- const homeDir = opts.homeDir ?? os.homedir();
2072
- const readFile2 = opts.readFile ?? ((p) => readFileSync3(p, "utf-8"));
2073
- const credentials = readFile2(path.join(homeDir, ".heyspex", "credentials.yaml"));
2074
- const match = credentials.match(/^console_url:\s*["']?([^"'\r\n#]+)/m);
2075
- const value = match?.[1]?.trim();
2076
- if (value && value.length > 0) return value.replace(/\/+$/, "");
3533
+ const rows = await db.select({ id: workspaces.id, displayName: workspaces.displayName }).from(workspaces).where(and(eq(workspaces.id, workspaceId), eq(workspaces.accountId, accountId2))).limit(1);
3534
+ const row = rows[0];
3535
+ return row ? { id: row.id, displayName: row.displayName } : null;
2077
3536
  } catch {
3537
+ return "unreachable";
2078
3538
  }
2079
- for (const name of ["console-url", "cockpit-url"]) {
2080
- try {
2081
- const discovery = readDiscovery(`${vaultRoot}/.heyspex/${name}`);
2082
- if (discovery && discovery.trim().length > 0) {
2083
- return discovery.trim().replace(/\/+$/, "");
2084
- }
2085
- } catch {
2086
- }
2087
- }
2088
- return DEFAULT_CONSOLE_URL;
2089
- }
2090
- var HELP = [
2091
- "Usage:",
2092
- " hs defer <spec-id> [--until <when>] [--json] [--no-daemon]",
2093
- " hs undefer <spec-id> [--json] [--no-daemon]",
2094
- "",
2095
- "Time-shift a spec via its `defer_until` frontmatter field. A deferred spec is",
2096
- "scheduler-invisible until the date passes, then auto-revives.",
2097
- "",
2098
- "Arguments:",
2099
- " <spec-id> The spec to defer/undefer (e.g. HS-TASK-EWL5).",
2100
- "",
2101
- "Flags:",
2102
- " --until <when> ISO date, or relative offset +Nd / +Nw / +Nm / +Ny.",
2103
- " Defaults to +1y (matches the console Defer button).",
2104
- " Ignored by `undefer` (which always clears the field).",
2105
- " --json Emit a machine-readable JSON result.",
2106
- " --no-daemon Write straight to the vault (no console needed; CI path).",
2107
- " --help, -h Show this help.",
2108
- "",
2109
- "Exit codes: 0 ok \xB7 1 usage \xB7 10 not-found \xB7 11 bad-arg \xB7 12 console-unreachable \xB7 13 write-failed"
2110
- ].join("\n");
2111
-
2112
- // spex-console/src/lib/cli/workspace.ts
2113
- init_account_pointer();
2114
-
2115
- // dist/cli/._admin-scope-inert.cli-shim.mjs
2116
- async function resolveScopedAccountId(callerAccountId, requestedAccountId) {
2117
- const target = requestedAccountId == null ? "" : String(requestedAccountId).trim();
2118
- if (!target || target === callerAccountId) {
2119
- return { ok: true, accountId: callerAccountId };
2120
- }
2121
- return {
2122
- ok: false,
2123
- code: "forbidden_account_override",
2124
- message: "Only a builder/admin may act on another account's workspaces or vaults."
2125
- };
2126
3539
  }
2127
-
2128
- // spex-console/src/lib/vault-registry/archive.ts
2129
- init_vault_workspace_shared();
3540
+ function isReservedForOtherAccountsDefault(workspaceId, accountId2) {
3541
+ return workspaceId.startsWith("ws-user_") && workspaceId !== defaultWorkspaceId(accountId2);
3542
+ }
2130
3543
 
2131
3544
  // spex-console/src/lib/vault-registry/active-repoint.ts
2132
- init_account_pointer();
2133
- init_registry_file();
2134
3545
  function pointerDeps(deps) {
2135
3546
  return deps.accountPointer ?? { registryOpts: deps.registryOpts };
2136
3547
  }
@@ -2151,7 +3562,9 @@ async function repointActiveWorkspaceOnRemoval(accountId2, removedWorkspaceId, d
2151
3562
  }
2152
3563
 
2153
3564
  // spex-console/src/lib/vault-registry/archive.ts
2154
- init_registry_file();
3565
+ function cloudWriteFailed(failure) {
3566
+ return { ok: false, code: "cloud_write_failed", message: failure.message };
3567
+ }
2155
3568
  function workspaceNotFound(id) {
2156
3569
  return { ok: false, code: "workspace_not_found", message: `workspace "${id}" not found` };
2157
3570
  }
@@ -2166,6 +3579,7 @@ async function setWorkspaceArchivedState(input, archived, deps = {}) {
2166
3579
  deps.neon ?? {}
2167
3580
  );
2168
3581
  if (neonResult === "not_found") return workspaceNotFound(input.id);
3582
+ if (isCloudWriteFailure(neonResult)) return cloudWriteFailed(neonResult);
2169
3583
  if (archived) {
2170
3584
  await repointActiveWorkspaceOnRemoval(input.accountId, input.id, {
2171
3585
  registryOpts: deps.registryOpts,
@@ -2218,24 +3632,68 @@ function buildCloseNotDestroyConfirm(kind, verb, displayName) {
2218
3632
  }
2219
3633
 
2220
3634
  // spex-console/src/lib/vault-registry/list.ts
2221
- init_vault_workspace_shared();
2222
- init_account_pointer();
2223
- init_registry_file();
2224
- import { existsSync as existsSync2 } from "node:fs";
3635
+ import { existsSync as existsSync5 } from "node:fs";
3636
+
3637
+ // spex-console/src/lib/vault-registry/side-effects.ts
3638
+ function isLocalRegistryHost() {
3639
+ const override = process.env.HEYSPEX_VAULT_WRITE_MODE?.trim().toLowerCase();
3640
+ if (override === "cloud") return false;
3641
+ if (override === "local") return true;
3642
+ return process.env.VERCEL !== "1";
3643
+ }
3644
+ function sideEffectErrorMessage(err) {
3645
+ return err instanceof Error ? err.message : String(err);
3646
+ }
3647
+ function runRegistrySideEffect(write) {
3648
+ const step = "registry_json";
3649
+ if (!isLocalRegistryHost()) return { step, ok: true, skipped: true };
3650
+ try {
3651
+ write();
3652
+ return { step, ok: true };
3653
+ } catch (err) {
3654
+ const error = sideEffectErrorMessage(err);
3655
+ console.error(`[vault-registry] post-write side step "${step}" failed: ${error}`);
3656
+ return { step, ok: false, error };
3657
+ }
3658
+ }
3659
+ function registryWritten(effect) {
3660
+ return effect.ok && effect.skipped !== true;
3661
+ }
3662
+
3663
+ // spex-console/src/lib/vault-registry/list.ts
2225
3664
  function pointerDeps2(deps) {
2226
3665
  return deps.accountPointer ?? { registryOpts: deps.registryOpts };
2227
3666
  }
3667
+ async function resolveSharedNeonReachable(deps, pDeps) {
3668
+ if (pDeps.neonReachable !== void 0) return pDeps.neonReachable;
3669
+ if (!isLocalRegistryHost()) return true;
3670
+ const probe = deps.neonReachableProbe ?? ((d) => isNeonReachableForWrite({ db: d.db, probe: d.probe }));
3671
+ return probe(pDeps);
3672
+ }
2228
3673
  function localWorkspaceRows(deps) {
2229
3674
  return loadVaultRegistry(deps.registryOpts).workspaces;
2230
3675
  }
3676
+ function originTaggedWorkspaceRows(neonRows, deps) {
3677
+ if (neonRows === null) {
3678
+ const rows = deps.allowLocalRegistryFallback ? localWorkspaceRows(deps) : [];
3679
+ return rows.map((w) => ({ ...w, origin: "registry-only" }));
3680
+ }
3681
+ const cloudRows = neonRows.map((w) => ({ ...w, origin: "cloud-backed" }));
3682
+ if (!deps.allowLocalRegistryFallback) return cloudRows;
3683
+ const cloudIds = new Set(cloudRows.map((w) => w.id));
3684
+ const localOnlyRows = localWorkspaceRows(deps).filter((w) => !cloudIds.has(w.id)).map((w) => ({ ...w, origin: "registry-only" }));
3685
+ return [...cloudRows, ...localOnlyRows];
3686
+ }
2231
3687
  async function listWorkspaces(accountId2, deps = {}) {
2232
3688
  const pDeps = pointerDeps2(deps);
3689
+ const neonReachable = await resolveSharedNeonReachable(deps, pDeps);
3690
+ const sharedPointerDeps = { ...pDeps, neonReachable };
2233
3691
  const neonRows = await (deps.neonListWorkspaces ?? listWorkspacesNeon)(accountId2, {
2234
3692
  db: pDeps.db,
2235
- neonReachable: pDeps.neonReachable
3693
+ neonReachable
2236
3694
  });
2237
- const rows = neonRows ?? (deps.allowLocalRegistryFallback ? localWorkspaceRows(deps) : []);
2238
- const activeId = await getActiveWorkspace(accountId2, pDeps);
3695
+ const rows = originTaggedWorkspaceRows(neonRows, deps);
3696
+ const activeId = await getActiveWorkspace(accountId2, sharedPointerDeps);
2239
3697
  const scoped = deps.includeArchived ? rows : rows.filter((w) => !w.archived);
2240
3698
  return {
2241
3699
  workspaces: scoped.map((w) => ({ ...w, active: w.id === activeId })),
@@ -2243,12 +3701,7 @@ async function listWorkspaces(accountId2, deps = {}) {
2243
3701
  };
2244
3702
  }
2245
3703
 
2246
- // spex-console/src/lib/cli/workspace.ts
2247
- init_registry_file();
2248
-
2249
3704
  // spex-console/src/lib/vault-registry/workspace-create.ts
2250
- init_vault_workspace_shared();
2251
- init_registry_file();
2252
3705
  var MAX_DISPLAY_NAME_LEN = 60;
2253
3706
  var MAX_SLUG_LEN = 40;
2254
3707
  var MAX_SLUG_SUFFIX = 9;
@@ -2285,7 +3738,7 @@ function exhaustedSlugError(trimmedName, base) {
2285
3738
  message: `could not find a free slug for "${trimmedName}" -- ${base} through ${base}-${MAX_SLUG_SUFFIX} are all taken`
2286
3739
  };
2287
3740
  }
2288
- function createOffline(input, trimmedName, now, deps) {
3741
+ function createOffline(input, trimmedName, now, deps, neonWriteReason) {
2289
3742
  const existingIds = new Set(loadVaultRegistry(deps.registryOpts).workspaces.map((w) => w.id));
2290
3743
  let id;
2291
3744
  if (input.id !== void 0) {
@@ -2303,10 +3756,22 @@ function createOffline(input, trimmedName, now, deps) {
2303
3756
  if (!free) return exhaustedSlugError(trimmedName, base);
2304
3757
  id = free;
2305
3758
  }
2306
- registerWorkspace({ id, display_name: trimmedName, created_at: now }, deps.registryOpts);
3759
+ const registry = runRegistrySideEffect(
3760
+ () => registerWorkspace(
3761
+ { id, display_name: trimmedName, created_at: now, account_id: input.accountId },
3762
+ deps.registryOpts
3763
+ )
3764
+ );
2307
3765
  return {
2308
3766
  ok: true,
2309
- workspace: { id, display_name: trimmedName, neon_written: false, registry_written: true }
3767
+ workspace: {
3768
+ id,
3769
+ display_name: trimmedName,
3770
+ neon_written: false,
3771
+ ...neonWriteReason ? { neon_write_reason: neonWriteReason } : {},
3772
+ registry_written: registryWritten(registry),
3773
+ side_effects: [registry]
3774
+ }
2310
3775
  };
2311
3776
  }
2312
3777
  async function createWorkspace(input, deps = {}) {
@@ -2328,19 +3793,40 @@ async function createWorkspace(input, deps = {}) {
2328
3793
  };
2329
3794
  }
2330
3795
  const id = input.id;
2331
- registerWorkspace({ id, display_name: trimmedName, created_at: now }, deps.registryOpts);
2332
- const neonWritten = await upsertWorkspaceNeon(
3796
+ if (isReservedForOtherAccountsDefault(id, input.accountId)) {
3797
+ return {
3798
+ ok: false,
3799
+ code: WORKSPACE_OWNED_BY_OTHER_ACCOUNT,
3800
+ message: workspaceOwnedByOtherAccountMessage(id)
3801
+ };
3802
+ }
3803
+ const upserted = await upsertWorkspaceNeonReasoned(
2333
3804
  { id, displayName: trimmedName },
2334
3805
  input.accountId,
2335
3806
  deps.neon
2336
3807
  );
3808
+ if (upserted.reason === "owned-by-other-account") {
3809
+ return {
3810
+ ok: false,
3811
+ code: WORKSPACE_OWNED_BY_OTHER_ACCOUNT,
3812
+ message: workspaceOwnedByOtherAccountMessage(id)
3813
+ };
3814
+ }
3815
+ const registry = runRegistrySideEffect(
3816
+ () => registerWorkspace(
3817
+ { id, display_name: trimmedName, created_at: now, account_id: input.accountId },
3818
+ deps.registryOpts
3819
+ )
3820
+ );
2337
3821
  return {
2338
3822
  ok: true,
2339
3823
  workspace: {
2340
3824
  id,
2341
3825
  display_name: trimmedName,
2342
- neon_written: neonWritten,
2343
- registry_written: true
3826
+ neon_written: upserted.written,
3827
+ ...upserted.reason ? { neon_write_reason: upserted.reason } : {},
3828
+ registry_written: registryWritten(registry),
3829
+ side_effects: [registry]
2344
3830
  }
2345
3831
  };
2346
3832
  }
@@ -2351,17 +3837,31 @@ async function createWorkspace(input, deps = {}) {
2351
3837
  message: `invalid workspace id "${input.id}" -- must be non-empty with no path separators`
2352
3838
  };
2353
3839
  }
3840
+ if (input.id !== void 0 && isReservedForOtherAccountsDefault(input.id, input.accountId)) {
3841
+ return {
3842
+ ok: false,
3843
+ code: WORKSPACE_OWNED_BY_OTHER_ACCOUNT,
3844
+ message: workspaceOwnedByOtherAccountMessage(input.id)
3845
+ };
3846
+ }
2354
3847
  const { base, candidates } = input.id !== void 0 ? { base: input.id, candidates: [input.id] } : derivedCandidates(trimmedName);
2355
3848
  for (const candidate of candidates) {
2356
- const result = await insertWorkspaceNeonStrict(
3849
+ const { result, reason } = await insertWorkspaceNeonStrictReasoned(
2357
3850
  { id: candidate, displayName: trimmedName },
2358
3851
  input.accountId,
2359
3852
  deps.neon ?? {}
2360
3853
  );
2361
3854
  if (result === "inserted") {
2362
- registerWorkspace(
2363
- { id: candidate, display_name: trimmedName, created_at: now },
2364
- deps.registryOpts
3855
+ const registry = runRegistrySideEffect(
3856
+ () => registerWorkspace(
3857
+ {
3858
+ id: candidate,
3859
+ display_name: trimmedName,
3860
+ created_at: now,
3861
+ account_id: input.accountId
3862
+ },
3863
+ deps.registryOpts
3864
+ )
2365
3865
  );
2366
3866
  return {
2367
3867
  ok: true,
@@ -2369,18 +3869,28 @@ async function createWorkspace(input, deps = {}) {
2369
3869
  id: candidate,
2370
3870
  display_name: trimmedName,
2371
3871
  neon_written: true,
2372
- registry_written: true
3872
+ registry_written: registryWritten(registry),
3873
+ side_effects: [registry]
2373
3874
  }
2374
3875
  };
2375
3876
  }
2376
3877
  if (result === "unreachable") {
2377
- return createOffline(input, trimmedName, now, deps);
3878
+ return createOffline(input, trimmedName, now, deps, reason);
2378
3879
  }
2379
3880
  if (input.id !== void 0) {
3881
+ const owner = await findWorkspaceForAccountNeon(input.id, input.accountId, deps.neon ?? {});
3882
+ if (owner === null) {
3883
+ return {
3884
+ ok: false,
3885
+ code: WORKSPACE_OWNED_BY_OTHER_ACCOUNT,
3886
+ message: workspaceOwnedByOtherAccountMessage(input.id)
3887
+ };
3888
+ }
2380
3889
  return {
2381
3890
  ok: false,
2382
3891
  code: "duplicate_workspace",
2383
- message: `workspace "${input.id}" already exists`
3892
+ message: `workspace "${input.id}" already exists`,
3893
+ ...owner !== "unreachable" ? { owned_by_caller: true } : {}
2384
3894
  };
2385
3895
  }
2386
3896
  }
@@ -2388,12 +3898,13 @@ async function createWorkspace(input, deps = {}) {
2388
3898
  }
2389
3899
 
2390
3900
  // spex-console/src/lib/vault-registry/workspace-manage.ts
2391
- init_vault_workspace_shared();
2392
- init_registry_file();
2393
3901
  var MAX_DISPLAY_NAME_LEN2 = 60;
2394
3902
  function notFound(id) {
2395
3903
  return { ok: false, code: "workspace_not_found", message: `workspace "${id}" not found` };
2396
3904
  }
3905
+ function cloudWriteFailed2(failure) {
3906
+ return { ok: false, code: "cloud_write_failed", message: failure.message };
3907
+ }
2397
3908
  async function renameWorkspace(input, deps = {}) {
2398
3909
  const trimmedName = input.displayName.trim();
2399
3910
  if (trimmedName.length < 1 || trimmedName.length > MAX_DISPLAY_NAME_LEN2) {
@@ -2413,6 +3924,7 @@ async function renameWorkspace(input, deps = {}) {
2413
3924
  deps.neon ?? {}
2414
3925
  );
2415
3926
  if (neonResult === "not_found") return notFound(input.id);
3927
+ if (isCloudWriteFailure(neonResult)) return cloudWriteFailed2(neonResult);
2416
3928
  registerWorkspace(
2417
3929
  {
2418
3930
  ...existing,
@@ -2436,6 +3948,7 @@ async function unregisterWorkspace(input, deps = {}) {
2436
3948
  if (!existing) return notFound(input.id);
2437
3949
  const neonResult = await deleteWorkspaceNeon(input.id, input.accountId, deps.neon ?? {});
2438
3950
  if (neonResult === "not_found") return notFound(input.id);
3951
+ if (isCloudWriteFailure(neonResult)) return cloudWriteFailed2(neonResult);
2439
3952
  await repointActiveWorkspaceOnRemoval(input.accountId, input.id, {
2440
3953
  registryOpts: deps.registryOpts,
2441
3954
  accountPointer: { ...deps.neon, registryOpts: deps.registryOpts }
@@ -2451,8 +3964,312 @@ async function unregisterWorkspace(input, deps = {}) {
2451
3964
  };
2452
3965
  }
2453
3966
 
3967
+ // spex-console/src/lib/cli/receipt.ts
3968
+ var RECEIPT_AGREEMENT_FIELDS = [
3969
+ "vault_id",
3970
+ "workspace_id",
3971
+ "account_id",
3972
+ "active_vault_id"
3973
+ ];
3974
+ var ALL_FIELDS = [
3975
+ ...RECEIPT_AGREEMENT_FIELDS,
3976
+ "display_name",
3977
+ "archived"
3978
+ ];
3979
+ function unreadSide(reason) {
3980
+ return {
3981
+ read: false,
3982
+ reason,
3983
+ vault_id: null,
3984
+ workspace_id: null,
3985
+ account_id: null,
3986
+ active_vault_id: null
3987
+ };
3988
+ }
3989
+ function valueOf(side, field) {
3990
+ const v = side[field];
3991
+ return v === void 0 ? null : v;
3992
+ }
3993
+ function comparable(field, a, b) {
3994
+ return field !== "account_id" || a !== null && b !== null;
3995
+ }
3996
+ function buildReceipt(input) {
3997
+ const local = input.local ?? unreadSide("not read back");
3998
+ const cloud = input.cloud ?? unreadSide("not read back");
3999
+ if (input.failed?.reason) {
4000
+ const target = input.failed.side === "cloud" ? cloud : local;
4001
+ target.reason = input.failed.reason;
4002
+ }
4003
+ const verified = local.read && cloud.read;
4004
+ const expected = input.expected ?? {};
4005
+ const fields = ALL_FIELDS.filter(
4006
+ (f) => RECEIPT_AGREEMENT_FIELDS.includes(f) || Object.hasOwn(expected, f)
4007
+ );
4008
+ const diffs = [];
4009
+ if (verified) {
4010
+ for (const field of fields) {
4011
+ const l = valueOf(local, field);
4012
+ const c = valueOf(cloud, field);
4013
+ const hasExpected = Object.hasOwn(expected, field);
4014
+ const want = hasExpected ? expected[field] ?? null : void 0;
4015
+ const sidesDiffer = comparable(field, l, c) && l !== c;
4016
+ const offTarget = hasExpected && (l !== want || c !== want);
4017
+ if (sidesDiffer || offTarget) {
4018
+ diffs.push({ field, local: l, cloud: c, ...hasExpected ? { expected: want } : {} });
4019
+ }
4020
+ }
4021
+ }
4022
+ const changed = [];
4023
+ for (const side of ["local", "cloud"]) {
4024
+ const before = input.before?.[side];
4025
+ const after = side === "local" ? local : cloud;
4026
+ if (!before?.read || !after.read) continue;
4027
+ for (const field of ALL_FIELDS) {
4028
+ const from = valueOf(before, field);
4029
+ const to = valueOf(after, field);
4030
+ if (from !== to) changed.push({ side, field, from, to });
4031
+ }
4032
+ }
4033
+ return {
4034
+ local,
4035
+ cloud,
4036
+ verified,
4037
+ agree: verified && diffs.length === 0,
4038
+ diffs,
4039
+ changed,
4040
+ ...input.failed ? { failed: input.failed.side } : {},
4041
+ ...input.retry ? { retry: input.retry } : {}
4042
+ };
4043
+ }
4044
+ function withReceipt(result, receipt) {
4045
+ return {
4046
+ ...result,
4047
+ message: [result.message, formatReceipt(receipt)].filter(Boolean).join("\n"),
4048
+ payload: { ...result.payload, receipt }
4049
+ };
4050
+ }
4051
+ function shellArg(value) {
4052
+ return /^[\w@%+=:,./\\-]+$/.test(value) ? value : JSON.stringify(value);
4053
+ }
4054
+ function accountFlag(account) {
4055
+ return account ? ` --account ${shellArg(account)}` : "";
4056
+ }
4057
+ function receiptStatus(receipt) {
4058
+ if (receipt.failed) return "failed";
4059
+ if (!receipt.verified) return "unverified";
4060
+ if (!receipt.agree) return "disagree";
4061
+ return "verified";
4062
+ }
4063
+ var LABELS = {
4064
+ vault_id: "vault",
4065
+ workspace_id: "workspace",
4066
+ account_id: "account",
4067
+ active_vault_id: "active vault",
4068
+ display_name: "name",
4069
+ archived: "archived"
4070
+ };
4071
+ function cell(side, field) {
4072
+ if (!side.read) return "(not read)";
4073
+ const v = valueOf(side, field);
4074
+ if (v === null) return "(none)";
4075
+ if (typeof v === "boolean") return v ? "yes" : "no";
4076
+ return v;
4077
+ }
4078
+ function formatReceipt(receipt) {
4079
+ const shown = ALL_FIELDS.filter(
4080
+ (f) => RECEIPT_AGREEMENT_FIELDS.includes(f) || receipt.local[f] !== void 0 || receipt.cloud[f] !== void 0
4081
+ );
4082
+ const rows = [
4083
+ ["", "local (this machine)", "cloud (console)"],
4084
+ ...shown.map((f) => [
4085
+ LABELS[f],
4086
+ cell(receipt.local, f),
4087
+ cell(receipt.cloud, f)
4088
+ ])
4089
+ ];
4090
+ const w0 = Math.max(...rows.map((r) => r[0].length));
4091
+ const w1 = Math.max(...rows.map((r) => r[1].length));
4092
+ const lines = [
4093
+ "Read back after the write:",
4094
+ ...rows.map(([a, b, c]) => ` ${a.padEnd(w0)} ${b.padEnd(w1)} ${c}`.trimEnd())
4095
+ ];
4096
+ const status = receiptStatus(receipt);
4097
+ if (status === "failed") {
4098
+ const side = receipt.failed;
4099
+ const reason = receipt[side].reason;
4100
+ lines.push(
4101
+ `Result: failed -- the ${side} half did not land${reason ? ` (${reason})` : ""}.` + (receipt.retry ? ` Retry: \`${receipt.retry}\`` : "")
4102
+ );
4103
+ } else if (status === "unverified") {
4104
+ const unread = ["local", "cloud"].filter((s) => !receipt[s].read);
4105
+ const why = unread.map((s) => `${s}${receipt[s].reason ? `: ${receipt[s].reason}` : ""}`).join("; ");
4106
+ lines.push(
4107
+ `Result: unverified -- the write was made, but the ${unread.join(" and ")} side was not read back (${why}).`
4108
+ );
4109
+ } else if (status === "disagree") {
4110
+ const fields = receipt.diffs.map((d) => LABELS[d.field]).join(", ");
4111
+ const reads = receipt.cloud_reads && receipt.cloud_reads > 1 ? ` after ${receipt.cloud_reads} reads of the cloud side` : "";
4112
+ const show = (v) => v === void 0 || v === null ? "(none)" : typeof v === "boolean" ? v ? "yes" : "no" : v;
4113
+ lines.push(
4114
+ `Result: the two sides disagree on ${fields}${reads}.` + (receipt.retry ? ` Retry: \`${receipt.retry}\`` : ""),
4115
+ ...receipt.diffs.map(
4116
+ (d) => ` ${LABELS[d.field]}: local ${show(d.local)} | cloud ${show(d.cloud)}` + (d.expected !== void 0 ? ` | expected ${show(d.expected)}` : "")
4117
+ )
4118
+ );
4119
+ } else {
4120
+ lines.push("Result: verified -- both sides were read back and agree.");
4121
+ }
4122
+ return lines.join("\n");
4123
+ }
4124
+
4125
+ // spex-console/src/lib/cli/receipt-readback.ts
4126
+ var RECEIPT_CLOUD_READS = 3;
4127
+ var RECEIPT_REREAD_DELAY_MS = 1e3;
4128
+ var realSleep = (ms) => new Promise((resolve5) => {
4129
+ setTimeout(resolve5, ms);
4130
+ });
4131
+ function message(err) {
4132
+ return err instanceof Error ? err.message : String(err);
4133
+ }
4134
+ function readLocalReceiptSide(subject, registryOpts = {}) {
4135
+ try {
4136
+ const registry = loadVaultRegistry(registryOpts);
4137
+ const pointer = accountPointersOf(registry, subject.accountId);
4138
+ const ownerOf = (account) => account && account !== LEGACY_REGISTRY_ACCOUNT_ID ? account : null;
4139
+ if (subject.vaultId) {
4140
+ const row2 = registry.vaults.find((v) => v.id === subject.vaultId);
4141
+ return {
4142
+ read: true,
4143
+ vault_id: row2?.id ?? null,
4144
+ workspace_id: row2?.workspace_id ?? null,
4145
+ account_id: ownerOf(row2?.account_id),
4146
+ active_vault_id: pointer.active_vault_id ?? null,
4147
+ display_name: row2?.display_name ?? null,
4148
+ archived: row2 ? row2.archived ?? false : null
4149
+ };
4150
+ }
4151
+ const row = subject.workspaceId ? registry.workspaces.find((w) => w.id === subject.workspaceId) : void 0;
4152
+ return {
4153
+ read: true,
4154
+ vault_id: null,
4155
+ workspace_id: row?.id ?? null,
4156
+ account_id: ownerOf(row?.account_id),
4157
+ active_vault_id: pointer.active_vault_id ?? null,
4158
+ ...subject.workspaceId ? {
4159
+ display_name: row?.display_name ?? null,
4160
+ archived: row ? row.archived ?? false : null
4161
+ } : {}
4162
+ };
4163
+ } catch (err) {
4164
+ return unreadSide(`registry.json could not be read: ${message(err)}`);
4165
+ }
4166
+ }
4167
+ async function readList(http, path4, key) {
4168
+ const res = await cloudHttpRequest(http, "GET", path4);
4169
+ const rows = res.json?.[key];
4170
+ if (!res.ok || !Array.isArray(rows)) {
4171
+ return { reason: `GET ${path4.split("?")[0]} answered HTTP ${res.status}` };
4172
+ }
4173
+ return { rows: rows.filter((r) => Boolean(r) && typeof r === "object") };
4174
+ }
4175
+ var str = (v) => typeof v === "string" && v ? v : null;
4176
+ async function readCloudReceiptSide(subject, http) {
4177
+ if (!http) return unreadSide("no cloud transport on this call");
4178
+ const active = await cloudHttpRequest(http, "GET", "/api/vaults/active");
4179
+ if (!active.ok || !active.json || typeof active.json !== "object") {
4180
+ return unreadSide(`GET /api/vaults/active answered HTTP ${active.status}`);
4181
+ }
4182
+ const activeVaultId = str(active.json.vault_id);
4183
+ if (subject.vaultId) {
4184
+ const list2 = await readList(http, "/api/vaults?include_archived=true", "vaults");
4185
+ if ("reason" in list2) return unreadSide(list2.reason);
4186
+ const row2 = list2.rows.find((r) => r.id === subject.vaultId);
4187
+ return {
4188
+ read: true,
4189
+ vault_id: row2 ? subject.vaultId : null,
4190
+ workspace_id: str(row2?.workspace_id),
4191
+ account_id: row2 && row2.granted !== true ? subject.accountId : null,
4192
+ active_vault_id: activeVaultId,
4193
+ display_name: str(row2?.display_name),
4194
+ archived: row2 ? row2.archived === true : null
4195
+ };
4196
+ }
4197
+ if (!subject.workspaceId) {
4198
+ return {
4199
+ read: true,
4200
+ vault_id: null,
4201
+ workspace_id: null,
4202
+ account_id: null,
4203
+ active_vault_id: activeVaultId
4204
+ };
4205
+ }
4206
+ const list = await readList(http, "/api/workspaces?include_archived=true", "workspaces");
4207
+ if ("reason" in list) return unreadSide(list.reason);
4208
+ const row = list.rows.find((r) => r.id === subject.workspaceId);
4209
+ return {
4210
+ read: true,
4211
+ vault_id: null,
4212
+ workspace_id: row ? subject.workspaceId : null,
4213
+ account_id: row ? subject.accountId : null,
4214
+ active_vault_id: activeVaultId,
4215
+ display_name: str(row?.display_name),
4216
+ archived: row ? row.archived === true : null
4217
+ };
4218
+ }
4219
+ async function readSettledReceipt(subject, deps, build) {
4220
+ const sides = await readReceiptSides(subject, deps);
4221
+ let receipt = build(sides);
4222
+ const maxReads = Math.max(1, deps.cloudReads ?? RECEIPT_CLOUD_READS);
4223
+ const sleep2 = deps.sleep ?? realSleep;
4224
+ let reads = 1;
4225
+ while (!receipt.failed && receipt.verified && !receipt.agree && reads < maxReads) {
4226
+ await sleep2(RECEIPT_REREAD_DELAY_MS);
4227
+ const cloud = await readCloudReceiptSide(subject, deps.http);
4228
+ reads += 1;
4229
+ receipt = build({ local: sides.local, cloud });
4230
+ }
4231
+ return { ...receipt, cloud_reads: reads };
4232
+ }
4233
+ function receiptDisagrees(receipt) {
4234
+ return !receipt.failed && receipt.verified && !receipt.agree;
4235
+ }
4236
+ async function finishWithReceipt(result, deps, subject, opts) {
4237
+ const failed = opts.failedReason !== void 0;
4238
+ const receipt = await readSettledReceipt(
4239
+ subject,
4240
+ deps,
4241
+ (sides) => buildReceipt({
4242
+ ...sides,
4243
+ ...opts.before ? { before: opts.before } : {},
4244
+ ...opts.expected && !failed ? { expected: opts.expected } : {},
4245
+ ...failed ? { failed: { side: "cloud", reason: opts.failedReason } } : {},
4246
+ ...failed && opts.retry ? { retry: opts.retry } : {}
4247
+ })
4248
+ );
4249
+ if (failed) {
4250
+ return withReceipt({ ...result, code: EXIT.CLOUD_WRITE_FAILED, isError: true }, receipt);
4251
+ }
4252
+ if (receiptDisagrees(receipt)) {
4253
+ if (opts.retry) receipt.retry = opts.retry;
4254
+ return withReceipt(
4255
+ {
4256
+ ...result,
4257
+ code: EXIT.RECEIPT_DISAGREE,
4258
+ isError: true,
4259
+ payload: { ...result.payload, ok: false, error: "receipt_disagree" }
4260
+ },
4261
+ receipt
4262
+ );
4263
+ }
4264
+ return withReceipt(result, receipt);
4265
+ }
4266
+ async function readReceiptSides(subject, deps) {
4267
+ const local = readLocalReceiptSide(subject, deps.registryOpts);
4268
+ const cloud = await readCloudReceiptSide(subject, deps.http);
4269
+ return { local, cloud };
4270
+ }
4271
+
2454
4272
  // spex-console/src/lib/cli/workspace.ts
2455
- init_vault_workspace_shared();
2456
4273
  function parseWorkspaceArgs(argv) {
2457
4274
  const subRaw = argv[0];
2458
4275
  if (subRaw === "--help" || subRaw === "-h" || subRaw === void 0) {
@@ -2564,8 +4381,78 @@ function parseWorkspaceArgs(argv) {
2564
4381
  function accountPointerDeps(deps) {
2565
4382
  return { registryOpts: deps.registryOpts, ...deps.neon ?? {} };
2566
4383
  }
4384
+ function scopedToActiveVault(deps) {
4385
+ const acct = accountId(deps);
4386
+ let vaultId = null;
4387
+ try {
4388
+ vaultId = accountPointersOf(loadVaultRegistry(deps.registryOpts), acct).active_vault_id ?? null;
4389
+ } catch {
4390
+ }
4391
+ return { ...deps, neon: withCloudScope(deps.neon, acct, vaultId) };
4392
+ }
4393
+ function readWorkspaceSides(deps, subject) {
4394
+ return readReceiptSides(subject, { registryOpts: deps.registryOpts, http: deps.neon?.http });
4395
+ }
4396
+ function finishWorkspaceWrite(result, deps, subject, opts) {
4397
+ return finishWithReceipt(
4398
+ result,
4399
+ { registryOpts: deps.registryOpts, http: deps.neon?.http, sleep: deps.receiptSleep },
4400
+ subject,
4401
+ opts
4402
+ );
4403
+ }
4404
+ async function manageWithReceipt(verb, workspaceId, effectiveAccountId, deps, retry, write, success, expected) {
4405
+ deps = scopedToActiveVault(deps);
4406
+ const subject = { accountId: effectiveAccountId, workspaceId };
4407
+ const before = await readWorkspaceSides(deps, subject);
4408
+ const result = await write(deps);
4409
+ if (!result.ok) {
4410
+ if (result.code === "cloud_write_failed") {
4411
+ return finishWorkspaceWrite(
4412
+ {
4413
+ code: EXIT.CLOUD_WRITE_FAILED,
4414
+ message: `hs workspace ${verb}: the cloud half failed -- ${result.message} Retry: \`${retry}\``,
4415
+ payload: { ok: false, error: result.code, message: result.message, retry },
4416
+ isError: true
4417
+ },
4418
+ deps,
4419
+ subject,
4420
+ { before, failedReason: result.message, retry }
4421
+ );
4422
+ }
4423
+ return {
4424
+ code: statusForWorkspaceManageError(result.code),
4425
+ message: `hs workspace ${verb}: ${result.message}`,
4426
+ payload: { ok: false, error: result.code, message: result.message },
4427
+ isError: true
4428
+ };
4429
+ }
4430
+ const selection = await activeWorkspaceSelection(deps, effectiveAccountId);
4431
+ const done = success(result.workspace, selection);
4432
+ return finishWorkspaceWrite(
4433
+ { code: EXIT.OK, message: done.message, payload: done.payload, isError: false },
4434
+ deps,
4435
+ subject,
4436
+ { before, expected, retry }
4437
+ );
4438
+ }
2567
4439
  function accountId(deps) {
2568
- return deps.accountId ?? resolveCliAccountId();
4440
+ if (deps.accountId) return deps.accountId;
4441
+ const strict = resolveCloudAccountIdOrRefuse(deps.accountIdOpts);
4442
+ if (strict.ok) return strict.accountId;
4443
+ throw new Error(`hs workspace: ${strict.message}`);
4444
+ }
4445
+ function strictAccountId(deps) {
4446
+ if (deps.accountId) return { ok: true, accountId: deps.accountId };
4447
+ return resolveCloudAccountIdOrRefuse(deps.accountIdOpts);
4448
+ }
4449
+ function signInRequiredResult(verb, message2) {
4450
+ return {
4451
+ code: EXIT.SIGN_IN_REQUIRED,
4452
+ message: `hs workspace ${verb}: ${message2}`,
4453
+ payload: { ok: false, error: "sign_in_required", message: message2 },
4454
+ isError: true
4455
+ };
2569
4456
  }
2570
4457
  async function resolveEffectiveAccountId(deps, requestedAccount, verb) {
2571
4458
  const scope = await resolveScopedAccountId(accountId(deps), requestedAccount);
@@ -2604,7 +4491,9 @@ async function runList(args, deps) {
2604
4491
  isError: false
2605
4492
  };
2606
4493
  }
2607
- const lines = workspaces2.length ? workspaces2.map((w) => `${w.active ? "* " : " "}${w.id} ${w.display_name}`).join("\n") : ' (no workspaces registered -- run `hs workspace create --id <slug> --name "..."`)';
4494
+ const lines = workspaces2.length ? workspaces2.map(
4495
+ (w) => `${w.active ? "* " : " "}${w.id} ${w.display_name}` + (w.origin === "registry-only" ? " (this machine only)" : "")
4496
+ ).join("\n") : ' (no workspaces registered -- run `hs workspace create --id <slug> --name "..."`)';
2608
4497
  return {
2609
4498
  code: EXIT.OK,
2610
4499
  message: `${workspaces2.length} registered workspace(s):
@@ -2613,13 +4502,58 @@ ${lines}`,
2613
4502
  isError: false
2614
4503
  };
2615
4504
  }
4505
+ function describeNeonWriteFailure(reason) {
4506
+ switch (reason) {
4507
+ case "not-configured":
4508
+ return "Neon is not configured on this machine";
4509
+ case "unauthorized":
4510
+ return "the cloud write was unauthorized -- run `hs login`";
4511
+ case "network-error":
4512
+ return "the cloud console could not be reached";
4513
+ case "server-error":
4514
+ return "the cloud console rejected the write";
4515
+ case "owned-by-other-account":
4516
+ return "that id belongs to another account";
4517
+ case "workspace-owned-by-other-account":
4518
+ return "that workspace belongs to another account";
4519
+ default:
4520
+ return "Neon unreachable";
4521
+ }
4522
+ }
2616
4523
  async function runCreate(args, deps) {
4524
+ const strict = strictAccountId(deps);
4525
+ if (!strict.ok) return signInRequiredResult("create", strict.message);
4526
+ deps = scopedToActiveVault(deps);
4527
+ const subject = { accountId: strict.accountId, workspaceId: args.id };
4528
+ const before = await readWorkspaceSides(deps, subject);
4529
+ const retry = `hs workspace create --id ${shellArg(args.id)} --name ${shellArg(args.name)}`;
4530
+ const holds = (s) => s.read && s.workspace_id === args.id && s.display_name === args.name && s.archived !== true;
4531
+ if (deps.neon?.http && holds(before.local) && holds(before.cloud)) {
4532
+ return finishWorkspaceWrite(
4533
+ {
4534
+ code: EXIT.OK,
4535
+ message: `Workspace "${args.id}" (${args.name}) already exists on both sides -- nothing to change.`,
4536
+ payload: {
4537
+ ok: true,
4538
+ id: args.id,
4539
+ display_name: args.name,
4540
+ unchanged: true,
4541
+ neon_written: false,
4542
+ registry_written: false
4543
+ },
4544
+ isError: false
4545
+ },
4546
+ deps,
4547
+ subject,
4548
+ { before, expected: { workspace_id: args.id, display_name: args.name }, retry }
4549
+ );
4550
+ }
2617
4551
  const result = await createWorkspace(
2618
- { accountId: accountId(deps), id: args.id, displayName: args.name },
4552
+ { accountId: strict.accountId, id: args.id, displayName: args.name },
2619
4553
  { registryOpts: deps.registryOpts, neon: deps.neon, now: deps.now, allowIdUpsert: true }
2620
4554
  );
2621
4555
  if (!result.ok) {
2622
- const code = result.code === "duplicate_workspace" ? EXIT.CONFLICT : EXIT.INVALID_ARG;
4556
+ const code = result.code === "duplicate_workspace" || result.code === "workspace_owned_by_other_account" ? EXIT.CONFLICT : EXIT.INVALID_ARG;
2623
4557
  return {
2624
4558
  code,
2625
4559
  message: `hs workspace create: ${result.message}`,
@@ -2627,15 +4561,36 @@ async function runCreate(args, deps) {
2627
4561
  isError: true
2628
4562
  };
2629
4563
  }
2630
- const { id, display_name, neon_written, registry_written } = result.workspace;
2631
- return {
2632
- code: EXIT.OK,
2633
- message: `Created workspace "${id}" (${display_name})` + (neon_written ? " in Neon + registry.json." : " in registry.json (Neon unreachable -- registry.json only)."),
2634
- payload: { ok: true, id, display_name, neon_written, registry_written },
2635
- isError: false
2636
- };
4564
+ const { id, display_name, neon_written, neon_write_reason, registry_written } = result.workspace;
4565
+ const cloudFailed = !neon_written && deps.neon?.http !== void 0;
4566
+ const failureText = describeNeonWriteFailure(neon_write_reason);
4567
+ return finishWorkspaceWrite(
4568
+ {
4569
+ code: EXIT.OK,
4570
+ message: cloudFailed ? `hs workspace create: created workspace "${id}" (${display_name}) in registry.json only -- the cloud half failed (${failureText}). Retry: \`${retry}\`` : `Created workspace "${id}" (${display_name})` + (neon_written ? " in Neon + registry.json." : ` in registry.json only (${failureText}).`),
4571
+ payload: {
4572
+ ok: !cloudFailed,
4573
+ ...cloudFailed ? { error: "cloud_write_failed", retry } : {},
4574
+ id,
4575
+ display_name,
4576
+ neon_written,
4577
+ ...neon_write_reason ? { neon_write_reason } : {},
4578
+ registry_written
4579
+ },
4580
+ isError: false
4581
+ },
4582
+ deps,
4583
+ { ...subject, workspaceId: id },
4584
+ {
4585
+ before,
4586
+ expected: { workspace_id: id, display_name },
4587
+ ...cloudFailed ? { failedReason: neon_write_reason ?? "server-error" } : {},
4588
+ retry
4589
+ }
4590
+ );
2637
4591
  }
2638
4592
  async function runSwitch(args, deps) {
4593
+ deps = scopedToActiveVault(deps);
2639
4594
  try {
2640
4595
  await setActiveWorkspace(accountId(deps), args.id, accountPointerDeps(deps));
2641
4596
  } catch (err) {
@@ -2647,6 +4602,21 @@ async function runSwitch(args, deps) {
2647
4602
  isError: true
2648
4603
  };
2649
4604
  }
4605
+ if (err instanceof CloudPointerWriteError) {
4606
+ const message2 = `hs workspace switch: active workspace pointer not updated in the cloud (${err.message}) -- re-run \`hs workspace switch ${args.id}\` to retry.`;
4607
+ return {
4608
+ code: EXIT.CLOUD_WRITE_FAILED,
4609
+ message: message2,
4610
+ payload: {
4611
+ ok: false,
4612
+ error: "cloud_pointer_failed",
4613
+ workspace_id: args.id,
4614
+ http_status: err.status,
4615
+ message: message2
4616
+ },
4617
+ isError: true
4618
+ };
4619
+ }
2650
4620
  throw err;
2651
4621
  }
2652
4622
  return {
@@ -2661,36 +4631,35 @@ async function activeWorkspaceSelection(deps, effectiveAccountId = accountId(dep
2661
4631
  return { line: `Active workspace: ${workspaceId ?? "(none)"}`, workspaceId };
2662
4632
  }
2663
4633
  function statusForWorkspaceManageError(code) {
2664
- return code === "workspace_not_found" ? EXIT.NOT_FOUND : EXIT.INVALID_ARG;
4634
+ if (code === "workspace_not_found" || code === "vault_not_found") return EXIT.NOT_FOUND;
4635
+ if (code === "cloud_write_failed") return EXIT.CLOUD_WRITE_FAILED;
4636
+ return EXIT.INVALID_ARG;
2665
4637
  }
2666
4638
  async function runRename(args, deps) {
2667
4639
  const scoped = await resolveEffectiveAccountId(deps, args.account, "rename");
2668
4640
  if (!scoped.ok) return scoped.result;
2669
- const result = await renameWorkspace(
2670
- { accountId: scoped.accountId, id: args.id, displayName: args.name },
2671
- { registryOpts: deps.registryOpts, neon: deps.neon }
2672
- );
2673
- if (!result.ok) {
2674
- return {
2675
- code: statusForWorkspaceManageError(result.code),
2676
- message: `hs workspace rename: ${result.message}`,
2677
- payload: { ok: false, error: result.code, message: result.message },
2678
- isError: true
2679
- };
2680
- }
2681
- const selection = await activeWorkspaceSelection(deps, scoped.accountId);
2682
- return {
2683
- code: EXIT.OK,
2684
- message: `Renamed workspace "${result.workspace.id}" to "${result.workspace.display_name}".
4641
+ return manageWithReceipt(
4642
+ "rename",
4643
+ args.id,
4644
+ scoped.accountId,
4645
+ deps,
4646
+ `hs workspace rename ${shellArg(args.id)} --name ${shellArg(args.name)}${accountFlag(args.account)}`,
4647
+ (d) => renameWorkspace(
4648
+ { accountId: scoped.accountId, id: args.id, displayName: args.name },
4649
+ { registryOpts: d.registryOpts, neon: d.neon }
4650
+ ),
4651
+ (workspace, selection) => ({
4652
+ message: `Renamed workspace "${workspace.id}" to "${workspace.display_name}".
2685
4653
  ${selection.line}`,
2686
- payload: {
2687
- ok: true,
2688
- id: result.workspace.id,
2689
- display_name: result.workspace.display_name,
2690
- active_workspace_id: selection.workspaceId
2691
- },
2692
- isError: false
2693
- };
4654
+ payload: {
4655
+ ok: true,
4656
+ id: workspace.id,
4657
+ display_name: workspace.display_name,
4658
+ active_workspace_id: selection.workspaceId
4659
+ }
4660
+ }),
4661
+ { display_name: args.name.trim() }
4662
+ );
2694
4663
  }
2695
4664
  async function confirmRemoval(deps, displayName, verb, yes) {
2696
4665
  if (yes) return true;
@@ -2714,6 +4683,7 @@ async function runUnregister(args, deps) {
2714
4683
  const displayName = existing?.display_name ?? args.id;
2715
4684
  const proceed = await confirmRemoval(deps, displayName, "unregister", args.yes);
2716
4685
  if (!proceed) return cancelledResult("unregister");
4686
+ deps = scopedToActiveVault(deps);
2717
4687
  const result = await unregisterWorkspace(
2718
4688
  { accountId: scoped.accountId, id: args.id },
2719
4689
  { registryOpts: deps.registryOpts, neon: deps.neon }
@@ -2747,62 +4717,59 @@ async function runArchive(args, deps) {
2747
4717
  const displayName = existing?.display_name ?? args.id;
2748
4718
  const proceed = await confirmRemoval(deps, displayName, "archive", args.yes);
2749
4719
  if (!proceed) return cancelledResult("archive");
2750
- const result = await archiveWorkspace(
2751
- { accountId: scoped.accountId, id: args.id },
2752
- { registryOpts: deps.registryOpts, neon: deps.neon }
2753
- );
2754
- if (!result.ok) {
2755
- return {
2756
- code: EXIT.NOT_FOUND,
2757
- message: `hs workspace archive: ${result.message}`,
2758
- payload: { ok: false, error: result.code, message: result.message },
2759
- isError: true
2760
- };
2761
- }
2762
- const selection = await activeWorkspaceSelection(deps, scoped.accountId);
2763
- return {
2764
- code: EXIT.OK,
2765
- message: `Archived workspace "${result.workspace.id}". ${CLOSE_NOT_DESTROY_STATEMENT}
4720
+ return manageWithReceipt(
4721
+ "archive",
4722
+ args.id,
4723
+ scoped.accountId,
4724
+ deps,
4725
+ `hs workspace archive ${shellArg(args.id)} --yes${accountFlag(args.account)}`,
4726
+ (d) => archiveWorkspace(
4727
+ { accountId: scoped.accountId, id: args.id },
4728
+ { registryOpts: d.registryOpts, neon: d.neon }
4729
+ ),
4730
+ (workspace, selection) => ({
4731
+ message: `Archived workspace "${workspace.id}". ${CLOSE_NOT_DESTROY_STATEMENT}
2766
4732
  ${selection.line}`,
2767
- payload: {
2768
- ok: true,
2769
- id: result.workspace.id,
2770
- archived: result.workspace.archived,
2771
- active_workspace_id: selection.workspaceId
2772
- },
2773
- isError: false
2774
- };
4733
+ payload: {
4734
+ ok: true,
4735
+ id: workspace.id,
4736
+ archived: workspace.archived,
4737
+ active_workspace_id: selection.workspaceId
4738
+ }
4739
+ }),
4740
+ { archived: true }
4741
+ );
2775
4742
  }
2776
4743
  async function runRestore(args, deps) {
2777
4744
  const scoped = await resolveEffectiveAccountId(deps, args.account, "restore");
2778
4745
  if (!scoped.ok) return scoped.result;
2779
- const result = await restoreWorkspace(
2780
- { accountId: scoped.accountId, id: args.id },
2781
- { registryOpts: deps.registryOpts, neon: deps.neon }
2782
- );
2783
- if (!result.ok) {
2784
- return {
2785
- code: EXIT.NOT_FOUND,
2786
- message: `hs workspace restore: ${result.message}`,
2787
- payload: { ok: false, error: result.code, message: result.message },
2788
- isError: true
2789
- };
2790
- }
2791
- const selection = await activeWorkspaceSelection(deps, scoped.accountId);
2792
- return {
2793
- code: EXIT.OK,
2794
- message: `Restored workspace "${result.workspace.id}".
4746
+ return manageWithReceipt(
4747
+ "restore",
4748
+ args.id,
4749
+ scoped.accountId,
4750
+ deps,
4751
+ `hs workspace restore ${shellArg(args.id)}${accountFlag(args.account)}`,
4752
+ (d) => restoreWorkspace(
4753
+ { accountId: scoped.accountId, id: args.id },
4754
+ { registryOpts: d.registryOpts, neon: d.neon }
4755
+ ),
4756
+ (workspace, selection) => ({
4757
+ message: `Restored workspace "${workspace.id}".
2795
4758
  ${selection.line}`,
2796
- payload: {
2797
- ok: true,
2798
- id: result.workspace.id,
2799
- archived: result.workspace.archived,
2800
- active_workspace_id: selection.workspaceId
2801
- },
2802
- isError: false
2803
- };
4759
+ payload: {
4760
+ ok: true,
4761
+ id: workspace.id,
4762
+ archived: workspace.archived,
4763
+ active_workspace_id: selection.workspaceId
4764
+ }
4765
+ }),
4766
+ { archived: false }
4767
+ );
2804
4768
  }
2805
4769
  async function runWorkspace(args, deps) {
4770
+ const strict = strictAccountId(deps);
4771
+ if (!strict.ok) return signInRequiredResult(args.sub, strict.message);
4772
+ deps = { ...deps, accountId: strict.accountId };
2806
4773
  if (args.sub === "list") return runList(args, deps);
2807
4774
  if (args.sub === "create") return runCreate(args, deps);
2808
4775
  if (args.sub === "switch") return runSwitch(args, deps);
@@ -2811,7 +4778,7 @@ async function runWorkspace(args, deps) {
2811
4778
  if (args.sub === "archive") return runArchive(args, deps);
2812
4779
  return runRestore(args, deps);
2813
4780
  }
2814
- var HELP2 = [
4781
+ var HELP3 = [
2815
4782
  "Usage:",
2816
4783
  " hs workspace list [--json] List registered workspaces, mark active",
2817
4784
  ' hs workspace create --id <slug> --name "<name>" Create a workspace (Neon + registry.json)',
@@ -2841,24 +4808,64 @@ var HELP2 = [
2841
4808
  " --account <id> Act on a DIFFERENT account's workspace instead of your own",
2842
4809
  " (list/rename/unregister/archive/restore only) -- builder/admin-gated",
2843
4810
  " server-side; refused for a non-builder caller (exit 15).",
4811
+ " --as <email|id> Confirm the SIGNED-IN account this write is made as (create/rename/",
4812
+ " unregister/archive/restore). Must match the account this machine is",
4813
+ " signed in as, or the command refuses. Not the same as --account:",
4814
+ " --account picks the TARGET account, --as confirms who is acting.",
4815
+ " --confirm-account Confirm whichever account is signed in, without naming it (--as is safer).",
4816
+ " A terminal is asked instead; with no terminal, a write verb needs --as or",
4817
+ " --confirm-account (exit 17 otherwise). A machine whose config.yaml",
4818
+ " account_id disagrees with its sign-in is always refused.",
2844
4819
  " --json Emit a machine-readable JSON result.",
2845
4820
  " --help, -h Show this help.",
2846
4821
  "",
2847
- "Exit codes: 0 ok \xB7 1 usage \xB7 9 conflict \xB7 10 not-found \xB7 11 bad-arg \xB7 15 forbidden (--account)"
4822
+ "A workspace id that belongs to ANOTHER account is never overwritten: create refuses (exit 9).",
4823
+ "",
4824
+ "Exit codes: 0 ok \xB7 1 usage \xB7 9 conflict \xB7 10 not-found \xB7 11 bad-arg \xB7 15 forbidden (--account) \xB7",
4825
+ "12 console unreachable (account check) \xB7 16 sign-in-required (create -- run `hs login`) \xB7",
4826
+ "17 account not confirmed (--as / --confirm-account)"
2848
4827
  ].join("\n");
2849
4828
 
4829
+ // spex-console/bin/run-entry.ts
4830
+ import process2 from "node:process";
4831
+ var BinExit = class extends Error {
4832
+ code;
4833
+ constructor(code) {
4834
+ super(`exit ${code}`);
4835
+ this.name = "BinExit";
4836
+ this.code = code;
4837
+ }
4838
+ };
4839
+ function runEntry(name, main2, options = {}) {
4840
+ const { fallbackCode = 1, unhandledLabel = "unhandled" } = options;
4841
+ void (async () => {
4842
+ try {
4843
+ await main2();
4844
+ } catch (err) {
4845
+ if (err instanceof BinExit) {
4846
+ process2.exitCode = err.code;
4847
+ return;
4848
+ }
4849
+ const message2 = err instanceof Error ? err.message : String(err);
4850
+ process2.stderr.write(`${name}: ${unhandledLabel}: ${message2}
4851
+ `);
4852
+ process2.exitCode = fallbackCode;
4853
+ }
4854
+ })();
4855
+ }
4856
+
2850
4857
  // spex-console/bin/hs-workspace.ts
2851
4858
  function readFile(p) {
2852
4859
  try {
2853
- return readFileSync6(p, "utf8");
4860
+ return readFileSync9(p, "utf8");
2854
4861
  } catch {
2855
4862
  return null;
2856
4863
  }
2857
4864
  }
2858
- var INTERACTIVE = process2.stdin.isTTY === true;
4865
+ var INTERACTIVE = process3.stdin.isTTY === true;
2859
4866
  async function confirm(question) {
2860
4867
  if (!INTERACTIVE) return false;
2861
- const rl = createInterface({ input: process2.stdin, output: process2.stdout });
4868
+ const rl = createInterface2({ input: process3.stdin, output: process3.stdout });
2862
4869
  try {
2863
4870
  const answer = (await rl.question(question)).trim().toLowerCase();
2864
4871
  return answer === "y" || answer === "yes" || answer === "proceed";
@@ -2867,32 +4874,51 @@ async function confirm(question) {
2867
4874
  }
2868
4875
  }
2869
4876
  async function main() {
2870
- const parsed = parseWorkspaceArgs(process2.argv.slice(3));
4877
+ const identityArgv = extractWriteIdentityFlags(process3.argv.slice(3));
4878
+ if (identityArgv.error) {
4879
+ process3.stderr.write(`hs workspace: ${identityArgv.error}
4880
+ `);
4881
+ process3.exitCode = EXIT.INVALID_ARG;
4882
+ return;
4883
+ }
4884
+ const parsed = parseWorkspaceArgs(identityArgv.rest);
2871
4885
  if (parsed.help) {
2872
- process2.stdout.write(`${HELP2}
4886
+ process3.stdout.write(`${HELP3}
2873
4887
  `);
2874
- process2.exit(EXIT.OK);
4888
+ process3.exitCode = EXIT.OK;
4889
+ return;
2875
4890
  }
2876
4891
  if (parsed.error) {
2877
- process2.stderr.write(`hs workspace: ${parsed.error.message}
4892
+ process3.stderr.write(`hs workspace: ${parsed.error.message}
2878
4893
  `);
2879
- process2.exit(parsed.error.code);
4894
+ process3.exitCode = parsed.error.code;
4895
+ return;
2880
4896
  }
2881
- const vaultRoot = process2.env.HS_VAULT_ROOT ? path4.resolve(process2.env.HS_VAULT_ROOT) : process2.cwd();
4897
+ const vaultRoot = process3.env.HS_VAULT_ROOT ? path3.resolve(process3.env.HS_VAULT_ROOT) : process3.cwd();
2882
4898
  const consoleUrl = resolveSpexConsoleUrl(vaultRoot, readFile);
4899
+ const gate = await gateWriteVerb({
4900
+ namespace: "hs workspace",
4901
+ sub: parsed.args.sub,
4902
+ writeVerbs: WORKSPACE_WRITE_VERBS,
4903
+ flags: identityArgv.flags,
4904
+ io: defaultIdentityGateIo({ consoleUrl, platformRoot: path3.resolve(process3.cwd(), "..") })
4905
+ });
4906
+ if (gate && !gate.ok) {
4907
+ process3.stderr.write(
4908
+ `${parsed.args.json ? JSON.stringify(gate.result.payload) : gate.result.message}
4909
+ `
4910
+ );
4911
+ process3.exitCode = gate.result.code;
4912
+ return;
4913
+ }
2883
4914
  const result = await runWorkspace(parsed.args, {
2884
4915
  now: () => (/* @__PURE__ */ new Date()).toISOString(),
2885
4916
  confirm,
2886
4917
  neon: { http: { fetchImpl: fetch, consoleUrl, token: readPairedToken() } }
2887
4918
  });
2888
- const sink = result.isError ? process2.stderr : process2.stdout;
4919
+ const sink = result.isError ? process3.stderr : process3.stdout;
2889
4920
  sink.write(`${parsed.args.json ? JSON.stringify(result.payload) : result.message}
2890
4921
  `);
2891
- process2.exit(result.code);
4922
+ process3.exitCode = result.code;
2892
4923
  }
2893
- main().catch((err) => {
2894
- const message = err instanceof Error ? err.message : String(err);
2895
- process2.stderr.write(`hs workspace: unhandled: ${message}
2896
- `);
2897
- process2.exit(EXIT.WRITE_FAILED);
2898
- });
4924
+ runEntry("hs workspace", main, { fallbackCode: EXIT.WRITE_FAILED });