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,52 @@
1
+ // vault-less-endpoints.mjs — the ONE list of console endpoints that have no
2
+ // vault yet ([[HS-MVG4-WW9R-TASK-mcp-and-hs-name-account-vault-on-every]],
3
+ // epic [[HS-BIWR-WW9R-EPIC-explicit-vault-scope-on-every-console]] invariant 2).
4
+ //
5
+ // Every other console request from the MCP server (console-fetch.mjs) and from
6
+ // the `hs` HTTP client (spex-console/src/lib/cli/control-shared.ts) MUST name
7
+ // `{ account, vault }` in its headers or refuse LOCALLY — a call that sends no
8
+ // vault is served from the account's leftover pointer, which is the 2026-10-02
9
+ // incident ([[HS-M95C-QQAV-DIAG-in-prod-mode-every-mcp-call-from-a]]). The
10
+ // endpoints below are the explicit exceptions: pairing, sign-in identity,
11
+ // creating/listing the vaults and workspaces themselves, and the dev-mode
12
+ // switch. They are matched by METHOD + exact pathname (query string ignored),
13
+ // never by prefix — `/api/vaults/active` is vault-scoped and stays gated.
14
+ //
15
+ // Plain Node ESM, zero dependencies, so the TS CLI imports it bare (same as
16
+ // `credentials.mjs`) and the two runtimes can never drift apart on the list.
17
+
18
+ /**
19
+ * `"<METHOD> <pathname>"` rows; `*` matches any method.
20
+ * @type {readonly string[]}
21
+ */
22
+ export const VAULT_LESS_ENDPOINTS = Object.freeze([
23
+ '* /api/mcp/session',
24
+ '* /api/mcp/pairing-codes',
25
+ 'POST /api/vaults',
26
+ 'GET /api/vaults',
27
+ 'POST /api/workspaces',
28
+ 'GET /api/workspaces',
29
+ '* /api/admin/dev-mode',
30
+ ]);
31
+
32
+ /** Pathname of a request path — the part before `?`, trailing slash dropped. */
33
+ export function pathnameOf(path) {
34
+ const raw = String(path ?? '').split('?')[0].split('#')[0];
35
+ const noTrailing = raw.length > 1 ? raw.replace(/\/+$/, '') : raw;
36
+ return noTrailing || '/';
37
+ }
38
+
39
+ /**
40
+ * True when `method` + `path` name an endpoint that legitimately has no vault
41
+ * yet, so the vault header is omitted and no local refusal fires.
42
+ * @param {string | null | undefined} method HTTP method (any case; missing → GET)
43
+ * @param {string} path request path, with or without a query string
44
+ */
45
+ export function isVaultLessEndpoint(method, path) {
46
+ const m = String(method || 'GET').toUpperCase();
47
+ const pathname = pathnameOf(path);
48
+ return VAULT_LESS_ENDPOINTS.some((row) => {
49
+ const [rowMethod, rowPath] = row.split(' ');
50
+ return rowPath === pathname && (rowMethod === '*' || rowMethod === m);
51
+ });
52
+ }
@@ -19,28 +19,43 @@
19
19
  * offline pointer is how an MCP restart picks up a CLI `hs vault use` switch
20
20
  * without Neon.
21
21
  *
22
- * Precedence stack (highest wins) — REORDERED by [[HS-C87T-0JNK-TASK]]: a
23
- * packaged server started with cwd inside an empty target vault used to
24
- * resolve to whatever vault another terminal/console last marked active on
25
- * the machine (old tier order put the registry pointer above cwd), silently
26
- * writing its bookkeeping into the WRONG repo. Now the folder the process was
27
- * actually started in/under wins over the registry's stale-by-comparison
28
- * pointer:
22
+ * Precedence stack (highest wins) — ONE order shared with the TS resolver
23
+ * (`spex-console/src/lib/vault-resolve/index.ts`), pinned row-by-row by the
24
+ * shared fixture `spex-console/src/lib/vault-resolve/precedence-fixtures.json`
25
+ * ([[HS-VVNQ]], decision memory `vault-resolver-precedence`). History: HS-C87T
26
+ * put the folder the process started in above the registry pointer; HS-VVNQ
27
+ * then put it above the env vars too, because the plugin manifest
28
+ * (`plugins/heyspex/.mcp.json`) sets `HEYSPEX_VAULT_ROOT=${CLAUDE_PROJECT_DIR}`
29
+ * on EVERY session — an env tier above the walk silently outranked every
30
+ * on-disk pin and marker:
29
31
  * 1. explicit vaultId -> registry lookup (vaults[].id)
