heyspex 0.1.0 → 0.2.1

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