heyspex 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (322) hide show
  1. package/README.md +21 -0
  2. package/bin/hs.mjs +931 -98
  3. package/bin/spexcode.mjs +262 -0
  4. package/dev-factory/engine/worktree.mjs +740 -0
  5. package/dev-factory/lib/code-repo-root.mjs +67 -0
  6. package/dev-factory/lib/context-budget.mjs +84 -0
  7. package/dev-factory/lib/epic-detect.mjs +190 -0
  8. package/dev-factory/lib/fleet-queue-file.mjs +846 -0
  9. package/dev-factory/lib/fleet-queue.mjs +426 -0
  10. package/dev-factory/lib/fleet-state.mjs +14 -18
  11. package/dev-factory/lib/model-catalog.mjs +338 -0
  12. package/dev-factory/lib/seat-context.mjs +199 -0
  13. package/dev-factory/lib/spec-io.mjs +93 -0
  14. package/dev-factory/lib/sufficiency-score.mjs +8 -2
  15. package/dev-factory/lib/worktree-allocator.mjs +744 -0
  16. package/dev-factory/model-catalog.json +222 -0
  17. package/dist/cli/hs-check.mjs +1085 -103
  18. package/dist/cli/hs-claim.mjs +936 -63
  19. package/dist/cli/hs-connect.mjs +9786 -1910
  20. package/dist/cli/hs-defer.mjs +5267 -3053
  21. package/dist/cli/hs-dev.mjs +564 -0
  22. package/dist/cli/hs-env.mjs +385 -299
  23. package/dist/cli/hs-index-rebuild.mjs +7129 -0
  24. package/dist/cli/hs-index-reconcile.mjs +7852 -0
  25. package/dist/cli/hs-init.mjs +777 -138
  26. package/dist/cli/hs-install.mjs +6583 -485
  27. package/dist/cli/hs-join.mjs +10137 -2368
  28. package/dist/cli/hs-vault-pull.mjs +4659 -5882
  29. package/dist/cli/hs-vault.mjs +3793 -1537
  30. package/dist/cli/hs-workspace.mjs +2550 -524
  31. package/dist/cli-templates/customer-vault/specs/entity-model.md +594 -601
  32. package/dist/cli-templates/customer-vault/specs/protocols/canonical-frontmatter-templates.md +110 -177
  33. package/dist/cli-templates/customer-vault/specs/protocols/destructive-action-checklist.md +122 -0
  34. package/dist/cli-templates/customer-vault/specs/protocols/memory-protocol.md +13 -15
  35. package/dist/cli-templates/customer-vault/specs/protocols/session-close-protocol.md +91 -0
  36. package/dist/cli-templates/customer-vault/specs/protocols/spec-hierarchy.md +150 -168
  37. package/dist/cli-templates/customer-vault/specs/protocols/spec-summary-authoring.md +83 -0
  38. package/dist/cli-templates/harness-commands/claude/hs-auto.md +36 -0
  39. package/dist/cli-templates/harness-commands/claude/hs-backfill.md +28 -0
  40. package/dist/cli-templates/harness-commands/claude/hs-capture.md +62 -0
  41. package/dist/cli-templates/harness-commands/claude/hs-defer.md +29 -0
  42. package/dist/cli-templates/harness-commands/claude/hs-dev.md +20 -0
  43. package/dist/cli-templates/harness-commands/claude/hs-doctor.md +32 -0
  44. package/dist/cli-templates/harness-commands/claude/hs-ground.md +28 -0
  45. package/dist/cli-templates/harness-commands/claude/hs-handoff.md +33 -0
  46. package/dist/cli-templates/harness-commands/claude/hs-log.md +27 -0
  47. package/dist/cli-templates/harness-commands/claude/hs-next.md +29 -0
  48. package/dist/cli-templates/harness-commands/claude/hs-promote.md +70 -0
  49. package/dist/cli-templates/harness-commands/claude/hs-push.md +35 -0
  50. package/dist/cli-templates/harness-commands/claude/hs-qc.md +27 -0
  51. package/dist/cli-templates/harness-commands/claude/hs-res.md +63 -0
  52. package/dist/cli-templates/harness-commands/claude/hs-resolve.md +23 -0
  53. package/dist/cli-templates/harness-commands/claude/hs-review.md +23 -0
  54. package/dist/cli-templates/harness-commands/claude/hs-view.md +26 -0
  55. package/dist/cli-templates/harness-commands/claude/kill-ports.md +32 -0
  56. package/dist/cli-templates/harness-commands/claude/prototype.md +91 -0
  57. package/dist/cli-templates/harness-commands/cursor/hs-auto.md +40 -0
  58. package/dist/cli-templates/harness-commands/cursor/hs-backfill.md +31 -0
  59. package/dist/cli-templates/harness-commands/cursor/hs-capture.md +65 -0
  60. package/dist/cli-templates/harness-commands/cursor/hs-defer.md +32 -0
  61. package/dist/cli-templates/harness-commands/cursor/hs-dev.md +24 -0
  62. package/dist/cli-templates/harness-commands/cursor/hs-doctor.md +36 -0
  63. package/dist/cli-templates/harness-commands/cursor/hs-ground.md +31 -0
  64. package/dist/cli-templates/harness-commands/cursor/hs-handoff.md +37 -0
  65. package/dist/cli-templates/harness-commands/cursor/hs-log.md +31 -0
  66. package/dist/cli-templates/harness-commands/cursor/hs-next.md +32 -0
  67. package/dist/cli-templates/harness-commands/cursor/hs-promote.md +73 -0
  68. package/dist/cli-templates/harness-commands/cursor/hs-push.md +38 -0
  69. package/dist/cli-templates/harness-commands/cursor/hs-qc.md +30 -0
  70. package/dist/cli-templates/harness-commands/cursor/hs-res.md +67 -0
  71. package/dist/cli-templates/harness-commands/cursor/hs-resolve.md +26 -0
  72. package/dist/cli-templates/harness-commands/cursor/hs-review.md +26 -0
  73. package/dist/cli-templates/harness-commands/cursor/hs-view.md +30 -0
  74. package/dist/cli-templates/harness-commands/cursor/kill-ports.md +35 -0
  75. package/dist/cli-templates/harness-commands/cursor/prototype.md +95 -0
  76. package/docs/mcp-client-config.md +259 -6
  77. package/package.json +231 -124
  78. package/plugins/heyspex/.claude-plugin/plugin.json +12 -0
  79. package/plugins/heyspex/.cursor-plugin/plugin.json +15 -0
  80. package/plugins/heyspex/.mcp.json +12 -0
  81. package/plugins/heyspex/bin/launch-mcp.mjs +95 -0
  82. package/plugins/heyspex/bin/run-hook.mjs +62 -0
  83. package/plugins/heyspex/hooks/cursor-hooks.json +227 -0
  84. package/plugins/heyspex/hooks/hooks.json +361 -0
  85. package/plugins/heyspex/mcp.json +13 -0
  86. package/scripts/check-mcp-freshness.mjs +17 -9
  87. package/scripts/claude-hooks/_env-vault-events.mjs +68 -0
  88. package/scripts/claude-hooks/_guardrail-events.mjs +96 -0
  89. package/scripts/claude-hooks/_guardrail-loader.mjs +494 -0
  90. package/scripts/claude-hooks/_intent-classifier-router.mjs +410 -0
  91. package/scripts/claude-hooks/_intent-classifier-semantic.mjs +351 -0
  92. package/scripts/claude-hooks/_run.mjs +120 -0
  93. package/scripts/claude-hooks/_transcript-text.mjs +60 -0
  94. package/scripts/claude-hooks/_vault-retrieval.mjs +322 -0
  95. package/scripts/claude-hooks/agent-spawn-delegation-gate.mjs +133 -0
  96. package/scripts/claude-hooks/capture-workflow-gate.mjs +11 -0
  97. package/scripts/claude-hooks/check-auto-memory-budget.mjs +396 -0
  98. package/scripts/claude-hooks/check-claude-md-budget.mjs +327 -0
  99. package/scripts/claude-hooks/check-mcp-freshness-hook.mjs +14 -0
  100. package/scripts/claude-hooks/claim-before-code-gate.mjs +11 -0
  101. package/scripts/claude-hooks/claim-liveness-shared.mjs +575 -0
  102. package/scripts/claude-hooks/close-terminal-on-ship.mjs +64 -0
  103. package/scripts/claude-hooks/coo-denylist-gate.mjs +11 -0
  104. package/scripts/claude-hooks/coo-guard-check.mjs +11 -0
  105. package/scripts/claude-hooks/deep-research-cost-gate.mjs +11 -0
  106. package/scripts/claude-hooks/dev-server-task-reminder.mjs +15 -0
  107. package/scripts/claude-hooks/emit-claim-status.mjs +41 -0
  108. package/scripts/claude-hooks/enforce-session-sequence.mjs +11 -0
  109. package/scripts/claude-hooks/enforce-vault-naming.mjs +11 -0
  110. package/scripts/claude-hooks/env-vault-guard.mjs +11 -0
  111. package/scripts/claude-hooks/epic-milestone-phrasing-gate.mjs +11 -0
  112. package/scripts/claude-hooks/git-identity-guard.mjs +11 -0
  113. package/scripts/claude-hooks/git-reflog-audit.mjs +15 -0
  114. package/scripts/claude-hooks/git-stash-gate.mjs +11 -0
  115. package/scripts/claude-hooks/guard-auto-memory-writes.mjs +137 -0
  116. package/scripts/claude-hooks/id-display-format-gate.mjs +11 -0
  117. package/scripts/claude-hooks/inject-communication-style.mjs +66 -0
  118. package/scripts/claude-hooks/inject-coo-seat.mjs +78 -0
  119. package/scripts/claude-hooks/inject-dev-server-awareness.mjs +11 -0
  120. package/scripts/claude-hooks/inject-doctrine.mjs +13 -0
  121. package/scripts/claude-hooks/inject-vault-context.mjs +14 -0
  122. package/scripts/claude-hooks/intent-classifier-trigger.mjs +247 -0
  123. package/scripts/claude-hooks/log-session-activity.mjs +341 -0
  124. package/scripts/claude-hooks/naughty-list-gate.mjs +11 -0
  125. package/scripts/claude-hooks/notify-status-flip.mjs +72 -0
  126. package/scripts/claude-hooks/plan-exit-ingest-gate.mjs +170 -0
  127. package/scripts/claude-hooks/plan-ingest-close-reminder.mjs +66 -0
  128. package/scripts/claude-hooks/plan-review-gate.mjs +11 -0
  129. package/scripts/claude-hooks/preflight-reminder.mjs +262 -0
  130. package/scripts/claude-hooks/protected-action-intent.mjs +221 -0
  131. package/scripts/claude-hooks/research-method-guard.mjs +398 -0
  132. package/scripts/claude-hooks/research-protocol-gate.mjs +11 -0
  133. package/scripts/claude-hooks/session-end.mjs +449 -0
  134. package/scripts/claude-hooks/settings-write-gate.mjs +11 -0
  135. package/scripts/claude-hooks/settlement-check-gate.mjs +39 -0
  136. package/scripts/claude-hooks/status-flip-detect.mjs +17 -0
  137. package/scripts/claude-hooks/terminal-close-shared.mjs +426 -0
  138. package/scripts/claude-hooks/terminal-close-watcher.mjs +122 -0
  139. package/scripts/claude-hooks/wip-checkpoint.mjs +298 -0
  140. package/scripts/cursor-hooks/manifest.mjs +235 -0
  141. package/scripts/dev-server-status.mjs +34 -13
  142. package/scripts/dev-server-stop.mjs +313 -0
  143. package/scripts/heyspex-mode.mjs +268 -0
  144. package/scripts/hs-auto-resolve.mjs +240 -0
  145. package/scripts/hs-auto-source.mjs +398 -0
  146. package/scripts/hs-auto.mjs +134 -0
  147. package/scripts/hs-capture-cli.mjs +326 -0
  148. package/scripts/hs-classifier.mjs +136 -0
  149. package/scripts/hs-companion.mjs +88 -0
  150. package/scripts/hs-gateway.mjs +115 -0
  151. package/scripts/hs-mode.mjs +125 -0
  152. package/scripts/hs-next.mjs +441 -0
  153. package/scripts/hs-settings.mjs +338 -0
  154. package/scripts/hs-view.mjs +333 -0
  155. package/scripts/hs-worktree.mjs +201 -0
  156. package/scripts/install-claude-code-hooks.mjs +270 -0
  157. package/scripts/install-cursor-hooks.mjs +471 -0
  158. package/scripts/lib/agent-env-markers.mjs +38 -0
  159. package/scripts/lib/console-url-resolver.mjs +197 -60
  160. package/scripts/lib/customer-kit.mjs +886 -0
  161. package/scripts/lib/dev-mode.mjs +244 -28
  162. package/scripts/lib/heyspex-home.mjs +72 -0
  163. package/scripts/lib/hs-command.mjs +277 -0
  164. package/scripts/lib/hs-companion-settings.mjs +472 -0
  165. package/scripts/lib/hs-source-commit.mjs +59 -5
  166. package/scripts/lib/hs-source-identity.mjs +526 -0
  167. package/scripts/lib/hs-source-log.mjs +11 -21
  168. package/scripts/lib/hs-source-push.mjs +107 -58
  169. package/scripts/lib/hs-worktree-args.mjs +115 -0
  170. package/scripts/lib/shared-path-helpers.mjs +134 -0
  171. package/scripts/lib/standalone-doctor.mjs +540 -3
  172. package/scripts/lib/subagent-git-lock.mjs +109 -0
  173. package/scripts/lib/template-pin.mjs +34 -0
  174. package/scripts/provenance-lib.mjs +91 -0
  175. package/scripts/stamp-provenance-on-edit.mjs +222 -0
  176. package/scripts/wip-ledger-health.mjs +653 -0
  177. package/spexharness/doctrine/baseline/SPEXCODE-DOCTRINE.baseline.json +29 -0
  178. package/spexharness/src/adapters/audit.mjs +197 -0
  179. package/spexharness/src/adapters/claude-code.mjs +441 -0
  180. package/spexharness/src/chat/tui/ansi.mjs +448 -0
  181. package/spexharness/src/chat/tui/palette.mjs +22 -0
  182. package/spexharness/src/chat/tui/select.mjs +208 -0
  183. package/spexharness/src/config-root.mjs +54 -0
  184. package/spexharness/src/context-budget.mjs +141 -0
  185. package/spexharness/src/dev-server-control.mjs +389 -0
  186. package/spexharness/src/doctrine/behaviour.mjs +371 -0
  187. package/spexharness/src/doctrine/corpus.mjs +733 -0
  188. package/spexharness/src/doctrine/inject.mjs +338 -0
  189. package/spexharness/src/doctrine/merge.mjs +184 -0
  190. package/spexharness/src/doctrine/sources.mjs +252 -0
  191. package/spexharness/src/doctrine/synthesize.mjs +401 -0
  192. package/spexharness/src/doctrine/vault-source.mjs +574 -0
  193. package/spexharness/src/doctrine/writer.mjs +176 -0
  194. package/spexharness/src/events.mjs +84 -0
  195. package/spexharness/src/gates/capture-workflow-gate.mjs +202 -0
  196. package/spexharness/src/gates/claim-before-code-gate.decide.mjs +33 -0
  197. package/spexharness/src/gates/claim-before-code-gate.mjs +129 -0
  198. package/spexharness/src/gates/deep-research-cost-gate.mjs +274 -0
  199. package/spexharness/src/gates/enforce-session-sequence.mjs +895 -0
  200. package/spexharness/src/gates/enforce-vault-naming.mjs +215 -0
  201. package/spexharness/src/gates/epic-milestone-phrasing-gate.mjs +233 -0
  202. package/spexharness/src/gates/git-identity-guard.decide.mjs +436 -0
  203. package/spexharness/src/gates/git-identity-guard.mjs +61 -0
  204. package/spexharness/src/gates/git-reflog-audit.mjs +151 -0
  205. package/spexharness/src/gates/git-stash-gate.decide.mjs +361 -0
  206. package/spexharness/src/gates/git-stash-gate.mjs +159 -0
  207. package/spexharness/src/gates/id-display-format-gate.mjs +190 -0
  208. package/spexharness/src/gates/inject-doctrine-gate.mjs +89 -0
  209. package/spexharness/src/gates/inject-vault-context.mjs +239 -0
  210. package/spexharness/src/gates/naughty-list-gate.mjs +213 -0
  211. package/spexharness/src/gates/plan-review-gate.mjs +233 -0
  212. package/spexharness/src/gates/preflight-reminder-gate.mjs +265 -0
  213. package/spexharness/src/gates/research-protocol-gate.mjs +175 -0
  214. package/spexharness/src/gates/settings-write-gate.decide.mjs +96 -0
  215. package/spexharness/src/gates/settings-write-gate.mjs +104 -0
  216. package/spexharness/src/git-reflog-audit.mjs +294 -0
  217. package/spexharness/src/intent-classifier.mjs +194 -0
  218. package/spexharness/src/intent-directive.mjs +541 -0
  219. package/spexharness/src/profile/learn.mjs +551 -0
  220. package/spexharness/src/profile/proposals.mjs +357 -0
  221. package/spexharness/src/profile/vault-inclusion.mjs +386 -0
  222. package/spexharness/src/redact.mjs +151 -0
  223. package/spexharness/src/rule-table.mjs +819 -0
  224. package/spexharness/src/seat-context.mjs +122 -0
  225. package/spexharness/src/session-bootstrap.mjs +512 -0
  226. package/spexharness/src/session-record.mjs +815 -0
  227. package/spexharness/src/token-inventory.mjs +207 -0
  228. package/vendor/heyspex-mcp/account-switch.mjs +95 -0
  229. package/vendor/heyspex-mcp/alignment-gate.mjs +315 -1
  230. package/vendor/heyspex-mcp/blocker-resolver.mjs +37 -2
  231. package/vendor/heyspex-mcp/breakdown-ladder.mjs +119 -4
  232. package/vendor/heyspex-mcp/breakdown-source.mjs +271 -0
  233. package/vendor/heyspex-mcp/breakdown-writer.mjs +23 -3
  234. package/vendor/heyspex-mcp/capture-buffer-patches.mjs +46 -5
  235. package/vendor/heyspex-mcp/capture-channel.mjs +55 -5
  236. package/vendor/heyspex-mcp/capture-console-client.mjs +80 -1
  237. package/vendor/heyspex-mcp/capture-focus.mjs +1035 -29
  238. package/vendor/heyspex-mcp/capture-outbox.mjs +5 -1
  239. package/vendor/heyspex-mcp/capture-tools.mjs +1219 -0
  240. package/vendor/heyspex-mcp/claim-tools.mjs +1124 -0
  241. package/vendor/heyspex-mcp/cli-verbs.mjs +2076 -0
  242. package/vendor/heyspex-mcp/cli.mjs +201 -15524
  243. package/vendor/heyspex-mcp/cloud-tool-surface.mjs +128 -0
  244. package/vendor/heyspex-mcp/cloud-vault-writes.mjs +412 -0
  245. package/vendor/heyspex-mcp/config-seed.yaml +15 -0
  246. package/vendor/heyspex-mcp/config-settings.mjs +290 -7
  247. package/vendor/heyspex-mcp/console-fetch.mjs +657 -16
  248. package/vendor/heyspex-mcp/console-transport.mjs +166 -0
  249. package/vendor/heyspex-mcp/credentials.mjs +584 -0
  250. package/vendor/heyspex-mcp/defer-spec.mjs +44 -4
  251. package/vendor/heyspex-mcp/deferred-reclaim.mjs +29 -3
  252. package/vendor/heyspex-mcp/design-handlers.mjs +31 -0
  253. package/vendor/heyspex-mcp/design-tools.mjs +4 -1
  254. package/vendor/heyspex-mcp/dev-factory-bridge.mjs +4 -4
  255. package/vendor/heyspex-mcp/dev-mode-core.mjs +45 -0
  256. package/vendor/heyspex-mcp/dev-tools.mjs +124 -0
  257. package/vendor/heyspex-mcp/diagnose-handlers.mjs +54 -0
  258. package/vendor/heyspex-mcp/events-tools.mjs +207 -0
  259. package/vendor/heyspex-mcp/filing-tools.mjs +688 -0
  260. package/vendor/heyspex-mcp/fleet-handlers.mjs +37 -0
  261. package/vendor/heyspex-mcp/fleet-queue-tools.mjs +69 -14
  262. package/vendor/heyspex-mcp/focus-tools.mjs +190 -0
  263. package/vendor/heyspex-mcp/folder-trust-store.mjs +59 -0
  264. package/vendor/heyspex-mcp/http-transport.mjs +300 -0
  265. package/vendor/heyspex-mcp/list-tools.mjs +257 -0
  266. package/vendor/heyspex-mcp/login-handoff.mjs +15 -1
  267. package/vendor/heyspex-mcp/mcp-config.mjs +94 -0
  268. package/vendor/heyspex-mcp/memory-tools.mjs +523 -0
  269. package/vendor/heyspex-mcp/mvp-mode.mjs +53 -6
  270. package/vendor/heyspex-mcp/occupied-keys.mjs +141 -0
  271. package/vendor/heyspex-mcp/on-deck-brief.mjs +3360 -0
  272. package/vendor/heyspex-mcp/on-deck-scheduler.mjs +1708 -0
  273. package/vendor/heyspex-mcp/package-root.mjs +32 -0
  274. package/vendor/heyspex-mcp/peer-presence.mjs +8 -1
  275. package/vendor/heyspex-mcp/plan-ground.mjs +621 -652
  276. package/vendor/heyspex-mcp/plan-push.mjs +906 -21
  277. package/vendor/heyspex-mcp/plan-review.mjs +124 -30
  278. package/vendor/heyspex-mcp/preflight-gate.mjs +0 -0
  279. package/vendor/heyspex-mcp/procedure-handlers.mjs +325 -0
  280. package/vendor/heyspex-mcp/procedure-tools.mjs +540 -351
  281. package/vendor/heyspex-mcp/promote-capture.mjs +386 -69
  282. package/vendor/heyspex-mcp/promote-diagnose.mjs +74 -0
  283. package/vendor/heyspex-mcp/promote-research-to-diagnose.mjs +9 -2
  284. package/vendor/heyspex-mcp/promote-research.mjs +660 -53
  285. package/vendor/heyspex-mcp/promotion-helpers.mjs +251 -0
  286. package/vendor/heyspex-mcp/prompts.mjs +1460 -0
  287. package/vendor/heyspex-mcp/read-path.mjs +29 -9
  288. package/vendor/heyspex-mcp/receipt-shape.mjs +146 -0
  289. package/vendor/heyspex-mcp/recipe-handlers.mjs +117 -0
  290. package/vendor/heyspex-mcp/regen-tasks.mjs +210 -3
  291. package/vendor/heyspex-mcp/register-spec.mjs +266 -54
  292. package/vendor/heyspex-mcp/request-context.mjs +59 -0
  293. package/vendor/heyspex-mcp/research-focus.mjs +207 -4
  294. package/vendor/heyspex-mcp/research-handlers.mjs +623 -0
  295. package/vendor/heyspex-mcp/research-tools.mjs +4 -4
  296. package/vendor/heyspex-mcp/response-budget.mjs +348 -0
  297. package/vendor/heyspex-mcp/review-focus.mjs +25 -1
  298. package/vendor/heyspex-mcp/search-specs.mjs +280 -0
  299. package/vendor/heyspex-mcp/seat-block-ledger.mjs +113 -0
  300. package/vendor/heyspex-mcp/seat-identity.mjs +2 -6
  301. package/vendor/heyspex-mcp/session-owner.mjs +108 -0
  302. package/vendor/heyspex-mcp/session-tools.mjs +152 -0
  303. package/vendor/heyspex-mcp/settings-registry.mjs +232 -0
  304. package/vendor/heyspex-mcp/settings-tools.mjs +85 -12
  305. package/vendor/heyspex-mcp/spec-edit-tools.mjs +446 -0
  306. package/vendor/heyspex-mcp/spec-id-lib.mjs +93 -17
  307. package/vendor/heyspex-mcp/split-research.mjs +301 -56
  308. package/vendor/heyspex-mcp/stdio-rpc.mjs +22 -0
  309. package/vendor/heyspex-mcp/submit-for-review.mjs +132 -6
  310. package/vendor/heyspex-mcp/summary-contract.mjs +105 -0
  311. package/vendor/heyspex-mcp/summary-write-tools.mjs +224 -0
  312. package/vendor/heyspex-mcp/symptom-query.mjs +146 -0
  313. package/vendor/heyspex-mcp/test-helpers.mjs +108 -1
  314. package/vendor/heyspex-mcp/tool-catalog.mjs +2813 -0
  315. package/vendor/heyspex-mcp/tool-dispatch.mjs +639 -0
  316. package/vendor/heyspex-mcp/tool-helpers.mjs +325 -0
  317. package/vendor/heyspex-mcp/validate-linkage.mjs +414 -27
  318. package/vendor/heyspex-mcp/vault-io.mjs +724 -0
  319. package/vendor/heyspex-mcp/vault-less-endpoints.mjs +52 -0
  320. package/vendor/heyspex-mcp/vault-resolve.mjs +366 -68
  321. package/vendor/heyspex-mcp/verify-reproduction.mjs +270 -30
  322. package/vendor/heyspex-mcp/whoami.mjs +91 -0