30
- * 2. HEYSPEX_VAULT_ROOT env -> repo root (legacy escape hatch)
31
- * 2. HEYSPEX_VAULT_DIR env -> the specs/ dir itself (repo root = parent)
32
- * 3. cwd-contained vault -> walk UP from cwd through every ancestor
33
- * looking for a heyspex.yaml marker or a
34
- * specs/ dir; the nearest match wins. Logs
35
- * one stderr notice iff this differs from
36
- * the registry's active vault (tier 4) —
37
- * silent when they agree (the common case:
38
- * the owner's own sessions start inside the
39
- * platform repo, which is also the
40
- * registry's active vault).
41
- * 4. registry active_vault_id -> the offline active pointer (vaults[].id)
32
+ * 2. cwd-contained vault OR a -> walk UP from cwd through every ancestor
33
+ * satellite attachment looking for a heyspex.yaml marker, a
34
+ * specs/ dir, or a satellite repo's MCP
35
+ * manifest pin (readSatellitePin); the
36
+ * nearest match wins, attachment first.
37
+ * Sources: 'satellite-pin' (attachment);
38
+ * 'cwd-marker' (heyspex.yaml carries a
39
+ * `vault_id` — that id IS the vault id,
40
+ * registry row or not); 'marker-registry-
41
+ * mismatch' (the marker id disagrees with
42
+ * the registry row for that root — the
43
+ * marker id wins and stderr names
44
+ * `hs connect --check`); 'cwd-heuristic'
45
+ * (a marker without `vault_id`, or a bare
46
+ * specs/ dir: id from the registry row, else
47
+ * the `unregistered` sentinel). Logs one
48
+ * stderr notice iff the cwd vault differs
49
+ * from the registry's active vault — silent
50
+ * when they agree.
51
+ * 3. HEYSPEX_VAULT_ROOT env -> repo root (legacy escape hatch)
52
+ * 3. HEYSPEX_VAULT_DIR env -> the specs/ dir itself (repo root = parent)
53
+ * 4. registry active_vault_id -> the offline active pointer (vaults[].id);
54
+ * schema v2: the current account's row
55
+ * (`accounts[current_account_id]`), v1:
56
+ * the machine-global field
42
57
  * — reached only when cwd sits inside no
43
- * vault at all (tier 3 found nothing)
58
+ * vault and no env var names one
44
59
  * 5. cwd fallback -> cwd itself (no marker found anywhere;
45
60
  * preserves the pre-existing default)
46
61
  *
@@ -51,7 +66,7 @@
51
66
  */
52
67
 
53
68
  import { existsSync, readFileSync } from 'node:fs';
54
- import { homedir } from 'node:os';
69
+ import { homedir, tmpdir } from 'node:os';
55
70
  import { dirname, join, resolve } from 'node:path';
56
71
 
