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,744 @@
1
+ #!/usr/bin/env node
2
+ // dev-factory/lib/worktree-allocator.mjs
3
+ // CLAIM-BOUND WORKTREE ALLOCATION — the session-scoped layer on top of the build-scoped
4
+ // engine primitives in dev-factory/engine/worktree.mjs.
5
+ // Task: HS-L4Q5 (epic HS-03DO "Claim-bound worktree isolation for parallel agent sessions").
6
+ //
7
+ // The engine owns ONE lifecycle: a short-lived, detached, per-candidate gate worktree that the
8
+ // dev-factory loop creates and tears down inside a single build (marker `kind: "build"`). This
9
+ // module owns a SECOND, longer-lived lifecycle: a worktree bound to an agent's CLAIM on a spec
10
+ // (marker `kind: "session"`), on its own `claim/<FILE KEY>` branch, which outlives any single
11
+ // build and is reclaimed only when the claim is released / the owner dies / the console says the
12
+ // claim is no longer active — and never while it holds uncommitted work.
13
+ //
14
+ // Everything here is built ON the engine primitives (makeCtx / setupWorktree / teardownWorktree /
15
+ // sweepStaleWorktrees / makeLink / git / isPidAlive / OWNER_FILE), never forked from them. The
16
+ // hard-won Windows lessons therefore apply unchanged:
17
+ // - node_modules is JUNCTIONED (root + workspace), never copied.
18
+ // - git always runs with core.longpaths=true (engine `git()`).
19
+ // - teardown removes the junction LINKS first, THEN `git worktree remove` — never a recursive
20
+ // delete through a live junction (that deletes the REAL node_modules).
21
+ // - Windows paths compare case-insensitively.
22
+ //
23
+ // Every exported function takes the engine `ctx` (from makeCtx) plus an injectable
24
+ // `deps = { git, fs, now, isPidAlive, claimIsActive, fetch, consoleUrl }` so the whole surface is
25
+ // testable without touching the real repo or the real console. Left unset, `claimIsActive` is
26
+ // backed by the console's /api/claims (createConsoleClaimChecker) — one round trip per reap.
27
+ //
28
+ // LIVENESS IS THREE-VALUED, and that is the safety property this module turns on. A session's
29
+ // owner pid is normally UNKNOWN (`null`), because on Windows the launcher that created the
30
+ // worktree exits seconds after the agent starts; and the console can answer "no claim", "claimed",
31
+ // or nothing at all. Only POSITIVE evidence of death — a release marker, a pid that is provably
32
+ // gone, or a successful console read that lists no claim — makes a worktree reclaimable, and only
33
+ // then if it holds nothing uncommitted.
34
+ //
35
+ // CLI:
36
+ // node dev-factory/lib/worktree-allocator.mjs <list|alloc|release|reap|rebind> [flags]
37
+
38
+ import {
39
+ existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync, lstatSync, rmSync, realpathSync,
40
+ } from "node:fs";
41
+ import { join, resolve, basename } from "node:path";
42
+ import {
43
+ makeCtx, setupWorktree, teardownWorktree, sweepStaleWorktrees,
44
+ OWNER_FILE, isPidAlive, makeLink, git, slug,
45
+ } from "../engine/worktree.mjs";
46
+ import { resolveConsoleUrl } from "../../scripts/lib/console-url-resolver.mjs";
47
+
48
+ // Re-exported so callers of this module never need to reach past it into the engine.
49
+ export { makeCtx, setupWorktree, teardownWorktree, sweepStaleWorktrees, OWNER_FILE };
50
+
51
+ // Stable exit codes for the CLI mode below.
52
+ // mirrors spex-console/src/lib/cli/control-shared.ts
53
+ const EXIT_OK = 0; // success
54
+ const EXIT_USAGE = 1; // unknown verb/flag, malformed command
55
+ const EXIT_CHECK_FAILED = 2; // a read-only check ran correctly and its answer is "no"
56
+ const EXIT_INVALID_ARG = 11; // a required argument is missing or unparseable
57
+ const EXIT_WRITE_FAILED = 13; // the write itself failed (git / fs)
58
+
59
+ /** How long an UNCLAIMED `launch-*` placeholder worktree may sit untouched before `reap` treats
60
+ * it as abandoned. Long enough that a human who opened a window yesterday evening and came back
61
+ * this morning still finds it; short enough that a week of false starts does not accumulate. */
62
+ export const LAUNCH_PLACEHOLDER_TTL_MS = 24 * 60 * 60 * 1000;
63
+
64
+ /** Hard deadline on the one console round trip a reap makes. Reaping is housekeeping — it must
65
+ * never hang on an unreachable console; an unanswered probe is simply "unknown". */
66
+ export const CLAIM_PROBE_TIMEOUT_MS = 4000;
67
+
68
+ /** The real implementations every function falls back to. Kept as ONE object so a test can
69
+ * spread-override a single member (`{ ...DEFAULT_DEPS, git: spy }`) without losing the rest. */
70
+ export const DEFAULT_DEPS = {
71
+ git,
72
+ fs: { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync, lstatSync, rmSync },
73
+ now: () => new Date(),
74
+ isPidAlive,
75
+ // `null`, not a function: `reapWorktrees` then builds a FRESH console-backed checker for that
76
+ // one call (createConsoleClaimChecker below), so the claim list is fetched exactly once per
77
+ // reap no matter how many worktrees it walks. A caller that supplies its own function — a test,
78
+ // or an offline caller wanting the old "always unknown" behaviour — overrides it here.
79
+ claimIsActive: null,
80
+ // Injection points for that checker; undefined → global fetch and the resolved console URL.
81
+ fetch: undefined,
82
+ consoleUrl: undefined,
83
+ };
84
+
85
+ const withDeps = (deps) => ({ ...DEFAULT_DEPS, ...(deps || {}) });
86
+
87
+ // Windows compares paths case-insensitively, `git worktree list` reports forward slashes, and a
88
+ // path under %TEMP% may reach us as an 8.3 SHORT NAME (C:\Users\PWHER~1\...) from one side and
89
+ // long-form from the other — three ways for the SAME directory to look like two. Every path
90
+ // identity check (and the list's dedup key) goes through this one canonicalisation.
91
+ function canonicalPath(p) {
92
+ if (!p) return "";
93
+ let out = resolve(String(p));
94
+ try { out = realpathSync.native(out); } catch { /* not on disk (a pruned registration) — resolve() is the best we have */ }
95
+ return out.replace(/[\\/]+$/, "").toLowerCase();
96
+ }
97
+ const samePath = (a, b) => !!a && !!b && canonicalPath(a) === canonicalPath(b);
98
+
99
+ // ── key / label / branch derivation ──────────────────────────────────────────────────────────
100
+
101
+ /** FNV-1a (32-bit), returned as 8 lowercase hex chars. `fnv1a32("")` is the offset basis,
102
+ * 811c9dc5 — the standard's own test vector, so a drift in this function is visible. */
103
+ export function fnv1a32(input) {
104
+ let h = 0x811c9dc5;
105
+ const s = String(input ?? "");
106
+ for (let i = 0; i < s.length; i++) {
107
+ h ^= s.charCodeAt(i);
108
+ // 32-bit multiply by the FNV prime (16777619) without losing the high bits to float64.
109
+ h = Math.imul(h, 0x01000193) >>> 0;
110
+ }
111
+ return (h >>> 0).toString(16).padStart(8, "0");
112
+ }
113
+
114
+ /** Deterministic dev-server port for a worktree key: 3001..3050 inclusive, never 3000 (the
115
+ * primary checkout's port — a session worktree must never collide with it). */
116
+ export function devServerPortFor(key) {
117
+ return 3000 + (parseInt(fnv1a32(key).slice(0, 4), 16) % 50) + 1;
118
+ }
119
+
120
+ /**
121
+ * Split a spec id into the pieces the allocator names things with.
122
+ *
123
+ * Naming V3: `HS-<file key>-<family key>-<TYPE>-<slug>` — the token right after `HS-` is the
124
+ * unique FILE ID. A bare `launch-<id>` key (an unfiled ad-hoc session) has no `HS-` prefix and
125
+ * is used as-is, slugged.
126
+ *
127
+ * → `{ key, label, branch, launch }`
128
+ */
129
+ export function keyInfoFor(specId) {
130
+ const raw = String(specId ?? "").trim();
131
+ const m = /^HS-([A-Za-z0-9]+)/i.exec(raw);
132
+ if (m) {
133
+ const key = m[1];
134
+ return {
135
+ key,
136
+ label: `session-${key.toLowerCase()}`,
137
+ branch: `claim/${key.toUpperCase()}`,
138
+ launch: false,
139
+ };
140
+ }
141
+ const key = slug(raw.toLowerCase());
142
+ return { key, label: `session-${key}`, branch: `claim/${key}`, launch: true };
143
+ }
144
+
145
+ /** The deterministic on-disk path for a spec's session worktree. Same `slug(label)` convention
146
+ * as the engine's setupWorktree, so both lifecycles live side by side in ctx.worktreeRoot. */
147
+ export function worktreePathFor(ctx, specId) {
148
+ return join(ctx.worktreeRoot, slug(keyInfoFor(specId).label));
149
+ }
150
+
151
+ // ── owner marker I/O ─────────────────────────────────────────────────────────────────────────
152
+
153
+ function readMarker(path, d) {
154
+ try { return JSON.parse(d.fs.readFileSync(join(path, OWNER_FILE), "utf8")); }
155
+ catch { return null; }
156
+ }
157
+
158
+ function writeMarker(path, marker, d) {
159
+ d.fs.writeFileSync(join(path, OWNER_FILE), JSON.stringify(marker, null, 2));
160
+ }
161
+
162
+ // ── allocate ─────────────────────────────────────────────────────────────────────────────────
163
+
164
+ /**
165
+ * Allocate (or re-attach to) the session worktree bound to `specId`.
166
+ *
167
+ * Idempotent: if the worktree already exists and its marker is for the same spec AND either the
168
+ * owner process is still alive or the sessionId matches, the existing worktree is returned with
169
+ * `created: false` — re-running `alloc` from the same session is a no-op, never a re-clone.
170
+ *
171
+ * → `{ path, branch, created, marker }`
172
+ */
173
+ export function allocateForClaim(ctx, { specId, sessionId, agent, pid, baseRef } = {}, deps) {
174
+ const d = withDeps(deps);
175
+ if (!specId) throw new Error("allocateForClaim: specId is required");
176
+ const info = keyInfoFor(specId);
177
+ const path = worktreePathFor(ctx, specId);
178
+
179
+ // ── re-attach path ────────────────────────────────────────────────────────────────────────
180
+ const existing = d.fs.existsSync(path) ? readMarker(path, d) : null;
181
+ const ownerUnknownButUnreleased = existing != null && existing.pid == null && !existing.releasedAt;
182
+ if (existing && existing.specId === specId
183
+ && (d.isPidAlive(existing.pid)
184
+ || (sessionId && existing.sessionId === sessionId)
185
+ // Same doctrine as reap: an unknown owner (pid null) on an UNRELEASED marker is presumed
186
+ // LIVE. Tearing it down to re-create it would silently discard another session's work.
187
+ || ownerUnknownButUnreleased)) {
188
+ return { path, branch: existing.branch || info.branch, created: false, marker: existing };
189
+ }
190
+ // A stale directory for the SAME key (dead owner, different session) is reclaimed links-first
191
+ // before we hand the path to `git worktree add`, which refuses a non-empty destination.
192
+ if (d.fs.existsSync(path)) {
193
+ teardownWorktree(ctx, path, true);
194
+ if (d.fs.existsSync(path)) { try { d.fs.rmSync(path, { recursive: true, force: true }); } catch { /* best-effort */ } }
195
+ }
196
+
197
+ // ── create path ───────────────────────────────────────────────────────────────────────────
198
+ d.fs.mkdirSync(ctx.worktreeRoot, { recursive: true });
199
+ const base = baseRef || "HEAD";
200
+ // The base is the CHECKED-OUT branch's HEAD, never `main`: the session must start from the
201
+ // code the agent is actually working on.
202
+ const baseSha = (d.git(ctx, ctx.repo, "rev-parse", base).stdout || "").trim() || null;
203
+ const baseBranch = (d.git(ctx, ctx.repo, "rev-parse", "--abbrev-ref", "HEAD").stdout || "").trim() || null;
204
+
205
+ const branchExists = d.git(ctx, ctx.repo, "rev-parse", "--verify", "--quiet", `refs/heads/${info.branch}`).status === 0;
206
+ const add = branchExists
207
+ ? d.git(ctx, ctx.repo, "worktree", "add", path, info.branch)
208
+ : d.git(ctx, ctx.repo, "worktree", "add", "-b", info.branch, path, base);
209
+ if (add.status !== 0) {
210
+ throw new Error(`git worktree add failed for ${specId}: ${String(add.stderr || add.stdout || "").trim()}`);
211
+ }
212
+
213
+ // Junction node_modules exactly as setupWorktree does — root (hoisted toolchain; Node
214
+ // resolution walks up) and the workspace. Only what actually exists (HS-OM28: a sibling repo
215
+ // may have no spex-console workspace).
216
+ const rootNM = join(ctx.repo, "node_modules");
217
+ if (d.fs.existsSync(rootNM) && !d.fs.existsSync(join(path, "node_modules"))) makeLink(join(path, "node_modules"), rootNM);
218
+ const spexNM = join(ctx.repo, ctx.spex, "node_modules");
219
+ if (d.fs.existsSync(spexNM) && d.fs.existsSync(join(path, ctx.spex)) && !d.fs.existsSync(join(path, ctx.spex, "node_modules"))) {
220
+ makeLink(join(path, ctx.spex, "node_modules"), spexNM);
221
+ }
222
+
223
+ const marker = {
224
+ kind: "session",
225
+ specId,
226
+ sessionId: sessionId ?? null,
227
+ agent: agent ?? null,
228
+ // pid is OPTIONAL and defaults to null — NOT to this process's pid (HS-L4Q5 integration
229
+ // finding). On Windows the `hs spawn` process and the `wt.exe` launcher both exit seconds
230
+ // after handing off to the terminal, so a pid recorded here is dead almost immediately while
231
+ // the agent session inside the worktree is very much alive. A dead pid is positive evidence
232
+ // for reaping; recording the launcher's pid would therefore make `reap` delete live sessions.
233
+ // `null` means UNKNOWN, and liveness has to come from releasedAt / claimIsActive instead.
234
+ // Build-kind markers (engine setupWorktree) keep their real pid — that process IS the owner.
235
+ pid: Number.isInteger(pid) && pid > 0 ? pid : null,
236
+ startedAt: d.now().toISOString(),
237
+ branch: info.branch,
238
+ baseSha,
239
+ baseBranch,
240
+ releasedAt: null,
241
+ };
242
+ writeMarker(path, marker, d);
243
+ return { path, branch: info.branch, created: true, marker };
244
+ }
245
+
246
+ // ── release ──────────────────────────────────────────────────────────────────────────────────
247
+
248
+ /**
249
+ * Mark the session worktree for `specId` released. NEVER deletes anything — release makes the
250
+ * worktree ELIGIBLE for reaping; `reapWorktrees` still refuses to remove it while it is dirty.
251
+ * → `{ found, alreadyReleased, marker }`
252
+ */
253
+ export function markReleased(ctx, { specId } = {}, deps) {
254
+ const d = withDeps(deps);
255
+ if (!specId) throw new Error("markReleased: specId is required");
256
+ const path = worktreePathFor(ctx, specId);
257
+ const marker = d.fs.existsSync(path) ? readMarker(path, d) : null;
258
+ if (!marker) return { found: false, alreadyReleased: false, marker: null };
259
+ if (marker.releasedAt) return { found: true, alreadyReleased: true, marker };
260
+ const next = { ...marker, releasedAt: d.now().toISOString() };
261
+ writeMarker(path, next, d);
262
+ return { found: true, alreadyReleased: false, marker: next };
263
+ }
264
+
265
+ // ── rebind ───────────────────────────────────────────────────────────────────────────────────
266
+
267
+ /**
268
+ * Re-point an existing session worktree at a DIFFERENT spec — the `/hs-defer` shape, where the
269
+ * same live checkout continues under a split child id. The marker's specId and the branch NAME
270
+ * change; the DIRECTORY does not move (moving a live worktree out from under an editor, a dev
271
+ * server and git's own administrative pointer is exactly the class of breakage this epic exists
272
+ * to avoid). → `{ found, branch }`
273
+ */
274
+ export function rebind(ctx, { fromSpecId, toSpecId } = {}, deps) {
275
+ const d = withDeps(deps);
276
+ if (!fromSpecId || !toSpecId) throw new Error("rebind: fromSpecId and toSpecId are required");
277
+ const path = worktreePathFor(ctx, fromSpecId);
278
+ const marker = d.fs.existsSync(path) ? readMarker(path, d) : null;
279
+ if (!marker) return { found: false, branch: null };
280
+ const nextBranch = keyInfoFor(toSpecId).branch;
281
+ const oldBranch = marker.branch || keyInfoFor(fromSpecId).branch;
282
+ if (oldBranch !== nextBranch) {
283
+ const mv = d.git(ctx, ctx.repo, "branch", "-m", oldBranch, nextBranch);
284
+ if (mv.status !== 0) {
285
+ throw new Error(`git branch -m ${oldBranch} ${nextBranch} failed: ${String(mv.stderr || mv.stdout || "").trim()}`);
286
+ }
287
+ }
288
+ writeMarker(path, { ...marker, specId: toSpecId, branch: nextBranch }, d);
289
+ return { found: true, branch: nextBranch };
290
+ }
291
+
292
+ // ── list ─────────────────────────────────────────────────────────────────────────────────────
293
+
294
+ /** Parse `git worktree list --porcelain` into `[{ path, head, branch, detached, bare }]`. */
295
+ function parsePorcelainWorktrees(stdout) {
296
+ const out = [];
297
+ let cur = null;
298
+ for (const raw of String(stdout ?? "").split(/\r?\n/)) {
299
+ const line = raw.trim();
300
+ if (line.startsWith("worktree ")) {
301
+ if (cur) out.push(cur);
302
+ cur = { path: line.slice("worktree ".length), head: null, branch: null, detached: false, bare: false };
303
+ continue;
304
+ }
305
+ if (!cur) continue;
306
+ if (line.startsWith("HEAD ")) cur.head = line.slice("HEAD ".length);
307
+ else if (line.startsWith("branch ")) cur.branch = line.slice("branch ".length).replace(/^refs\/heads\//, "");
308
+ else if (line === "detached") cur.detached = true;
309
+ else if (line === "bare") cur.bare = true;
310
+ }
311
+ if (cur) out.push(cur);
312
+ return out;
313
+ }
314
+
315
+ /** Dirty line count for a worktree — `git status --porcelain -uall`. `null` when the path is
316
+ * gone (a registration git has not pruned yet) or git refuses to run there. */
317
+ function dirtyCountFor(ctx, path, d) {
318
+ if (!d.fs.existsSync(path)) return null;
319
+ const r = d.git(ctx, path, "status", "--porcelain", "-uall");
320
+ if (r.status !== 0) return null;
321
+ return String(r.stdout ?? "").split(/\r?\n/)
322
+ .filter((l) => l.trim().length > 0)
323
+ // The OWNER MARKER never counts as the agent's work: the allocator writes
324
+ // .hsewt-owner.json into every worktree it creates, so counting it would make EVERY
325
+ // session worktree permanently "dirty" and therefore permanently unreapable — the
326
+ // bookkeeping file masquerading as somebody's unsaved changes.
327
+ .filter((l) => l.slice(3).trim().replace(/^"|"$/g, "").replace(/\\/g, "/") !== OWNER_FILE)
328
+ .length;
329
+ }
330
+
331
+ /**
332
+ * Every worktree this repo knows about, joined with its owner marker.
333
+ *
334
+ * Two sources, because they disagree in exactly the cases that matter:
335
+ * - `git worktree list --porcelain` (from ctx.repo) — the REGISTRY. The main checkout is the
336
+ * first entry and is reported as `kind: "primary"`.
337
+ * - the directory entries under ctx.worktreeRoot — anything here that the registry does not
338
+ * know about is an UNREGISTERED leftover (`registered: false`), which is precisely what an
339
+ * interrupted run or a `git worktree prune` behind our back leaves behind.
340
+ *
341
+ * `kind` comes from the marker (`"session"` / `"build"`); a worktree with no usable marker is
342
+ * `"foreign"` — someone else's, and never reaped without an explicit opt-in.
343
+ */
344
+ export function listWorktrees(ctx, deps) {
345
+ const d = withDeps(deps);
346
+ const nowMs = d.now().getTime();
347
+ const rows = [];
348
+ const seen = new Set();
349
+
350
+ const push = (path, base, registered) => {
351
+ const marker = readMarker(path, d);
352
+ const isPrimary = samePath(path, ctx.repo);
353
+ const kind = isPrimary ? "primary" : (marker?.kind === "session" || marker?.kind === "build" ? marker.kind : "foreign");
354
+ const startedAt = marker?.startedAt ?? null;
355
+ const startedMs = startedAt ? Date.parse(startedAt) : NaN;
356
+ const pid = Number.isInteger(marker?.pid) ? marker.pid : null;
357
+ rows.push({
358
+ // resolve(): `git worktree list` reports FORWARD slashes on Windows, so a raw registry
359
+ // path would never string-compare equal to one a caller built with path.join().
360
+ path: resolve(path),
361
+ branch: base?.branch ?? marker?.branch ?? null,
362
+ head: base?.head ?? null,
363
+ kind,
364
+ specId: marker?.specId ?? null,
365
+ sessionId: marker?.sessionId ?? null,
366
+ pid,
367
+ // THREE-valued: true (alive), false (positively gone), null (UNKNOWN — the marker records
368
+ // no pid, which is the normal case for a session worktree whose launcher has exited).
369
+ // `null` must never be read as "dead"; see reapWorktrees.
370
+ pidAlive: pid == null ? null : d.isPidAlive(pid),
371
+ startedAt,
372
+ ageMs: Number.isFinite(startedMs) ? nowMs - startedMs : null,
373
+ releasedAt: marker?.releasedAt ?? null,
374
+ dirtyCount: dirtyCountFor(ctx, path, d),
375
+ registered,
376
+ });
377
+ seen.add(canonicalPath(path));
378
+ };
379
+
380
+ const listed = d.git(ctx, ctx.repo, "worktree", "list", "--porcelain");
381
+ if (listed.status === 0) {
382
+ for (const wt of parsePorcelainWorktrees(listed.stdout)) push(wt.path, wt, true);
383
+ }
384
+
385
+ if (d.fs.existsSync(ctx.worktreeRoot)) {
386
+ for (const entry of d.fs.readdirSync(ctx.worktreeRoot)) {
387
+ const path = join(ctx.worktreeRoot, entry);
388
+ if (seen.has(canonicalPath(path))) continue;
389
+ let st; try { st = d.fs.lstatSync(path); } catch { continue; }
390
+ if (!st.isDirectory() || st.isSymbolicLink()) continue;
391
+ push(path, null, false);
392
+ }
393
+ }
394
+ return rows;
395
+ }
396
+
397
+ // ── claim liveness (the console probe) ───────────────────────────────────────────────────────
398
+
399
+ /** Is this claim row still live? Same two-field test peer-presence.mjs applies when it builds the
400
+ * PEER AGENTS block — a row past its `staleAt` or `expiresAt` is not somebody working. */
401
+ function claimRowIsLive(row, nowMs) {
402
+ const staleAtMs = row?.staleAt ? Date.parse(row.staleAt) : null;
403
+ const expiresAtMs = row?.expiresAt ? Date.parse(row.expiresAt) : null;
404
+ if (staleAtMs != null && Number.isFinite(staleAtMs) && staleAtMs < nowMs) return false;
405
+ if (expiresAtMs != null && Number.isFinite(expiresAtMs) && expiresAtMs < nowMs) return false;
406
+ return true;
407
+ }
408
+
409
+ /**
410
+ * A `claimIsActive(specId)` implementation backed by the console's `/api/claims`.
411
+ *
412
+ * → `true` a live claim for that spec exists · `false` the read SUCCEEDED and there is none ·
413
+ * `null` the read failed (no console URL, network error, non-OK response, bad payload). The
414
+ * three-way answer is the whole point: "the console says nobody holds this" is grounds to reclaim
415
+ * a worktree, "I could not ask" never is.
416
+ *
417
+ * ONE round trip for the lifetime of the checker: the claim list is fetched on the first call and
418
+ * every later spec id is answered from that array. `reapWorktrees` builds a fresh checker per
419
+ * call, so a reap over fifty worktrees still makes exactly one request.
420
+ *
421
+ * Ids are compared by FILE KEY via `keyInfoFor`, so a marker holding the full basename
422
+ * (`HS-L4Q5-03DO-TASK-…`), the store id (`HS-L4Q5`) and a bare `L4Q5` all match the same claim.
423
+ *
424
+ * The vendor modules are imported LAZILY: the controller imports this file on every build, and a
425
+ * console/auth module it never calls has no business being on that import path.
426
+ */
427
+ export function createConsoleClaimChecker(ctx, {
428
+ fetchFn = globalThis.fetch, timeoutMs = CLAIM_PROBE_TIMEOUT_MS, consoleUrl, now = () => new Date(),
429
+ } = {}) {
430
+ let pending = null;
431
+ const loadClaims = () => {
432
+ if (!pending) {
433
+ pending = (async () => {
434
+ const url = consoleUrl ?? consoleUrlForWorktree(ctx);
435
+ if (!url || typeof fetchFn !== "function") return null;
436
+ const [{ readClaimsFromConsole }, { getAuthHeader }] = await Promise.all([
437
+ import("../../vendor/heyspex-mcp/peer-presence.mjs"),
438
+ import("../../vendor/heyspex-mcp/console-fetch.mjs"),
439
+ ]);
440
+ // Same bearer every other console read sends (scripts/validate-no-foreign-session-sweep.mjs
441
+ // builds it identically) — a cloud console 401s an unauthenticated /api/claims, which
442
+ // would otherwise look like "no claims" instead of "could not check".
443
+ return await readClaimsFromConsole(url, fetchFn, timeoutMs, getAuthHeader());
444
+ })().catch(() => null);
445
+ }
446
+ return pending;
447
+ };
448
+
449
+ return async function claimIsActive(specId) {
450
+ if (!specId) return null;
451
+ const claims = await loadClaims();
452
+ if (!Array.isArray(claims)) return null; // could not check — never "no claim"
453
+ const want = keyInfoFor(specId).key.toLowerCase();
454
+ const nowMs = now().getTime();
455
+ return claims.some((c) => keyInfoFor(String(c?.specId ?? "")).key.toLowerCase() === want
456
+ && claimRowIsLive(c, nowMs));
457
+ };
458
+ }
459
+
460
+ // ── reap ─────────────────────────────────────────────────────────────────────────────────────
461
+
462
+ /**
463
+ * Retire the `claim/<KEY>` branch a session worktree was sitting on, once that worktree is gone.
464
+ *
465
+ * ALWAYS lowercase `-d`, never `-D`. `-d` refuses a branch holding commits that are not merged
466
+ * anywhere else, and that refusal is the entire safety property: the worktree's files are already
467
+ * deleted by this point, so a force-delete would be the last step of destroying work nobody has
468
+ * reviewed. It equally refuses a branch checked out in another worktree — also surfaced verbatim
469
+ * rather than worked around.
470
+ *
471
+ * → `{ name, deleted: true }` · `{ name, deleted: false, reason }` where reason is "unmerged",
472
+ * "missing", or git's own first stderr line.
473
+ */
474
+ function deleteClaimBranch(ctx, name, d) {
475
+ const r = d.git(ctx, ctx.repo, "branch", "-d", name);
476
+ if (r.status === 0) return { name, deleted: true };
477
+ const firstLine = String(r.stderr || r.stdout || "").trim().split(/\r?\n/)[0] || "git branch -d failed";
478
+ let reason = firstLine;
479
+ if (/not fully merged/i.test(firstLine)) reason = "unmerged";
480
+ else if (/not found|didn't match|does not exist/i.test(firstLine)) reason = "missing";
481
+ return { name, deleted: false, reason };
482
+ }
483
+
484
+ /**
485
+ * Reclaim worktrees that are finished with. Removal needs BOTH halves:
486
+ *
487
+ * ELIGIBLE, which depends on what kind of worktree it is —
488
+ * · foreign — only ever with `includeForeign && yes`; the operator's explicit say-so IS the
489
+ * evidence, because a worktree with no marker tells us nothing else.
490
+ * · launch-* — an UNCLAIMED placeholder (a window opened before any spec was claimed). Its
491
+ * `pid` is meaningless and there is no claim to ask about, so it ages out:
492
+ * `releasedAt` set OR at least `ttlMs` old (LAUNCH_PLACEHOLDER_TTL_MS). A
493
+ * window opened ten minutes ago is never touched; one abandoned yesterday is,
494
+ * and `ttlMs: 0` (`--ttl-hours 0`) ages every placeholder out at once.
495
+ * · otherwise — `releasedAt` set, OR the console says the claim is NOT active, OR the owner
496
+ * process is POSITIVELY gone (`pidAlive === false`).
497
+ * and CLEAN — `dirtyCount === 0`.
498
+ *
499
+ * `pidAlive === null` (UNKNOWN — the normal state of a session marker, whose launcher process
500
+ * exits seconds after the agent starts) is NOT evidence of death and never makes a worktree
501
+ * eligible on its own; it is reported as `owner_unknown`. Liveness must then come from
502
+ * `releasedAt` or from `claimIsActive`. This is the difference between housekeeping and deleting
503
+ * a colleague's uncommitted afternoon.
504
+ *
505
+ * The dirty check is the load-bearing one: every other signal is a heuristic about a process,
506
+ * but uncommitted work is unrecoverable once the worktree is gone.
507
+ *
508
+ * `dryRun` performs ZERO writes — no teardown, and no branch deletion either.
509
+ * The primary checkout is never a candidate, under any flags.
510
+ *
511
+ * → `{ dryRun, removed: [{ path, branch }], skipped: [{ path, reason }] }`, where `branch` is
512
+ * `null` (a dry run, or a non-session worktree with no branch of ours) or
513
+ * `{ name, deleted: true }` / `{ name, deleted: false, reason }`.
514
+ * Reasons: "primary" · "foreign" · "claim_active" · "owner_alive" · "owner_unknown" ·
515
+ * "placeholder_fresh" · "dirty".
516
+ */
517
+ export async function reapWorktrees(ctx, {
518
+ dryRun = false, includeForeign = false, yes = false, ttlMs = LAUNCH_PLACEHOLDER_TTL_MS,
519
+ } = {}, deps) {
520
+ const d = withDeps(deps);
521
+ const removed = [];
522
+ const skipped = [];
523
+ // ONE checker per reap call → one console round trip, whatever the worktree count.
524
+ const claimIsActive = typeof d.claimIsActive === "function"
525
+ ? d.claimIsActive
526
+ : createConsoleClaimChecker(ctx, { fetchFn: d.fetch, consoleUrl: d.consoleUrl, now: d.now });
527
+
528
+ for (const wt of listWorktrees(ctx, d)) {
529
+ if (wt.kind === "primary") { skipped.push({ path: wt.path, reason: "primary" }); continue; }
530
+ if (wt.kind === "foreign" && !(includeForeign && yes)) { skipped.push({ path: wt.path, reason: "foreign" }); continue; }
531
+
532
+ let claimActive = null;
533
+ if (wt.specId) {
534
+ try { claimActive = await claimIsActive(wt.specId); } catch { claimActive = null; }
535
+ }
536
+ if (claimActive === true) { skipped.push({ path: wt.path, reason: "claim_active" }); continue; }
537
+
538
+ const isPlaceholder = Boolean(wt.specId) && keyInfoFor(wt.specId).launch;
539
+ let eligible, reason;
540
+ if (wt.kind === "foreign") {
541
+ eligible = true; // gated above on includeForeign && yes
542
+ } else if (isPlaceholder) {
543
+ // `>=`, not `>`. `--ttl-hours 0` means "age it out immediately", and a placeholder
544
+ // allocated in the same millisecond as the reap has `ageMs === 0`. Under a strict `>`
545
+ // that worktree read as FRESH, so a zero TTL silently kept it AND the dirty gate below
546
+ // was never reached — a dirty aged-out placeholder came back "placeholder_fresh"
547
+ // instead of "dirty", which is a refusal for the wrong reason (right answer, wrong
548
+ // evidence: a dirty-detection regression would have been invisible). At the real 24h
549
+ // TTL the two comparisons differ by exactly one millisecond and nothing else.
550
+ eligible = Boolean(wt.releasedAt) || (wt.ageMs != null && wt.ageMs >= ttlMs);
551
+ reason = "placeholder_fresh";
552
+ } else {
553
+ eligible = Boolean(wt.releasedAt) || claimActive === false || wt.pidAlive === false;
554
+ reason = wt.pidAlive === null ? "owner_unknown" : "owner_alive";
555
+ }
556
+ if (!eligible) { skipped.push({ path: wt.path, reason }); continue; }
557
+
558
+ if ((wt.dirtyCount ?? 0) > 0) { skipped.push({ path: wt.path, reason: "dirty" }); continue; }
559
+
560
+ if (dryRun) { removed.push({ path: wt.path, branch: null }); continue; }
561
+
562
+ // Links-first teardown, always — never a recursive delete through a live junction.
563
+ const clean = teardownWorktree(ctx, wt.path, true);
564
+ if (d.fs.existsSync(wt.path)) {
565
+ if (clean || !d.fs.existsSync(join(wt.path, "node_modules"))) {
566
+ try { d.fs.rmSync(wt.path, { recursive: true, force: true }); } catch { /* best-effort */ }
567
+ }
568
+ }
569
+ if (d.fs.existsSync(wt.path)) { skipped.push({ path: wt.path, reason: "dirty" }); continue; }
570
+
571
+ // The worktree is gone; retire its branch too. Removing the checkout but leaving
572
+ // `claim/<KEY>` behind is how a repo silently accumulates dozens of dead refs. Only SESSION
573
+ // worktrees have one — build worktrees are cut `--detach`, and a foreign worktree's branch
574
+ // is not ours to touch.
575
+ const branch = wt.kind === "session" && wt.branch ? deleteClaimBranch(ctx, wt.branch, d) : null;
576
+ removed.push({ path: wt.path, branch });
577
+ }
578
+ return { dryRun: Boolean(dryRun), removed, skipped };
579
+ }
580
+
581
+ // ── console URL ──────────────────────────────────────────────────────────────────────────────
582
+
583
+ /**
584
+ * The console URL a session worktree should talk to — deliberately the PRIMARY checkout's, not
585
+ * a per-worktree one: a worktree is an isolated *file* tree, not an isolated control plane, and
586
+ * every claim/progress/release call must land on the same console the human is looking at.
587
+ */
588
+ export function consoleUrlForWorktree(ctx) {
589
+ try {
590
+ const url = resolveConsoleUrl({ repoRoot: ctx.repo });
591
+ if (url) return url;
592
+ } catch { /* fall through to the repo discovery file */ }
593
+ try {
594
+ const discovery = readFileSync(join(ctx.repo, ".heyspex", "console-url"), "utf8").trim();
595
+ if (discovery) return discovery.replace(/\/+$/, "");
596
+ } catch { /* nothing to fall back to */ }
597
+ return null;
598
+ }
599
+
600
+ // ── CLI ──────────────────────────────────────────────────────────────────────────────────────
601
+
602
+ const USAGE = `usage: node dev-factory/lib/worktree-allocator.mjs <list|alloc|release|reap|rebind> [options]
603
+
604
+ list every worktree (registry + unregistered leftovers) with its marker
605
+ alloc --spec ID allocate/re-attach the claim-bound worktree for a spec
606
+ release --spec ID mark that worktree released (never deletes)
607
+ reap reclaim released/dead/claim-inactive worktrees that are CLEAN
608
+ rebind --spec ID --to ID re-point a worktree (and rename its branch) at another spec
609
+
610
+ options:
611
+ --spec ID the spec id (required for alloc/release/rebind)
612
+ --to ID the new spec id (rebind)
613
+ --session ID session id recorded in the owner marker
614
+ --agent NAME agent name recorded in the owner marker
615
+ --pid N owner pid recorded in the owner marker (default: NONE — a session's
616
+ launcher process exits immediately, so a recorded pid would read as dead)
617
+ --ttl-hours N how long an unclaimed launch-* placeholder may idle before reap takes it
618
+ (default 24)
619
+ --root DIR worktree root (default: %USERPROFILE%/.hsewt)
620
+ --repo DIR repo root (default: this checkout)
621
+ --json print ONE JSON object instead of human lines
622
+ --dry-run compute the answer, write nothing
623
+ --include-foreign let reap consider worktrees with no owner marker (requires --yes)
624
+ --yes confirm a destructive reap
625
+ `;
626
+
627
+ const FLAGS_WITH_VALUE = new Set(["--spec", "--to", "--session", "--agent", "--pid", "--root", "--repo", "--ttl-hours"]);
628
+ const BOOL_FLAGS = new Set(["--json", "--dry-run", "--include-foreign", "--yes"]);
629
+ const VERBS = new Set(["list", "alloc", "release", "reap", "rebind"]);
630
+
631
+ export function parseCliArgs(argv) {
632
+ const out = { verb: null, json: false, dryRun: false, includeForeign: false, yes: false };
633
+ let i = 0;
634
+ if (argv[0] && !argv[0].startsWith("-")) { out.verb = argv[0]; i = 1; }
635
+ for (; i < argv.length; i++) {
636
+ const a = argv[i];
637
+ if (BOOL_FLAGS.has(a)) {
638
+ if (a === "--json") out.json = true;
639
+ else if (a === "--dry-run") out.dryRun = true;
640
+ else if (a === "--include-foreign") out.includeForeign = true;
641
+ else out.yes = true;
642
+ continue;
643
+ }
644
+ if (FLAGS_WITH_VALUE.has(a)) {
645
+ const v = argv[++i];
646
+ if (v === undefined) return { error: `missing value for ${a}`, code: EXIT_INVALID_ARG };
647
+ if (a === "--spec") out.spec = v;
648
+ else if (a === "--to") out.to = v;
649
+ else if (a === "--session") out.session = v;
650
+ else if (a === "--agent") out.agent = v;
651
+ else if (a === "--pid") {
652
+ const n = Number.parseInt(v, 10);
653
+ if (!Number.isInteger(n) || n <= 0) return { error: `bad --pid ${v}`, code: EXIT_INVALID_ARG };
654
+ out.pid = n;
655
+ } else if (a === "--ttl-hours") {
656
+ const n = Number.parseFloat(v);
657
+ if (!Number.isFinite(n) || n < 0) return { error: `bad --ttl-hours ${v}`, code: EXIT_INVALID_ARG };
658
+ out.ttlMs = Math.round(n * 60 * 60 * 1000);
659
+ } else if (a === "--root") out.root = v;
660
+ else out.repo = v;
661
+ continue;
662
+ }
663
+ return { error: `unknown flag ${a}`, code: EXIT_USAGE };
664
+ }
665
+ if (!out.verb || !VERBS.has(out.verb)) return { error: `unknown verb ${out.verb ?? "(none)"}`, code: EXIT_USAGE };
666
+ return out;
667
+ }
668
+
669
+ async function runCli(argv) {
670
+ const args = parseCliArgs(argv);
671
+ if (args.error) { process.stderr.write(`${args.error}\n\n${USAGE}`); return args.code; }
672
+
673
+ const ctx = makeCtx({ repo: args.repo || undefined, worktreeRoot: args.root || undefined });
674
+ const emit = (obj, lines) => {
675
+ if (args.json) process.stdout.write(`${JSON.stringify(obj, null, 2)}\n`);
676
+ else for (const l of lines) process.stdout.write(`${l}\n`);
677
+ };
678
+
679
+ if (args.verb === "list") {
680
+ const worktrees = listWorktrees(ctx);
681
+ emit({ worktrees }, worktrees.map((w) =>
682
+ `${w.kind.padEnd(8)} ${String(w.branch ?? "(detached)").padEnd(28)} dirty=${w.dirtyCount ?? "?"} `
683
+ + `${w.registered ? "" : "UNREGISTERED "}${w.specId ?? ""}${w.releasedAt ? " released" : ""} ${w.path}`));
684
+ return EXIT_OK;
685
+ }
686
+
687
+ if (args.verb === "alloc") {
688
+ if (!args.spec) { process.stderr.write(`alloc requires --spec\n`); return EXIT_INVALID_ARG; }
689
+ const info = keyInfoFor(args.spec);
690
+ if (args.dryRun) {
691
+ const baseSha = (git(ctx, ctx.repo, "rev-parse", "HEAD").stdout || "").trim() || null;
692
+ emit({ dryRun: true, path: worktreePathFor(ctx, args.spec), branch: info.branch, baseSha },
693
+ [`would allocate ${worktreePathFor(ctx, args.spec)} on ${info.branch} from ${baseSha}`]);
694
+ return EXIT_OK;
695
+ }
696
+ try {
697
+ const r = allocateForClaim(ctx, {
698
+ specId: args.spec, sessionId: args.session, agent: args.agent, pid: args.pid,
699
+ });
700
+ emit(r, [`${r.created ? "allocated" : "re-attached"} ${r.path} on ${r.branch} `
701
+ + `(port ${devServerPortFor(info.key)})`]);
702
+ return EXIT_OK;
703
+ } catch (e) { process.stderr.write(`${e.message}\n`); return EXIT_WRITE_FAILED; }
704
+ }
705
+
706
+ if (args.verb === "release") {
707
+ if (!args.spec) { process.stderr.write(`release requires --spec\n`); return EXIT_INVALID_ARG; }
708
+ const r = markReleased(ctx, { specId: args.spec });
709
+ emit(r, [r.found
710
+ ? `${r.alreadyReleased ? "already released" : "released"} ${worktreePathFor(ctx, args.spec)}`
711
+ : `no worktree for ${args.spec}`]);
712
+ return r.found ? EXIT_OK : EXIT_CHECK_FAILED;
713
+ }
714
+
715
+ if (args.verb === "rebind") {
716
+ if (!args.spec || !args.to) { process.stderr.write(`rebind requires --spec and --to\n`); return EXIT_INVALID_ARG; }
717
+ try {
718
+ const r = rebind(ctx, { fromSpecId: args.spec, toSpecId: args.to });
719
+ emit(r, [r.found ? `rebound ${args.spec} → ${args.to} on ${r.branch}` : `no worktree for ${args.spec}`]);
720
+ return r.found ? EXIT_OK : EXIT_CHECK_FAILED;
721
+ } catch (e) { process.stderr.write(`${e.message}\n`); return EXIT_WRITE_FAILED; }
722
+ }
723
+
724
+ // reap
725
+ const r = await reapWorktrees(ctx, {
726
+ dryRun: args.dryRun, includeForeign: args.includeForeign, yes: args.yes,
727
+ ...(args.ttlMs != null ? { ttlMs: args.ttlMs } : {}),
728
+ });
729
+ emit(r, [
730
+ `${r.dryRun ? "would remove" : "removed"} ${r.removed.length}:`,
731
+ ...r.removed.map((x) => ` ${x.path}${x.branch
732
+ ? ` — branch ${x.branch.name} ${x.branch.deleted ? "deleted" : `KEPT (${x.branch.reason})`}`
733
+ : ""}`),
734
+ ...(r.removed.length ? [] : [" (none)"]),
735
+ ...r.skipped.map((s) => ` skipped ${s.path} — ${s.reason}`),
736
+ ]);
737
+ return EXIT_OK;
738
+ }
739
+
740
+ // Guard on MAIN-entry (not just on a flag) so importing this module from the controller never
741
+ // fires the CLI — the same convention engine/worktree.mjs uses for its --self-test.
742
+ if (import.meta.url.endsWith(`/${basename(process.argv[1] ?? "")}`)) {
743
+ runCli(process.argv.slice(2)).then((code) => { process.exitCode = code; });
744
+ }