@@ -0,0 +1,584 @@
1
+ // credentials.mjs — the ONE bearer reader for routing fact A ("which account is
2
+ // this machine signed in as"), [[HS-HL0D-WW9R-TASK-share-one-bearer-reader-and-one-console]]
3
+ // under [[HS-3PEQ-WW9R-EPIC-one-home-for-every-routing-fact-and-per]].
4
+ //
5
+ // Before this file, four readers parsed `~/.heyspex/credentials.yaml` with
6
+ // different rules: the MCP transport (console-fetch.mjs) and doctor honoured
7
+ // `HEYSPEX_MCP_TOKEN` then the `mcp_token:` key, while `hs whoami` and the CLI's
8
+ // `readPairedToken` took the FIRST `hsmcp_`/`hscmp_` match anywhere in the file
9
+ // and ignored the env var — so `hs whoami` could name one account while the MCP
10
+ // server sent another. Invariant 3 (one fact, one home): every read site now
11
+ // imports this module, and `scripts/check-shared-helpers.mjs` refuses a new
12
+ // `credentials.yaml` token regex anywhere else.
13
+ //
14
+ // Precedence, everywhere:
15
+ // 1. `HEYSPEX_MCP_TOKEN` env (trimmed; blank counts as unset)
16
+ // 2. the top-level `mcp_token:` key of `~/.heyspex/credentials.yaml`
17
+ // (never a bare token-shaped regex over the whole file — a Companion
18
+ // `hscmp_` token under `clerk:` is NOT this machine's MCP bearer)
19
+ //
20
+ // Plain Node ESM, zero dependencies: spex-console imports it straight from
21
+ // TypeScript (same as whoami.mjs), hook scripts and dev-factory import it bare.
22
+ // Pure core ({@link resolveBearer}, {@link parseMcpToken}) + thin shell
23
+ // ({@link readBearer}); the shell never throws — an unreadable file is "no
24
+ // file token". Never logs token material.
25
+ //
26
+ // Per-account store, schema v2 ([[HS-UA9S-WW9R-TASK-store-credentials-per-account-login]],
27
+ // invariant 4: credentials, pointer and active vault are keyed by account and
28
+ // switch together). The file keeps its line-oriented shape so the blocks other
29
+ // writers own (the Companion's `clerk:`, comments, `neon:`) survive byte for byte:
30
+ //
31
+ // schema_version: 2
32
+ // current_account_id: user_B
33
+ // mcp_token: hsmcp_B # mirror of the CURRENT account (see below)
34
+ // console_url: https://… # mirror of the CURRENT account's console
35
+ // accounts:
36
+ // user_A:
37
+ // mcp_token: "hsmcp_A"
38
+ // console_url: "https://…"
39
+ // email: "a@example.test"
40
+ // paired_at: "2026-10-03T…"
41
+ // user_B:
42
+ // …
43
+ // clerk:
44
+ // companion_token: … # untouched
45
+ //
46
+ // The top-level `mcp_token` / `console_url` mirror the current account so every
47
+ // reader that predates v2 (an older published MCP package, the shared console
48
+ // resolver's `console_url:` rung, `hs dev`'s console write) keeps reading the
49
+ // right answer with no change. This module is the only writer of the v2 keys
50
+ // and rewrites the mirror on every write, so the two never disagree.
51
+ //
52
+ // A v1 file (flat `mcp_token:` / `console_url:`, no `schema_version: 2`) is read
53
+ // as ONE account named `legacy` — nothing is rewritten on read. The account id
54
+ // of that legacy token is the `account_id:` an earlier `hs login` left in
55
+ // `~/.heyspex/config.yaml`; that key is now read ONLY for a v1 file, and only
56
+ // `hs login` / `hs login --migrate` (an explicit verb, with `--dry-run`)
57
+ // re-keys the legacy token under the account the console confirms for it.
58
+
59
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
60
+ import os, { homedir } from 'node:os';
61
+ import { dirname, join, resolve } from 'node:path';
62
+
63
+ /** Env var that outranks the credentials file. */
64
+ export const MCP_TOKEN_ENV = 'HEYSPEX_MCP_TOKEN';
65
+
66
+ /**
67
+ * @typedef {'env' | 'credentials-file'} BearerSource
68
+ * @typedef {{ token: string | null, source: BearerSource | null }} Bearer
69
+ */
70
+
71
+ /** `~/.heyspex/credentials.yaml` for `homeDir`. */
72
+ export function credentialsFilePath(homeDir = homedir()) {
73
+ return join(homeDir, '.heyspex', 'credentials.yaml');
74
+ }
75
+
76
+ // The ONE token regex. Anchored to a TOP-LEVEL `mcp_token:` line; an optional
77
+ // quote; the value up to a quote, comment or line end. Deliberately not a
78
+ // token-shape (`hsmcp_…`) match: the key is the fact, the prefix is not.
79
+ // `[ \t]*` (not `\s*`): the inherited copies used `\s*`, which swallowed the
80
+ // newline after an EMPTY `mcp_token:` line and captured the next line's text
81
+ // as the token — the fixture case "empty mcp_token key is no token" pins it.
82
+ const MCP_TOKEN_LINE_RE = /^mcp_token:[ \t]*["']?([^"'\r\n#]+)/m;
83
+
84
+ /**
85
+ * The `mcp_token:` value of a credentials.yaml text, or `null` when the key is
86
+ * absent/empty. Pure.
87
+ * @param {string | null | undefined} raw
88
+ * @returns {string | null}
89
+ */
90
+ export function parseMcpToken(raw) {
91
+ if (typeof raw !== 'string' || raw.length === 0) return null;
92
+ const token = raw.match(MCP_TOKEN_LINE_RE)?.[1]?.trim();
93
+ return token ? token : null;
94
+ }
95
+
96
+ // ---------------------------------------------------------------------------
97
+ // The per-account store (schema v2)
98
+ // ---------------------------------------------------------------------------
99
+
100
+ /** On-disk schema this module writes. */
101
+ export const CREDENTIALS_SCHEMA_VERSION = 2;
102
+
103
+ /** The account a v1 (flat, single-token) file's token is read under. */
104
+ export const LEGACY_ACCOUNT_ID = 'legacy';
105
+
106
+ /** The per-account fields, in the order they are written. */
107
+ const ACCOUNT_FIELDS = ['mcp_token', 'console_url', 'email', 'paired_at'];
108
+
109
+ /** Top-level keys this module owns (every other top-level line is preserved). */
110
+ const MANAGED_TOP_LEVEL_KEYS = ['schema_version', 'current_account_id', 'mcp_token', 'console_url'];
111
+
112
+ // An account id is written as a bare YAML key, so it is restricted to a safe
113
+ // alphabet (Clerk ids `user_…`, `legacy`, test ids). Anything else is refused.
114
+ const ACCOUNT_ID_RE = /^[A-Za-z0-9][A-Za-z0-9_.@+-]*$/;
115
+
116
+ /**
117
+ * @typedef {{ mcp_token: string | null, console_url: string | null, email: string | null, paired_at: string | null }} AccountCredentials
118
+ * @typedef {{ schemaVersion: 1 | 2, currentAccountId: string | null, accounts: Record<string, AccountCredentials> }} CredentialsStore
119
+ */
120
+
121
+ /** True when `id` can be stored as an account key. */
122
+ export function isValidAccountId(id) {
123
+ return typeof id === 'string' && ACCOUNT_ID_RE.test(id);
124
+ }
125
+
126
+ function topLevelScalar(raw, key) {
127
+ const re = new RegExp(`^${key}:[ \\t]*["']?([^"'\\r\\n#]+)`, 'm');
128
+ const value = raw.match(re)?.[1]?.trim();
129
+ return value ? value : null;
130
+ }
131
+
132
+ function decodeScalar(text) {
133
+ const t = text.trim();
134
+ if (t === '' || t === 'null' || t === '~') return null;
135
+ if (t.startsWith('"')) {
136
+ try {
137
+ const v = JSON.parse(t);
138
+ return typeof v === 'string' && v ? v : null;
139
+ } catch {
140
+ return t.replace(/^"|"$/g, '') || null;
141
+ }
142
+ }
143
+ if (t.startsWith("'")) return t.replace(/^'|'$/g, '') || null;
144
+ const bare = t.replace(/\s+#.*$/, '').trim();
145
+ return bare || null;
146
+ }
147
+
148
+ function emptyAccount() {
149
+ return { mcp_token: null, console_url: null, email: null, paired_at: null };
150
+ }
151
+
152
+ /** Index of the top-level `accounts:` line and the end (exclusive) of its block. */
153
+ function findAccountsBlock(lines) {
154
+ const start = lines.findIndex((l) => /^accounts:[ \t]*(#.*)?$/.test(l));
155
+ if (start < 0) return null;
156
+ let end = start + 1;
157
+ while (end < lines.length && (lines[end].trim() === '' || /^[ \t]/.test(lines[end]))) end += 1;
158
+ // Trailing blank lines belong to whatever follows, not to the block.
159
+ while (end > start + 1 && lines[end - 1].trim() === '') end -= 1;
160
+ return { start, end };
161
+ }
162
+
163
+ function parseAccountsBlock(lines) {
164
+ /** @type {Record<string, AccountCredentials>} */
165
+ const accounts = {};
166
+ const block = findAccountsBlock(lines);
167
+ if (!block) return accounts;
168
+ let current = null;
169
+ for (const line of lines.slice(block.start + 1, block.end)) {
170
+ const header = line.match(/^ {2}([^\s:#][^:]*):[ \t]*(#.*)?$/);
171
+ if (header) {
172
+ const id = decodeScalar(header[1]);
173
+ current = isValidAccountId(id) ? id : null;
174
+ if (current) accounts[current] = emptyAccount();
175
+ continue;
176
+ }
177
+ const field = line.match(/^ {4}([a-z_]+):(.*)$/);
178
+ if (field && current && ACCOUNT_FIELDS.includes(field[1])) {
179
+ accounts[current][field[1]] = decodeScalar(field[2]);
180
+ }
181
+ }
182
+ return accounts;
183
+ }
184
+
185
+ /**
186
+ * Parse a credentials.yaml text into the per-account store. Pure, never throws.
187
+ *
188
+ * A file without `schema_version: 2` (every file written before this task, an
189
+ * empty file, a missing file) is v1: its top-level `mcp_token` (if any) is the
190
+ * one account {@link LEGACY_ACCOUNT_ID}, which is then also current.
191
+ * @param {string | null | undefined} raw
192
+ * @returns {CredentialsStore}
193
+ */
194
+ export function parseCredentialsStore(raw) {
195
+ const text = typeof raw === 'string' ? raw : '';
196
+ if (topLevelScalar(text, 'schema_version') !== String(CREDENTIALS_SCHEMA_VERSION)) {
197
+ const token = parseMcpToken(text);
198
+ if (!token) return { schemaVersion: 1, currentAccountId: null, accounts: {} };
199
+ return {
200
+ schemaVersion: 1,
201
+ currentAccountId: LEGACY_ACCOUNT_ID,
202
+ accounts: {
203
+ [LEGACY_ACCOUNT_ID]: { ...emptyAccount(), mcp_token: token, console_url: topLevelScalar(text, 'console_url') },
204
+ },
205
+ };
206
+ }
207
+ const accounts = parseAccountsBlock(text.split(/\r?\n/));
208
+ const current = topLevelScalar(text, 'current_account_id');
209
+ return {
210
+ schemaVersion: 2,
211
+ currentAccountId: current && accounts[current] ? current : null,
212
+ accounts,
213
+ };
214
+ }
215
+
216
+ /** Find an account by id, or by email (case-insensitive). Pure. */
217
+ export function findStoredAccount(store, idOrEmail) {
218
+ if (typeof idOrEmail !== 'string' || !idOrEmail.trim()) return null;
219
+ const needle = idOrEmail.trim();
220
+ if (store.accounts[needle]) return { accountId: needle, ...store.accounts[needle] };
221
+ const lower = needle.toLowerCase();
222
+ for (const [accountId, entry] of Object.entries(store.accounts)) {
223
+ if (entry.email && entry.email.toLowerCase() === lower) return { accountId, ...entry };
224
+ }
225
+ return null;
226
+ }
227
+
228
+ /**
229
+ * Pure core: fold an env value and a credentials-file text into ONE bearer
230
+ * verdict. `credentialsText` is `null` when there is no readable file.
231
+ *
232
+ * `accountId` names an account: its stored token is returned (or none). Without
233
+ * it the CURRENT account's token is returned. On a v1 file the one flat token is
234
+ * the `legacy` account, which `legacyAccountId` (the v1 `config.yaml`
235
+ * `account_id`) may also name. The env var outranks the file either way — it is
236
+ * the explicit tier.
237
+ * @param {{ envToken?: string | null | undefined, credentialsText?: string | null | undefined, accountId?: string | null, legacyAccountId?: string | null }} input
238
+ * @returns {Bearer}
239
+ */
240
+ export function resolveBearer({ envToken, credentialsText, accountId, legacyAccountId } = {}) {
241
+ const fromEnv = typeof envToken === 'string' ? envToken.trim() : '';
242
+ if (fromEnv) return { token: fromEnv, source: 'env' };
243
+ const store = parseCredentialsStore(credentialsText);
244
+ let entry;
245
+ if (typeof accountId === 'string' && accountId.trim()) {
246
+ const wanted = accountId.trim();
247
+ entry = store.accounts[wanted];
248
+ if (!entry && store.schemaVersion === 1 && legacyAccountId && wanted === legacyAccountId) {
249
+ entry = store.accounts[LEGACY_ACCOUNT_ID];
250
+ }
251
+ } else {
252
+ entry = store.currentAccountId ? store.accounts[store.currentAccountId] : undefined;
253
+ }
254
+ if (entry?.mcp_token) return { token: entry.mcp_token, source: 'credentials-file' };
255
+ return { token: null, source: null };
256
+ }
257
+
258
+ function readTextOrNull(read, path) {
259
+ try {
260
+ const raw = read(path);
261
+ return typeof raw === 'string' ? raw : null;
262
+ } catch {
263
+ return null; // missing / unreadable file
264
+ }
265
+ }
266
+
267
+ // Same line-anchored shape as session-owner.mjs's historical reader.
268
+ const CONFIG_ACCOUNT_ID_RE = /^account_id:\s*["']?([^"'\r\n#]+)/m;
269
+
270
+ /**
271
+ * The v1 `account_id:` of `~/.heyspex/config.yaml` — read ONLY to name the
272
+ * account of a v1 file's legacy token (migration read). Never written.
273
+ */
274
+ function readLegacyConfigAccountId(read, homeDir) {
275
+ const raw = readTextOrNull(read, join(homeDir, '.heyspex', 'config.yaml'));
276
+ const value = raw?.match(CONFIG_ACCOUNT_ID_RE)?.[1]?.trim() ?? '';
277
+ return value || null;
278
+ }
279
+
280
+ /**
281
+ * Thin shell: read the env and the file, then {@link resolveBearer}. Never
282
+ * throws. `readFile` is a test seam (defaults to `readFileSync` utf8).
283
+ * `accountId` selects a named account's token instead of the current one's.
284
+ * @param {{ env?: NodeJS.ProcessEnv | Record<string, string | undefined>, homeDir?: string, readFile?: (p: string) => string | null, accountId?: string | null }} [opts]
285
+ * @returns {Bearer}
286
+ */
287
+ export function readBearer({ env = process.env, homeDir = homedir(), readFile, accountId } = {}) {
288
+ const read = readFile ?? ((p) => readFileSync(p, 'utf8'));
289
+ const credentialsText = readTextOrNull(read, credentialsFilePath(homeDir));
290
+ const named = typeof accountId === 'string' && accountId.trim() ? accountId.trim() : null;
291
+ const legacyAccountId =
292
+ named && parseCredentialsStore(credentialsText).schemaVersion === 1
293
+ ? readLegacyConfigAccountId(read, homeDir)
294
+ : null;
295
+ return resolveBearer({ envToken: env?.[MCP_TOKEN_ENV], credentialsText, accountId: named, legacyAccountId });
296
+ }
297
+
298
+ /**
299
+ * Read the whole store from disk (never throws; a missing file is an empty v1
300
+ * store). For v1, `legacyAccountId` carries the `config.yaml` `account_id` the
301
+ * legacy token was last confirmed as (null when none).
302
+ * @param {{ homeDir?: string, readFile?: (p: string) => string | null }} [opts]
303
+ * @returns {CredentialsStore & { legacyAccountId: string | null }}
304
+ */
305
+ export function readCredentialsStore({ homeDir = homedir(), readFile } = {}) {
306
+ const read = readFile ?? ((p) => readFileSync(p, 'utf8'));
307
+ const store = parseCredentialsStore(readTextOrNull(read, credentialsFilePath(homeDir)));
308
+ const legacyAccountId = store.schemaVersion === 1 ? readLegacyConfigAccountId(read, homeDir) : null;
309
+ return { ...store, legacyAccountId };
310
+ }
311
+
312
+ /**
313
+ * The account id this machine is signed in as, from the credentials store —
314
+ * the ONE stored account-id fact (invariant 3, fact A). No env tier here: the
315
+ * `HS_ACCOUNT_ID` override is the caller's (session-owner.mjs).
316
+ *
317
+ * - v2: `current_account_id` (a real account; `legacy` defers to the v1 rule);
318
+ * - v1 (an existing single-account file, or no file): the `config.yaml`
319
+ * `account_id` the last `hs login` confirmed — read-compatible, no manual step.
320
+ * @param {{ homeDir?: string, readFile?: (p: string) => string | null }} [opts]
321
+ * @returns {string | null}
322
+ */
323
+ export function readCurrentAccountId({ homeDir = homedir(), readFile } = {}) {
324
+ const read = readFile ?? ((p) => readFileSync(p, 'utf8'));
325
+ const store = parseCredentialsStore(readTextOrNull(read, credentialsFilePath(homeDir)));
326
+ if (store.schemaVersion === 2 && store.currentAccountId && store.currentAccountId !== LEGACY_ACCOUNT_ID) {
327
+ return store.currentAccountId;
328
+ }
329
+ if (store.schemaVersion === 2 && store.currentAccountId === null) return null;
330
+ return readLegacyConfigAccountId(read, homeDir);
331
+ }
332
+
333
+ // ---------------------------------------------------------------------------
334
+ // Real-home guard + writers
335
+ // ---------------------------------------------------------------------------
336
+
337
+ /** True under vitest (`VITEST`) or the node:test runner (`NODE_TEST_CONTEXT`). */
338
+ function underTestRunner(env = process.env) {
339
+ return Boolean(env.VITEST) || Boolean(env.NODE_TEST_CONTEXT);
340
+ }
341
+
342
+ function samePathLoose(a, b) {
343
+ const norm = (p) => {
344
+ const r = resolve(p).replace(/[\\/]+$/, '');
345
+ return process.platform === 'win32' ? r.toLowerCase() : r;
346
+ };
347
+ return norm(a) === norm(b);
348
+ }
349
+
350
+ /**
351
+ * The OS account's real home directory — `os.userInfo().homedir` (the passwd
352
+ * entry / the Windows profile), which a test's `HOME` / `USERPROFILE` swap
353
+ * does NOT move. `os.homedir()` is the wrong yardstick here: the node:test
354
+ * suites point it at a temp dir, so the real home would look "foreign".
355
+ */
356
+ function realUserHome() {
357
+ try {
358
+ const h = os.userInfo().homedir;
359
+ if (typeof h === 'string' && h) return h;
360
+ } catch {
361
+ // no passwd entry (some containers) — fall through
362
+ }
363
+ return null;
364
+ }
365
+
366
+ /**
367
+ * Refuse, under a test runner, to write anything into the REAL
368
+ * `~/.heyspex/` (credentials.yaml, config.yaml, registry.json). A sibling
369
+ * test once fell through to the real home and rewrote the owner's
370
+ * registry.json; this is the same guard registry-file.ts carries, for the
371
+ * credentials/config writers. Checked on the resolved target directory, so no
372
+ * call shape slips past it.
373
+ * @param {string} filePath
374
+ */
375
+ export function assertNotRealHomeUnderTest(filePath) {
376
+ if (!underTestRunner()) return;
377
+ const real = realUserHome();
378
+ if (real && samePathLoose(dirname(filePath), join(real, '.heyspex'))) {
379
+ throw new Error(
380
+ `credentials: refusing to write ${filePath} under a test runner — that is the real ` +
381
+ '~/.heyspex directory. Point the test at a temp home.',
382
+ );
383
+ }
384
+ }
385
+
386
+ function encodeScalar(value) {
387
+ return JSON.stringify(String(value));
388
+ }
389
+
390
+ /**
391
+ * Serialize `store` into `existingText`, rewriting only the keys this module
392
+ * owns (the managed top-level keys and the `accounts:` block) and keeping every
393
+ * other line byte-identical. Pure. Keeps an existing top-level `console_url`
394
+ * when no account is current (it is also the machine's console, `hs dev`).
395
+ * @param {string} existingText
396
+ * @param {CredentialsStore} store
397
+ * @returns {string}
398
+ */
399
+ export function serializeCredentialsStore(existingText, store) {
400
+ const lines = existingText ? existingText.split(/\r?\n/) : [];
401
+ const block = findAccountsBlock(lines);
402
+ const kept = block ? [...lines.slice(0, block.start), ...lines.slice(block.end)] : [...lines];
403
+ const previousConsoleUrl = topLevelScalar(existingText ?? '', 'console_url');
404
+ // `accounts:` too: an empty store is written as the one-line `accounts: {}`.
405
+ const managed = new RegExp(`^(${[...MANAGED_TOP_LEVEL_KEYS, 'accounts'].join('|')}):`);
406
+ const rest = kept.filter((l) => !managed.test(l));
407
+ while (rest.length > 0 && rest[rest.length - 1].trim() === '') rest.pop();
408
+ while (rest.length > 0 && rest[0].trim() === '') rest.shift();
409
+
410
+ const current = store.currentAccountId ? store.accounts[store.currentAccountId] : null;
411
+ const head = [`schema_version: ${CREDENTIALS_SCHEMA_VERSION}`];
412
+ if (current) head.push(`current_account_id: ${store.currentAccountId}`);
413
+ if (current?.mcp_token) head.push(`mcp_token: ${current.mcp_token}`);
414
+ const mirrorConsole = current?.console_url ?? previousConsoleUrl;
415
+ if (mirrorConsole) head.push(`console_url: ${mirrorConsole}`);
416
+
417
+ const accountLines = ['accounts:'];
418
+ for (const [id, entry] of Object.entries(store.accounts)) {
419
+ if (!isValidAccountId(id)) continue;
420
+ accountLines.push(` ${id}:`);
421
+ for (const field of ACCOUNT_FIELDS) {
422
+ if (entry[field]) accountLines.push(` ${field}: ${encodeScalar(entry[field])}`);
423
+ }
424
+ }
425
+ if (accountLines.length === 1) accountLines[0] = 'accounts: {}';
426
+
427
+ return `${[...head, ...accountLines, ...rest].join('\n')}\n`;
428
+ }
429
+
430
+ function writeStore(homeDir, store) {
431
+ const filePath = credentialsFilePath(homeDir);
432
+ assertNotRealHomeUnderTest(filePath);
433
+ let existing = '';
434
+ try {
435
+ existing = readFileSync(filePath, 'utf8');
436
+ } catch {
437
+ existing = '';
438
+ }
439
+ mkdirSync(dirname(filePath), { recursive: true, mode: 0o700 });
440
+ const tmp = `${filePath}.tmp`;
441
+ writeFileSync(tmp, serializeCredentialsStore(existing, store), { mode: 0o600 });
442
+ renameSync(tmp, filePath);
443
+ return filePath;
444
+ }
445
+
446
+ function loadForWrite(homeDir) {
447
+ const { legacyAccountId, ...store } = readCredentialsStore({ homeDir });
448
+ return { store: { ...store, accounts: { ...store.accounts } }, legacyAccountId };
449
+ }
450
+
451
+ function requireAccountId(fn, accountId) {
452
+ if (!isValidAccountId(accountId)) throw new Error(`${fn}: a valid account id is required`);
453
+ return accountId;
454
+ }
455
+
456
+ /**
457
+ * Store (or replace) one account's credentials, leaving every other account
458
+ * untouched, and (by default) make it current. Converts a v1 file to v2: the
459
+ * flat legacy token is kept under `rekeyLegacyAs` (the account the console
460
+ * confirmed for it) when given, else under `legacy` — never dropped, never
461
+ * guessed. Returns the file path. Never logs token material.
462
+ * @param {{ homeDir?: string, accountId: string, mcpToken: string, consoleUrl?: string | null, email?: string | null, pairedAt?: string | null, makeCurrent?: boolean, rekeyLegacyAs?: string | null }} input
463
+ */
464
+ export function writeAccountCredentials({
465
+ homeDir = homedir(),
466
+ accountId,
467
+ mcpToken,
468
+ consoleUrl = null,
469
+ email = null,
470
+ pairedAt = null,
471
+ makeCurrent = true,
472
+ rekeyLegacyAs = null,
473
+ }) {
474
+ requireAccountId('writeAccountCredentials', accountId);
475
+ if (typeof mcpToken !== 'string' || !mcpToken.trim()) {
476
+ throw new Error('writeAccountCredentials: mcpToken is required');
477
+ }
478
+ const { store } = loadForWrite(homeDir);
479
+ if (store.schemaVersion === 1) rekeyLegacy(store, rekeyLegacyAs);
480
+ store.accounts[accountId] = {
481
+ mcp_token: mcpToken.trim(),
482
+ console_url: consoleUrl ? String(consoleUrl).trim().replace(/\/+$/, '') : null,
483
+ email: email ?? null,
484
+ paired_at: pairedAt ?? new Date().toISOString(),
485
+ };
486
+ if (makeCurrent || !store.currentAccountId) store.currentAccountId = accountId;
487
+ store.schemaVersion = 2;
488
+ return writeStore(homeDir, store);
489
+ }
490
+
491
+ function rekeyLegacy(store, rekeyAs) {
492
+ const legacy = store.accounts[LEGACY_ACCOUNT_ID];
493
+ if (!legacy || !isValidAccountId(rekeyAs) || rekeyAs === LEGACY_ACCOUNT_ID) return;
494
+ delete store.accounts[LEGACY_ACCOUNT_ID];
495
+ if (!store.accounts[rekeyAs]) store.accounts[rekeyAs] = legacy;
496
+ if (store.currentAccountId === LEGACY_ACCOUNT_ID) store.currentAccountId = rekeyAs;
497
+ }
498
+
499
+ /**
500
+ * Plan (and with `apply`, perform) the v1 → v2 re-key of an existing
501
+ * single-account file: the flat token moves under `confirmedAccountId` — the
502
+ * account GET /api/mcp/session confirmed for THAT token. `hs login --migrate`
503
+ * is the only caller; `--dry-run` prints the plan without writing.
504
+ * @param {{ homeDir?: string, confirmedAccountId: string, email?: string | null, apply?: boolean }} input
505
+ * @returns {{ action: 'rekey' | 'already-v2' | 'nothing-to-migrate', accountId: string | null, path: string }}
506
+ */
507
+ export function migrateLegacyCredentials({ homeDir = homedir(), confirmedAccountId, email = null, apply = false }) {
508
+ const path = credentialsFilePath(homeDir);
509
+ const { store } = loadForWrite(homeDir);
510
+ if (store.schemaVersion === 2) return { action: 'already-v2', accountId: store.currentAccountId, path };
511
+ const legacy = store.accounts[LEGACY_ACCOUNT_ID];
512
+ if (!legacy) return { action: 'nothing-to-migrate', accountId: null, path };
513
+ requireAccountId('migrateLegacyCredentials', confirmedAccountId);
514
+ if (apply) {
515
+ rekeyLegacy(store, confirmedAccountId);
516
+ const entry = store.accounts[confirmedAccountId];
517
+ if (entry && !entry.email && email) entry.email = email;
518
+ store.currentAccountId = confirmedAccountId;
519
+ store.schemaVersion = 2;
520
+ writeStore(homeDir, store);
521
+ }
522
+ return { action: 'rekey', accountId: confirmedAccountId, path };
523
+ }
524
+
525
+ /**
526
+ * Make `accountId` current. It must already hold a token (a switch never
527
+ * pairs). Returns the file path.
528
+ * @param {{ homeDir?: string, accountId: string }} input
529
+ */
530
+ export function setCurrentAccount({ homeDir = homedir(), accountId }) {
531
+ requireAccountId('setCurrentAccount', accountId);
532
+ const { store } = loadForWrite(homeDir);
533
+ if (!store.accounts[accountId]?.mcp_token) {
534
+ throw new Error(`setCurrentAccount: no stored credentials for account ${accountId}`);
535
+ }
536
+ store.currentAccountId = accountId;
537
+ store.schemaVersion = 2;
538
+ return writeStore(homeDir, store);
539
+ }
540
+
541
+ /**
542
+ * Remove one account (default: the current one). Removing the current account
543
+ * clears `current_account_id` (and the top-level token mirror); every other
544
+ * account is untouched. A no-op write is skipped when nothing matched.
545
+ * @param {{ homeDir?: string, accountId?: string | null }} [input]
546
+ * @returns {{ removed: string | null, wasCurrent: boolean, currentAccountId: string | null, path: string }}
547
+ */
548
+ export function removeAccount({ homeDir = homedir(), accountId = null } = {}) {
549
+ const path = credentialsFilePath(homeDir);
550
+ const { store } = loadForWrite(homeDir);
551
+ const target = accountId ?? store.currentAccountId;
552
+ if (!target || !store.accounts[target]) {
553
+ return { removed: null, wasCurrent: false, currentAccountId: store.currentAccountId, path };
554
+ }
555
+ delete store.accounts[target];
556
+ const wasCurrent = store.currentAccountId === target;
557
+ if (wasCurrent) store.currentAccountId = null;
558
+ store.schemaVersion = 2;
559
+ writeStore(homeDir, store);
560
+ return { removed: target, wasCurrent, currentAccountId: store.currentAccountId, path };
561
+ }
562
+
563
+ /**
564
+ * Convenience for the many "just the token or nothing" call sites.
565
+ * @param {Parameters<typeof readBearer>[0]} [opts]
566
+ * @returns {string | null}
567
+ */
568
+ export function readBearerToken(opts) {
569
+ return readBearer(opts).token;
570
+ }
571
+
572
+ /**
573
+ * The receipt wording for a {@link BearerSource} — what `hs whoami`, the
574
+ * install identity gate and `hs mcp install` print so a human can see WHICH
575
+ * token was confirmed ([[HS-6V9X-WW9R-TASK-make-hs-install-remote-confirm-the]]).
576
+ * Never includes token material.
577
+ * @param {BearerSource | null | undefined} source
578
+ * @returns {string}
579
+ */
580
+ export function describeBearerSource(source) {
581
+ if (source === 'env') return `${MCP_TOKEN_ENV} (env)`;
582
+ if (source === 'credentials-file') return '~/.heyspex/credentials.yaml (mcp_token)';
583
+ return 'none';
584
+ }
@@ -13,7 +13,11 @@ import { join } from 'node:path';
13
13
 
14
14
  import { allocateRandomVaultId } from '../../scripts/vault-id-lib.mjs';
15
15
  import { familyOf, mintFamilyKey, mintFileKey, parseSpecId } from './spec-id-lib.mjs';
16
+ import { collectOccupiedKeysEitherKindViaTier } from './occupied-keys.mjs';
17
+ import { resolveReadTier } from './read-path.mjs';
18
+ import { effectiveConsoleUrl } from './mcp-config.mjs';
16
19
  import { yamlScalar } from './yaml-scalar.mjs';
20
+ import { resolveSessionOwner } from './session-owner.mjs';
17
21
  import {
18
22
  isoNow,
19
23
  deriveSlug,
@@ -199,6 +203,7 @@ function buildChildTaskContent({
199
203
  effort,
200
204
  handoffId,
201
205
  now,
206
+ sessionOwner = null,
202
207
  }) {
203
208
  const slug = deriveSlug(childName);
204
209
  const parentBasename = parentTask.basename;
@@ -217,6 +222,13 @@ function buildChildTaskContent({
217
222
  envelope.reason,
218
223
  ].filter(Boolean);
219
224
 
225
+ // `owner:` is inherited from the parent task; a parent with none gets the
226
+ // paired session account; with neither the line is OMITTED — never a literal
227
+ // handle ([[HS-WE87-WW9R-TASK-thread-the-gated-caller-through-every]]).
228
+ const inheritedOwner = getScalarField(parentFm.block, 'owner')?.trim();
229
+ const owner = inheritedOwner || (typeof sessionOwner === 'string' && sessionOwner.trim()) || null;
230
+ const ownerLine = owner ? `\nowner: ${yamlScalar(owner)}` : '';
231
+
220
232
  const fm = `---
221
233
  id: ${childId}
222
234
  file_id: ${childFileKey}
@@ -225,8 +237,7 @@ name: ${yamlScalar(childName)}
225
237
  type: task
226
238
  status: open
227
239
  kind: ${yamlScalar(kind)}
228
- effort: ${effort}
229
- owner: ${yamlScalar(getScalarField(parentFm.block, 'owner') ?? 'pwher')}
240
+ effort: ${effort}${ownerLine}
230
241
  parent: '${parentEpicLink}'
231
242
  milestone: '${parentMilestoneLink}'
232
243
  requires_user_testing: ${requiresUserTesting}
@@ -427,6 +438,12 @@ export async function executeDeferSpec(vaultRoot, args = {}, deps = {}) {
427
438
  // source; `id:` keys onto the file key alone.
428
439
  const parentIdValue = getScalarField(parentFm.block, 'id')?.replace(/^['"]|['"]$/g, '') ?? null;
429
440
  const parentFamilyValue = getScalarField(parentFm.block, 'family')?.replace(/^['"]|['"]$/g, '') ?? null;
441
+ // The FAMILY fallback draw (`collectOccupiedTaskIds` / `mintFamilyKey`) stays
442
+ // a DISK scan on purpose ([[HS-FJWL]]): family keys are not required to be
443
+ // globally unique — any number of specs may legally share one — so that scan
444
+ // is a "pick something fresh" default, not a uniqueness gate, and it is only
445
+ // reached at all when the parent has no inheritable family. Do not convert it
446
+ // into a console walk. The FILE key below is the opposite case.
430
447
  const childFamilyKey =
431
448
  familyOf({
432
449
  id: parentIdValue,
@@ -438,8 +455,30 @@ export async function executeDeferSpec(vaultRoot, args = {}, deps = {}) {
438
455
  )?.familyKey ??
439
456
  mintFamilyKey(vaultRoot, { randomUnit: deps?.randomUnit });
440
457
  // Per-file FILE KEY (owner ruling 2026-07-10) — filename + `file_id:` SSOT,
441
- // and (V3) the anchor for `id:` itself.
442
- const childFileKey = mintFileKey(vaultRoot, { randomUnit: deps?.randomUnit });
458
+ // and (V3) the anchor for `id:` itself. TIER-AWARE ([[HS-FJWL]]): a file key
459
+ // is the unique per-file identity, so occupancy has to come from whichever
460
+ // vault is of record for this session. An unreachable console REFUSES the
461
+ // mint rather than falling back to the stale local clone — a duplicate id is
462
+ // worse than a refusal. `deps.tier` / `deps.consoleUrl` / `deps.consoleFetch`
463
+ // are test seams; production resolves them the same way every other
464
+ // `*ViaTier` caller does.
465
+ let occupiedKeys;
466
+ try {
467
+ occupiedKeys = await collectOccupiedKeysEitherKindViaTier(vaultRoot, {
468
+ tier: deps?.tier ?? (await resolveReadTier()),
469
+ consoleUrl: deps?.consoleUrl ?? effectiveConsoleUrl(),
470
+ ...(deps?.consoleFetch ? { consoleFetch: deps.consoleFetch } : {}),
471
+ });
472
+ } catch (err) {
473
+ return {
474
+ error: 'CONSOLE_UNREACHABLE_CLOUD_MODE',
475
+ message:
476
+ `Refusing to mint a file key for the split child: ${String(err?.message ?? err)}. ` +
477
+ `In cloud reliance the occupied-key set lives in the console's vault, not on this ` +
478
+ `machine — minting from the local clone would hand out an id another spec may already hold.`,
479
+ };
480
+ }
481
+ const childFileKey = mintFileKey(occupiedKeys, { randomUnit: deps?.randomUnit });
443
482
  const childId = `HS-${childFileKey}`;
444
483
  const childSlug = deriveSlug(childName);
445
484
  const childBasename = `${childId}-${childFamilyKey}-TASK-${childSlug}`;
@@ -461,6 +500,7 @@ export async function executeDeferSpec(vaultRoot, args = {}, deps = {}) {
461
500
  effort,
462
501
  handoffId,
463
502
  now,
503
+ sessionOwner: (deps?.resolveSessionOwner ?? resolveSessionOwner)(),
464
504
  }),
465
505
  });
466
506