57
72
  /**
@@ -90,6 +105,8 @@ function isNonEmptyString(v) {
90
105
  * Read + defensively parse `~/.heyspex/registry.json`. A missing or malformed
91
106
  * file returns `null` (never throws) so callers fall through to env/cwd tiers —
92
107
  * mirroring registry-file.ts `loadVaultRegistry`'s fail-closed contract.
108
+ * Reads schema v1 and v2 alike; `active_vault_id` is the pointer
109
+ * {@link registryPointerVaultId} picks.
93
110
  *
94
111
  * @param {{ registryDir?: string }} [opts]
95
112
  * @returns {{ vaults: Array<{ id: string, repo_root_abs: string }>, active_vault_id: string|null }|null}
@@ -113,8 +130,49 @@ function loadRegistry(opts = {}) {
113
130
  if (!isNonEmptyString(raw.repo_root_abs)) continue;
114
131
  vaults.push({ id: raw.id, repo_root_abs: raw.repo_root_abs });
115
132
  }
116
- const active_vault_id = isNonEmptyString(json.active_vault_id) ? json.active_vault_id : null;
117
- return { vaults, active_vault_id };
133
+ return { vaults, active_vault_id: registryPointerVaultId(json) };
134
+ }
135
+
136
+ /** Mirrors registry-file.ts `LEGACY_REGISTRY_ACCOUNT_ID`. */
137
+ const LEGACY_REGISTRY_ACCOUNT_ID = 'legacy';
138
+
139
+ /**
140
+ * The offline active-vault pointer of a parsed registry file, schema v1 or v2
141
+ * ([[HS-RXLG-WW9R-TASK-key-registry-json-per-account-schema-v2]]). Mirrors
142
+ * registry-file.ts `currentAccountPointers`:
143
+ * - v2: `accounts[current_account_id].active_vault_id`. An ABSENT field
144
+ * reads the pre-account `legacy` row a not-yet-migrated machine left
145
+ * behind; an explicit `null` is a clear (reads null) when the account
146
+ * owns a row here, else it too reads `legacy`. No current account → the
147
+ * `legacy` value or null — never some other account's.
148
+ * - v1: the machine-global `active_vault_id`.
149
+ *
150
+ * @param {Record<string, unknown>} json
151
+ * @returns {string|null}
152
+ */
153
+ export function registryPointerVaultId(json) {
154
+ if (!json || typeof json !== 'object') return null;
155
+ if (json.schema_version !== 2) {
156
+ return isNonEmptyString(json.active_vault_id) ? json.active_vault_id : null;
157
+ }
158
+ const accounts =
159
+ json.accounts && typeof json.accounts === 'object' && !Array.isArray(json.accounts)
160
+ ? json.accounts
161
+ : {};
162
+ const current = isNonEmptyString(json.current_account_id) ? json.current_account_id : null;
163
+ const rowOf = (id) => (id && accounts[id] && typeof accounts[id] === 'object' ? accounts[id] : null);
164
+ const own = rowOf(current);
165
+ if (own && isNonEmptyString(own.active_vault_id)) return own.active_vault_id;
166
+ // An explicit null is a CLEAR — but only from an account that owns a row
167
+ // here; a stray row of an account that owns nothing still reads legacy.
168
+ const ownsRows = [json.vaults, json.workspaces].some(
169
+ (rows) => Array.isArray(rows) && rows.some((r) => r && r.account_id === current),
170
+ );
171
+ if (own && own.active_vault_id === null && ownsRows && current !== LEGACY_REGISTRY_ACCOUNT_ID) {
172
+ return null;
173
+ }
174
+ const legacy = rowOf(LEGACY_REGISTRY_ACCOUNT_ID);
175
+ return legacy && isNonEmptyString(legacy.active_vault_id) ? legacy.active_vault_id : null;
118
176
  }
119
177
 
120
178
  /** Find a vault row by id. Returns null when absent. */
@@ -124,40 +182,214 @@ function findVaultById(registry, vaultId) {
124
182
  }
125
183
 
