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,740 @@
1
+ #!/usr/bin/env node
2
+ // dev-factory/engine/worktree.mjs
3
+ // ENGINE — per-task git worktree isolation + deterministic gate.
4
+ // Task: HS-OBJC-W6AD-TASK-per-task-git-worktree-isolation-baseline (epic HS-UB1R).
5
+ //
6
+ // Lifted from the proven dev-factory/phase2-loop.mjs so the loop controller (controller.mjs) can run each
7
+ // task's build + gate inside an ISOLATED worktree: the gate sees ONLY that task's diff, the live tree / dev
8
+ // server / Neon DB are never touched, and the net-new tsc/test baseline is captured in the clean tree.
9
+ //
10
+ // Windows-safe (the hard-won lessons):
11
+ // - node_modules is JUNCTIONED to the monorepo root (hoisted toolchain — Node resolution walks up) AND the
12
+ // workspace — never copied.
13
+ // - git runs with core.longpaths=true so deep specs/ filenames don't trip MAX_PATH (260).
14
+ // - teardown removes the junction LINKS first, THEN `git worktree remove` — NEVER recurse through a junction
15
+ // (that would delete the real node_modules).
16
+ //
17
+ // API:
18
+ // const ctx = makeCtx({ repo?, spex?, worktreeRoot? });
19
+ // const wt = setupWorktree(ctx, label); // → abs worktree path (node_modules junctioned)
20
+ // const base = baselineTsc(ctx, wt); // → { count } net-new-error baseline (clean tree)
21
+ // const tbase = baselineTest(ctx, wt, testRel); // → { pass } RED-BASELINE pre-check (HS-AY5K):
22
+ // // runs testRel (auto-routed node:test/vitest) against
23
+ // // the UNMODIFIED tree — pass:false means the gate can
24
+ // // never go green regardless of the candidate code.
25
+ // const res = runGate(ctx, wt, { target, test }, code, base); // target/test REPO-ROOT-relative; write→tsc(≤baseline)→
26
+ // // test(auto-routed node:test / plain self-test script / vitest)→restore
27
+ // teardownWorktree(ctx, wt); // links-first, then git worktree remove
28
+ //
29
+ // Self-test (NO models — proves the isolation plumbing, the deterministic gate's foundation):
30
+ // node dev-factory/engine/worktree.mjs --self-test
31
+
32
+ import { symlinkSync, existsSync, lstatSync, rmdirSync, rmSync, mkdirSync, writeFileSync, readFileSync, readdirSync } from "node:fs";
33
+ import { spawnSync } from "node:child_process";
34
+ import { join, resolve, dirname } from "node:path";
35
+ import { fileURLToPath } from "node:url";
36
+ import { tmpdir } from "node:os";
37
+
38
+ const __dirname = dirname(fileURLToPath(import.meta.url));
39
+
40
+ export const slug = (s) => String(s).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 40);
41
+
42
+ // Liveness marker written into every worktree at setup. The startup sweep uses it to tell an
43
+ // ACTIVE concurrent worktree (owner process still alive → leave it) from a STRANDED one an
44
+ // interrupted run left behind (owner dead, or no marker at all → safe to reclaim). Without this,
45
+ // a blanket sweep would delete a sibling build's live worktree.
46
+ export const OWNER_FILE = ".hsewt-owner.json";
47
+
48
+ // Is a process still running? process.kill(pid, 0) sends no signal — it throws ESRCH if the pid
49
+ // is gone, and succeeds (or throws EPERM, which still means "exists") if it is alive. Works on win32.
50
+ export function isPidAlive(pid) {
51
+ if (!Number.isInteger(pid) || pid <= 0) return false;
52
+ try { process.kill(pid, 0); return true; }
53
+ catch (e) { return e.code === "EPERM"; }
54
+ }
55
+
56
+ /** Build the context the worktree functions thread through. All paths absolute. */
57
+ export function makeCtx(opts = {}) {
58
+ const repo = resolve(opts.repo || join(__dirname, "..", ".."));
59
+ const spex = opts.spex || "spex-console";
60
+ const worktreeRoot = resolve(opts.worktreeRoot
61
+ || (process.env.USERPROFILE ? join(process.env.USERPROFILE, ".hsewt") : join(repo, ".hsewt")));
62
+ return {
63
+ repo, spex, worktreeRoot,
64
+ tscTimeout: opts.tscTimeout ?? 180000,
65
+ testTimeout: opts.testTimeout ?? 120000,
66
+ gitTimeout: opts.gitTimeout ?? 180000,
67
+ };
68
+ }
69
+
70
+ export function makeLink(link, target) {
71
+ symlinkSync(resolve(target), link, process.platform === "win32" ? "junction" : "dir");
72
+ }
73
+ // Remove ONLY the junction/symlink reparse point — never recurse into (and delete) the real target.
74
+ function removeLink(link) {
75
+ if (!existsSync(link)) return true;
76
+ try {
77
+ const st = lstatSync(link);
78
+ if (st.isSymbolicLink() || process.platform === "win32") { rmdirSync(link); return !existsSync(link); }
79
+ rmSync(link, { force: true }); return !existsSync(link);
80
+ } catch (e) {
81
+ console.error(` ⚠ could NOT remove link ${link}: ${e.message} — leaving worktree in place (refusing to risk node_modules).`);
82
+ return false;
83
+ }
84
+ }
85
+
86
+ /** git with core.longpaths=true so a full checkout's deep filenames don't trip Windows MAX_PATH. */
87
+ export function git(ctx, cwd, ...a) {
88
+ return spawnSync("git", ["-c", "core.longpaths=true", ...a], { cwd, encoding: "utf8", timeout: ctx.gitTimeout });
89
+ }
90
+
91
+ /** Count `tsc --noEmit` errors in <worktree>/<spex>. Returns { count, status, output, timedOut }. */
92
+ export function runTsc(ctx, worktree) {
93
+ const r = spawnSync("npx --no-install tsc --noEmit -p tsconfig.json", {
94
+ cwd: join(worktree, ctx.spex), shell: true, encoding: "utf8", timeout: ctx.tscTimeout,
95
+ });
96
+ const out = ((r.stdout || "") + (r.stderr || ""));
97
+ return { count: (out.match(/error TS\d+/g) || []).length, status: r.status, output: out.trim().slice(0, 1200), timedOut: r.error?.code === "ETIMEDOUT" };
98
+ }
99
+
100
+ /** Run a scoped vitest file in <worktree>/<spex>. Returns { pass, output, timedOut }. */
101
+ export function runVitest(ctx, worktree, testRel) {
102
+ const r = spawnSync(`npx --no-install vitest run ${testRel}`, {
103
+ cwd: join(worktree, ctx.spex), shell: true, encoding: "utf8", timeout: ctx.testTimeout,
104
+ });
105
+ const out = ((r.stdout || "") + (r.stderr || ""));
106
+ return { pass: r.status === 0 && !r.error, output: out.trim().slice(-1200), timedOut: r.error?.code === "ETIMEDOUT" };
107
+ }
108
+
109
+ // HS-AY5K: the deterministic gate used to run EVERY test file through scoped
110
+ // vitest, no matter what test framework it was actually written against. Every
111
+ // vendor/heyspex-mcp/*.test.mjs imports describe/it/test from node:test — under vitest
112
+ // they either execute on import and register with nothing ("no test suite found") or,
113
+ // once vitest.config's `include` glob excludes the path, never even get picked up ("no
114
+ // test files found") — a permanently red gate for code that is fully green under the
115
+ // correct runner (`node --test`, 18/18). Detect the runner from the test file's own
116
+ // imports (cheap — one regex over the source, no execution) and dispatch to the correct
117
+ // one instead of hardcoding vitest.
118
+ const NODE_TEST_IMPORT_RE = /\bfrom\s+['"]node:test['"]|\brequire\(\s*['"]node:test['"]\s*\)/;
119
+
120
+ /** Does this test file import node:test (vs vitest/jest/etc.)? `testAbsPath` is read
121
+ * directly off disk. Never throws — an unreadable/missing file is simply "not
122
+ * node:test" and falls through to the existing vitest path (the pre-HS-XCCE-DIAG
123
+ * default), so a caller still using the old spex-relative test-path convention (which
124
+ * resolves to nothing under this REPO-ROOT-relative check) degrades safely rather than
125
+ * misrouting. */
126
+ export function isNodeTestFile(testAbsPath) {
127
+ try {
128
+ return NODE_TEST_IMPORT_RE.test(readFileSync(testAbsPath, "utf8"));
129
+ } catch {
130
+ return false;
131
+ }
132
+ }
133
+
134
+ /** Run a node:test file via `node --test <file>`. `testRel` is REPO-ROOT-relative
135
+ * (same convention as `task.target`, HS-W2F6) and resolved against the WORKTREE
136
+ * ROOT — not under ctx.spex, since vendor/ and other non-spex-console directories have
137
+ * no spex-console project to run inside, and `node --test` needs no project at all.
138
+ * Mirrors runVitest's { pass, output, timedOut } contract so runGate/baselineTest can
139
+ * dispatch to either interchangeably. */
140
+ export function runNodeTest(ctx, worktree, testRel) {
141
+ const abs = join(worktree, testRel);
142
+ const r = spawnSync(`node --test "${abs}"`, {
143
+ cwd: worktree, shell: true, encoding: "utf8", timeout: ctx.testTimeout,
144
+ });
145
+ const out = ((r.stdout || "") + (r.stderr || ""));
146
+ return { pass: r.status === 0 && !r.error, output: out.trim().slice(-1200), timedOut: r.error?.code === "ETIMEDOUT" };
147
+ }
148
+
149
+ // HS-AY5K edge (c): a THIRD gate convention alongside node:test and vitest — a
150
+ // plain script with NO test framework at all, verified by running it and checking its
151
+ // raw exit code (0 = green, non-zero = red). Two shapes exist in this repo today:
152
+ // 1. Inline argv guard: `if (process.argv.includes("--self-test")) { ...; process.exit(ok?0:1); }`
153
+ // — the SAME convention controller.mjs's `detectSelfTestGate` already detects for
154
+ // dev-factory/*.mjs TARGETS (Diagram 5a); this mirrors that regex so `--test`
155
+ // arguments pointing at such a file route the same way.
156
+ // 2. Companion `<name>.selftest.mjs` file: running the file directly (no flag) IS the
157
+ // self-test — e.g. scripts/validate-vault-ids.selftest.mjs. Before this branch
158
+ // existed, a `--test` pointing at either shape fell through to vitest, which found
159
+ // no describe/it and reported a permanently red gate unrelated to the candidate.
160
+ const SELF_TEST_ARGV_GUARD_RE = /(?:process\.)?argv\.includes\(\s*["']--self-test["']\s*\)/;
161
+ const SELF_TEST_COMPANION_FILE_RE = /\.selftest\.[cm]?js$/;
162
+
163
+ /** Does `testAbsPath` verify via a plain exit-code self-test (no node:test/vitest
164
+ * import)? Never throws — mirrors {@link isNodeTestFile}'s safe-degrade contract. */
165
+ export function isPlainSelfTestScript(testAbsPath) {
166
+ if (SELF_TEST_COMPANION_FILE_RE.test(testAbsPath)) return true;
167
+ try {
168
+ return SELF_TEST_ARGV_GUARD_RE.test(readFileSync(testAbsPath, "utf8"));
169
+ } catch {
170
+ return false;
171
+ }
172
+ }
173
+
174
+ /** Run a plain exit-code self-test script. `testRel` is REPO-ROOT-relative (same
175
+ * convention as {@link runNodeTest} — these scripts live outside spex-console, e.g.
176
+ * scripts/ or dev-factory/, and need no project to run inside). The companion-file
177
+ * convention (`*.selftest.mjs`) runs bare — invoking the file IS the check; the
178
+ * inline-argv-guard convention needs `--self-test` appended so the guarded branch
179
+ * actually fires. Mirrors runNodeTest/runVitest's { pass, output, timedOut } contract. */
180
+ export function runPlainSelfTestScript(ctx, worktree, testRel) {
181
+ const abs = join(worktree, testRel);
182
+ const needsFlag = !SELF_TEST_COMPANION_FILE_RE.test(abs);
183
+ const r = spawnSync(`node "${abs}"${needsFlag ? " --self-test" : ""}`, {
184
+ cwd: worktree, shell: true, encoding: "utf8", timeout: ctx.testTimeout,
185
+ });
186
+ const out = ((r.stdout || "") + (r.stderr || ""));
187
+ return { pass: r.status === 0 && !r.error, output: out.trim().slice(-1200), timedOut: r.error?.code === "ETIMEDOUT" };
188
+ }
189
+
190
+ /** Dispatch to the correct runner for `testRel` (auto-detected) — the single seam
191
+ * runGate and baselineTest both call through, so the routing rule lives in exactly
192
+ * one place. Order matters: node:test and the plain-self-test conventions are both
193
+ * narrow, cheap-to-check positives; vitest is the default fallback for everything else. */
194
+ function runScopedTest(ctx, worktree, testRel) {
195
+ const abs = join(worktree, testRel);
196
+ if (isNodeTestFile(abs)) return runNodeTest(ctx, worktree, testRel);
197
+ if (isPlainSelfTestScript(abs)) return runPlainSelfTestScript(ctx, worktree, testRel);
198
+ return runVitest(ctx, worktree, testRel);
199
+ }
200
+
201
+ /** Capture the net-new-error baseline in the CLEAN worktree (before any task code is written). */
202
+ export function baselineTsc(ctx, worktree) {
203
+ const t = runTsc(ctx, worktree);
204
+ return { count: t.count, timedOut: t.timedOut };
205
+ }
206
+
207
+ // HS-2HK7: the deterministic gate only knew the spex-console TS toolchain (tsc +
208
+ // scoped vitest) or a .mjs self-test — a companion C# task (dotnet build + dotnet
209
+ // test) had no fleet-gateable path and fell out of the free local build seat entirely.
210
+ // A LANGUAGE-PROFILE gate lets a caller (controller.mjs, keyed off `--gate-build-cmd`/
211
+ // `--gate-test-cmd`, or a per-task checks manifest) supply its own build/test commands
212
+ // instead of the hardcoded pair — same worktree isolation, same write→run→restore
213
+ // contract as `runGate`.
214
+
215
+ /** Run an arbitrary shell command in `cwd`. Same `{ pass, output, timedOut }` shape as
216
+ * runVitest/runNodeTest, so a language-profile command slots into the same dispatch
217
+ * contract as the TS toolchain. */
218
+ export function runShellCmd(cmd, cwd, timeoutMs) {
219
+ const r = spawnSync(cmd, { cwd, shell: true, encoding: "utf8", timeout: timeoutMs });
220
+ const out = ((r.stdout || "") + (r.stderr || ""));
221
+ return { pass: r.status === 0 && !r.error, output: out.trim().slice(-1200), timedOut: r.error?.code === "ETIMEDOUT" };
222
+ }
223
+
224
+ /** Baseline for a language-profile gate: run `buildCmd` (and `testCmd`, if given)
225
+ * against the UNMODIFIED worktree. Mirrors baselineTsc/baselineTest's "red-before-any-
226
+ * candidate" contract — a command already red on the clean tree is a gate/environment
227
+ * fault, not a code defect. `cwdRel` is worktree-root-relative (e.g. "companion" for a
228
+ * .sln that lives at `<repo>/companion/*.sln`); omit for the worktree root. */
229
+ export function baselineLanguageGate(ctx, worktree, { buildCmd, testCmd, cwdRel = "" } = {}) {
230
+ if (!buildCmd) return { pass: true, output: "", timedOut: false };
231
+ const cwd = cwdRel ? join(worktree, cwdRel) : worktree;
232
+ const b = runShellCmd(buildCmd, cwd, ctx.tscTimeout);
233
+ if (!b.pass || b.timedOut) return b;
234
+ if (!testCmd) return { pass: true, output: "", timedOut: false };
235
+ return runShellCmd(testCmd, cwd, ctx.testTimeout);
236
+ }
237
+
238
+ /** Run a LANGUAGE-PROFILE gate instead of the hardcoded tsc+vitest pair (HS-2HK7) —
239
+ * e.g. a companion C# task: `buildCmd: "dotnet build companion/HeySpex.Companion.sln"`,
240
+ * `testCmd: "dotnet test companion/HeySpex.Companion.sln"`. Returns
241
+ * `{ pass, tscOk, testOk, output }` — the SAME field NAMES `runGate` returns (`tscOk`/
242
+ * `testOk` repurposed here as build-ok/test-ok) so a caller's existing best-of-N
243
+ * scoring (`errorCount` derived from tscOk/testOk) works unmodified for either gate.
244
+ * ALWAYS restores the target via `git checkout`, same as `runGate`. */
245
+ export function runLanguageGate(ctx, worktree, task, code, { buildCmd, testCmd, cwdRel = "" } = {}) {
246
+ const abs = join(worktree, task.target);
247
+ let result = { pass: false, tscOk: false, testOk: false, output: "" };
248
+ try {
249
+ // HS-HECA: a genuinely new file's parent dir may exist neither at HEAD nor in the
250
+ // dirty working tree the overlay copied from — writeFileSync then threw ENOENT
251
+ // before the build/test command ever ran, reporting a gate/environment fault for
252
+ // work the builder had actually produced. Mirrors overlayIntoWorktree's own
253
+ // mkdirSync(dirname(to), { recursive: true }) before its writeFileSync.
254
+ mkdirSync(dirname(abs), { recursive: true });
255
+ writeFileSync(abs, code);
256
+ const cwd = cwdRel ? join(worktree, cwdRel) : worktree;
257
+ const b = runShellCmd(buildCmd, cwd, ctx.tscTimeout);
258
+ const tscOk = b.pass && !b.timedOut;
259
+ let testOk = false, testOut = "";
260
+ if (tscOk && testCmd) { const t = runShellCmd(testCmd, cwd, ctx.testTimeout); testOk = t.pass && !t.timedOut; testOut = t.output; }
261
+ else if (tscOk) testOk = true; // build-only gate when no test command is specified
262
+ result = {
263
+ pass: tscOk && testOk, tscOk, testOk,
264
+ output: (tscOk ? "" : `BUILD (${buildCmd}):\n${b.output}\n`)
265
+ + (tscOk && testCmd ? `TEST (${testCmd}):\n${testOut}` : "").slice(0, 1000),
266
+ };
267
+ } catch (e) {
268
+ // Environmental (bad path, missing dotnet SDK, permissions) — NOT a candidate defect.
269
+ // Mirrors runGate's gateException contract (HS-W2F6).
270
+ result = { pass: false, tscOk: false, testOk: false, gateException: true, output: `gate exception: ${e.message}` };
271
+ } finally {
272
+ git(ctx, worktree, "checkout", "--", task.target);
273
+ }
274
+ return result;
275
+ }
276
+
277
+ /** Capture the TEST HALF of the gate's baseline (HS-AY5K): run the correctly-routed
278
+ * test (auto-detected node:test vs vitest) against the UNMODIFIED target in the clean
279
+ * worktree, before any candidate code is written. If this is already red, no candidate
280
+ * can ever pass the gate — the fault is the runner/environment, not the generated code
281
+ * — so the caller should abort before spending any best-of-N attempts on it. Mirrors
282
+ * baselineTsc's pattern. `testRel` falsy → nothing to baseline, trivially green (a
283
+ * tsc-only gate has no test half). */
284
+ // HS-AY5K edge (a): a NEW-FILE task's test doesn't exist yet at baseline — the
285
+ // builder is EXPECTED to create it alongside the target. Without this check, the
286
+ // missing file made runScopedTest's underlying runner report "red" (file not found /
287
+ // no test files found), which the caller then wrongly treated as a genuine
288
+ // gate/environment fault and aborted before the builder ever ran. Check BOTH path
289
+ // conventions runScopedTest's runners use (node:test resolves testRel repo-root-
290
+ // relative against `worktree`; vitest resolves it spex-relative against
291
+ // `worktree/ctx.spex`) — a NEW-FILE test could land under either once the builder
292
+ // creates it.
293
+ function testExistsAtBaseline(ctx, worktree, testRel) {
294
+ return existsSync(join(worktree, testRel)) || existsSync(join(worktree, ctx.spex, testRel));
295
+ }
296
+
297
+ export function baselineTest(ctx, worktree, testRel) {
298
+ if (!testRel) return { pass: true, output: "", timedOut: false };
299
+ if (!testExistsAtBaseline(ctx, worktree, testRel)) {
300
+ return {
301
+ pass: true, notFound: true, timedOut: false,
302
+ output: `test file does not exist at baseline (${testRel}) — expected NEW-FILE condition, not a gate/environment fault`,
303
+ };
304
+ }
305
+ return runScopedTest(ctx, worktree, testRel);
306
+ }
307
+
308
+ // HS-L9BX: `setupWorktree` builds the gate's worktree from `git worktree add --detach
309
+ // <path> HEAD` — committed content only. A wave-N+1 task importing a wave-N module that
310
+ // is still `review_pending`/untracked (agents don't commit; the human reviewer commits
311
+ // on Pass) is structurally invisible to the worktree: TS2307 fires even though the
312
+ // builder's code is correct and the file exists in the main tree. Detect exactly that
313
+ // shape and let the caller classify it as `gate_environment_fault` instead of spending
314
+ // the retry/replan budget "fixing" code that already imports a real file correctly.
315
+ const TS2307_RE = /^(.+?)\(\d+,\d+\): error TS2307: Cannot find module '([^']+)'/gm;
316
+ const RELATIVE_IMPORT_EXTS = [".ts", ".tsx", ".mts", ".cts", ".d.ts", "/index.ts", "/index.tsx", ""];
317
+
318
+ /** Resolve a relative import specifier from `fromFileAbs` to a file on disk, trying the
319
+ * extensions TS allows an extensionless relative specifier to resolve to. Returns the
320
+ * absolute path if found, else null. Never throws. */
321
+ function resolveRelativeImport(fromFileAbs, specifier) {
322
+ const base = resolve(dirname(fromFileAbs), specifier);
323
+ for (const ext of RELATIVE_IMPORT_EXTS) {
324
+ const candidate = base + ext;
325
+ try { if (existsSync(candidate)) return candidate; } catch { /* ignore */ }
326
+ }
327
+ return null;
328
+ }
329
+
330
+ /** Does `tscOutput` contain a TS2307 on a RELATIVE import (bare/package specifiers are a
331
+ * different bug class) that fails to resolve inside `worktree` but DOES resolve to a
332
+ * real file at the same relative location in `ctx.repo` (the main tree)? Returns
333
+ * `{ specifier, relMissing }` on a match, else `null`. `tscOutput` is tsc's raw stdout+
334
+ * stderr, cwd `<worktree>/<ctx.spex>` (see `runTsc`), so reported file paths are
335
+ * relative to that cwd. */
336
+ function detectMissingWorktreeDependency(ctx, worktree, tscOutput) {
337
+ const tscCwd = join(worktree, ctx.spex);
338
+ let m;
339
+ TS2307_RE.lastIndex = 0;
340
+ while ((m = TS2307_RE.exec(tscOutput))) {
341
+ const [, filePart, specifier] = m;
342
+ if (!specifier.startsWith(".")) continue; // bare/package specifiers: not this bug
343
+ const fileAbsInWorktree = resolve(tscCwd, filePart.trim());
344
+ if (resolveRelativeImport(fileAbsInWorktree, specifier)) continue; // genuinely resolves — not this bug
345
+ const relFromWorktreeRoot = fileAbsInWorktree.slice(worktree.length).replace(/^[\\/]/, "");
346
+ const fileAbsInMain = join(ctx.repo, relFromWorktreeRoot);
347
+ const foundInMain = resolveRelativeImport(fileAbsInMain, specifier);
348
+ if (foundInMain) {
349
+ return { specifier, relMissing: foundInMain.slice(ctx.repo.length).replace(/^[\\/]/, "") };
350
+ }
351
+ }
352
+ return null;
353
+ }
354
+
355
+ // The deterministic gate on a worktree: write code → tsc (no NEW errors vs baseline) AND scoped vitest → restore.
356
+ // Returns { pass, tscOk, testOk, output }. ALWAYS restores the target via `git checkout` so the tree stays pristine.
357
+ //
358
+ // `task.target` is REPO-ROOT-relative (e.g. "spex-console/src/x.ts" OR a repo-root path
359
+ // like "schemas/foo.json") and is resolved against the WORKTREE ROOT. It used to be joined
360
+ // under ctx.spex, which fabricated a non-existent path for any target outside spex-console/
361
+ // — the gate then threw an ENOENT before tsc/vitest ran, and the caller mistook that
362
+ // environmental failure for a red gate (HS-W2F6). tsc/vitest still run in ctx.spex (the
363
+ // only TS/test project); a repo-root edit leaves the spex-console baseline untouched, so a
364
+ // tsc-only gate on such a target correctly passes.
365
+ export function runGate(ctx, worktree, task, code, baseline) {
366
+ const abs = join(worktree, task.target);
367
+ let result = { pass: false, tscOk: false, testOk: false, output: "" };
368
+ try {
369
+ // HS-HECA: same greenfield gap as runLanguageGate — a brand-new file in a
370
+ // brand-new subdirectory has no parent dir at HEAD or in the overlay, so
371
+ // writeFileSync ENOENTs before tsc/vitest ever runs. Create it first.
372
+ mkdirSync(dirname(abs), { recursive: true });
373
+ writeFileSync(abs, code);
374
+ const tsc = runTsc(ctx, worktree);
375
+ const tscOk = !tsc.timedOut && tsc.count <= baseline;
376
+ if (!tscOk && !tsc.timedOut) {
377
+ const missing = detectMissingWorktreeDependency(ctx, worktree, tsc.output);
378
+ if (missing) {
379
+ result = {
380
+ pass: false, tscOk: false, testOk: false, gateException: true,
381
+ output: `gate environment fault (HS-L9BX): worktree is missing '${missing.relMissing}' — imported via '${missing.specifier}'. The file exists in the main tree but the gate worktree is built from HEAD (committed content only), so an untracked wave-N dependency is structurally invisible to it. Commit the dependency's output, or overlay it into the worktree before gating — not a defect in this candidate's code.`,
382
+ };
383
+ return result;
384
+ }
385
+ }
386
+ let testOk = false, testOut = "";
387
+ if (tscOk && task.test) { const v = runScopedTest(ctx, worktree, task.test); testOk = v.pass; testOut = v.output; }
388
+ else if (tscOk) testOk = true; // tsc-only gate when no test is specified
389
+ result = {
390
+ pass: tscOk && testOk, tscOk, testOk,
391
+ output: (tscOk ? "" : `TSC (${tsc.count} err vs baseline ${baseline})${tsc.timedOut ? " [TIMEOUT]" : ""}:\n${tsc.output}\n`)
392
+ + (tscOk && task.test ? `VITEST:\n${testOut}` : "").slice(0, 1000),
393
+ };
394
+ } catch (e) {
395
+ // An exception here is ENVIRONMENTAL (bad path, permissions, missing toolchain) — NOT a
396
+ // defect in the candidate code. `gateException: true` lets the caller escalate it as
397
+ // such instead of self-repairing it or burning no-progress iterations on it (HS-W2F6).
398
+ result = { pass: false, tscOk: false, testOk: false, gateException: true, output: `gate exception: ${e.message}` };
399
+ } finally {
400
+ git(ctx, worktree, "checkout", "--", task.target);
401
+ }
402
+ return result;
403
+ }
404
+
405
+ /**
406
+ * Reclaim STRANDED worktrees an interrupted run left in ctx.worktreeRoot — links-first, so a
407
+ * later recursive delete can never follow a live junction into the real node_modules (root cause
408
+ * of HS-QSQX). Skips any worktree whose owner process is still alive (an active concurrent
409
+ * build). A worktree with no owner marker is treated as stranded (legacy / pre-marker strays).
410
+ * Returns { swept: string[], skipped: string[] }.
411
+ */
412
+ export function sweepStaleWorktrees(ctx, { quiet = false } = {}) {
413
+ const swept = [], skipped = [];
414
+ if (!existsSync(ctx.worktreeRoot)) return { swept, skipped };
415
+ for (const entry of readdirSync(ctx.worktreeRoot)) {
416
+ const path = join(ctx.worktreeRoot, entry);
417
+ let st; try { st = lstatSync(path); } catch { continue; }
418
+ if (!st.isDirectory() || st.isSymbolicLink()) continue;
419
+ // Owner still alive? Leave the live worktree untouched.
420
+ let owner = null;
421
+ try { owner = JSON.parse(readFileSync(join(path, OWNER_FILE), "utf8")); } catch { /* no/invalid marker → stranded */ }
422
+ if (owner && isPidAlive(owner.pid)) { skipped.push(entry); continue; }
423
+ // HS-L4Q5: claim-bound SESSION worktrees are owned by the allocator (dev-factory/lib/
424
+ // worktree-allocator.mjs) and are reaped ONLY by reapWorktrees, which additionally checks
425
+ // releasedAt / claim liveness / dirtiness. A dead-pid session worktree still holds a human
426
+ // agent's uncommitted work, so the build-side sweep must never reclaim it.
427
+ if (owner && owner.kind === "session") { skipped.push(entry); continue; }
428
+ // teardownWorktree removes the junction LINKS first and refuses if any survives — never a raw
429
+ // recursive delete. For strays git can't recognise it then prunes; sweep the leftover plain
430
+ // (now junction-free) checkout tree.
431
+ const clean = teardownWorktree(ctx, path, quiet);
432
+ if (existsSync(path)) {
433
+ // Only reachable once both known junctions are confirmed gone (teardownWorktree refuses
434
+ // otherwise), so the remaining tree is ordinary files — safe to remove recursively.
435
+ if (clean || !existsSync(join(path, "node_modules"))) { try { rmSync(path, { recursive: true, force: true }); } catch { /* best-effort */ } }
436
+ }
437
+ if (!existsSync(path)) swept.push(entry);
438
+ else if (!quiet) console.error(` ⚠ sweep left ${path} in place (a node_modules link survived) — inspect manually.`);
439
+ }
440
+ if (!quiet && (swept.length || skipped.length)) {
441
+ console.log(` worktree sweep: reclaimed ${swept.length} stranded [${swept.join(", ")}]${skipped.length ? `, left ${skipped.length} live [${skipped.join(", ")}]` : ""}`);
442
+ }
443
+ return { swept, skipped };
444
+ }
445
+
446
+ /** Create a fresh detached worktree with node_modules junctioned (root + workspace). → abs path. */
447
+ export function setupWorktree(ctx, label) {
448
+ const path = join(ctx.worktreeRoot, slug(label));
449
+ mkdirSync(ctx.worktreeRoot, { recursive: true });
450
+ // Reclaim any stranded worktrees (dead owners) BEFORE `git worktree add` — the prevention for
451
+ // HS-QSQX: an interrupted prior run must never leave a live junction for a later delete to
452
+ // follow. Live sibling worktrees (owner alive) are preserved.
453
+ sweepStaleWorktrees(ctx, { quiet: true });
454
+ if (existsSync(path)) teardownWorktree(ctx, path, true);
455
+ const add = git(ctx, ctx.repo, "worktree", "add", "--detach", path, "HEAD");
456
+ if (add.status !== 0) throw new Error(`git worktree add failed: ${(add.stderr || add.stdout || "").trim()}`);
457
+ // HS-OM28 multi-repo builds: ctx.repo may now be a SIBLING checkout (e.g. heyspex-com),
458
+ // which has its own root node_modules but no spex-console workspace. Junction only what
459
+ // actually exists — the missing-workspace symlink used to throw ENOENT and take the whole
460
+ // worktree setup down to critic-only fidelity. Vault behavior is unchanged (both exist).
461
+ const rootNM = join(ctx.repo, "node_modules");
462
+ if (existsSync(rootNM)) makeLink(join(path, "node_modules"), rootNM);
463
+ const spexNM = join(ctx.repo, ctx.spex, "node_modules");
464
+ if (existsSync(spexNM) && existsSync(join(path, ctx.spex))) makeLink(join(path, ctx.spex, "node_modules"), spexNM);
465
+ writeFileSync(join(path, OWNER_FILE), JSON.stringify({ kind: "build", pid: process.pid, startedAt: new Date().toISOString(), label }));
466
+ return path;
467
+ }
468
+
469
+ // HS-6NFD follow-on (the HS-L9BX fix, not just its detector): `setupWorktree` builds the
470
+ // gate tree from `git worktree add --detach HEAD` — COMMITTED CONTENT ONLY. Agents don't
471
+ // commit (the human reviewer commits on Pass), so a wave-N+1 task whose dependency is a
472
+ // wave-N task sitting at in_review/review_pending compiles against a HEAD where that
473
+ // dependency DOES NOT EXIST. Proven live on epic HS-4UU2: HEAD's GatherOutcome.cs contains
474
+ // zero occurrences of `GatherRegion` while the working tree contains 8, so every wave-2
475
+ // extractor candidate would fail with CS0246 and burn its whole retry+replan budget on code
476
+ // that is in fact correct. HS-L9BX detects exactly this for TypeScript (TS2307) and can only
477
+ // REPORT it ("commit the dependency's output, or overlay it into the worktree") — this is
478
+ // that overlay.
479
+ //
480
+ // Deliberately NARROW: only paths the caller passes (the task's own DECLARED context files),
481
+ // and only when the main tree's content actually differs from the worktree's. Everything else
482
+ // stays HEAD-pure, so the gate still sees essentially one task's diff. Every overlaid file is
483
+ // returned so the caller can log it and put it in the run record — this weakens isolation, so
484
+ // it must never be silent.
485
+
486
+ /** Every DIRTY (modified or untracked) path in the main tree under `subdirRel`, repo-root-
487
+ * relative with forward slashes. Uses `git status --porcelain`, so .gitignore'd build output
488
+ * (bin/, obj/, node_modules/) is excluded for free. Deleted paths are omitted — there is
489
+ * nothing to overlay. Returns [] on any git failure (the caller degrades to declared-only).
490
+ *
491
+ * Why a whole subtree and not just declared dependencies (HS-6NFD, proven on HS-YUGJ): a
492
+ * declared-only overlay is TRANSITIVELY INCOMPLETE. HS-YUGJ declared GatherEngine.cs, so it
493
+ * was overlaid — but the working-tree GatherEngine.cs references `ICrawlGate`, declared in
494
+ * PolitenessGate.cs, which is ALSO uncommitted and which HS-YUGJ never declared. The baseline
495
+ * went red with CS0246 on code that is perfectly correct in the real tree. Chasing that
496
+ * closure per-language is a compiler's job, not a gate's; overlaying the project's dirty set
497
+ * is the honest answer, because "HEAD" is not a state this repo is ever actually in — agents
498
+ * never commit, the human commits on review Pass. */
499
+ export function dirtyPathsUnder(ctx, subdirRel = "") {
500
+ // `-uall` is load-bearing: plain --porcelain COLLAPSES a fully-untracked directory into one
501
+ // entry with a trailing slash ("companion/src/.../Voice/"). Overlaying that path hits EISDIR
502
+ // and silently drops the entire subsystem — which is exactly how the HS-YUGJ baseline still
503
+ // failed on WorkspaceWatcher/GhostPredictor after the first overlay fix. -uall enumerates the
504
+ // individual files instead.
505
+ const r = git(ctx, ctx.repo, "status", "--porcelain", "-uall", "--", subdirRel || ".");
506
+ if (r.status !== 0) return [];
507
+ const out = [];
508
+ for (const line of String(r.stdout ?? "").split(/\r?\n/)) {
509
+ if (!line.trim()) continue;
510
+ const code = line.slice(0, 2);
511
+ if (code.includes("D")) continue; // deleted — nothing to copy
512
+ let p = line.slice(3).trim();
513
+ if (p.startsWith('"') && p.endsWith('"')) p = p.slice(1, -1); // git quotes odd paths
514
+ const arrow = p.indexOf(" -> "); // renames: take the destination
515
+ if (arrow !== -1) p = p.slice(arrow + 4);
516
+ if (p) out.push(p.replace(/\\/g, "/"));
517
+ }
518
+ return out;
519
+ }
520
+
521
+ /** Copy the MAIN-TREE working content of `relPaths` (repo-root-relative) into `worktree`,
522
+ * but only where it differs from what the worktree already has. Returns
523
+ * `{ overlaid: [{path, bytes}], unchanged: string[], missing: string[] }`. Never throws —
524
+ * an unreadable/unwritable path lands in `missing` and the gate proceeds without it. */
525
+ export function overlayIntoWorktree(ctx, worktree, relPaths = []) {
526
+ const overlaid = [], unchanged = [], missing = [];
527
+ for (const rel of relPaths) {
528
+ const from = join(ctx.repo, rel);
529
+ const to = join(worktree, rel);
530
+ try {
531
+ if (!existsSync(from)) { missing.push(rel); continue; }
532
+ // Defensive: a caller-supplied path may still name a directory (a hand-authored
533
+ // `context_files:` entry, or a git output shape we did not anticipate). Skip it
534
+ // explicitly instead of throwing EISDIR into `missing`, where it reads like a real fault.
535
+ if (lstatSync(from).isDirectory()) { unchanged.push(rel); continue; }
536
+ const src = readFileSync(from, "utf8");
537
+ let dst = null;
538
+ try { dst = readFileSync(to, "utf8"); } catch { /* absent in the worktree — a NEW dependency */ }
539
+ if (dst === src) { unchanged.push(rel); continue; }
540
+ mkdirSync(dirname(to), { recursive: true });
541
+ writeFileSync(to, src);
542
+ overlaid.push({ path: rel, bytes: src.length, reason: dst == null ? "absent at HEAD" : "differs from HEAD" });
543
+ } catch (e) {
544
+ missing.push(`${rel} (${e.message})`);
545
+ }
546
+ }
547
+ return { overlaid, unchanged, missing };
548
+ }
549
+
550
+ /** Teardown: remove junction LINKS first, THEN git worktree remove. Returns true on a clean teardown. */
551
+ export function teardownWorktree(ctx, path, quiet = false) {
552
+ const a = removeLink(join(path, ctx.spex, "node_modules"));
553
+ const b = removeLink(join(path, "node_modules"));
554
+ if (!a || !b) { if (!quiet) console.error(` ⚠ refusing to git-remove ${path} — a node_modules link survived.`); return false; }
555
+ const rm = git(ctx, ctx.repo, "worktree", "remove", "--force", path);
556
+ if (rm.status !== 0) git(ctx, ctx.repo, "worktree", "prune");
557
+ return !existsSync(path);
558
+ }
559
+
560
+ // ── self-test (no models): prove the isolation plumbing — the deterministic gate's foundation ─────────
561
+ // Guard on MAIN-entry too (not just the flag) so importing this module from the loop
562
+ // controller doesn't fire the self-test when the controller is run with --self-test.
563
+ if (process.argv.includes("--self-test") && import.meta.url.endsWith("/" + (process.argv[1]?.split(/[\\/]/).pop() ?? ""))) {
564
+ const ctx = makeCtx();
565
+ let ok = true;
566
+ const check = (name, cond) => { console.log(` ${cond ? "✓" : "✗"} ${name}`); if (!cond) ok = false; };
567
+ console.log("== engine/worktree.mjs self-test ==");
568
+ console.log(`repo=${ctx.repo}\nspex=${ctx.spex}\nworktreeRoot=${ctx.worktreeRoot}\n`);
569
+ let wt;
570
+ try {
571
+ wt = setupWorktree(ctx, "selftest");
572
+ check("worktree dir created", existsSync(wt));
573
+ check("root node_modules junctioned", existsSync(join(wt, "node_modules")));
574
+ check("workspace node_modules junctioned", existsSync(join(wt, ctx.spex, "node_modules")));
575
+ const base = baselineTsc(ctx, wt);
576
+ check(`baseline tsc runs through the junctioned toolchain (count=${base.count}${base.timedOut ? ", TIMEOUT" : ""})`, !base.timedOut && typeof base.count === "number");
577
+
578
+ // HS-W2F6 regression: a REPO-ROOT target (a dir that exists at repo root but NOT
579
+ // under spex-console/, e.g. dev-factory/) must resolve to <worktree>/<target>. The old
580
+ // join-under-ctx.spex fabricated <worktree>/spex-console/dev-factory/… whose parent does
581
+ // not exist → ENOENT gate exception before tsc/vitest ran. A tsc-only gate on a repo-root
582
+ // JSON edit leaves the spex-console baseline untouched, so it must PASS (no gate exception).
583
+ const probe = { target: "dev-factory/.hsewt-gate-probe.json", test: null };
584
+ const probeAbs = join(wt, probe.target);
585
+ const g = runGate(ctx, wt, probe, "{}\n", base.count);
586
+ check("repo-root target resolves under the worktree root (no gate exception)", !g.gateException);
587
+ check("repo-root target gate ran tsc-only and passed (HS-JZBI path fixed)", g.pass === true && g.tscOk === true);
588
+ try { if (existsSync(probeAbs)) rmSync(probeAbs, { force: true }); } catch { /* best-effort cleanup of the untracked probe */ }
589
+
590
+ // HS-HECA regression: a target in a brand-new subdirectory (absent at HEAD AND in
591
+ // the dirty working tree — the overlay never creates it) used to ENOENT before the
592
+ // gate ever ran, misreporting a real builder output as a gate/environment fault.
593
+ // `mkdirSync(dirname(abs), { recursive: true })` in both runGate and runLanguageGate
594
+ // must create the whole missing chain (two nested new dirs, not just one level).
595
+ const greenfieldRoot = "dev-factory/.hsewt-greenfield-probe";
596
+ const greenfieldTarget = `${greenfieldRoot}/nested/probe.json`;
597
+ check("greenfield probe precondition: neither dir exists yet", !existsSync(join(wt, greenfieldRoot)));
598
+ try {
599
+ const greenfieldGate = runGate(ctx, wt, { target: greenfieldTarget, test: null }, "{}\n", base.count);
600
+ check("new file in a brand-new nested subdirectory reaches a real verdict (no gate exception / ENOENT)", !greenfieldGate.gateException);
601
+ check("new file in a brand-new nested subdirectory gate ran tsc-only and passed", greenfieldGate.pass === true && greenfieldGate.tscOk === true);
602
+ } finally {
603
+ try { if (existsSync(join(wt, greenfieldRoot))) rmSync(join(wt, greenfieldRoot), { recursive: true, force: true }); } catch { /* best-effort */ }
604
+ }
605
+
606
+ // HS-AY5K regression: the gate must DETECT node:test vs vitest from the test
607
+ // file's own imports and dispatch to the correct runner — vitest reports every
608
+ // vendor/heyspex-mcp/*.test.mjs FAILED (they import describe/it/test from node:test,
609
+ // not vitest) even though the same file is 18/18 green under `node --test`. Proven
610
+ // end-to-end against the REAL fixture that motivated the diagnosis (unmodified
611
+ // content — this proves runner ROUTING, not the builder).
612
+ const mcpTarget = "vendor/heyspex-mcp/capture-console-client.mjs";
613
+ const mcpTest = "vendor/heyspex-mcp/capture-console-client.test.mjs";
614
+ check("isNodeTestFile detects the real vendor/heyspex-mcp node:test fixture", isNodeTestFile(join(wt, mcpTest)) === true);
615
+ const vitestProbePath = join(tmpdir(), `hsewt-vitest-probe-${process.pid}.test.ts`);
616
+ writeFileSync(vitestProbePath, "import { describe, it, expect } from \"vitest\";\ndescribe(\"probe\", () => { it(\"passes\", () => { expect(1).toBe(1); }); });\n");
617
+ check("isNodeTestFile returns false for a vitest-authored file (not a blanket route-everything-to-node--test)", isNodeTestFile(vitestProbePath) === false);
618
+ try { rmSync(vitestProbePath, { force: true }); } catch { /* best-effort */ }
619
+
620
+ const mcpOriginal = readFileSync(join(wt, mcpTarget), "utf8");
621
+ const testBaseGreen = baselineTest(ctx, wt, mcpTest);
622
+ check("baselineTest: GREEN on the unmodified vendor/heyspex-mcp fixture (pass=true)", testBaseGreen.pass === true);
623
+ const mcpGate = runGate(ctx, wt, { target: mcpTarget, test: mcpTest }, mcpOriginal, base.count);
624
+ check("node:test file routed to `node --test` → GREEN gate on unmodified vendor/heyspex-mcp code (HS-AY5K)",
625
+ mcpGate.pass === true && mcpGate.tscOk === true && mcpGate.testOk === true);
626
+
627
+ // A vitest-authored fixture must still route through vitest (detection is not a
628
+ // blanket switch to node --test for every test file). `target` stays REPO-ROOT-
629
+ // relative (runGate's own convention, HS-W2F6); `test` stays SPEX-RELATIVE —
630
+ // the pre-existing runVitest contract (cwd = <worktree>/<spex>) that every real
631
+ // spex-console --test caller already relies on. The two conventions differing is
632
+ // exactly why a repo-root-relative test path safely falls through to "not found →
633
+ // not node:test → vitest" instead of misrouting: see isNodeTestFile's doc comment.
634
+ const vitestFixtureTargetRel = "spex-console/src/__hsewt_selftest_vitest_fixture__.test.ts";
635
+ const vitestFixtureTestRel = "src/__hsewt_selftest_vitest_fixture__.test.ts";
636
+ const vitestFixtureAbs = join(wt, vitestFixtureTargetRel);
637
+ const vitestFixtureContent = "import { describe, it, expect } from \"vitest\";\n\ndescribe(\"hsewt selftest vitest fixture\", () => {\n it(\"passes\", () => {\n expect(1 + 1).toBe(2);\n });\n});\n";
638
+ writeFileSync(vitestFixtureAbs, vitestFixtureContent);
639
+ try {
640
+ const vitestGate = runGate(ctx, wt, { target: vitestFixtureTargetRel, test: vitestFixtureTestRel }, vitestFixtureContent, base.count);
641
+ check("vitest-authored file is still routed to vitest (green gate)", vitestGate.pass === true && vitestGate.tscOk === true && vitestGate.testOk === true);
642
+ } finally {
643
+ try { if (existsSync(vitestFixtureAbs)) rmSync(vitestFixtureAbs, { force: true }); } catch { /* best-effort */ }
644
+ }
645
+
646
+ // Red-baseline pre-check (HS-AY5K AC2/AC3): a test that is red BEFORE any
647
+ // candidate code is written must be reported red by baselineTest, so the caller can
648
+ // abort before spending any best-of-N attempts on a fault that has nothing to do
649
+ // with the generated code.
650
+ const alwaysFailRel = "dev-factory/.hsewt-selftest-alwaysfail.test.mjs";
651
+ const alwaysFailAbs = join(wt, alwaysFailRel);
652
+ writeFileSync(alwaysFailAbs, "import { test } from \"node:test\";\nimport assert from \"node:assert/strict\";\ntest(\"always fails\", () => { assert.equal(1, 2); });\n");
653
+ try {
654
+ const redBaseline = baselineTest(ctx, wt, alwaysFailRel);
655
+ check("baselineTest: RED on an always-failing node:test fixture (pass=false)", redBaseline.pass === false);
656
+ } finally {
657
+ try { if (existsSync(alwaysFailAbs)) rmSync(alwaysFailAbs, { force: true }); } catch { /* best-effort */ }
658
+ }
659
+
660
+ // HS-AY5K edge (a): a NEW-FILE task's test doesn't exist yet at baseline —
661
+ // must report pass:true + notFound:true (expected-new), NEVER a red baseline.
662
+ const newFileTestRel = "dev-factory/.hsewt-selftest-newfile.test.mjs";
663
+ const newFileTestAbs = join(wt, newFileTestRel);
664
+ check("precondition: the NEW-FILE test fixture truly doesn't exist at baseline", !existsSync(newFileTestAbs));
665
+ const newFileBaseline = baselineTest(ctx, wt, newFileTestRel);
666
+ check("baselineTest: a NEW-FILE test (missing at baseline) reports pass:true + notFound:true (never red)",
667
+ newFileBaseline.pass === true && newFileBaseline.notFound === true);
668
+
669
+ // HS-AY5K edge (c): a plain exit-code self-test script (no node:test/vitest
670
+ // import) must route to runPlainSelfTestScript, not fall through to vitest (which
671
+ // would report "no test files found" — a permanently red gate unrelated to the
672
+ // candidate). Both real-repo conventions: inline `--self-test` argv guard, and the
673
+ // bare `<name>.selftest.mjs` companion-file convention (scripts/validate-vault-ids
674
+ // .selftest.mjs is the real fixture that motivated this).
675
+ const argvGuardRel = "dev-factory/.hsewt-selftest-argvguard.mjs";
676
+ const argvGuardAbs = join(wt, argvGuardRel);
677
+ const argvGuardPassSrc = "if (process.argv.includes(\"--self-test\")) {\n console.log(\"self-test PASS\");\n process.exit(0);\n}\n";
678
+ writeFileSync(argvGuardAbs, argvGuardPassSrc);
679
+ try {
680
+ check("isPlainSelfTestScript detects the inline --self-test argv-guard convention", isPlainSelfTestScript(argvGuardAbs) === true);
681
+ check("isNodeTestFile does NOT misclassify a plain argv-guard script", isNodeTestFile(argvGuardAbs) === false);
682
+ const argvGuardGreen = baselineTest(ctx, wt, argvGuardRel);
683
+ check("baselineTest: argv-guard script routed correctly → GREEN (exit 0)", argvGuardGreen.pass === true);
684
+ writeFileSync(argvGuardAbs, "if (process.argv.includes(\"--self-test\")) {\n console.log(\"self-test FAIL\");\n process.exit(1);\n}\n");
685
+ const argvGuardRed = baselineTest(ctx, wt, argvGuardRel);
686
+ check("baselineTest: argv-guard script exit 1 → RED", argvGuardRed.pass === false);
687
+ } finally {
688
+ try { if (existsSync(argvGuardAbs)) rmSync(argvGuardAbs, { force: true }); } catch { /* best-effort */ }
689
+ }
690
+
691
+ const companionRel = "dev-factory/.hsewt-selftest-fixture.selftest.mjs";
692
+ const companionAbs = join(wt, companionRel);
693
+ writeFileSync(companionAbs, "console.log(\"self-test PASS\");\nprocess.exit(0);\n");
694
+ try {
695
+ check("isPlainSelfTestScript detects the *.selftest.mjs companion-file convention", isPlainSelfTestScript(companionAbs) === true);
696
+ const companionGreen = baselineTest(ctx, wt, companionRel);
697
+ check("baselineTest: companion .selftest.mjs runs BARE (no flag) → GREEN (exit 0)", companionGreen.pass === true);
698
+ writeFileSync(companionAbs, "console.log(\"self-test FAIL\");\nprocess.exit(1);\n");
699
+ const companionRed = baselineTest(ctx, wt, companionRel);
700
+ check("baselineTest: companion .selftest.mjs exit 1 → RED", companionRed.pass === false);
701
+ // Restore green content — runGate writes `code` to `task.target`, not `task.test`;
702
+ // the .selftest.mjs file on disk is what actually gets EXECUTED as the gate's test.
703
+ writeFileSync(companionAbs, "console.log(\"self-test PASS\");\nprocess.exit(0);\n");
704
+ const companionTargetRel = companionRel.replace(/\.selftest\.mjs$/, ".mjs");
705
+ const companionGate = runGate(ctx, wt, { target: companionTargetRel, test: companionRel }, "// candidate target content\n", base.count);
706
+ check("runGate routes a .selftest.mjs test through the plain self-test runner (green)", companionGate.pass === true && companionGate.testOk === true);
707
+ } finally {
708
+ try { if (existsSync(companionAbs)) rmSync(companionAbs, { force: true }); } catch { /* best-effort */ }
709
+ try {
710
+ const companionTargetAbs = join(wt, companionRel.replace(/\.selftest\.mjs$/, ".mjs"));
711
+ if (existsSync(companionTargetAbs)) rmSync(companionTargetAbs, { force: true });
712
+ } catch { /* best-effort */ }
713
+ }
714
+
715
+ // HS-QSQX regression: the stranded-worktree sweep must reclaim a dead-owner worktree
716
+ // links-first (never following its junction into the real node_modules) AND preserve a
717
+ // live-owner worktree. Canary the real root node_modules count across the whole sweep.
718
+ const rootNM = join(ctx.repo, "node_modules");
719
+ const canaryBefore = readdirSync(rootNM).length;
720
+ // Create the LIVE one first — setupWorktree sweeps on entry, so a stranded worktree made first
721
+ // would be reclaimed by the next setup before the explicit sweep could see it.
722
+ const live = setupWorktree(ctx, "sweep-live"); // owner = this live process
723
+ const stranded = setupWorktree(ctx, "sweep-stranded"); // real worktree + live junctions
724
+ writeFileSync(join(stranded, OWNER_FILE), JSON.stringify({ pid: 999999999, startedAt: "x", label: "sweep-stranded" })); // fake a DEAD owner
725
+ const sweep = sweepStaleWorktrees(ctx, { quiet: true });
726
+ check("sweep reclaimed the dead-owner worktree", !existsSync(stranded) && sweep.swept.includes("sweep-stranded"));
727
+ check("sweep PRESERVED the live-owner worktree", existsSync(live) && sweep.skipped.includes("sweep-live"));
728
+ check(`sweep left the real root node_modules intact (canary ${canaryBefore})`, readdirSync(rootNM).length === canaryBefore);
729
+ teardownWorktree(ctx, live, true);
730
+ } catch (e) {
731
+ check(`setup/baseline threw: ${e.message}`, false);
732
+ } finally {
733
+ if (wt) {
734
+ const clean = teardownWorktree(ctx, wt);
735
+ check("teardown removed the worktree (no stray dir/junction)", clean && !existsSync(wt));
736
+ }
737
+ }
738
+ console.log(`\n${ok ? "✓ self-test PASS — worktree isolation + baseline + clean teardown verified." : "✗ self-test FAIL"}`);
739
+ process.exit(ok ? 0 : 1);
740
+ }