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
@@ -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
  *
@@ -50,8 +65,9 @@
50
65
  * behavior, so nothing regresses when no registry exists.
51
66
  */
52
67
 
68
+ import { heyspexHome } from '../../scripts/lib/heyspex-home.mjs';
53
69
  import { existsSync, readFileSync } from 'node:fs';
54
- import { homedir } from 'node:os';
70
+ import { homedir, tmpdir } from 'node:os';
55
71
  import { dirname, join, resolve } from 'node:path';
56
72
 
57
73
  /**
@@ -70,7 +86,7 @@ export const UNREGISTERED_VAULT_ID = 'unregistered';
70
86
  * @returns {string}
71
87
  */
72
88
  function vaultRegistryDir(opts = {}) {
73
- return opts.registryDir ?? join(homedir(), '.heyspex');
89
+ return opts.registryDir ?? join(heyspexHome(), '.heyspex');
74
90
  }
75
91
 
76
92
  /**
@@ -90,6 +106,8 @@ function isNonEmptyString(v) {
90
106
  * Read + defensively parse `~/.heyspex/registry.json`. A missing or malformed
91
107
  * file returns `null` (never throws) so callers fall through to env/cwd tiers —
92
108
  * mirroring registry-file.ts `loadVaultRegistry`'s fail-closed contract.
109
+ * Reads schema v1 and v2 alike; `active_vault_id` is the pointer
110
+ * {@link registryPointerVaultId} picks.
93
111
  *
94
112
  * @param {{ registryDir?: string }} [opts]
95
113
  * @returns {{ vaults: Array<{ id: string, repo_root_abs: string }>, active_vault_id: string|null }|null}
@@ -113,8 +131,76 @@ function loadRegistry(opts = {}) {
113
131
  if (!isNonEmptyString(raw.repo_root_abs)) continue;
114
132
  vaults.push({ id: raw.id, repo_root_abs: raw.repo_root_abs });
115
133
  }
116
- const active_vault_id = isNonEmptyString(json.active_vault_id) ? json.active_vault_id : null;
117
- return { vaults, active_vault_id };
134
+ return { vaults, active_vault_id: registryPointerVaultId(json) };
135
+ }
136
+
137
+ /** Mirrors registry-file.ts `LEGACY_REGISTRY_ACCOUNT_ID`. */
138
+ const LEGACY_REGISTRY_ACCOUNT_ID = 'legacy';
139
+
140
+ /**
141
+ * The offline active-vault pointer of a parsed registry file, schema v1 or v2
142
+ * ([[HS-RXLG-WW9R-TASK-key-registry-json-per-account-schema-v2]]). Mirrors
143
+ * registry-file.ts `currentAccountPointers`:
144
+ * - v2: `accounts[current_account_id].active_vault_id`. An ABSENT field
145
+ * reads the pre-account `legacy` row a not-yet-migrated machine left
146
+ * behind; an explicit `null` is a clear (reads null) when the account
147
+ * owns a row here, else it too reads `legacy`. No current account → the
148
+ * `legacy` value or null — never some other account's.
149
+ * - v1: the machine-global `active_vault_id`.
150
+ *
151
+ * @param {Record<string, unknown>} json
152
+ * @returns {string|null}
153
+ */
154
+ export function registryPointerVaultId(json) {
155
+ if (!json || typeof json !== 'object') return null;
156
+ if (json.schema_version !== 2) {
157
+ return isNonEmptyString(json.active_vault_id) ? json.active_vault_id : null;
158
+ }
159
+ const accounts =
160
+ json.accounts && typeof json.accounts === 'object' && !Array.isArray(json.accounts)
161
+ ? json.accounts
162
+ : {};
163
+ const current = isNonEmptyString(json.current_account_id) ? json.current_account_id : null;
164
+ const rowOf = (id) => (id && accounts[id] && typeof accounts[id] === 'object' ? accounts[id] : null);
165
+ const own = rowOf(current);
166
+ if (own && isNonEmptyString(own.active_vault_id)) return own.active_vault_id;
167
+ // An explicit null is a CLEAR — but only from an account that owns a row
168
+ // here; a stray row of an account that owns nothing still reads legacy.
169
+ const ownsRows = [json.vaults, json.workspaces].some(
170
+ (rows) => Array.isArray(rows) && rows.some((r) => r && r.account_id === current),
171
+ );
172
+ if (own && own.active_vault_id === null && ownsRows && current !== LEGACY_REGISTRY_ACCOUNT_ID) {
173
+ return null;
174
+ }
175
+ const legacy = rowOf(LEGACY_REGISTRY_ACCOUNT_ID);
176
+ return legacy && isNonEmptyString(legacy.active_vault_id) ? legacy.active_vault_id : null;
177
+ }
178
+
179
+ /**
180
+ * The account the MACHINE-LOCAL registry row for `repoRootAbs` was registered
181
+ * under (`vaults[].account_id`), or null (no row, or the pre-account `legacy`
182
+ * bucket). The repo-to-account binding lives here - per machine, per user -
183
+ * and NEVER in the committed heyspex.yaml: a second developer with another
184
+ * account must not inherit the first one's account (owner ruling 2026-10-16).
185
+ *
186
+ * @param {string} repoRootAbs
187
+ * @param {{ registryDir?: string }} [opts]
188
+ * @returns {string|null}
189
+ */
190
+ export function registryAccountIdForRoot(repoRootAbs, opts = {}) {
191
+ if (!isNonEmptyString(repoRootAbs)) return null;
192
+ const file = vaultRegistryFilePath(opts);
193
+ let json;
194
+ try {
195
+ json = JSON.parse(readFileSync(file, 'utf8'));
196
+ } catch {
197
+ return null;
198
+ }
199
+ const target = normalizeRoot(repoRootAbs);
200
+ const rows = Array.isArray(json?.vaults) ? json.vaults : [];
201
+ const row = rows.find((v) => v && isNonEmptyString(v.repo_root_abs) && normalizeRoot(v.repo_root_abs) === target);
202
+ const id = row?.account_id;
203
+ return isNonEmptyString(id) && id !== LEGACY_REGISTRY_ACCOUNT_ID ? id.trim() : null;
118
204
  }