126
184
  /**
127
- * Walk UP from `startDir` through every ancestor directory looking for a
128
- * `heyspex.yaml` marker file or a `specs/` dir — the two on-disk signals that
129
- * a folder IS a vault root (mirrors `hs init`'s marker + the entity-model's
130
- * folder layout). Returns the nearest matching ancestor (closest to
131
- * `startDir` wins), or `null` if the walk reaches the filesystem root (or
132
- * `stopAt`, inclusive) with no match. Bounded to guard against any path
133
- * pathology; real filesystems never nest this deep.
185
+ * Normalize a path for comparison: forward slashes, no trailing separator,
186
+ * lower-cased (this CLI's primary platform is Windows, where `C:\Dev` and
187
+ * `c:\dev` name one directory). Mirrors `satellite.ts`'s
188
+ * `normalizePathForCompare`.
189
+ */
190
+ function normalizeRoot(p) {
191
+ return resolve(p)
192
+ .replace(/[\\/]+/g, '/')
193
+ .replace(/\/+$/, '')
194
+ .toLowerCase();
195
+ }
196
+
197
+ /**
198
+ * REVERSE-resolve a filesystem repo root to its registered vault row — the
199
+ * `.mjs` twin of the TS resolver's `registryLookupByRoot`. Without it a
200
+ * path-first tier reports the `unregistered` sentinel even when the registry
201
+ * knows that exact repo, which misfiles the whole index under `unregistered`
202
+ * ([[HS-XOZ3-DIAG-NWG6-vault-identity-split-reconciled-to]]).
203
+ *
204
+ * @param {{ vaults: Array<{id: string, repo_root_abs: string}> }|null} registry
205
+ * @param {string} repoRootAbs
206
+ */
207
+ function findVaultByRoot(registry, repoRootAbs) {
208
+ if (!registry || !isNonEmptyString(repoRootAbs)) return null;
209
+ const target = normalizeRoot(repoRootAbs);
210
+ return registry.vaults.find((v) => normalizeRoot(v.repo_root_abs) === target) ?? null;
211
+ }
212
+
213
+ /**
214
+ * The MCP manifests a SATELLITE repo records its vault attachment in, and the
215
+ * env key inside them. Mirrors `spex-console/src/lib/cli/satellite.ts`
216
+ * (`SATELLITE_MANIFEST_RELS` / `SATELLITE_VAULT_ENV`) — keep the two in sync;
217
+ * the TS module carries the full rationale.
218
+ */
219
+ const SATELLITE_MANIFESTS = [['.mcp.json'], ['.cursor', 'mcp.json']];
220
+
221
+ /**
222
+ * Is `p` an ABSOLUTE path? Pure string test over the three shapes a real pin
223
+ * can carry: a Windows drive path (`C:\vault`, `C:/vault`), a UNC share
224
+ * (`\\server\share\vault`) and a POSIX root (`/vault`). Mirrors
225
+ * `satellite.ts`'s `isAbsolutePathForCompare` — keep the two in sync; the TS
226
+ * module carries the full rationale for why a non-absolute pin is refused
227
+ * rather than resolved.
228
+ *
229
+ * @param {string} p
230
+ * @returns {boolean}
231
+ */
232
+ function isAbsolutePin(p) {
233
+ const s = p.trim();
234
+ return /^[\\/]/.test(s) || /^[a-zA-Z]:[\\/]/.test(s);
235
+ }
236
+
237
+ /**
238
+ * Read a satellite attachment out of `dir`'s MCP manifests: the heyspex
239
+ * server's `env.HEYSPEX_VAULT_ROOT` pin, when it names a path OUTSIDE `dir`.
240
+ * Two kinds of value are NOT an attachment:
241
+ *
242
+ * - one still carrying an unexpanded `${...}` — the client's own
243
+ * project-dir placeholder that a normal (this-repo-IS-the-vault) install
244
+ * writes; and
245
+ * - one that is not an ABSOLUTE path ({@link isAbsolutePin}). This used to
246
+ * be `resolve(trimmed)`, which resolved a relative pin against
247
+ * `process.cwd()` — NOT the manifest's own directory — so a manifest in
248
+ * `<repo>` pinning `../some-vault` returned a directory beside whatever
249
+ * folder the MCP server happened to be launched from. No resolution is
250
+ * defensible here: the same string also reaches the server as the
251
+ * `HEYSPEX_VAULT_ROOT` env var, where tier 2 resolves it against the
252
+ * server process's cwd, so a manifest-dir rule would contradict the
253
+ * runtime. Refusing fails safe and matches `satellite.ts`.
254
+ *
255
+ * Pure + synchronous and never throws — an unreadable/unparseable manifest
256
+ * reads as "no attachment", the same fail-open posture as the registry
257
+ * loader above.
258
+ *
259
+ * @param {string} dir
260
+ * @returns {string|null} the attached vault's absolute repo root
261
+ */
262
+ export function readSatellitePin(dir) {
263
+ for (const rel of SATELLITE_MANIFESTS) {
264
+ let raw;
265
+ try {
266
+ raw = readFileSync(join(dir, ...rel), 'utf8');
267
+ } catch {
268
+ continue;
269
+ }
270
+ let value;
271
+ try {
272
+ value = JSON.parse(raw)?.mcpServers?.heyspex?.env?.HEYSPEX_VAULT_ROOT;
273
+ } catch {
274
+ continue;
275
+ }
276
+ if (!isNonEmptyString(value)) continue;
277
+ const trimmed = value.trim();
278
+ if (trimmed.includes('${')) continue;
279
+ if (!isAbsolutePin(trimmed)) continue;
280
+ const pinned = normalizeRoot(trimmed);
281
+ const here = normalizeRoot(dir);
282
+ if (pinned === here || pinned.startsWith(`${here}/`)) continue; // ordinary install
283
+ return resolve(trimmed);
284
+ }
285
+ return null;
286
+ }
287
+
288
+ /**
289
+ * Is `dir` a vault root? True when it contains a `heyspex.yaml` marker file
290
+ * OR a `specs/` dir — the two on-disk signals that a folder IS a vault root
291
+ * (mirrors `hs init`'s marker + the entity-model's folder layout). This is
292
+ * the ONE definition of "is this a vault root" — every walk/check in this
293
+ * file (and in `bin/hs.mjs`) routes through it rather than re-testing the
294
+ * two signals inline. Pure and synchronous: no config reads, no walking, no
295
+ * side effects — just the two existsSync checks.
296
+ *
297
+ * One exclusion ([[HS-VVNQ]]): the OS temp directory ITSELF is never a vault
298
+ * root. It is shared scratch for every process on the machine, test runs
299
+ * leave a stray `specs/` there, and once the walk outranks env and the
300
+ * pointer, every process started anywhere under it would otherwise resolve
301
+ * to that junk folder. Subdirectories of it (a real scratch vault) still
302
+ * count. The TS resolver applies the same rule.
303
+ *
304
+ * @param {string} dir
305
+ * @returns {boolean}
306
+ */
307
+ export function isVaultRoot(dir) {
308
+ if (normalizeRoot(dir) === normalizeRoot(tmpdir())) return false;
309
+ return existsSync(join(dir, 'heyspex.yaml')) || existsSync(join(dir, 'specs'));
310
+ }
311
+
312
+ /**
313
+ * Extract `vault_id: <value>` from raw heyspex.yaml text, or `null` if absent.
314
+ * Same semantics as `spex-console/src/lib/cli/init.ts` `parseVaultId` (the
315
+ * writer's own reader): first line-anchored `vault_id:` key, value trimmed,
316
+ * one matching pair of surrounding quotes stripped, empty -> `null`. Kept to
317
+ * this one line-regex on purpose — `hs init` writes the marker as flat
318
+ * `key: value` lines, so a YAML parser buys nothing here.
319
+ *
320
+ * @param {string} content
321
+ * @returns {string|null}
322
+ */
323
+ export function parseMarkerVaultId(content) {
324
+ const m = String(content ?? '').match(/^vault_id:\s*(.+?)\s*$/m);
325
+ if (!m) return null;
326
+ const unquoted = m[1].trim().replace(/^"(.*)"$/, '$1').replace(/^'(.*)'$/, '$1');
327
+ return unquoted.length > 0 ? unquoted : null;
328
+ }
329
+
330
+ /**
331
+ * The `vault_id` written in `<dir>/heyspex.yaml`, or `null` (no marker, no
332
+ * key, unreadable). Never throws.
333
+ *
334
+ * @param {string} dir
335
+ * @returns {string|null}
336
+ */
337
+ function readMarkerVaultId(dir) {
338
+ try {
339
+ return parseMarkerVaultId(readFileSync(join(dir, 'heyspex.yaml'), 'utf8'));
340
+ } catch {
341
+ return null;
342
+ }
343
+ }
344
+
345
+ /**
346
+ * The marker id and the registry row for the same root disagree. The marker
347
+ * wins (it is the repo's own on-disk declaration, written by `hs init` /
348
+ * `hs connect`); this notice names the command whose `id_agreement` row
349
+ * reports the same disagreement so the operator can repair the registry.
350
+ *
351
+ * @param {string} repoRootAbs
352
+ * @param {string} markerVaultId
353
+ * @param {string} registryVaultId
354
+ */
355
+ function logMarkerRegistryMismatch(repoRootAbs, markerVaultId, registryVaultId) {
356
+ process.stderr.write(
357
+ `[heyspex-mcp] vault resolution: ${resolve(repoRootAbs)}/heyspex.yaml says vault_id ${markerVaultId} but the registry row for that root says ${registryVaultId} — using the marker id. Run \`hs connect --check\` to see and repair the disagreement.\n`,
358
+ );
359
+ }
360
+
361
+ /**
362
+ * Walk UP from `startDir` taking the NEAREST of the two tier-2 (walk) signals: a
363
+ * vault root (see {@link isVaultRoot}) or a satellite attachment (see
364
+ * {@link readSatellitePin}). The attachment is checked FIRST at each level,
365
+ * because a repo that explicitly names its vault is never also asking to be
366
+ * one.
134
367
  *
135
368
  * @param {string} startDir
136
369
  * @param {string|undefined} stopAt - test-only walk boundary (checked, then
137
370
  * the walk stops — never climbs past it). Unset in production, so the walk
138
371
  * always reaches the real filesystem root; tests use it to fence the walk
139
- * inside a disposable tmp fixture so ambient/unrelated directories higher up
140
- * the real tree (e.g. a stray `specs` folder under the OS temp dir) can't
141
- * produce a false match.
142
- * @returns {string|null}
372
+ * inside a disposable tmp fixture so ambient/unrelated directories higher
373
+ * up the real tree cannot produce a false match.
374
+ * @returns {{ repoRootAbs: string, satellite: boolean }|null}
143
375
  */
