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,426 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * fleet-queue.mjs — a generic, file-backed queue for any machine-gated fleet resource.
4
+ *
5
+ * Owner ask (2026-07-12): "If I have one agent already sending research request in progress
6
+ * to Night Action, and another agent has research that needs to be done, that agent can
7
+ * submit it successfully to the research queue - instead of getting a fail or rejection.
8
+ * This queue can also be used when Night Action has an existing ollama process. Research can
9
+ * be sent to the queue to be presented to Night Action as soon as it is done." And: "this
10
+ * queue concept can be used across the whole fleet for different use cases."
11
+ *
12
+ * FIRST LANE: "research-firecrawl" — research requests that lose the Ollama-XOR-Firecrawl
13
+ * mode-lease race in night-action-mode.mjs's requestOrEnqueueFirecrawlMode() land here
14
+ * instead of failing (see night-action-mode.mjs). Lanes are free-form strings; any future
15
+ * machine-gated resource (a build slot, a breakdown job, a GPU node) reuses this same module
16
+ * under its own lane name — see the design note at the bottom of this file.
17
+ *
18
+ * STORAGE — event-sourced, append-only JSONL: `.heyspex/fleet-queue.jsonl` (repo root,
19
+ * resolved by walking up to the heyspex.yaml vault marker — same convention as
20
+ * dev-factory/lib/fleet-state.mjs). Every mutation (enqueue / claim / complete / fail) is
21
+ * appended as ONE JSON line via a fresh open("a") + write + close (mirrors
22
+ * dev-factory/lib/run-events.mjs) — never a whole-file rewrite. Current item state is derived
23
+ * by FOLDING an item's events in file order, the same event-sourcing shape
24
+ * .heyspex/claim-history.jsonl and .heyspex/focus-history.jsonl already use elsewhere in this
25
+ * repo for durable ledgers (append pattern confirmed against both before writing this).
26
+ *
27
+ * WHY EVENT-SOURCED APPEND OVER REWRITE-WITH-STATUS (the crash-safety justification the task
28
+ * asked for): a whole-file rewrite (read all items, mutate one, write a temp file, rename over
29
+ * the original) is atomic PER WRITE but requires a read-modify-write on the FULL file for every
30
+ * single mutation, and a crash between "read" and "rename" either loses the mutation or (worse,
31
+ * on a partial rename) can corrupt every other item's state too. An append-only event log makes
32
+ * each mutation a single new line: a crash mid-write can only ever cost the LAST line (which
33
+ * readAllEvents() below already tolerates — a corrupt/partial trailing line is skipped, not
34
+ * thrown, mirroring run-events.mjs), and it can NEVER corrupt or lose any prior item's history.
35
+ * The trade is that readers must fold history instead of reading a snapshot — cheap here since
36
+ * the queue is small and local.
37
+ *
38
+ * CONCURRENCY — double-claim rejection: enqueue/complete/fail don't need cross-process
39
+ * exclusion (each only ever touches its own new line or an item only its own claimant should be
40
+ * mutating), but claim() has a genuine race — two processes could both read "pending" for the
41
+ * same id before either appends its claim event. claim() (and complete/fail, for symmetry) take
42
+ * a short-lived, per-id, TTL-bounded advisory file lock (`<queue>.lock.<id>`, exclusive-create
43
+ * `wx`) around their read-decide-append critical section — true mutual exclusion for same-id
44
+ * ops across processes, while different ids proceed fully in parallel. A lock file left behind
45
+ * by a crashed holder is auto-broken after `staleLockMs` (default 5s) so the queue can never
46
+ * wedge on a dead process.
47
+ *
48
+ * CRASH-RECOVERY (claim TTL): a claim() that never gets a matching complete()/fail() within
49
+ * `CLAIM_TTL_MS` (default 15 min, override via options or HEYSPEX_FLEET_QUEUE_CLAIM_TTL_MS) is
50
+ * treated as STALE — effectiveStatus() reports it back as "pending" so any caller (including
51
+ * the original claimant retrying after a crash) can reclaim it. The stale claim's history is
52
+ * never deleted, only superseded by a fresh claim event — so `list()` still shows the full
53
+ * claim history for audit.
54
+ *
55
+ * API:
56
+ * enqueue({ lane, payload, requestedBy }) -> { id, position } // position = 1-indexed rank among pending items in that lane
57
+ * nextPending(lane) -> item | null // oldest pending (or stale-reclaimable) item in the lane
58
+ * claim(id, claimant) -> { ok, reason? } // rejects double-claim / terminal states
59
+ * complete(id, result?) -> { ok, reason? } // must currently be claimed
60
+ * fail(id, reason?) -> { ok, reason? } // from pending or claimed
61
+ * list(lane?, status?) -> item[] // oldest-first
62
+ *
63
+ * CLI: node dev-factory/lib/fleet-queue.mjs --self-test
64
+ */
65
+
66
+ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, statSync, writeSync } from "node:fs";
67
+ import { dirname, join } from "node:path";
68
+ import { codeRepoRoot } from "./code-repo-root.mjs";
69
+
70
+ // ── repo-root + store resolution: this queue holds CODE-repo state (fleet-queue.jsonl), not
71
+ // vault state, so it anchors on the code repo itself rather than walking for a vault marker.
72
+ export function findRepoRoot() {
73
+ return codeRepoRoot();
74
+ }
75
+
76
+ function defaultQueuePath() {
77
+ const dir = join(findRepoRoot(), ".heyspex");
78
+ mkdirSync(dir, { recursive: true });
79
+ return process.env.HEYSPEX_FLEET_QUEUE_PATH || join(dir, "fleet-queue.jsonl");
80
+ }
81
+
82
+ export const CLAIM_TTL_MS = Number(process.env.HEYSPEX_FLEET_QUEUE_CLAIM_TTL_MS) || 15 * 60 * 1000; // 15 min
83
+
84
+ /** First lane (owner ask 2026-07-12): research requests that lose the Ollama-XOR-Firecrawl
85
+ * mode-lease race in night-action-mode.mjs land here instead of failing. */
86
+ export const RESEARCH_FIRECRAWL_LANE = "research-firecrawl";
87
+
88
+ // ── event log I/O (mirrors run-events.mjs: line-atomic append, corrupt-tail-tolerant read) ──────
89
+ function appendEvent(queuePath, record) {
90
+ mkdirSync(dirname(queuePath), { recursive: true });
91
+ const line = `${JSON.stringify(record)}\n`;
92
+ const fd = openSync(queuePath, "a");
93
+ try {
94
+ writeSync(fd, line);
95
+ } finally {
96
+ closeSync(fd);
97
+ }
98
+ return record;
99
+ }
100
+
101
+ function readAllEvents(queuePath) {
102
+ if (!existsSync(queuePath)) return [];
103
+ const text = readFileSync(queuePath, "utf8");
104
+ const events = [];
105
+ for (const line of text.split("\n")) {
106
+ const trimmed = line.trim();
107
+ if (!trimmed) continue;
108
+ try {
109
+ events.push(JSON.parse(trimmed));
110
+ } catch {
111
+ // corrupt/partial trailing line (e.g. a crash mid-write) — skip, never throw
112
+ }
113
+ }
114
+ return events;
115
+ }
116
+
117
+ // ── per-id advisory lock (exclusive-create; auto-breaks a stale/crashed holder) ─────────────────
118
+ function lockPathFor(queuePath, id) {
119
+ return `${queuePath}.lock.${id}`;
120
+ }
121
+
122
+ function acquireLock(queuePath, id, { timeoutMs = 3000, staleLockMs = 5000, pollMs = 15 } = {}) {
123
+ const lp = lockPathFor(queuePath, id);
124
+ mkdirSync(dirname(queuePath), { recursive: true });
125
+ const start = Date.now();
126
+ for (;;) {
127
+ try {
128
+ const fd = openSync(lp, "wx");
129
+ writeSync(fd, String(process.pid));
130
+ closeSync(fd);
131
+ return true;
132
+ } catch (e) {
133
+ if (e?.code !== "EEXIST") throw e;
134
+ try {
135
+ const st = statSync(lp);
136
+ if (Date.now() - st.mtimeMs > staleLockMs) {
137
+ rmSync(lp, { force: true }); // break a lock left by a crashed holder
138
+ continue;
139
+ }
140
+ } catch {
141
+ continue; // lock vanished between the failed create and the stat — retry immediately
142
+ }
143
+ if (Date.now() - start > timeoutMs) return false;
144
+ // best-effort short busy-wait; this is a local dev tool, not a high-throughput queue
145
+ const until = Date.now() + pollMs;
146
+ while (Date.now() < until) { /* spin */ }
147
+ }
148
+ }
149
+ }
150
+
151
+ function releaseLock(queuePath, id) {
152
+ try {
153
+ rmSync(lockPathFor(queuePath, id), { force: true });
154
+ } catch {
155
+ // already gone — fine
156
+ }
157
+ }
158
+
159
+ function withLock(queuePath, id, fn) {
160
+ if (!acquireLock(queuePath, id)) return { ok: false, reason: `fleet-queue: could not acquire lock for "${id}" (held too long)` };
161
+ try {
162
+ return fn();
163
+ } finally {
164
+ releaseLock(queuePath, id);
165
+ }
166
+ }
167
+
168
+ // ── state derivation (event fold) ─────────────────────────────────────────────────────────────
169
+ function foldOne(events) {
170
+ let state = null;
171
+ for (const ev of events) {
172
+ if (ev.type === "enqueue") {
173
+ state = {
174
+ id: ev.id,
175
+ lane: ev.lane,
176
+ payload: ev.payload ?? null,
177
+ requestedBy: ev.requestedBy ?? null,
178
+ status: "pending",
179
+ enqueuedAt: ev.at,
180
+ claimant: null,
181
+ claimedAt: null,
182
+ result: null,
183
+ failReason: null,
184
+ completedAt: null,
185
+ };
186
+ } else if (!state) {
187
+ continue; // orphan event with no enqueue in this log slice — ignore
188
+ } else if (ev.type === "claim") {
189
+ state.status = "claimed";
190
+ state.claimant = ev.claimant ?? null;
191
+ state.claimedAt = ev.at;
192
+ } else if (ev.type === "complete") {
193
+ state.status = "completed";
194
+ state.result = ev.result ?? null;
195
+ state.completedAt = ev.at;
196
+ } else if (ev.type === "fail") {
197
+ state.status = "failed";
198
+ state.failReason = ev.reason ?? null;
199
+ state.completedAt = ev.at;
200
+ }
201
+ }
202
+ return state;
203
+ }
204
+
205
+ /** Raw folded state (claimed stays "claimed" even if stale) keyed by id, oldest enqueue first. */
206
+ function deriveRaw(queuePath) {
207
+ const events = readAllEvents(queuePath);
208
+ const byId = new Map();
209
+ for (const ev of events) {
210
+ if (!ev?.id) continue;
211
+ if (!byId.has(ev.id)) byId.set(ev.id, []);
212
+ byId.get(ev.id).push(ev);
213
+ }
214
+ const out = new Map();
215
+ for (const [id, evs] of byId) {
216
+ const s = foldOne(evs);
217
+ if (s) out.set(id, s);
218
+ }
219
+ return out;
220
+ }
221
+
222
+ /** Folded state with the TTL rule applied: a claim older than ttlMs reports back as "pending"
223
+ * (reclaimable) without erasing claimant/claimedAt history — callers can see it was stale. */
224
+ function deriveEffective(queuePath, { now = Date.now(), ttlMs = CLAIM_TTL_MS } = {}) {
225
+ const raw = deriveRaw(queuePath);
226
+ const out = new Map();
227
+ for (const [id, s] of raw) {
228
+ const stale = s.status === "claimed" && now - Date.parse(s.claimedAt) > ttlMs;
229
+ out.set(id, stale ? { ...s, status: "pending", stale: true } : { ...s, stale: false });
230
+ }
231
+ return out;
232
+ }
233
+
234
+ function genId() {
235
+ return `fq_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
236
+ }
237
+
238
+ // ── public API ────────────────────────────────────────────────────────────────────────────────
239
+ export function enqueue({ lane, payload = null, requestedBy = null, queuePath = defaultQueuePath(), now = Date.now() } = {}) {
240
+ if (!lane) throw new Error("fleet-queue.enqueue: lane is required");
241
+ const id = genId();
242
+ const at = new Date(now).toISOString();
243
+ appendEvent(queuePath, { type: "enqueue", id, lane, payload, requestedBy, at });
244
+ const items = [...deriveEffective(queuePath, { now }).values()]
245
+ .filter((s) => s.lane === lane && s.status === "pending")
246
+ .sort((a, b) => a.enqueuedAt.localeCompare(b.enqueuedAt));
247
+ const position = items.findIndex((s) => s.id === id) + 1;
248
+ return { id, position };
249
+ }
250
+
251
+ export function nextPending(lane, { queuePath = defaultQueuePath(), now = Date.now(), ttlMs = CLAIM_TTL_MS } = {}) {
252
+ const items = [...deriveEffective(queuePath, { now, ttlMs }).values()]
253
+ .filter((s) => s.lane === lane && s.status === "pending")
254
+ .sort((a, b) => a.enqueuedAt.localeCompare(b.enqueuedAt));
255
+ return items[0] ?? null;
256
+ }
257
+
258
+ export function claim(id, claimant, { queuePath = defaultQueuePath(), now = Date.now(), ttlMs = CLAIM_TTL_MS } = {}) {
259
+ if (!id) return { ok: false, reason: "fleet-queue.claim: id is required" };
260
+ if (!claimant) return { ok: false, reason: "fleet-queue.claim: claimant is required" };
261
+ return withLock(queuePath, id, () => {
262
+ const state = deriveEffective(queuePath, { now, ttlMs }).get(id);
263
+ if (!state) return { ok: false, reason: `unknown id "${id}"` };
264
+ if (state.status === "completed") return { ok: false, reason: "already completed" };
265
+ if (state.status === "failed") return { ok: false, reason: "already failed" };
266
+ if (state.status === "claimed") return { ok: false, reason: `already claimed by ${state.claimant}` };
267
+ appendEvent(queuePath, { type: "claim", id, claimant, at: new Date(now).toISOString() });
268
+ return { ok: true, id, claimant, reclaimed: Boolean(state.stale) };
269
+ });
270
+ }
271
+
272
+ export function complete(id, result = null, { queuePath = defaultQueuePath(), now = Date.now() } = {}) {
273
+ if (!id) return { ok: false, reason: "fleet-queue.complete: id is required" };
274
+ return withLock(queuePath, id, () => {
275
+ const state = deriveEffective(queuePath, { now }).get(id);
276
+ if (!state) return { ok: false, reason: `unknown id "${id}"` };
277
+ if (state.status !== "claimed") return { ok: false, reason: `cannot complete from status "${state.status}" (must be claimed)` };
278
+ appendEvent(queuePath, { type: "complete", id, result, at: new Date(now).toISOString() });
279
+ return { ok: true, id };
280
+ });
281
+ }
282
+
283
+ export function fail(id, reason = null, { queuePath = defaultQueuePath(), now = Date.now() } = {}) {
284
+ if (!id) return { ok: false, reason: "fleet-queue.fail: id is required" };
285
+ return withLock(queuePath, id, () => {
286
+ const state = deriveEffective(queuePath, { now }).get(id);
287
+ if (!state) return { ok: false, reason: `unknown id "${id}"` };
288
+ if (state.status === "completed" || state.status === "failed") {
289
+ return { ok: false, reason: `cannot fail from terminal status "${state.status}"` };
290
+ }
291
+ appendEvent(queuePath, { type: "fail", id, reason, at: new Date(now).toISOString() });
292
+ return { ok: true, id };
293
+ });
294
+ }
295
+
296
+ export function list(lane = null, status = null, { queuePath = defaultQueuePath(), now = Date.now(), ttlMs = CLAIM_TTL_MS } = {}) {
297
+ const items = [...deriveEffective(queuePath, { now, ttlMs }).values()];
298
+ return items
299
+ .filter((s) => (lane ? s.lane === lane : true))
300
+ .filter((s) => (status ? s.status === status : true))
301
+ .sort((a, b) => a.enqueuedAt.localeCompare(b.enqueuedAt));
302
+ }
303
+
304
+ /*
305
+ * ── DESIGN NOTE: how this generalizes fleet-wide, and the future upgrade path ──────────────────
306
+ *
307
+ * This module is deliberately domain-blind: `lane` is a free-form string, `payload` is opaque
308
+ * JSON, and nothing in enqueue/claim/complete/fail/list knows about research, Firecrawl, or
309
+ * Night Action. The first lane ("research-firecrawl") is a CONSUMER of this queue, not part of
310
+ * its design. Any other machine-gated resource in the fleet can stand up its own lane the same
311
+ * way, with zero changes to this file:
312
+ * - build jobs waiting on a busy GPU box -> lane "build-<machine>"
313
+ * - breakdown/critic jobs waiting on the DeepSeek seat -> lane "breakdown-deepseek"
314
+ * - any single-tenant resource with a "one workload at a time" mode lease, mirroring
315
+ * night-action-mode.mjs's Ollama-XOR-Firecrawl shape (mode lease refuses -> caller enqueues
316
+ * instead of failing -> a drain step pops the lane once the resource frees up)
317
+ * The pattern each consumer follows is the same three-step shape night-action-mode.mjs
318
+ * demonstrates: (1) try the gated resource directly, (2) on refusal, enqueue() instead of
319
+ * returning a hard failure, (3) a --drain (or equivalent poll) on the resource-owning side pops
320
+ * nextPending()/list() once the resource is free and dispatches the work.
321
+ *
322
+ * MULTI-MACHINE UPGRADE PATH: this file-backed JSONL queue is correct for a SINGLE shared
323
+ * filesystem (one repo checkout, or machines that all see the same network share) — the
324
+ * per-id file lock only provides mutual exclusion within one filesystem. The moment lanes need
325
+ * to be claimed/drained from genuinely separate machines with no shared disk (the fleet's
326
+ * long-run shape), the natural port is a Neon control-plane table with the SAME event-sourced
327
+ * shape (an append-only `fleet_queue_events` table, `claim` as an `INSERT ... WHERE NOT EXISTS a
328
+ * live claim` guarded by a DB-level unique constraint instead of a file lock, `nextPending` as an
329
+ * indexed `ORDER BY enqueued_at LIMIT 1` query) — the API surface above (enqueue/nextPending/
330
+ * claim/complete/fail/list) stays identical, so callers do not change; only defaultQueuePath()'s
331
+ * resolution swaps for a connection string. Do not build that port speculatively — this local
332
+ * file-backed version is the right MVP for the current single-box (Night Action) + single-vault
333
+ * (this repo) topology, and the Neon swap is scoped exactly when a second always-on machine
334
+ * needs to claim from the same lane.
335
+ */
336
+
337
+ // ── self-test (local temp queue file — never touches the real .heyspex/fleet-queue.jsonl) ──────
338
+ if (import.meta.url.endsWith("/" + (process.argv[1]?.split(/[\\/]/).pop() ?? "")) && process.argv.includes("--self-test")) {
339
+ (async () => {
340
+ const { tmpdir } = await import("node:os");
341
+ const { mkdtempSync, rmSync: rm } = await import("node:fs");
342
+ let ok = true;
343
+ const check = (n, c) => {
344
+ console.log(` ${c ? "✓" : "✗"} ${n}`);
345
+ if (!c) ok = false;
346
+ };
347
+ console.log("== fleet-queue.mjs self-test ==\n");
348
+
349
+ const dir = mkdtempSync(join(tmpdir(), "hs-fleet-queue-selftest-"));
350
+ const qp = join(dir, "fleet-queue.jsonl");
351
+
352
+ // 1. enqueue: id + 1-indexed position, oldest-first within a lane
353
+ const a = enqueue({ lane: "research-firecrawl", payload: { q: "a" }, requestedBy: "agent-1", queuePath: qp, now: 1000 });
354
+ check("1 first enqueue gets position 1", a.position === 1 && typeof a.id === "string");
355
+ const b = enqueue({ lane: "research-firecrawl", payload: { q: "b" }, requestedBy: "agent-2", queuePath: qp, now: 2000 });
356
+ check("2 second enqueue in same lane gets position 2", b.position === 2);
357
+ const otherLane = enqueue({ lane: "build-gpu0", payload: { job: "x" }, queuePath: qp, now: 2500 });
358
+ check("3 a different lane starts its own position count", otherLane.position === 1);
359
+
360
+ // 2. nextPending returns oldest first, scoped to the lane
361
+ check("4 nextPending returns the oldest pending item in the lane", nextPending("research-firecrawl", { queuePath: qp, now: 3000 })?.id === a.id);
362
+ check("5 nextPending is lane-scoped", nextPending("build-gpu0", { queuePath: qp, now: 3000 })?.id === otherLane.id);
363
+
364
+ // 3. claim -> complete happy path
365
+ const claimA = claim(a.id, "claimant-1", { queuePath: qp, now: 4000 });
366
+ check("6 claim succeeds on a pending item", claimA.ok === true && claimA.reclaimed === false);
367
+ check("7 nextPending skips the now-claimed item", nextPending("research-firecrawl", { queuePath: qp, now: 4500 })?.id === b.id);
368
+ const doneA = complete(a.id, { savedTo: "specs/research/x.md" }, { queuePath: qp, now: 5000 });
369
+ check("8 complete succeeds on a claimed item", doneA.ok === true);
370
+ check("9 list reflects completed status + result", list("research-firecrawl", null, { queuePath: qp, now: 5500 }).find((i) => i.id === a.id)?.status === "completed");
371
+
372
+ // 4. double-claim rejection
373
+ const claimB1 = claim(b.id, "claimant-1", { queuePath: qp, now: 6000 });
374
+ check("10 first claim on b succeeds", claimB1.ok === true);
375
+ const claimB2 = claim(b.id, "claimant-2", { queuePath: qp, now: 6100 });
376
+ check("11 a second claim on the SAME still-live item is rejected", claimB2.ok === false && /already claimed/.test(claimB2.reason));
377
+
378
+ // 5. fail path
379
+ const failB = fail(b.id, "endpoint unreachable", { queuePath: qp, now: 7000 });
380
+ check("12 fail succeeds on a claimed item", failB.ok === true);
381
+ check("13 list reflects failed status + reason", list(null, "failed", { queuePath: qp, now: 7500 }).find((i) => i.id === b.id)?.failReason === "endpoint unreachable");
382
+ check("14 cannot claim a failed (terminal) item", claim(b.id, "claimant-3", { queuePath: qp, now: 7600 }).ok === false);
383
+ check("15 cannot complete an already-completed item", complete(a.id, {}, { queuePath: qp, now: 7700 }).ok === false);
384
+
385
+ // 6. crash-recovery semantics: a claimed-but-stale entry is reclaimable after the TTL
386
+ const c = enqueue({ lane: "research-firecrawl", payload: { q: "c" }, queuePath: qp, now: 10_000 });
387
+ const claimC = claim(c.id, "claimant-crashed", { queuePath: qp, now: 10_100, ttlMs: 1000 });
388
+ check("16 c gets claimed", claimC.ok === true);
389
+ const tooSoon = claim(c.id, "claimant-4", { queuePath: qp, now: 10_500, ttlMs: 1000 }); // 400ms later, still under 1000ms TTL
390
+ check("17 reclaim attempt before TTL expiry is rejected", tooSoon.ok === false);
391
+ const afterTtl = claim(c.id, "claimant-4", { queuePath: qp, now: 11_200, ttlMs: 1000 }); // 1100ms later, TTL exceeded
392
+ check("18 reclaim attempt after TTL expiry succeeds (stale claim reclaimed)", afterTtl.ok === true && afterTtl.reclaimed === true);
393
+ check("19 the ORIGINAL claim event is not erased — still visible in list()'s claimant history via the latest fold", list("research-firecrawl", "claimed", { queuePath: qp, now: 11_300, ttlMs: 1000 }).find((i) => i.id === c.id)?.claimant === "claimant-4");
394
+
395
+ // 7. unknown id handling
396
+ check("20 claim on an unknown id fails cleanly", claim("fq_does_not_exist", "x", { queuePath: qp }).ok === false);
397
+ check("21 complete on an unknown id fails cleanly", complete("fq_does_not_exist", {}, { queuePath: qp }).ok === false);
398
+
399
+ // 8. concurrent double-claim: two "processes" race claim() on the same id — only one wins
400
+ const d = enqueue({ lane: "research-firecrawl", payload: { q: "d" }, queuePath: qp, now: 12_000 });
401
+ const [raceA, raceB] = await Promise.all([
402
+ Promise.resolve().then(() => claim(d.id, "racer-A", { queuePath: qp, now: 12_100 })),
403
+ Promise.resolve().then(() => claim(d.id, "racer-B", { queuePath: qp, now: 12_100 })),
404
+ ]);
405
+ const winners = [raceA, raceB].filter((r) => r.ok);
406
+ check("22 concurrent claim() calls on the same id: exactly one wins", winners.length === 1);
407
+
408
+ // 9. append-only: the file only grows, corrupt trailing line tolerated
409
+ const beforeLines = readFileSync(qp, "utf8").split("\n").filter(Boolean).length;
410
+ const { appendFileSync } = await import("node:fs");
411
+ appendFileSync(qp, '{"type":"claim","id":"broken", partial-json');
412
+ check("23 a corrupt trailing line does not throw when reading state", (() => {
413
+ try {
414
+ list(null, null, { queuePath: qp });
415
+ return true;
416
+ } catch {
417
+ return false;
418
+ }
419
+ })());
420
+ check("24 file only grew (never rewritten) across the whole run", readFileSync(qp, "utf8").split("\n").filter(Boolean).length >= beforeLines);
421
+
422
+ rm(dir, { recursive: true, force: true });
423
+ console.log(`\n${ok ? "✓ self-test PASS" : "✗ self-test FAIL"}`);
424
+ process.exit(ok ? 0 : 1);
425
+ })();
426
+ }
@@ -29,22 +29,18 @@
29
29
  */
30
30
 
31
31
  import { existsSync, readFileSync, writeFileSync, renameSync, mkdirSync } from "node:fs";
32
- import { dirname, join, resolve } from "node:path";
32
+ import { join } from "node:path";
33
33
  import { pathToFileURL } from "node:url";
34
34
  import { tmpdir } from "node:os";
35
+ import { codeRepoRoot } from "./code-repo-root.mjs";
36
+ import { OLLAMA } from "./model-catalog.mjs";
35
37
 
36
38
  const SCHEMA_VERSION = 1;
37
39
 
38
- // ── Repo-root resolution (walk up to the heyspex.yaml vault marker — same as join-codes.mjs) ──
39
- export function findRepoRoot(startDir = process.cwd()) {
40
- let dir = resolve(startDir);
41
- for (;;) {
42
- if (existsSync(join(dir, "heyspex.yaml"))) return dir;
43
- const parent = dirname(dir);
44
- if (parent === dir) break;
45
- dir = parent;
46
- }
47
- return resolve(startDir);
40
+ // ── Repo-root resolution: this store holds CODE-repo state (fleet-state.json), not vault
41
+ // state, so it anchors on the code repo itself rather than walking for a vault marker.
42
+ export function findRepoRoot() {
43
+ return codeRepoRoot();
48
44
  }
49
45
 
50
46
  function heyspexDir(opts = {}) {
@@ -184,20 +180,20 @@ function runSelfTest() {
184
180
  return s.version === 1 && Object.keys(s.machines).length === 0;
185
181
  })());
186
182
 
187
- const first = recordProbe("night-action", { ok: true, models: ["gpt-oss:20b"] }, opts);
183
+ const first = recordProbe("night-action", { ok: true, models: [OLLAMA.BUILDER] }, opts);
188
184
  check("first ok probe creates the machine with firstSeen/lastSeen/lastProbe stamped",
189
185
  first.firstSeen != null && first.lastSeen != null && first.lastProbe?.ok === true);
190
186
  check("first-ever models land in newModels (nothing is silently 'known')",
191
- first.newModels.includes("gpt-oss:20b") && first.knownModels.length === 0);
187
+ first.newModels.includes(OLLAMA.BUILDER) && first.knownModels.length === 0);
192
188
 
193
- acknowledgeModels("night-action", ["gpt-oss:20b"], opts);
189
+ acknowledgeModels("night-action", [OLLAMA.BUILDER], opts);
194
190
  const acked = loadFleetState(opts).machines["night-action"];
195
191
  check("acknowledgeModels moves the model newModels → knownModels",
196
- acked.knownModels.includes("gpt-oss:20b") && !acked.newModels.includes("gpt-oss:20b"));
192
+ acked.knownModels.includes(OLLAMA.BUILDER) && !acked.newModels.includes(OLLAMA.BUILDER));
197
193
 
198
- const drift = recordProbe("night-action", { ok: true, models: ["gpt-oss:20b", "qwen3:32b"] }, opts);
194
+ const drift = recordProbe("night-action", { ok: true, models: [OLLAMA.BUILDER, "qwen3:32b"] }, opts);
199
195
  check("re-probe diffs against knownModels — only the NEW model joins newModels",
200
- drift.newModels.length === 1 && drift.newModels[0] === "qwen3:32b" && drift.knownModels.includes("gpt-oss:20b"));
196
+ drift.newModels.length === 1 && drift.newModels[0] === "qwen3:32b" && drift.knownModels.includes(OLLAMA.BUILDER));
201
197
 
202
198
  const seenBefore = drift.lastSeen;
203
199
  const failed = recordProbe("night-action", { ok: false }, opts);
@@ -213,7 +209,7 @@ function runSelfTest() {
213
209
 
214
210
  check("state persists across a fresh load (atomic tmp+rename write)", (() => {
215
211
  const s = loadFleetState(opts);
216
- return s.machines["night-action"]?.knownModels.includes("gpt-oss:20b")
212
+ return s.machines["night-action"]?.knownModels.includes(OLLAMA.BUILDER)
217
213
  && s.machines["basement-geek"]?.fleetVersion === "0.3.1"
218
214
  && s.version === 1;
219
215
  })());