119
205
 
120
206
  /** Find a vault row by id. Returns null when absent. */
@@ -124,40 +210,358 @@ function findVaultById(registry, vaultId) {
124
210
  }
125
211
 
126
212
  /**
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.
213
+ * Normalize a path for comparison: forward slashes, no trailing separator,
214
+ * lower-cased (this CLI's primary platform is Windows, where `C:\Dev` and
215
+ * `c:\dev` name one directory). Mirrors `satellite.ts`'s
216
+ * `normalizePathForCompare`.
217
+ */
218
+ function normalizeRoot(p) {
219
+ return resolve(p)
220
+ .replace(/[\\/]+/g, '/')
221
+ .replace(/\/+$/, '')
222
+ .toLowerCase();
223
+ }
224
+
225
+ /**
226
+ * REVERSE-resolve a filesystem repo root to its registered vault row — the
227
+ * `.mjs` twin of the TS resolver's `registryLookupByRoot`. Without it a
228
+ * path-first tier reports the `unregistered` sentinel even when the registry
229
+ * knows that exact repo, which misfiles the whole index under `unregistered`
230
+ * ([[HS-XOZ3-DIAG-NWG6-vault-identity-split-reconciled-to]]).
231
+ *
232
+ * @param {{ vaults: Array<{id: string, repo_root_abs: string}> }|null} registry
233
+ * @param {string} repoRootAbs
234
+ */
235
+ function findVaultByRoot(registry, repoRootAbs) {
236
+ if (!registry || !isNonEmptyString(repoRootAbs)) return null;
237
+ const target = normalizeRoot(repoRootAbs);
238
+ return registry.vaults.find((v) => normalizeRoot(v.repo_root_abs) === target) ?? null;
239
+ }
240
+
241
+ /**
242
+ * The MCP manifests a SATELLITE repo records its vault attachment in, and the
243
+ * env key inside them. Mirrors `spex-console/src/lib/cli/satellite.ts`
244
+ * (`SATELLITE_MANIFEST_RELS` / `SATELLITE_VAULT_ENV`) — keep the two in sync;
245
+ * the TS module carries the full rationale.
246
+ */
247
+ const SATELLITE_MANIFESTS = [['.mcp.json'], ['.cursor', 'mcp.json']];
248
+
249
+ /**
250
+ * Is `p` an ABSOLUTE path? Pure string test over the three shapes a real pin
251
+ * can carry: a Windows drive path (`C:\vault`, `C:/vault`), a UNC share
252
+ * (`\\server\share\vault`) and a POSIX root (`/vault`). Mirrors
253
+ * `satellite.ts`'s `isAbsolutePathForCompare` — keep the two in sync; the TS
254
+ * module carries the full rationale for why a non-absolute pin is refused
255
+ * rather than resolved.
256
+ *
257
+ * @param {string} p
258
+ * @returns {boolean}
259
+ */
260
+ function isAbsolutePin(p) {
261
+ const s = p.trim();
262
+ return /^[\\/]/.test(s) || /^[a-zA-Z]:[\\/]/.test(s);
263
+ }
264
+
265
+ /**
266
+ * Read a satellite attachment out of `dir`'s MCP manifests: the heyspex
267
+ * server's `env.HEYSPEX_VAULT_ROOT` pin, when it names a path OUTSIDE `dir`.
268
+ * Two kinds of value are NOT an attachment:
269
+ *
270
+ * - one still carrying an unexpanded `${...}` — the client's own
271
+ * project-dir placeholder that a normal (this-repo-IS-the-vault) install
272
+ * writes; and
273
+ * - one that is not an ABSOLUTE path ({@link isAbsolutePin}). This used to
274
+ * be `resolve(trimmed)`, which resolved a relative pin against
275
+ * `process.cwd()` — NOT the manifest's own directory — so a manifest in
276
+ * `<repo>` pinning `../some-vault` returned a directory beside whatever
277
+ * folder the MCP server happened to be launched from. No resolution is
278
+ * defensible here: the same string also reaches the server as the
279
+ * `HEYSPEX_VAULT_ROOT` env var, where tier 2 resolves it against the
280
+ * server process's cwd, so a manifest-dir rule would contradict the
281
+ * runtime. Refusing fails safe and matches `satellite.ts`.
282
+ *
283
+ * Pure + synchronous and never throws — an unreadable/unparseable manifest
284
+ * reads as "no attachment", the same fail-open posture as the registry
285
+ * loader above.
286
+ *
287
+ * @param {string} dir
288
+ * @returns {string|null} the attached vault's absolute repo root
289
+ */
290
+ export function readSatellitePin(dir) {
291
+ const d = inspectSatellitePin(dir);
292
+ return d && d.kind === 'pin' ? d.root : null;
293
+ }
294
+
295
+ /**
296
+ * Expand the client placeholders a PORTABLE pin may carry against the
297
+ * manifest's own directory: ${CLAUDE_PROJECT_DIR[:-default]} (Claude Code) and
298
+ * ${workspaceFolder} (Cursor). Anything else left as ${...} is unresolvable.
299
+ */
300
+ function expandPinPlaceholders(value, dir) {
301
+ return value.replace(/\$\{(?:CLAUDE_PROJECT_DIR|workspaceFolder)(?::-[^}]*)?\}/g, dir);
302
+ }
303
+
304
+ /**
305
+ * Classify the heyspex server's HEYSPEX_VAULT_ROOT in `dir`'s MCP manifests.
306
+ * Returns null when no manifest names a pin at all, else one of:
307
+ * { kind: 'pin', root, raw, source } an attachment to a vault OUTSIDE dir
308
+ * { kind: 'self' } ordinary install (pin is dir or under it)
309
+ * { kind: 'broken', raw, source, reason } a pin that cannot be resolved
310
+ * (unknown ${...} placeholder). A broken pin must REFUSE, never fall
311
+ * through to the account-wide pointer (owner principle P1).
312
+ *
313
+ * PORTABLE pins (owner principle P3, 2026-10-16): a pin may be RELATIVE
314
+ * ('../mlc-specs') or built on the project-dir placeholder
315
+ * ('${CLAUDE_PROJECT_DIR:-.}/../mlc-specs'); both resolve against the
316
+ * MANIFEST'S OWN directory, never the process cwd, so the committed manifest
317
+ * carries no machine-specific absolute path.
318
+ *
319
+ * @param {string} dir
320
+ */
321
+ export function inspectSatellitePin(dir) {
322
+ // ONE precedence rule, identical in the TS twin (satellite.ts
323
+ // readPinnedVaultRoot + the resolver walk): at each directory a committed
324
+ // satellite MARKER (heyspex.yaml with vault_path) beats any manifest pin; of
325
+ // the manifests, the first VALID outside pin in read order (.mcp.json, then
326
+ // .cursor/mcp.json) wins no matter what the other manifest says; a broken
327
+ // pin refuses only when no manifest carries a valid one; 'self' (an ordinary
328
+ // install) is the weakest signal. Shared table: satellite-precedence-fixtures.json.
329
+ let broken = null;
330
+ let self = false;
331
+ for (const rel of SATELLITE_MANIFESTS) {
332
+ let raw;
333
+ try {
334
+ raw = readFileSync(join(dir, ...rel), 'utf8');
335
+ } catch {
336
+ continue;
337
+ }
338
+ let value;
339
+ try {
340
+ value = JSON.parse(raw)?.mcpServers?.heyspex?.env?.HEYSPEX_VAULT_ROOT;
341
+ } catch {
342
+ continue;
343
+ }
344
+ if (!isNonEmptyString(value)) continue;
345
+ const source = rel.join('/');
346
+ const trimmed = value.trim();
347
+ const expanded = expandPinPlaceholders(trimmed, dir);
348
+ if (expanded.includes('${')) {
349
+ broken ??= { kind: 'broken', raw: trimmed, source, reason: 'it contains a placeholder other than ${CLAUDE_PROJECT_DIR} / ${workspaceFolder}' };
350
+ continue;
351
+ }
352
+ if (/^[a-zA-Z]:(?![\\/])/.test(expanded)) {
353
+ broken ??= { kind: 'broken', raw: trimmed, source, reason: 'a drive-relative path (C:dir) names no single directory' };
354
+ continue;
355
+ }
356
+ const abs = isAbsolutePin(expanded) ? resolve(expanded) : resolve(dir, expanded);
357
+ const pinned = normalizeRoot(abs);
358
+ const here = normalizeRoot(dir);
359
+ if (pinned === here || pinned.startsWith(`${here}/`)) {
360
+ self = true;
361
+ continue;
362
+ }
363
+ return { kind: 'pin', root: abs, raw: trimmed, source };
364
+ }
365
+ if (broken) return broken;
366
+ return self ? { kind: 'self' } : null;
367
+ }
368
+
369
+ /**
370
+ * Walk up from `startDir` looking for the NEAREST manifest pin that cannot be
371
+ * honoured: unresolvable placeholder, or a pin whose target is not a vault
372
+ * root on this machine. Returns null when the nearest pin (or marker) is fine
373
+ * or there is none. `hs` uses it to REFUSE instead of falling back to the
374
+ * account-wide pointer ([[HS-O40H]], owner principle P1).
375
+ *
376
+ * @param {string} startDir
377
+ * @param {string} [stopAt]
378
+ * @returns {{ dir: string, raw: string, source: string, reason: string }|null}
379
+ */
380
+ export function findBrokenSatellitePin(startDir, stopAt) {
381
+ let dir = resolve(startDir);
382
+ const boundary = isNonEmptyString(stopAt) ? resolve(stopAt) : null;
383
+ for (let i = 0; i < 256; i += 1) {
384
+ if (readMarkerSatellite(dir)) return null; // marker outranks any pin
385
+ const d = inspectSatellitePin(dir);
386
+ if (d?.kind === 'broken') return { dir, raw: d.raw, source: d.source, reason: d.reason };
387
+ if (d?.kind === 'pin') {
388
+ if (isVaultRoot(d.root)) return null;
389
+ return { dir, raw: d.raw, source: d.source, reason: `${d.root} is not a vault on this machine (no heyspex.yaml or specs/ there; clone the vault repo there or fix the pin)` };
390
+ }
391
+ if (d?.kind === 'self') return null;
392
+ if (readMarkerSatellite(dir) || isVaultRoot(dir)) return null;
393
+ if (boundary && dir === boundary) return null;
394
+ const parent = dirname(dir);
395
+ if (parent === dir) return null;
396
+ dir = parent;
397
+ }
398
+ return null;
399
+ }
400
+
401
+ /**
402
+ * Is `dir` a vault root? True when it contains a `heyspex.yaml` marker file
403
+ * OR a `specs/` dir — the two on-disk signals that a folder IS a vault root
404
+ * (mirrors `hs init`'s marker + the entity-model's folder layout). This is
405
+ * the ONE definition of "is this a vault root" — every walk/check in this
406
+ * file (and in `bin/hs.mjs`) routes through it rather than re-testing the
407
+ * two signals inline. Pure and synchronous: no config reads, no walking, no
408
+ * side effects — just the two existsSync checks.
409
+ *
410
+ * One exclusion ([[HS-VVNQ]]): the OS temp directory ITSELF is never a vault
411
+ * root. It is shared scratch for every process on the machine, test runs
412
+ * leave a stray `specs/` there, and once the walk outranks env and the
413
+ * pointer, every process started anywhere under it would otherwise resolve
414
+ * to that junk folder. Subdirectories of it (a real scratch vault) still
415
+ * count. The TS resolver applies the same rule.
416
+ *
417
+ * @param {string} dir
418
+ * @returns {boolean}
419
+ */
420
+ export function isVaultRoot(dir) {
421
+ if (normalizeRoot(dir) === normalizeRoot(tmpdir())) return false;
422
+ return existsSync(join(dir, 'heyspex.yaml')) || existsSync(join(dir, 'specs'));
423
+ }
424
+
425
+ /**
426
+ * Extract `vault_id: <value>` from raw heyspex.yaml text, or `null` if absent.
427
+ * Same semantics as `spex-console/src/lib/cli/init.ts` `parseVaultId` (the
428
+ * writer's own reader): first line-anchored `vault_id:` key, value trimmed,
429
+ * one matching pair of surrounding quotes stripped, empty -> `null`. Kept to
430
+ * this one line-regex on purpose — `hs init` writes the marker as flat
431
+ * `key: value` lines, so a YAML parser buys nothing here.
432
+ *
433
+ * @param {string} content
434
+ * @returns {string|null}
435
+ */
436
+ export function parseMarkerVaultId(content) {
437
+ const m = String(content ?? '').match(/^vault_id:\s*(.+?)\s*$/m);
438
+ if (!m) return null;
439
+ const unquoted = m[1].trim().replace(/^"(.*)"$/, '$1').replace(/^'(.*)'$/, '$1');
440
+ return unquoted.length > 0 ? unquoted : null;
441
+ }
442
+
443
+ /**
444
+ * The `vault_id` written in `<dir>/heyspex.yaml`, or `null` (no marker, no
445
+ * key, unreadable). Never throws.
446
+ *
447
+ * @param {string} dir
448
+ * @returns {string|null}
449
+ */
450
+ function readMarkerVaultId(dir) {
451
+ try {
452
+ return parseMarkerVaultId(readFileSync(join(dir, 'heyspex.yaml'), 'utf8'));
453
+ } catch {
454
+ return null;
455
+ }
456
+ }
457
+
458
+ /**
459
+ * Extract `vault_path: <value>` from raw heyspex.yaml text, or `null`.
460
+ * The PORTABLE satellite declaration (owner principles P1/P3, 2026-10-16): a
461
+ * code repo (e.g. mlc-app) commits a `heyspex.yaml` carrying its `vault_id`
462
+ * plus a RELATIVE `vault_path` (e.g. `../mlc-specs`) to where the attached
463
+ * vault is usually cloned. No absolute path is ever committed. An absolute,
464
+ * empty or unexpanded `${...}` value is not a declaration (returns null).
465
+ *
466
+ * @param {string} content
467
+ * @returns {string|null}
468
+ */
469
+ export function parseMarkerVaultPath(content) {
470
+ const m = String(content ?? '').match(/^vault_path:[ \t]*(.+?)[ \t]*$/m);
471
+ if (!m) return null;
472
+ const v = m[1].trim().replace(/\s+#.*$/, '').replace(/^"(.*)"$/, '$1').replace(/^'(.*)'$/, '$1').trim();
473
+ if (!v || v.includes('${') || isAbsolutePin(v)) return null;
474
+ return v;
475
+ }
476
+
477
+ /**
478
+ * Is `<dir>/heyspex.yaml` a committed SATELLITE marker (it carries a relative
479
+ * `vault_path`)? Returns the attachment it declares, or `null`.
480
+ *
481
+ * - `vaultId`: the marker's `vault_id` (the vault this repo is bound to; the
482
+ * ONLY thing a fresh machine without the vault clone needs, because
483
+ * cloud-routed writes name the vault by id).
484
+ * - `vaultRootAbs`: `resolve(dir, vault_path)` when that directory exists on
485
+ * THIS machine and is a vault root whose own marker does not name a
486
+ * DIFFERENT vault id; otherwise `null` (not cloned here, or a different
487
+ * vault sits at that path - never read the wrong vault's disk).
488
+ *
489
+ * @param {string} dir
490
+ * @returns {{ vaultId: string|null, vaultPath: string, vaultRootAbs: string|null }|null}
491
+ */
492
+ export function readMarkerSatellite(dir) {
493
+ let raw;
494
+ try {
495
+ raw = readFileSync(join(dir, 'heyspex.yaml'), 'utf8');
496
+ } catch {
497
+ return null;
498
+ }
499
+ const vaultPath = parseMarkerVaultPath(raw);
500
+ if (!vaultPath) return null;
501
+ const vaultId = parseMarkerVaultId(raw);
502
+ const candidate = resolve(dir, vaultPath);
503
+ let vaultRootAbs = null;
504
+ if (normalizeRoot(candidate) !== normalizeRoot(dir) && isVaultRoot(candidate)) {
505
+ const theirs = readMarkerVaultId(candidate);
506
+ if (!vaultId || !theirs || theirs === vaultId) vaultRootAbs = candidate;
507
+ }
508
+ return { vaultId, vaultPath, vaultRootAbs };
509
+ }
510
+
511
+ /**
512
+ * The marker id and the registry row for the same root disagree. The marker
513
+ * wins (it is the repo's own on-disk declaration, written by `hs init` /
514
+ * `hs connect`); this notice names the command whose `id_agreement` row
515
+ * reports the same disagreement so the operator can repair the registry.
516
+ *
517
+ * @param {string} repoRootAbs
518
+ * @param {string} markerVaultId
519
+ * @param {string} registryVaultId
520
+ */
521
+ function logMarkerRegistryMismatch(repoRootAbs, markerVaultId, registryVaultId) {
522
+ process.stderr.write(
523
+ `[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`,
524
+ );
525
+ }
526
+
527
+ /**
528
+ * Walk UP from `startDir` taking the NEAREST of the two tier-2 (walk) signals: a
529
+ * vault root (see {@link isVaultRoot}) or a satellite attachment (see
530
+ * {@link readSatellitePin}). The attachment is checked FIRST at each level,
531
+ * because a repo that explicitly names its vault is never also asking to be
532
+ * one.
134
533
  *
135
534
  * @param {string} startDir
136
535
  * @param {string|undefined} stopAt - test-only walk boundary (checked, then
137
536
  * the walk stops — never climbs past it). Unset in production, so the walk
138
537
  * 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}
538
+ * inside a disposable tmp fixture so ambient/unrelated directories higher
539
+ * up the real tree cannot produce a false match.
540
+ * @returns {{ repoRootAbs: string, satellite: boolean, markerSatellite?: ReturnType<typeof readMarkerSatellite> }|null}
143
541
  */
144
- function findCwdVaultRoot(startDir, stopAt) {
542
+ function findCwdVaultOrAttachment(startDir, stopAt) {
145
543
  let dir = startDir;
146
544
  const boundary = isNonEmptyString(stopAt) ? resolve(stopAt) : null;
147
545
  for (let i = 0; i < 256; i += 1) {
148
- if (existsSync(join(dir, 'heyspex.yaml')) || existsSync(join(dir, 'specs'))) {
149
- return dir;
546
+ // Precedence (see inspectSatellitePin): committed satellite marker, then
547
+ // any valid manifest pin, then the generic vault-root test.
548
+ const marked = readMarkerSatellite(dir);
549
+ if (marked) {
550
+ return { repoRootAbs: marked.vaultRootAbs ?? dir, satellite: true, markerSatellite: marked };
150
551
  }
151
- if (boundary && dir === boundary) break; // hit the test-only fence
552
+ const pinned = readSatellitePin(dir);
553
+ if (pinned) return { repoRootAbs: pinned, satellite: true };
554
+ if (isVaultRoot(dir)) return { repoRootAbs: dir, satellite: false };
555
+ if (boundary && dir === boundary) break;
152
556
  const parent = dirname(dir);
153
- if (parent === dir) break; // reached filesystem root
557
+ if (parent === dir) break;
154
558
  dir = parent;
155
559
  }
156
560
  return null;
157
561
  }
158
562
 
159
563
  /**
160
- * When the cwd-contained vault (tier 3) wins, tell the operator if that
564
+ * When the cwd-contained vault (tier 2) wins, tell the operator if that
161
565
  * silently overrode the registry's active vault — the customer-visible
162
566
  * failure this task fixes (packaged server writing bookkeeping into the
163
567
  * WRONG repo with no warning). Prints nothing when there is no registry
@@ -186,7 +590,19 @@ function maybeLogCwdOverrideNotice(cwdVaultRoot, registry) {
186
590
  *
187
591
  * @param {string} vaultId
188
592
  * @param {string} repoRootAbs
189
- * @param {'explicit'|'account-pointer'|'env-vault-root'|'env-vault-dir'|'cwd-heuristic'} source
593
+ * `source` names WHICH tier answered, and callers gate on it — `bin/hs.mjs`'s
594
+ * `findRegistryVaultRoot`, `dev-factory/judge.mjs`'s `resolveVaultRoot`,
595
+ * `dev-factory/lib/vault-root.mjs` and `dev-factory/lib/worker-lifecycle.mjs`
596
+ * all accept anything that is not the last-resort `'cwd-heuristic'`. So the
597
+ * label has to be true: a satellite attachment found by the walk (tier 2) is
598
+ * `'satellite-pin'`, NOT `'env-vault-root'` (which it reported until
599
+ * 2026-09-18, even with no env var set anywhere). Both are accepted by every
600
+ * gate above, so the rename is behavior-preserving.
601
+ *
602
+ * `'cwd-marker'` and `'marker-registry-mismatch'` (a heyspex.yaml `vault_id`
603
+ * named the vault) are likewise real answers, accepted by the same gates.
604
+ *
605
+ * @param {'explicit'|'account-pointer'|'env-vault-root'|'env-vault-dir'|'satellite-pin'|'cwd-marker'|'marker-registry-mismatch'|'cwd-heuristic'} source
190
606
  */
191
607
  function finalize(vaultId, repoRootAbs, source) {
192
608
  const resolvedRoot = resolve(repoRootAbs);
@@ -203,11 +619,11 @@ function finalize(vaultId, repoRootAbs, source) {
203
619
  *
204
620
  * @param {{ vaultId?: string, cwd?: string, registryDir?: string, cwdWalkRoot?: string }} [options]
205
621
  * - `vaultId`: explicit override (tier 1).
206
- * - `cwd`: working dir for the cwd-contained-vault walk-up (tier 3; defaults
622
+ * - `cwd`: working dir for the cwd-contained-vault walk-up (tier 2; defaults
207
623
  * to `CLAUDE_PROJECT_DIR` then `process.cwd()`).
208
624
  * - `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.
625
+ * - `cwdWalkRoot`: test-only fence for the tier-2 walk-up (see
626
+ * {@link findCwdVaultOrAttachment}); production code never sets this.
211
627
  * @returns {{ vaultId: string, repoRootAbs: string, specsDirAbs: string, source: string }}
212
628
  */
213
629
  export function resolveActiveVault(options = {}) {
@@ -221,23 +637,7 @@ export function resolveActiveVault(options = {}) {
221
637
  // Unknown explicit id: cascade rather than fail closed (matches TS resolver).
222
638
  }
223
639
 
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
640
+ // Tier 2 — cwd-contained vault (HS-C87T). Backward-compat note: the OLD
241
641
  // getConfig() fell back to CLAUDE_PROJECT_DIR then process.cwd() as a bare
242
642
  // repo root. We keep honoring CLAUDE_PROJECT_DIR as the cwd base (an
243
643
  // explicit `options.cwd` caller/test override still takes precedence over
@@ -252,17 +652,103 @@ export function resolveActiveVault(options = {}) {
252
652
  ? process.env.CLAUDE_PROJECT_DIR
253
653
  : process.cwd()),
254
654
  );
255
- const cwdVaultRoot = findCwdVaultRoot(cwdBase, options.cwdWalkRoot);
256
- if (cwdVaultRoot) {
257
- maybeLogCwdOverrideNotice(cwdVaultRoot, registry);
258
- return finalize(UNREGISTERED_VAULT_ID, cwdVaultRoot, 'cwd-heuristic');
655
+ //
656
+ // A SATELLITE repo ([[satellite.ts]]) has no marker of its own — it records
657
+ // an explicit attachment in its MCP manifests instead — so the walk checks
658
+ // BOTH signals at each level and takes the nearest: an explicit attachment
659
+ // in this repo beats a vault that merely happens to sit further up the
660
+ // tree (e.g. two sibling checkouts under one parent that has a stray
661
+ // `specs/`), and both still beat the machine-global pointer at tier 4.
662
+ const cwdMatch = findCwdVaultOrAttachment(cwdBase, options.cwdWalkRoot);
663
+ if (cwdMatch) {
664
+ // The override notice is for an ACCIDENT of where the process started —
665
+ // "you are inside vault X, the registry says Y". A satellite attachment
666
+ // is not an accident: the repo names its vault on disk, deliberately, and
667
+ // that pin is meant to outrank the machine-global pointer. Warning on it
668
+ // would fire on every single satellite session. Stays suppressed.
669
+ if (!cwdMatch.satellite) maybeLogCwdOverrideNotice(cwdMatch.repoRootAbs, registry);
670
+ if (cwdMatch.satellite) {
671
+ // `'satellite-pin'`, not `'env-vault-root'`: nothing set an env var —
672
+ // the walk read an on-disk manifest pin. See finalize()'s doc comment
673
+ // for the gate sites that consume this (all of them reject only
674
+ // `'cwd-heuristic'`, so both labels are accepted identically).
675
+ const pinnedRecord = findVaultByRoot(registry, cwdMatch.repoRootAbs);
676
+ // A committed satellite marker names the vault by id: that id IS the
677
+ // binding (works on a fresh machine with no registry row and no clone).
678
+ const markerId = cwdMatch.markerSatellite?.vaultId ?? null;
679
+ const bound = finalize(
680
+ markerId ?? pinnedRecord?.id ?? UNREGISTERED_VAULT_ID,
681
+ cwdMatch.repoRootAbs,
682
+ 'satellite-pin',
683
+ );
684
+ // The marker names a vault that is NOT cloned on this machine: the
685
+ // binding is cloud-only. Disk readers must not report an empty vault -
686
+ // they answer from the cloud and say so (read-path.mjs).
687
+ if (cwdMatch.markerSatellite && !cwdMatch.markerSatellite.vaultRootAbs) {
688
+ return { ...bound, vaultCloned: false, vaultPath: cwdMatch.markerSatellite.vaultPath };
689
+ }
690
+ return bound;
691
+ }
692
+ // A heyspex.yaml `vault_id` IS the vault's id ([[HS-VVNQ]]): `hs init` /
693
+ // `hs connect` wrote it there. Before this, the id came ONLY from a
694
+ // registry row keyed by this root, so a connected repo on a machine
695
+ // whose registry lacked (or mis-keyed) that row resolved to the
696
+ // `unregistered` sentinel and every console call was refused. The
697
+ // registry row is still consulted — as the thing the marker is checked
698
+ // against, not as the source.
699
+ const markerRecord = findVaultByRoot(registry, cwdMatch.repoRootAbs);
700
+ const markerVaultId = readMarkerVaultId(cwdMatch.repoRootAbs);
701
+ if (markerVaultId) {
702
+ if (markerRecord && markerRecord.id !== markerVaultId) {
703
+ logMarkerRegistryMismatch(cwdMatch.repoRootAbs, markerVaultId, markerRecord.id);
704
+ return finalize(markerVaultId, cwdMatch.repoRootAbs, 'marker-registry-mismatch');
705
+ }
706
+ return finalize(markerVaultId, cwdMatch.repoRootAbs, 'cwd-marker');
707
+ }
708
+ // No `vault_id` on the marker (or a bare specs/ dir): same reverse lookup
709
+ // as the satellite and env tiers — a vault the registry knows reports its
710
+ // REGISTERED id, so the console-scoping header (console-fetch.mjs
711
+ // `getVaultIdHeader`) can name it ([[HS-M95C]]).
712
+ return finalize(markerRecord?.id ?? UNREGISTERED_VAULT_ID, cwdMatch.repoRootAbs, 'cwd-heuristic');
713
+ }
714
+
715
+ // Tier 3 — HEYSPEX_VAULT_ROOT / HEYSPEX_VAULT_DIR env (legacy escape hatch).
716
+ // [[HS-VVNQ]]: BELOW the walk. The plugin manifest sets
717
+ // HEYSPEX_VAULT_ROOT=${CLAUDE_PROJECT_DIR} on every session, so an env tier
718
+ // above the walk outranked every satellite pin and heyspex.yaml marker on
719
+ // disk. It still outranks the machine-global registry pointer.
720
+ const envRoot = process.env.HEYSPEX_VAULT_ROOT;
721
+ if (isNonEmptyString(envRoot)) {
722
+ // Reverse-resolve the pinned root to its registered id when the registry
723
+ // knows that repo ([[HS-XOZ3-DIAG-NWG6-vault-identity-split-reconciled-to]]).
724
+ const envRecord = findVaultByRoot(registry, envRoot);
725
+ return finalize(envRecord?.id ?? UNREGISTERED_VAULT_ID, resolve(envRoot), 'env-vault-root');
726
+ }
727
+ const envDir = process.env.HEYSPEX_VAULT_DIR;
728
+ if (isNonEmptyString(envDir)) {
729
+ // Historically points AT the specs/ dir itself, so repo root = its parent
730
+ // (specsDirAbs then round-trips back).
731
+ // Same reverse lookup as HEYSPEX_VAULT_ROOT, matching the TS resolver.
732
+ const specsDirFromEnv = resolve(envDir);
733
+ const envDirRecord = findVaultByRoot(registry, dirname(specsDirFromEnv));
734
+ return finalize(
735
+ envDirRecord?.id ?? UNREGISTERED_VAULT_ID,
736
+ dirname(specsDirFromEnv),
737
+ 'env-vault-dir',
738
+ );
259
739
  }
260
740
 
261
741
  // 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
742
+ // only when cwd sits inside no vault at all (the walk found neither marker
743
+ // anywhere up the tree) and no env var names one. This is how an MCP
264
744
  // restart picks up a CLI `hs vault use` switch without Neon. Mirrors the TS
265
745
  // resolver's account-pointer tier, sourced from the registry file here.
746
+ // The ORDER is pinned (decision memory `vault-resolver-precedence`); what a
747
+ // consumer does with `source: 'account-pointer'` is its own call — the
748
+ // console transport (console-fetch.mjs `vaultIdHeaderFor`) REFUSES
749
+ // vault-scoped requests on it (`VAULT_NOT_PINNED`) unless
750
+ // HEYSPEX_ALLOW_POINTER_VAULT=1, because the pointer mirrors whatever the
751
+ // owner last clicked in the console, not anything this repo declared.
266
752
  if (registry && isNonEmptyString(registry.active_vault_id)) {
267
753
  const record = findVaultById(registry, registry.active_vault_id);
268
754
  if (record) return finalize(record.id, record.repo_root_abs, 'account-pointer');