144
- function findCwdVaultRoot(startDir, stopAt) {
376
+ function findCwdVaultOrAttachment(startDir, stopAt) {
145
377
  let dir = startDir;
146
378
  const boundary = isNonEmptyString(stopAt) ? resolve(stopAt) : null;
147
379
  for (let i = 0; i < 256; i += 1) {
148
- if (existsSync(join(dir, 'heyspex.yaml')) || existsSync(join(dir, 'specs'))) {
149
- return dir;
150
- }
151
- if (boundary && dir === boundary) break; // hit the test-only fence
380
+ const pinned = readSatellitePin(dir);
381
+ if (pinned) return { repoRootAbs: pinned, satellite: true };
382
+ if (isVaultRoot(dir)) return { repoRootAbs: dir, satellite: false };
383
+ if (boundary && dir === boundary) break;
152
384
  const parent = dirname(dir);
153
- if (parent === dir) break; // reached filesystem root
385
+ if (parent === dir) break;
154
386
  dir = parent;
155
387
  }
156
388
  return null;
157
389
  }
158
390
 
159
391
  /**
160
- * When the cwd-contained vault (tier 3) wins, tell the operator if that
392
+ * When the cwd-contained vault (tier 2) wins, tell the operator if that
161
393
  * silently overrode the registry's active vault — the customer-visible
162
394
  * failure this task fixes (packaged server writing bookkeeping into the
163
395
  * WRONG repo with no warning). Prints nothing when there is no registry
@@ -186,7 +418,19 @@ function maybeLogCwdOverrideNotice(cwdVaultRoot, registry) {
186
418
  *
187
419
  * @param {string} vaultId
188
420
  * @param {string} repoRootAbs
189
- * @param {'explicit'|'account-pointer'|'env-vault-root'|'env-vault-dir'|'cwd-heuristic'} source
421
+ * `source` names WHICH tier answered, and callers gate on it — `bin/hs.mjs`'s
422
+ * `findRegistryVaultRoot`, `dev-factory/judge.mjs`'s `resolveVaultRoot`,
423
+ * `dev-factory/lib/vault-root.mjs` and `dev-factory/lib/worker-lifecycle.mjs`
424
+ * all accept anything that is not the last-resort `'cwd-heuristic'`. So the
425
+ * label has to be true: a satellite attachment found by the walk (tier 2) is
426
+ * `'satellite-pin'`, NOT `'env-vault-root'` (which it reported until
427
+ * 2026-09-18, even with no env var set anywhere). Both are accepted by every
428
+ * gate above, so the rename is behavior-preserving.
429
+ *
430
+ * `'cwd-marker'` and `'marker-registry-mismatch'` (a heyspex.yaml `vault_id`
431
+ * named the vault) are likewise real answers, accepted by the same gates.
432
+ *
433
+ * @param {'explicit'|'account-pointer'|'env-vault-root'|'env-vault-dir'|'satellite-pin'|'cwd-marker'|'marker-registry-mismatch'|'cwd-heuristic'} source
190
434
  */
191
435
  function finalize(vaultId, repoRootAbs, source) {
192
436
  const resolvedRoot = resolve(repoRootAbs);
@@ -203,11 +447,11 @@ function finalize(vaultId, repoRootAbs, source) {
203
447
  *
204
448
  * @param {{ vaultId?: string, cwd?: string, registryDir?: string, cwdWalkRoot?: string }} [options]
205
449
  * - `vaultId`: explicit override (tier 1).
206
- * - `cwd`: working dir for the cwd-contained-vault walk-up (tier 3; defaults
450
+ * - `cwd`: working dir for the cwd-contained-vault walk-up (tier 2; defaults
207
451
  * to `CLAUDE_PROJECT_DIR` then `process.cwd()`).
208
452
  * - `registryDir`: registry dir override (tests only; defaults to ~/.heyspex).
209
- * - `cwdWalkRoot`: test-only fence for the tier-3 walk-up (see
210
- * {@link findCwdVaultRoot}); production code never sets this.
453
+ * - `cwdWalkRoot`: test-only fence for the tier-2 walk-up (see
454
+ * {@link findCwdVaultOrAttachment}); production code never sets this.
211
455
  * @returns {{ vaultId: string, repoRootAbs: string, specsDirAbs: string, source: string }}
212
456
  */
213
457
  export function resolveActiveVault(options = {}) {
@@ -221,23 +465,7 @@ export function resolveActiveVault(options = {}) {
221
465
  // Unknown explicit id: cascade rather than fail closed (matches TS resolver).
222
466
  }
223
467
 
224
- // Tier 2 — HEYSPEX_VAULT_ROOT / HEYSPEX_VAULT_DIR env (legacy escape hatch).
225
- // [[HS-C87T-0JNK-TASK]]: moved ABOVE both the cwd-contained vault and the
226
- // registry pointer — an explicit env override must win "regardless" of
227
- // where the process was started or what the registry says is active.
228
- const envRoot = process.env.HEYSPEX_VAULT_ROOT;
229
- if (isNonEmptyString(envRoot)) {
230
- return finalize(UNREGISTERED_VAULT_ID, resolve(envRoot), 'env-vault-root');
231
- }
232
- const envDir = process.env.HEYSPEX_VAULT_DIR;
233
- if (isNonEmptyString(envDir)) {
234
- // Historically points AT the specs/ dir itself, so repo root = its parent
235
- // (specsDirAbs then round-trips back).
236
- const specsDirFromEnv = resolve(envDir);
237
- return finalize(UNREGISTERED_VAULT_ID, dirname(specsDirFromEnv), 'env-vault-dir');
238
- }
239
-
240
- // Tier 3 — cwd-contained vault (HS-C87T). Backward-compat note: the OLD
468
+ // Tier 2 — cwd-contained vault (HS-C87T). Backward-compat note: the OLD
241
469
  // getConfig() fell back to CLAUDE_PROJECT_DIR then process.cwd() as a bare
242
470
  // repo root. We keep honoring CLAUDE_PROJECT_DIR as the cwd base (an
243
471
  // explicit `options.cwd` caller/test override still takes precedence over
@@ -252,15 +480,85 @@ export function resolveActiveVault(options = {}) {
252
480
  ? process.env.CLAUDE_PROJECT_DIR
253
481
  : process.cwd()),
254
482
  );
255
- const cwdVaultRoot = findCwdVaultRoot(cwdBase, options.cwdWalkRoot);
256
- if (cwdVaultRoot) {
257
- maybeLogCwdOverrideNotice(cwdVaultRoot, registry);
258
- return finalize(UNREGISTERED_VAULT_ID, cwdVaultRoot, 'cwd-heuristic');
483
+ //
484
+ // A SATELLITE repo ([[satellite.ts]]) has no marker of its own — it records
485
+ // an explicit attachment in its MCP manifests instead — so the walk checks
486
+ // BOTH signals at each level and takes the nearest: an explicit attachment
487
+ // in this repo beats a vault that merely happens to sit further up the
488
+ // tree (e.g. two sibling checkouts under one parent that has a stray
489
+ // `specs/`), and both still beat the machine-global pointer at tier 4.
490
+ const cwdMatch = findCwdVaultOrAttachment(cwdBase, options.cwdWalkRoot);
491
+ if (cwdMatch) {
492
+ // The override notice is for an ACCIDENT of where the process started —
493
+ // "you are inside vault X, the registry says Y". A satellite attachment
494
+ // is not an accident: the repo names its vault on disk, deliberately, and
495
+ // that pin is meant to outrank the machine-global pointer. Warning on it
496
+ // would fire on every single satellite session. Stays suppressed.
497
+ if (!cwdMatch.satellite) maybeLogCwdOverrideNotice(cwdMatch.repoRootAbs, registry);
498
+ if (cwdMatch.satellite) {
499
+ // `'satellite-pin'`, not `'env-vault-root'`: nothing set an env var —
500
+ // the walk read an on-disk manifest pin. See finalize()'s doc comment
501
+ // for the gate sites that consume this (all of them reject only
502
+ // `'cwd-heuristic'`, so both labels are accepted identically).
503
+ const pinnedRecord = findVaultByRoot(registry, cwdMatch.repoRootAbs);
504
+ return finalize(
505
+ pinnedRecord?.id ?? UNREGISTERED_VAULT_ID,
506
+ cwdMatch.repoRootAbs,
507
+ 'satellite-pin',
508
+ );
509
+ }
510
+ // A heyspex.yaml `vault_id` IS the vault's id ([[HS-VVNQ]]): `hs init` /
511
+ // `hs connect` wrote it there. Before this, the id came ONLY from a
512
+ // registry row keyed by this root, so a connected repo on a machine
513
+ // whose registry lacked (or mis-keyed) that row resolved to the
514
+ // `unregistered` sentinel and every console call was refused. The
515
+ // registry row is still consulted — as the thing the marker is checked
516
+ // against, not as the source.
517
+ const markerRecord = findVaultByRoot(registry, cwdMatch.repoRootAbs);
518
+ const markerVaultId = readMarkerVaultId(cwdMatch.repoRootAbs);
519
+ if (markerVaultId) {
520
+ if (markerRecord && markerRecord.id !== markerVaultId) {
521
+ logMarkerRegistryMismatch(cwdMatch.repoRootAbs, markerVaultId, markerRecord.id);
522
+ return finalize(markerVaultId, cwdMatch.repoRootAbs, 'marker-registry-mismatch');
523
+ }
524
+ return finalize(markerVaultId, cwdMatch.repoRootAbs, 'cwd-marker');
525
+ }
526
+ // No `vault_id` on the marker (or a bare specs/ dir): same reverse lookup
527
+ // as the satellite and env tiers — a vault the registry knows reports its
528
+ // REGISTERED id, so the console-scoping header (console-fetch.mjs
529
+ // `getVaultIdHeader`) can name it ([[HS-M95C]]).
530
+ return finalize(markerRecord?.id ?? UNREGISTERED_VAULT_ID, cwdMatch.repoRootAbs, 'cwd-heuristic');
531
+ }
532
+
533
+ // Tier 3 — HEYSPEX_VAULT_ROOT / HEYSPEX_VAULT_DIR env (legacy escape hatch).
534
+ // [[HS-VVNQ]]: BELOW the walk. The plugin manifest sets
535
+ // HEYSPEX_VAULT_ROOT=${CLAUDE_PROJECT_DIR} on every session, so an env tier
536
+ // above the walk outranked every satellite pin and heyspex.yaml marker on
537
+ // disk. It still outranks the machine-global registry pointer.
538
+ const envRoot = process.env.HEYSPEX_VAULT_ROOT;
539
+ if (isNonEmptyString(envRoot)) {
540
+ // Reverse-resolve the pinned root to its registered id when the registry
541
+ // knows that repo ([[HS-XOZ3-DIAG-NWG6-vault-identity-split-reconciled-to]]).
542
+ const envRecord = findVaultByRoot(registry, envRoot);
543
+ return finalize(envRecord?.id ?? UNREGISTERED_VAULT_ID, resolve(envRoot), 'env-vault-root');
544
+ }
545
+ const envDir = process.env.HEYSPEX_VAULT_DIR;
546
+ if (isNonEmptyString(envDir)) {
547
+ // Historically points AT the specs/ dir itself, so repo root = its parent
548
+ // (specsDirAbs then round-trips back).
549
+ // Same reverse lookup as HEYSPEX_VAULT_ROOT, matching the TS resolver.
550
+ const specsDirFromEnv = resolve(envDir);
551
+ const envDirRecord = findVaultByRoot(registry, dirname(specsDirFromEnv));
552
+ return finalize(
553
+ envDirRecord?.id ?? UNREGISTERED_VAULT_ID,
554
+ dirname(specsDirFromEnv),
555
+ 'env-vault-dir',
556
+ );
259
557
  }
260
558
 
261
559
  // Tier 4 — registry active_vault_id (the OFFLINE active pointer). Reached
262
- // only when cwd sits inside no vault at all (tier 3 found neither marker
263
- // anywhere up the tree) — e.g. an ad-hoc script dir. This is how an MCP
560
+ // only when cwd sits inside no vault at all (the walk found neither marker
561
+ // anywhere up the tree) and no env var names one. This is how an MCP
264
562
  // restart picks up a CLI `hs vault use` switch without Neon. Mirrors the TS
265
563
  // resolver's account-pointer tier, sourced from the registry file here.
266
564
  if (registry && isNonEmptyString(registry.active_vault_id)) {