heyspex 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (352) hide show
  1. package/.claude-plugin/marketplace.json +14 -0
  2. package/README.md +21 -0
  3. package/bin/hs.mjs +1089 -104
  4. package/bin/spexcode.mjs +262 -0
  5. package/dev-factory/engine/worktree.mjs +740 -0
  6. package/dev-factory/lib/code-repo-root.mjs +67 -0
  7. package/dev-factory/lib/context-budget.mjs +84 -0
  8. package/dev-factory/lib/epic-detect.mjs +190 -0
  9. package/dev-factory/lib/fleet-queue-file.mjs +846 -0
  10. package/dev-factory/lib/fleet-queue.mjs +426 -0
  11. package/dev-factory/lib/fleet-state.mjs +14 -18
  12. package/dev-factory/lib/model-catalog.mjs +338 -0
  13. package/dev-factory/lib/seat-context.mjs +199 -0
  14. package/dev-factory/lib/spec-io.mjs +93 -0
  15. package/dev-factory/lib/sufficiency-score.mjs +8 -2
  16. package/dev-factory/lib/worktree-allocator.mjs +744 -0
  17. package/dev-factory/model-catalog.json +222 -0
  18. package/dist/cli/hs-check.mjs +1127 -103
  19. package/dist/cli/hs-claim.mjs +981 -63
  20. package/dist/cli/hs-connect.mjs +10059 -1729
  21. package/dist/cli/hs-defer.mjs +5507 -3072
  22. package/dist/cli/hs-dev.mjs +576 -0
  23. package/dist/cli/hs-env.mjs +416 -302
  24. package/dist/cli/hs-index-rebuild.mjs +7334 -0
  25. package/dist/cli/hs-index-reconcile.mjs +8138 -0
  26. package/dist/cli/hs-init.mjs +2840 -149
  27. package/dist/cli/hs-install.mjs +7214 -594
  28. package/dist/cli/hs-join.mjs +10640 -2421
  29. package/dist/cli/hs-vault-pull.mjs +8157 -6370
  30. package/dist/cli/hs-vault-push.mjs +6433 -0
  31. package/dist/cli/hs-vault.mjs +4029 -1555
  32. package/dist/cli/hs-workspace.mjs +2702 -538
  33. package/dist/cli-templates/customer-vault/specs/entity-model.md +594 -601
  34. package/dist/cli-templates/customer-vault/specs/protocols/canonical-frontmatter-templates.md +110 -177
  35. package/dist/cli-templates/customer-vault/specs/protocols/destructive-action-checklist.md +122 -0
  36. package/dist/cli-templates/customer-vault/specs/protocols/memory-protocol.md +13 -15
  37. package/dist/cli-templates/customer-vault/specs/protocols/session-close-protocol.md +91 -0
  38. package/dist/cli-templates/customer-vault/specs/protocols/spec-hierarchy.md +150 -168
  39. package/dist/cli-templates/customer-vault/specs/protocols/spec-summary-authoring.md +83 -0
  40. package/dist/cli-templates/harness-commands/claude/hs-auto.md +36 -0
  41. package/dist/cli-templates/harness-commands/claude/hs-backfill.md +28 -0
  42. package/dist/cli-templates/harness-commands/claude/hs-capture.md +62 -0
  43. package/dist/cli-templates/harness-commands/claude/hs-defer.md +29 -0
  44. package/dist/cli-templates/harness-commands/claude/hs-dev.md +20 -0
  45. package/dist/cli-templates/harness-commands/claude/hs-doctor.md +32 -0
  46. package/dist/cli-templates/harness-commands/claude/hs-ground.md +30 -0
  47. package/dist/cli-templates/harness-commands/claude/hs-handoff.md +33 -0
  48. package/dist/cli-templates/harness-commands/claude/hs-log.md +27 -0
  49. package/dist/cli-templates/harness-commands/claude/hs-next.md +29 -0
  50. package/dist/cli-templates/harness-commands/claude/hs-promote.md +70 -0
  51. package/dist/cli-templates/harness-commands/claude/hs-push.md +35 -0
  52. package/dist/cli-templates/harness-commands/claude/hs-qc.md +27 -0
  53. package/dist/cli-templates/harness-commands/claude/hs-res.md +63 -0
  54. package/dist/cli-templates/harness-commands/claude/hs-resolve.md +23 -0
  55. package/dist/cli-templates/harness-commands/claude/hs-review.md +23 -0
  56. package/dist/cli-templates/harness-commands/claude/hs-view.md +28 -0
  57. package/dist/cli-templates/harness-commands/claude/kill-ports.md +32 -0
  58. package/dist/cli-templates/harness-commands/claude/prototype.md +91 -0
  59. package/dist/cli-templates/harness-commands/cursor/hs-auto.md +40 -0
  60. package/dist/cli-templates/harness-commands/cursor/hs-backfill.md +31 -0
  61. package/dist/cli-templates/harness-commands/cursor/hs-capture.md +65 -0
  62. package/dist/cli-templates/harness-commands/cursor/hs-defer.md +32 -0
  63. package/dist/cli-templates/harness-commands/cursor/hs-dev.md +24 -0
  64. package/dist/cli-templates/harness-commands/cursor/hs-doctor.md +36 -0
  65. package/dist/cli-templates/harness-commands/cursor/hs-ground.md +33 -0
  66. package/dist/cli-templates/harness-commands/cursor/hs-handoff.md +37 -0
  67. package/dist/cli-templates/harness-commands/cursor/hs-log.md +31 -0
  68. package/dist/cli-templates/harness-commands/cursor/hs-next.md +32 -0
  69. package/dist/cli-templates/harness-commands/cursor/hs-promote.md +73 -0
  70. package/dist/cli-templates/harness-commands/cursor/hs-push.md +38 -0
  71. package/dist/cli-templates/harness-commands/cursor/hs-qc.md +30 -0
  72. package/dist/cli-templates/harness-commands/cursor/hs-res.md +67 -0
  73. package/dist/cli-templates/harness-commands/cursor/hs-resolve.md +26 -0
  74. package/dist/cli-templates/harness-commands/cursor/hs-review.md +26 -0
  75. package/dist/cli-templates/harness-commands/cursor/hs-view.md +32 -0
  76. package/dist/cli-templates/harness-commands/cursor/kill-ports.md +35 -0
  77. package/dist/cli-templates/harness-commands/cursor/prototype.md +95 -0
  78. package/docs/copilot-plugin-install.md +162 -0
  79. package/docs/driving-heyspex-from-copilot.md +165 -0
  80. package/docs/mcp-client-config.md +259 -6
  81. package/package.json +249 -124
  82. package/plugins/heyspex/.claude-plugin/plugin.json +12 -0
  83. package/plugins/heyspex/.cursor-plugin/plugin.json +15 -0
  84. package/plugins/heyspex/.mcp.json +12 -0
  85. package/plugins/heyspex/README.md +309 -0
  86. package/plugins/heyspex/bin/launch-mcp.mjs +95 -0
  87. package/plugins/heyspex/bin/run-hook.mjs +80 -0
  88. package/plugins/heyspex/hooks/cursor-hooks.json +308 -0
  89. package/plugins/heyspex/hooks/hooks.json +418 -0
  90. package/plugins/heyspex/mcp.json +13 -0
  91. package/scripts/check-mcp-freshness.mjs +17 -9
  92. package/scripts/claude-hooks/_env-vault-events.mjs +68 -0
  93. package/scripts/claude-hooks/_guardrail-events.mjs +96 -0
  94. package/scripts/claude-hooks/_guardrail-loader.mjs +494 -0
  95. package/scripts/claude-hooks/_intent-classifier-router.mjs +456 -0
  96. package/scripts/claude-hooks/_intent-classifier-semantic.mjs +356 -0
  97. package/scripts/claude-hooks/_run.mjs +413 -0
  98. package/scripts/claude-hooks/_transcript-text.mjs +60 -0
  99. package/scripts/claude-hooks/_vault-retrieval.mjs +322 -0
  100. package/scripts/claude-hooks/agent-spawn-delegation-gate.mjs +133 -0
  101. package/scripts/claude-hooks/capture-execution-gate.mjs +51 -0
  102. package/scripts/claude-hooks/capture-workflow-gate.mjs +11 -0
  103. package/scripts/claude-hooks/check-auto-memory-budget.mjs +396 -0
  104. package/scripts/claude-hooks/check-claude-md-budget.mjs +327 -0
  105. package/scripts/claude-hooks/check-mcp-freshness-hook.mjs +14 -0
  106. package/scripts/claude-hooks/claim-before-code-gate.mjs +11 -0
  107. package/scripts/claude-hooks/claim-liveness-shared.mjs +577 -0
  108. package/scripts/claude-hooks/close-terminal-on-ship.mjs +64 -0
  109. package/scripts/claude-hooks/coo-denylist-gate.mjs +11 -0
  110. package/scripts/claude-hooks/coo-guard-check.mjs +11 -0
  111. package/scripts/claude-hooks/deep-research-cost-gate.mjs +11 -0
  112. package/scripts/claude-hooks/dev-server-task-reminder.mjs +15 -0
  113. package/scripts/claude-hooks/diagnose-capture-reminder.mjs +11 -0
  114. package/scripts/claude-hooks/directive-obligation-gate.mjs +11 -0
  115. package/scripts/claude-hooks/emit-claim-status.mjs +41 -0
  116. package/scripts/claude-hooks/enforce-session-sequence.mjs +11 -0
  117. package/scripts/claude-hooks/enforce-vault-naming.mjs +11 -0
  118. package/scripts/claude-hooks/env-vault-guard.mjs +11 -0
  119. package/scripts/claude-hooks/epic-milestone-phrasing-gate.mjs +11 -0
  120. package/scripts/claude-hooks/git-identity-guard.mjs +11 -0
  121. package/scripts/claude-hooks/git-reflog-audit.mjs +15 -0
  122. package/scripts/claude-hooks/git-stash-gate.mjs +11 -0
  123. package/scripts/claude-hooks/guard-auto-memory-writes.mjs +137 -0
  124. package/scripts/claude-hooks/id-display-format-gate.mjs +11 -0
  125. package/scripts/claude-hooks/inject-communication-style.mjs +86 -0
  126. package/scripts/claude-hooks/inject-coo-seat.mjs +78 -0
  127. package/scripts/claude-hooks/inject-dev-server-awareness.mjs +11 -0
  128. package/scripts/claude-hooks/inject-doctrine.mjs +13 -0
  129. package/scripts/claude-hooks/inject-vault-context.mjs +14 -0
  130. package/scripts/claude-hooks/intent-classifier-trigger.mjs +279 -0
  131. package/scripts/claude-hooks/log-session-activity.mjs +341 -0
  132. package/scripts/claude-hooks/naughty-list-gate.mjs +11 -0
  133. package/scripts/claude-hooks/notify-status-flip.mjs +72 -0
  134. package/scripts/claude-hooks/plan-exit-ingest-gate.mjs +170 -0
  135. package/scripts/claude-hooks/plan-ingest-close-reminder.mjs +66 -0
  136. package/scripts/claude-hooks/plan-review-gate.mjs +11 -0
  137. package/scripts/claude-hooks/preflight-reminder.mjs +262 -0
  138. package/scripts/claude-hooks/protected-action-intent.mjs +221 -0
  139. package/scripts/claude-hooks/research-method-guard.mjs +398 -0
  140. package/scripts/claude-hooks/research-protocol-gate.mjs +11 -0
  141. package/scripts/claude-hooks/session-end.mjs +451 -0
  142. package/scripts/claude-hooks/settings-write-gate.mjs +11 -0
  143. package/scripts/claude-hooks/settlement-check-gate.mjs +39 -0
  144. package/scripts/claude-hooks/status-flip-detect.mjs +17 -0
  145. package/scripts/claude-hooks/terminal-close-shared.mjs +426 -0
  146. package/scripts/claude-hooks/terminal-close-watcher.mjs +122 -0
  147. package/scripts/claude-hooks/vault-mirror-catchup.mjs +11 -0
  148. package/scripts/claude-hooks/vault-write-tier-guard.mjs +11 -0
  149. package/scripts/claude-hooks/wip-checkpoint.mjs +302 -0
  150. package/scripts/cursor-hooks/manifest.mjs +281 -0
  151. package/scripts/dev-server-status.mjs +34 -13
  152. package/scripts/dev-server-stop.mjs +313 -0
  153. package/scripts/heyspex-mode.mjs +288 -0
  154. package/scripts/hs-auto-resolve.mjs +240 -0
  155. package/scripts/hs-auto-source.mjs +398 -0
  156. package/scripts/hs-auto.mjs +134 -0
  157. package/scripts/hs-capture-cli.mjs +326 -0
  158. package/scripts/hs-classifier.mjs +136 -0
  159. package/scripts/hs-companion.mjs +88 -0
  160. package/scripts/hs-gateway.mjs +115 -0
  161. package/scripts/hs-mode.mjs +125 -0
  162. package/scripts/hs-next.mjs +441 -0
  163. package/scripts/hs-settings.mjs +338 -0
  164. package/scripts/hs-view.mjs +496 -0
  165. package/scripts/hs-worktree.mjs +201 -0
  166. package/scripts/install-claude-code-hooks.mjs +272 -0
  167. package/scripts/install-cursor-hooks.mjs +471 -0
  168. package/scripts/lib/agent-env-markers.mjs +38 -0
  169. package/scripts/lib/console-url-resolver.mjs +199 -61
  170. package/scripts/lib/customer-kit.mjs +1203 -0
  171. package/scripts/lib/dev-mode.mjs +249 -32
  172. package/scripts/lib/directive-obligations.mjs +349 -0
  173. package/scripts/lib/heyspex-home.mjs +72 -0
  174. package/scripts/lib/hook-dispositions.mjs +210 -0
  175. package/scripts/lib/hook-vault-header.mjs +30 -0
  176. package/scripts/lib/hs-command.mjs +277 -0
  177. package/scripts/lib/hs-companion-settings.mjs +472 -0
  178. package/scripts/lib/hs-source-branch.mjs +196 -0
  179. package/scripts/lib/hs-source-commit.mjs +59 -5
  180. package/scripts/lib/hs-source-identity.mjs +526 -0
  181. package/scripts/lib/hs-source-log.mjs +13 -22
  182. package/scripts/lib/hs-source-preflight.mjs +18 -4
  183. package/scripts/lib/hs-source-push.mjs +386 -307
  184. package/scripts/lib/hs-worktree-args.mjs +115 -0
  185. package/scripts/lib/shared-path-helpers.mjs +134 -0
  186. package/scripts/lib/standalone-doctor.mjs +540 -3
  187. package/scripts/lib/subagent-git-lock.mjs +109 -0
  188. package/scripts/lib/template-pin.mjs +34 -0
  189. package/scripts/lib/window-placement.mjs +109 -0
  190. package/scripts/provenance-lib.mjs +91 -0
  191. package/scripts/stamp-provenance-on-edit.mjs +222 -0
  192. package/scripts/wip-ledger-health.mjs +653 -0
  193. package/spexharness/doctrine/baseline/SPEXCODE-DOCTRINE.baseline.json +29 -0
  194. package/spexharness/src/adapters/audit.mjs +197 -0
  195. package/spexharness/src/adapters/claude-code.mjs +449 -0
  196. package/spexharness/src/adapters/copilot.mjs +236 -0
  197. package/spexharness/src/adapters/detect-harness.mjs +34 -0
  198. package/spexharness/src/chat/tui/ansi.mjs +448 -0
  199. package/spexharness/src/chat/tui/palette.mjs +22 -0
  200. package/spexharness/src/chat/tui/select.mjs +208 -0
  201. package/spexharness/src/config-root.mjs +54 -0
  202. package/spexharness/src/context-budget.mjs +141 -0
  203. package/spexharness/src/dev-server-control.mjs +389 -0
  204. package/spexharness/src/doctrine/behaviour.mjs +371 -0
  205. package/spexharness/src/doctrine/corpus.mjs +733 -0
  206. package/spexharness/src/doctrine/inject.mjs +338 -0
  207. package/spexharness/src/doctrine/merge.mjs +184 -0
  208. package/spexharness/src/doctrine/sources.mjs +252 -0
  209. package/spexharness/src/doctrine/synthesize.mjs +401 -0
  210. package/spexharness/src/doctrine/vault-source.mjs +574 -0
  211. package/spexharness/src/doctrine/writer.mjs +176 -0
  212. package/spexharness/src/events.mjs +84 -0
  213. package/spexharness/src/gates/_shell-write-targets.mjs +77 -0
  214. package/spexharness/src/gates/capture-workflow-gate.mjs +202 -0
  215. package/spexharness/src/gates/claim-before-code-gate.decide.mjs +33 -0
  216. package/spexharness/src/gates/claim-before-code-gate.mjs +129 -0
  217. package/spexharness/src/gates/deep-research-cost-gate.mjs +275 -0
  218. package/spexharness/src/gates/diagnose-capture-reminder.mjs +112 -0
  219. package/spexharness/src/gates/directive-obligation-gate.decide.mjs +132 -0
  220. package/spexharness/src/gates/directive-obligation-gate.mjs +183 -0
  221. package/spexharness/src/gates/enforce-session-sequence.mjs +895 -0
  222. package/spexharness/src/gates/enforce-vault-naming.mjs +215 -0
  223. package/spexharness/src/gates/epic-milestone-phrasing-gate.mjs +233 -0
  224. package/spexharness/src/gates/git-identity-guard.decide.mjs +436 -0
  225. package/spexharness/src/gates/git-identity-guard.mjs +61 -0
  226. package/spexharness/src/gates/git-reflog-audit.mjs +151 -0
  227. package/spexharness/src/gates/git-stash-gate.decide.mjs +361 -0
  228. package/spexharness/src/gates/git-stash-gate.mjs +159 -0
  229. package/spexharness/src/gates/id-display-format-gate.mjs +190 -0
  230. package/spexharness/src/gates/inject-doctrine-gate.mjs +89 -0
  231. package/spexharness/src/gates/inject-vault-context.mjs +239 -0
  232. package/spexharness/src/gates/naughty-list-gate.mjs +213 -0
  233. package/spexharness/src/gates/plan-review-gate.mjs +233 -0
  234. package/spexharness/src/gates/preflight-reminder-gate.mjs +265 -0
  235. package/spexharness/src/gates/research-protocol-gate.mjs +175 -0
  236. package/spexharness/src/gates/settings-write-gate.decide.mjs +96 -0
  237. package/spexharness/src/gates/settings-write-gate.mjs +104 -0
  238. package/spexharness/src/gates/vault-mirror-catchup.mjs +124 -0
  239. package/spexharness/src/gates/vault-write-tier-guard.decide.mjs +125 -0
  240. package/spexharness/src/gates/vault-write-tier-guard.mjs +42 -0
  241. package/spexharness/src/git-reflog-audit.mjs +294 -0
  242. package/spexharness/src/intent-classifier.mjs +196 -0
  243. package/spexharness/src/intent-directive.mjs +626 -0
  244. package/spexharness/src/profile/learn.mjs +551 -0
  245. package/spexharness/src/profile/proposals.mjs +357 -0
  246. package/spexharness/src/profile/vault-inclusion.mjs +386 -0
  247. package/spexharness/src/redact.mjs +151 -0
  248. package/spexharness/src/rule-table.mjs +849 -0
  249. package/spexharness/src/seat-context.mjs +122 -0
  250. package/spexharness/src/session-bootstrap.mjs +512 -0
  251. package/spexharness/src/session-record.mjs +815 -0
  252. package/spexharness/src/token-inventory.mjs +207 -0
  253. package/vendor/heyspex-mcp/account-switch.mjs +96 -0
  254. package/vendor/heyspex-mcp/alignment-gate.mjs +315 -1
  255. package/vendor/heyspex-mcp/blocker-resolver.mjs +37 -2
  256. package/vendor/heyspex-mcp/breakdown-ladder.mjs +119 -4
  257. package/vendor/heyspex-mcp/breakdown-source.mjs +271 -0
  258. package/vendor/heyspex-mcp/breakdown-writer.mjs +23 -3
  259. package/vendor/heyspex-mcp/capture-buffer-patches.mjs +77 -8
  260. package/vendor/heyspex-mcp/capture-channel.mjs +55 -5
  261. package/vendor/heyspex-mcp/capture-console-client.mjs +117 -3
  262. package/vendor/heyspex-mcp/capture-focus.mjs +1061 -31
  263. package/vendor/heyspex-mcp/capture-outbox.mjs +15 -5
  264. package/vendor/heyspex-mcp/capture-sources.mjs +195 -0
  265. package/vendor/heyspex-mcp/capture-tools.mjs +1264 -0
  266. package/vendor/heyspex-mcp/claim-tools.mjs +1147 -0
  267. package/vendor/heyspex-mcp/cli-verbs.mjs +2074 -0
  268. package/vendor/heyspex-mcp/cli.mjs +207 -15524
  269. package/vendor/heyspex-mcp/close-branch-report.mjs +102 -0
  270. package/vendor/heyspex-mcp/cloud-tool-surface.mjs +128 -0
  271. package/vendor/heyspex-mcp/cloud-vault-writes.mjs +602 -0
  272. package/vendor/heyspex-mcp/config-seed.yaml +15 -0
  273. package/vendor/heyspex-mcp/config-settings.mjs +290 -7
  274. package/vendor/heyspex-mcp/console-fetch.mjs +822 -17
  275. package/vendor/heyspex-mcp/console-transport.mjs +172 -0
  276. package/vendor/heyspex-mcp/credentials.mjs +585 -0
  277. package/vendor/heyspex-mcp/defer-spec.mjs +44 -4
  278. package/vendor/heyspex-mcp/deferred-reclaim.mjs +29 -3
  279. package/vendor/heyspex-mcp/design-handlers.mjs +31 -0
  280. package/vendor/heyspex-mcp/design-tools.mjs +4 -1
  281. package/vendor/heyspex-mcp/dev-factory-bridge.mjs +4 -4
  282. package/vendor/heyspex-mcp/dev-mode-core.mjs +45 -0
  283. package/vendor/heyspex-mcp/dev-tools.mjs +131 -0
  284. package/vendor/heyspex-mcp/diagnose-handlers.mjs +54 -0
  285. package/vendor/heyspex-mcp/events-tools.mjs +207 -0
  286. package/vendor/heyspex-mcp/filing-tools.mjs +723 -0
  287. package/vendor/heyspex-mcp/fleet-handlers.mjs +37 -0
  288. package/vendor/heyspex-mcp/fleet-queue-tools.mjs +71 -15
  289. package/vendor/heyspex-mcp/focus-tools.mjs +190 -0
  290. package/vendor/heyspex-mcp/folder-trust-store.mjs +60 -0
  291. package/vendor/heyspex-mcp/http-transport.mjs +302 -0
  292. package/vendor/heyspex-mcp/list-tools.mjs +257 -0
  293. package/vendor/heyspex-mcp/login-handoff.mjs +15 -1
  294. package/vendor/heyspex-mcp/mcp-config.mjs +107 -0
  295. package/vendor/heyspex-mcp/memory-tools.mjs +523 -0
  296. package/vendor/heyspex-mcp/mvp-mode.mjs +53 -6
  297. package/vendor/heyspex-mcp/obligation-backstop.mjs +103 -0
  298. package/vendor/heyspex-mcp/occupied-keys.mjs +142 -0
  299. package/vendor/heyspex-mcp/on-deck-brief.mjs +3377 -0
  300. package/vendor/heyspex-mcp/on-deck-scheduler.mjs +1708 -0
  301. package/vendor/heyspex-mcp/package-root.mjs +32 -0
  302. package/vendor/heyspex-mcp/peer-presence.mjs +8 -1
  303. package/vendor/heyspex-mcp/plan-ground.mjs +621 -652
  304. package/vendor/heyspex-mcp/plan-push.mjs +908 -22
  305. package/vendor/heyspex-mcp/plan-review.mjs +124 -30
  306. package/vendor/heyspex-mcp/preflight-gate.mjs +0 -0
  307. package/vendor/heyspex-mcp/procedure-handlers.mjs +327 -0
  308. package/vendor/heyspex-mcp/procedure-tools.mjs +1181 -970
  309. package/vendor/heyspex-mcp/promote-capture.mjs +388 -69
  310. package/vendor/heyspex-mcp/promote-diagnose.mjs +74 -0
  311. package/vendor/heyspex-mcp/promote-research-to-diagnose.mjs +9 -2
  312. package/vendor/heyspex-mcp/promote-research.mjs +666 -53
  313. package/vendor/heyspex-mcp/promotion-helpers.mjs +251 -0
  314. package/vendor/heyspex-mcp/prompts.mjs +1460 -0
  315. package/vendor/heyspex-mcp/read-path.mjs +191 -15
  316. package/vendor/heyspex-mcp/receipt-shape.mjs +146 -0
  317. package/vendor/heyspex-mcp/recipe-handlers.mjs +117 -0
  318. package/vendor/heyspex-mcp/regen-tasks.mjs +210 -3
  319. package/vendor/heyspex-mcp/register-spec.mjs +266 -54
  320. package/vendor/heyspex-mcp/request-context.mjs +83 -0
  321. package/vendor/heyspex-mcp/research-focus.mjs +207 -4
  322. package/vendor/heyspex-mcp/research-handlers.mjs +625 -0
  323. package/vendor/heyspex-mcp/research-tools.mjs +4 -4
  324. package/vendor/heyspex-mcp/response-budget.mjs +348 -0
  325. package/vendor/heyspex-mcp/review-focus.mjs +25 -1
  326. package/vendor/heyspex-mcp/roadmap-default.mjs +70 -0
  327. package/vendor/heyspex-mcp/search-specs.mjs +280 -0
  328. package/vendor/heyspex-mcp/seat-block-ledger.mjs +113 -0
  329. package/vendor/heyspex-mcp/seat-identity.mjs +2 -6
  330. package/vendor/heyspex-mcp/session-owner.mjs +109 -0
  331. package/vendor/heyspex-mcp/session-tools.mjs +162 -0
  332. package/vendor/heyspex-mcp/settings-registry.mjs +232 -0
  333. package/vendor/heyspex-mcp/settings-tools.mjs +85 -12
  334. package/vendor/heyspex-mcp/spec-edit-tools.mjs +453 -0
  335. package/vendor/heyspex-mcp/spec-id-lib.mjs +93 -17
  336. package/vendor/heyspex-mcp/split-research.mjs +304 -56
  337. package/vendor/heyspex-mcp/stdio-rpc.mjs +22 -0
  338. package/vendor/heyspex-mcp/submit-for-review.mjs +150 -7
  339. package/vendor/heyspex-mcp/summary-contract.mjs +105 -0
  340. package/vendor/heyspex-mcp/summary-write-tools.mjs +228 -0
  341. package/vendor/heyspex-mcp/symptom-query.mjs +146 -0
  342. package/vendor/heyspex-mcp/test-helpers.mjs +123 -1
  343. package/vendor/heyspex-mcp/tool-catalog.mjs +2819 -0
  344. package/vendor/heyspex-mcp/tool-dispatch.mjs +657 -0
  345. package/vendor/heyspex-mcp/tool-helpers.mjs +325 -0
  346. package/vendor/heyspex-mcp/validate-linkage.mjs +414 -27
  347. package/vendor/heyspex-mcp/vault-io.mjs +720 -0
  348. package/vendor/heyspex-mcp/vault-less-endpoints.mjs +52 -0
  349. package/vendor/heyspex-mcp/vault-resolve.mjs +554 -68
  350. package/vendor/heyspex-mcp/verify-reproduction.mjs +270 -30
  351. package/vendor/heyspex-mcp/whoami.mjs +92 -0
  352. package/vendor/heyspex-mcp/write-outbox.mjs +512 -0
@@ -0,0 +1,846 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * fleet-queue-file.mjs — the file-backed (JSONL) queue implementation for a single machine /
4
+ * shared filesystem. THIS FILE WAS fleet-queue.mjs, moved here verbatim as part of the Neon
5
+ * fleet-queue port (HS-XOCK phase 4) — logic is unchanged from the original single-file
6
+ * module; only this header and the export surface note below are new.
7
+ *
8
+ * fleet-queue.mjs is now a thin backend-selecting façade (file vs. neon) that re-exports the
9
+ * same six functions as async wrappers — see fleet-queue.mjs and fleet-queue-neon.mjs. THIS
10
+ * module remains the offline / no-dispatcher fallback and the target of its own `--self-test`.
11
+ *
12
+ * Owner ask (2026-07-12): "If I have one agent already sending research request in progress
13
+ * to Night Action, and another agent has research that needs to be done, that agent can
14
+ * submit it successfully to the research queue - instead of getting a fail or rejection.
15
+ * This queue can also be used when Night Action has an existing ollama process. Research can
16
+ * be sent to the queue to be presented to Night Action as soon as it is done." And: "this
17
+ * queue concept can be used across the whole fleet for different use cases."
18
+ *
19
+ * FIRST LANE: "research-firecrawl" — research requests that lose the Ollama-XOR-Firecrawl
20
+ * mode-lease race in night-action-mode.mjs's requestOrEnqueueFirecrawlMode() land here
21
+ * instead of failing (see night-action-mode.mjs). Lanes are free-form strings; any future
22
+ * machine-gated resource (a build slot, a breakdown job, a GPU node) reuses this same module
23
+ * under its own lane name — see the design note at the bottom of this file.
24
+ *
25
+ * STORAGE — event-sourced, append-only JSONL: `.heyspex/fleet-queue.jsonl` (repo root,
26
+ * resolved by walking up to the heyspex.yaml vault marker — same convention as
27
+ * dev-factory/lib/fleet-state.mjs). Every mutation (enqueue / claim / complete / fail) is
28
+ * appended as ONE JSON line via a fresh open("a") + write + close (mirrors
29
+ * dev-factory/lib/run-events.mjs) — never a whole-file rewrite. Current item state is derived
30
+ * by FOLDING an item's events in file order, the same event-sourcing shape
31
+ * .heyspex/claim-history.jsonl and .heyspex/focus-history.jsonl already use elsewhere in this
32
+ * repo for durable ledgers (append pattern confirmed against both before writing this).
33
+ *
34
+ * WHY EVENT-SOURCED APPEND OVER REWRITE-WITH-STATUS (the crash-safety justification the task
35
+ * asked for): a whole-file rewrite (read all items, mutate one, write a temp file, rename over
36
+ * the original) is atomic PER WRITE but requires a read-modify-write on the FULL file for every
37
+ * single mutation, and a crash between "read" and "rename" either loses the mutation or (worse,
38
+ * on a partial rename) can corrupt every other item's state too. An append-only event log makes
39
+ * each mutation a single new line: a crash mid-write can only ever cost the LAST line (which
40
+ * readAllEvents() below already tolerates — a corrupt/partial trailing line is skipped, not
41
+ * thrown, mirroring run-events.mjs), and it can NEVER corrupt or lose any prior item's history.
42
+ * The trade is that readers must fold history instead of reading a snapshot — cheap here since
43
+ * the queue is small and local.
44
+ *
45
+ * CONCURRENCY — double-claim rejection: enqueue/complete/fail don't need cross-process
46
+ * exclusion (each only ever touches its own new line or an item only its own claimant should be
47
+ * mutating), but claim() has a genuine race — two processes could both read "pending" for the
48
+ * same id before either appends its claim event. claim() (and complete/fail, for symmetry) take
49
+ * a short-lived, per-id, TTL-bounded advisory file lock (`<queue>.lock.<id>`, exclusive-create
50
+ * `wx`) around their read-decide-append critical section — true mutual exclusion for same-id
51
+ * ops across processes, while different ids proceed fully in parallel. A lock file left behind
52
+ * by a crashed holder is auto-broken after `staleLockMs` (default 5s) so the queue can never
53
+ * wedge on a dead process.
54
+ *
55
+ * CRASH-RECOVERY (claim TTL): a claim() that never gets a matching complete()/fail() within
56
+ * `CLAIM_TTL_MS` (default 15 min, override via options or HEYSPEX_FLEET_QUEUE_CLAIM_TTL_MS) is
57
+ * treated as STALE — effectiveStatus() reports it back as "pending" so any caller (including
58
+ * the original claimant retrying after a crash) can reclaim it. The stale claim's history is
59
+ * never deleted, only superseded by a fresh claim event — so `list()` still shows the full
60
+ * claim history for audit.
61
+ *
62
+ * API (all synchronous — the façade in fleet-queue.mjs wraps these as async for backend parity
63
+ * with fleet-queue-neon.mjs):
64
+ * enqueue({ lane, payload, requestedBy }) -> { id, position } // position = 1-indexed rank among pending items in that lane
65
+ * nextPending(lane) -> item | null // oldest pending (or stale-reclaimable) item in the lane
66
+ * claim(id, claimant) -> { ok, reason? } // rejects double-claim / terminal states
67
+ * complete(id, result?) -> { ok, reason? } // must currently be claimed
68
+ * fail(id, reason?) -> { ok, reason? } // from pending or claimed
69
+ * list(lane?, status?) -> item[] // oldest-first
70
+ *
71
+ * CLI: node dev-factory/lib/fleet-queue-file.mjs --self-test
72
+ */
73
+
74
+ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, statSync, writeSync } from "node:fs";
75
+ import { dirname, join } from "node:path";
76
+ import { codeRepoRoot } from "./code-repo-root.mjs";
77
+
78
+ // ── repo-root + store resolution: this queue holds CODE-repo state (fleet-queue.jsonl), not
79
+ // vault state, so it anchors on the code repo itself rather than walking for a vault marker.
80
+ export function findRepoRoot() {
81
+ return codeRepoRoot();
82
+ }
83
+
84
+ function defaultQueuePath() {
85
+ const dir = join(findRepoRoot(), ".heyspex");
86
+ mkdirSync(dir, { recursive: true });
87
+ return process.env.HEYSPEX_FLEET_QUEUE_PATH || join(dir, "fleet-queue.jsonl");
88
+ }
89
+
90
+ export const CLAIM_TTL_MS = Number(process.env.HEYSPEX_FLEET_QUEUE_CLAIM_TTL_MS) || 15 * 60 * 1000; // 15 min
91
+
92
+ /** First lane (owner ask 2026-07-12): research requests that lose the Ollama-XOR-Firecrawl
93
+ * mode-lease race in night-action-mode.mjs land here instead of failing. */
94
+ export const RESEARCH_FIRECRAWL_LANE = "research-firecrawl";
95
+
96
+ // ── event log I/O (mirrors run-events.mjs: line-atomic append, corrupt-tail-tolerant read) ──────
97
+ function appendEvent(queuePath, record) {
98
+ mkdirSync(dirname(queuePath), { recursive: true });
99
+ const line = `${JSON.stringify(record)}\n`;
100
+ const fd = openSync(queuePath, "a");
101
+ try {
102
+ writeSync(fd, line);
103
+ } finally {
104
+ closeSync(fd);
105
+ }
106
+ return record;
107
+ }
108
+
109
+ function readAllEvents(queuePath) {
110
+ if (!existsSync(queuePath)) return [];
111
+ const text = readFileSync(queuePath, "utf8");
112
+ const events = [];
113
+ for (const line of text.split("\n")) {
114
+ const trimmed = line.trim();
115
+ if (!trimmed) continue;
116
+ try {
117
+ events.push(JSON.parse(trimmed));
118
+ } catch {
119
+ // corrupt/partial trailing line (e.g. a crash mid-write) — skip, never throw
120
+ }
121
+ }
122
+ return events;
123
+ }
124
+
125
+ // ── per-id advisory lock (exclusive-create; auto-breaks a stale/crashed holder) ─────────────────
126
+ function lockPathFor(queuePath, id) {
127
+ return `${queuePath}.lock.${id}`;
128
+ }
129
+
130
+ function acquireLock(queuePath, id, { timeoutMs = 3000, staleLockMs = 5000, pollMs = 15 } = {}) {
131
+ const lp = lockPathFor(queuePath, id);
132
+ mkdirSync(dirname(queuePath), { recursive: true });
133
+ const start = Date.now();
134
+ for (;;) {
135
+ try {
136
+ const fd = openSync(lp, "wx");
137
+ writeSync(fd, String(process.pid));
138
+ closeSync(fd);
139
+ return true;
140
+ } catch (e) {
141
+ if (e?.code !== "EEXIST") throw e;
142
+ try {
143
+ const st = statSync(lp);
144
+ if (Date.now() - st.mtimeMs > staleLockMs) {
145
+ rmSync(lp, { force: true }); // break a lock left by a crashed holder
146
+ continue;
147
+ }
148
+ } catch {
149
+ continue; // lock vanished between the failed create and the stat — retry immediately
150
+ }
151
+ if (Date.now() - start > timeoutMs) return false;
152
+ // best-effort short busy-wait; this is a local dev tool, not a high-throughput queue
153
+ const until = Date.now() + pollMs;
154
+ while (Date.now() < until) { /* spin */ }
155
+ }
156
+ }
157
+ }
158
+
159
+ function releaseLock(queuePath, id) {
160
+ try {
161
+ rmSync(lockPathFor(queuePath, id), { force: true });
162
+ } catch {
163
+ // already gone — fine
164
+ }
165
+ }
166
+
167
+ function withLock(queuePath, id, fn) {
168
+ if (!acquireLock(queuePath, id)) return { ok: false, reason: `fleet-queue: could not acquire lock for "${id}" (held too long)` };
169
+ try {
170
+ return fn();
171
+ } finally {
172
+ releaseLock(queuePath, id);
173
+ }
174
+ }
175
+
176
+ // ── state derivation (event fold) ─────────────────────────────────────────────────────────────
177
+ function foldOne(events) {
178
+ let state = null;
179
+ for (const ev of events) {
180
+ if (ev.type === "enqueue") {
181
+ state = {
182
+ id: ev.id,
183
+ lane: ev.lane,
184
+ payload: ev.payload ?? null,
185
+ requestedBy: ev.requestedBy ?? null,
186
+ status: "pending",
187
+ enqueuedAt: ev.at,
188
+ claimant: null,
189
+ claimedAt: null,
190
+ result: null,
191
+ failReason: null,
192
+ completedAt: null,
193
+ };
194
+ } else if (!state) {
195
+ continue; // orphan event with no enqueue in this log slice — ignore
196
+ } else if (ev.type === "claim") {
197
+ state.status = "claimed";
198
+ state.claimant = ev.claimant ?? null;
199
+ state.claimedAt = ev.at;
200
+ } else if (ev.type === "complete") {
201
+ state.status = "completed";
202
+ state.result = ev.result ?? null;
203
+ state.completedAt = ev.at;
204
+ } else if (ev.type === "fail") {
205
+ state.status = "failed";
206
+ state.failReason = ev.reason ?? null;
207
+ state.completedAt = ev.at;
208
+ }
209
+ }
210
+ return state;
211
+ }
212
+
213
+ /** Raw folded state (claimed stays "claimed" even if stale) keyed by id, oldest enqueue first. */
214
+ function deriveRaw(queuePath) {
215
+ const events = readAllEvents(queuePath);
216
+ const byId = new Map();
217
+ for (const ev of events) {
218
+ if (!ev?.id) continue;
219
+ if (!byId.has(ev.id)) byId.set(ev.id, []);
220
+ byId.get(ev.id).push(ev);
221
+ }
222
+ const out = new Map();
223
+ for (const [id, evs] of byId) {
224
+ const s = foldOne(evs);
225
+ if (s) out.set(id, s);
226
+ }
227
+ return out;
228
+ }
229
+
230
+ /** Folded state with the TTL rule applied: a claim older than ttlMs reports back as "pending"
231
+ * (reclaimable) without erasing claimant/claimedAt history — callers can see it was stale. */
232
+ function deriveEffective(queuePath, { now = Date.now(), ttlMs = CLAIM_TTL_MS } = {}) {
233
+ const raw = deriveRaw(queuePath);
234
+ const out = new Map();
235
+ for (const [id, s] of raw) {
236
+ const stale = s.status === "claimed" && now - Date.parse(s.claimedAt) > ttlMs;
237
+ out.set(id, stale ? { ...s, status: "pending", stale: true } : { ...s, stale: false });
238
+ }
239
+ return out;
240
+ }
241
+
242
+ function genId() {
243
+ return `fq_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
244
+ }
245
+
246
+ // ── public API ────────────────────────────────────────────────────────────────────────────────
247
+ export function enqueue({ lane, payload = null, requestedBy = null, queuePath = defaultQueuePath(), now = Date.now() } = {}) {
248
+ if (!lane) throw new Error("fleet-queue.enqueue: lane is required");
249
+ const id = genId();
250
+ const at = new Date(now).toISOString();
251
+ appendEvent(queuePath, { type: "enqueue", id, lane, payload, requestedBy, at });
252
+ const items = [...deriveEffective(queuePath, { now }).values()]
253
+ .filter((s) => s.lane === lane && s.status === "pending")
254
+ .sort((a, b) => a.enqueuedAt.localeCompare(b.enqueuedAt));
255
+ const position = items.findIndex((s) => s.id === id) + 1;
256
+ return { id, position };
257
+ }
258
+
259
+ export function nextPending(lane, { queuePath = defaultQueuePath(), now = Date.now(), ttlMs = CLAIM_TTL_MS } = {}) {
260
+ const items = [...deriveEffective(queuePath, { now, ttlMs }).values()]
261
+ .filter((s) => s.lane === lane && s.status === "pending")
262
+ .sort((a, b) => a.enqueuedAt.localeCompare(b.enqueuedAt));
263
+ return items[0] ?? null;
264
+ }
265
+
266
+ export function claim(id, claimant, { queuePath = defaultQueuePath(), now = Date.now(), ttlMs = CLAIM_TTL_MS } = {}) {
267
+ if (!id) return { ok: false, reason: "fleet-queue.claim: id is required" };
268
+ if (!claimant) return { ok: false, reason: "fleet-queue.claim: claimant is required" };
269
+ return withLock(queuePath, id, () => {
270
+ const state = deriveEffective(queuePath, { now, ttlMs }).get(id);
271
+ if (!state) return { ok: false, reason: `unknown id "${id}"` };
272
+ if (state.status === "completed") return { ok: false, reason: "already completed" };
273
+ if (state.status === "failed") return { ok: false, reason: "already failed" };
274
+ if (state.status === "claimed") return { ok: false, reason: `already claimed by ${state.claimant}` };
275
+ appendEvent(queuePath, { type: "claim", id, claimant, at: new Date(now).toISOString() });
276
+ return { ok: true, id, claimant, reclaimed: Boolean(state.stale) };
277
+ });
278
+ }
279
+
280
+ export function complete(id, result = null, { queuePath = defaultQueuePath(), now = Date.now() } = {}) {
281
+ if (!id) return { ok: false, reason: "fleet-queue.complete: id is required" };
282
+ return withLock(queuePath, id, () => {
283
+ const state = deriveEffective(queuePath, { now }).get(id);
284
+ if (!state) return { ok: false, reason: `unknown id "${id}"` };
285
+ if (state.status !== "claimed") return { ok: false, reason: `cannot complete from status "${state.status}" (must be claimed)` };
286
+ appendEvent(queuePath, { type: "complete", id, result, at: new Date(now).toISOString() });
287
+ return { ok: true, id };
288
+ });
289
+ }
290
+
291
+ export function fail(id, reason = null, { queuePath = defaultQueuePath(), now = Date.now() } = {}) {
292
+ if (!id) return { ok: false, reason: "fleet-queue.fail: id is required" };
293
+ return withLock(queuePath, id, () => {
294
+ const state = deriveEffective(queuePath, { now }).get(id);
295
+ if (!state) return { ok: false, reason: `unknown id "${id}"` };
296
+ if (state.status === "completed" || state.status === "failed") {
297
+ return { ok: false, reason: `cannot fail from terminal status "${state.status}"` };
298
+ }
299
+ appendEvent(queuePath, { type: "fail", id, reason, at: new Date(now).toISOString() });
300
+ return { ok: true, id };
301
+ });
302
+ }
303
+
304
+ export function list(lane = null, status = null, { queuePath = defaultQueuePath(), now = Date.now(), ttlMs = CLAIM_TTL_MS } = {}) {
305
+ const items = [...deriveEffective(queuePath, { now, ttlMs }).values()];
306
+ return items
307
+ .filter((s) => (lane ? s.lane === lane : true))
308
+ .filter((s) => (status ? s.status === status : true))
309
+ .sort((a, b) => a.enqueuedAt.localeCompare(b.enqueuedAt));
310
+ }
311
+
312
+ // ═══ RESOURCE GOVERNOR (HS-MUJ6) — implementation ══════════════════════════════════════════════
313
+ // See the design-note block below for the full rationale, the stated liveness rule, and the
314
+ // configuration contract. Everything in this section is additive; the queue API above is untouched.
315
+
316
+ /** Governor tunables — DEFAULTS only. Never read these directly for behavior; go through
317
+ * resolveGovernorConfig() so explicit overrides and HEYSPEX_GOVERNOR_* env vars apply
318
+ * ([[HS-DEC-dev-factory-rules-are-configuration-not]]: every rule is a setting, never code). */
319
+ export const GOVERNOR_DEFAULTS = Object.freeze({
320
+ /** Liveness rule input: a non-paused holder whose last liveness signal (grant, heartbeat,
321
+ * resume, or idempotent re-request) is older than this is DEAD and reclaimable.
322
+ * Default matches the queue's CLAIM_TTL_MS convention (15 min). */
323
+ leaseTtlMs: 15 * 60 * 1000,
324
+ /** Blocking acquire() poll interval — how quickly a waiter observes its wake-on-release. */
325
+ wakePollMs: 250,
326
+ /** Blocking acquire() gives up (and withdraws from the FIFO line) after this long. */
327
+ waitTimeoutMs: 10 * 60 * 1000,
328
+ });
329
+
330
+ const GOVERNOR_ENV_VARS = Object.freeze({
331
+ leaseTtlMs: "HEYSPEX_GOVERNOR_LEASE_TTL_MS",
332
+ wakePollMs: "HEYSPEX_GOVERNOR_WAKE_POLL_MS",
333
+ waitTimeoutMs: "HEYSPEX_GOVERNOR_WAIT_TIMEOUT_MS",
334
+ });
335
+
336
+ /** Layered config resolution: explicit overrides > HEYSPEX_GOVERNOR_* env > GOVERNOR_DEFAULTS.
337
+ * Env is read at CALL time (not module load) so a process can be re-configured without a
338
+ * restart and tests can exercise the env layer deterministically. */
339
+ export function resolveGovernorConfig(overrides = {}) {
340
+ const out = {};
341
+ for (const key of Object.keys(GOVERNOR_DEFAULTS)) {
342
+ const explicit = overrides?.[key];
343
+ if (explicit !== undefined && explicit !== null && Number.isFinite(Number(explicit))) {
344
+ out[key] = Number(explicit);
345
+ continue;
346
+ }
347
+ const envRaw = process.env[GOVERNOR_ENV_VARS[key]];
348
+ const envNum = envRaw != null && String(envRaw).trim() !== "" ? Number(envRaw) : NaN;
349
+ out[key] = Number.isFinite(envNum) ? envNum : GOVERNOR_DEFAULTS[key];
350
+ }
351
+ return out;
352
+ }
353
+
354
+ /** THE stated liveness rule, in one place — stamped into every gov-reclaimed event so the audit
355
+ * trail records not just THAT a lease was taken back, but under exactly which rule. */
356
+ export function describeLivenessRule({ leaseTtlMs } = resolveGovernorConfig()) {
357
+ return (
358
+ `a holder is DEAD (lease reclaimable) iff it is NOT paused AND now - lastLivenessAt > ` +
359
+ `leaseTtlMs (${leaseTtlMs}ms); a PAUSED holder is never reclaimed by liveness — only ` +
360
+ `resume() or an administrator forceBreak() ends a pause`
361
+ );
362
+ }
363
+
364
+ // ── governor stores (the engine is store-agnostic; these two ship here) ────────────────────────
365
+
366
+ /** Pure in-process store — for tests, and for the console dispatcher to mount server-side. */
367
+ export function createMemoryGovernorStore() {
368
+ const byResource = new Map(); // resource -> event[]
369
+ return {
370
+ read(resource) {
371
+ return [...(byResource.get(resource) ?? [])];
372
+ },
373
+ append(record) {
374
+ if (!byResource.has(record.resource)) byResource.set(record.resource, []);
375
+ byResource.get(record.resource).push(record);
376
+ },
377
+ withLock(_resource, fn) {
378
+ return fn(); // single-process, synchronous — no cross-process exclusion needed
379
+ },
380
+ };
381
+ }
382
+
383
+ function defaultGovernorPath() {
384
+ const dir = join(findRepoRoot(), ".heyspex");
385
+ mkdirSync(dir, { recursive: true });
386
+ return process.env.HEYSPEX_RESOURCE_GOVERNOR_PATH || join(dir, "resource-governor.jsonl");
387
+ }
388
+
389
+ /** Resource keys ("git:repo") appear in lock FILENAMES — ':' is illegal on Windows, so the lock
390
+ * key is sanitized. The event log itself keeps the exact resource string. */
391
+ function sanitizeLockKey(resource) {
392
+ return String(resource).replace(/[^A-Za-z0-9._-]/g, "-");
393
+ }
394
+
395
+ /** Durable local store: append-only JSONL + the same per-key advisory lock the queue's claim()
396
+ * uses — true cross-process mutual exclusion per resource on one filesystem. */
397
+ export function createFileGovernorStore({ governorPath = defaultGovernorPath() } = {}) {
398
+ return {
399
+ read(resource) {
400
+ return readAllEvents(governorPath).filter((ev) => ev?.resource === resource);
401
+ },
402
+ append(record) {
403
+ appendEvent(governorPath, record);
404
+ },
405
+ withLock(resource, fn) {
406
+ const key = `gov.${sanitizeLockKey(resource)}`;
407
+ if (!acquireLock(governorPath, key)) {
408
+ return { ok: false, reason: `resource-governor: could not acquire lock for "${resource}" (held too long)` };
409
+ }
410
+ try {
411
+ return fn();
412
+ } finally {
413
+ releaseLock(governorPath, key);
414
+ }
415
+ },
416
+ };
417
+ }
418
+
419
+ // ── governor state derivation (event fold — same event-sourcing shape as the queue) ────────────
420
+
421
+ /** Fold one resource's governor events (in log order) into { resource, holder, waiters }.
422
+ * holder: { holderId, leaseId, grantedAt, livenessAt, paused } | null.
423
+ * waiters: FIFO-ordered [{ holderId, requestId, enqueuedAt }]. Exported for the console
424
+ * dispatcher's server-side wiring (later task) to reuse verbatim. */
425
+ export function foldGovernorResource(events, resource) {
426
+ const st = { resource, holder: null, waiters: [] };
427
+ for (const ev of events) {
428
+ if (!ev || ev.resource !== resource) continue;
429
+ switch (ev.type) {
430
+ case "gov-granted":
431
+ st.holder = { holderId: ev.holderId, leaseId: ev.leaseId ?? null, grantedAt: ev.at, livenessAt: ev.at, paused: false };
432
+ st.waiters = st.waiters.filter((w) => w.holderId !== ev.holderId);
433
+ break;
434
+ case "gov-waiting":
435
+ if (st.holder?.holderId !== ev.holderId && !st.waiters.some((w) => w.holderId === ev.holderId)) {
436
+ st.waiters.push({ holderId: ev.holderId, requestId: ev.requestId ?? null, enqueuedAt: ev.at });
437
+ }
438
+ break;
439
+ case "gov-heartbeat":
440
+ if (st.holder?.holderId === ev.holderId) st.holder.livenessAt = ev.at;
441
+ break;
442
+ case "gov-paused":
443
+ if (st.holder?.holderId === ev.holderId) st.holder.paused = true;
444
+ break;
445
+ case "gov-resumed":
446
+ if (st.holder?.holderId === ev.holderId) {
447
+ st.holder.paused = false;
448
+ st.holder.livenessAt = ev.at; // resuming is itself a liveness signal
449
+ }
450
+ break;
451
+ case "gov-released":
452
+ case "gov-reclaimed":
453
+ case "gov-broken":
454
+ if (st.holder?.holderId === ev.holderId) st.holder = null;
455
+ break;
456
+ case "gov-abandoned":
457
+ st.waiters = st.waiters.filter((w) => w.requestId !== ev.requestId);
458
+ break;
459
+ default:
460
+ break; // unknown event kinds are ignored, never fatal (forward compatibility)
461
+ }
462
+ }
463
+ return st;
464
+ }
465
+
466
+ function governorGenId() {
467
+ return `lease_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
468
+ }
469
+
470
+ const defaultGovernorSleep = (ms) => new Promise((res) => setTimeout(res, ms));
471
+
472
+ // ── the governor engine ────────────────────────────────────────────────────────────────────────
473
+
474
+ /**
475
+ * createResourceGovernor({ store, now, genId, config }) -> governor
476
+ *
477
+ * Pure-logic engine over an injected event store. All mutations are read-decide-append critical
478
+ * sections under store.withLock(resource) — the same atomic-claim discipline as claim() above.
479
+ *
480
+ * request(resource, holderId) -> { ok, granted:true, leaseId, alreadyHeld }
481
+ * | { ok, granted:false, position, holder }
482
+ * Non-blocking. Grants when free (or when this holder is the FIFO head after a release/
483
+ * reclaim); otherwise JOINS the FIFO line — never a refusal. Idempotent for the current
484
+ * holder (re-request refreshes liveness: "held across multiple operations").
485
+ * Runs dead-holder reclaim first, so any poller drives progress.
486
+ * acquire(resource, holderId, { timeoutMs, pollMs, sleep }) -> Promise<same shapes>
487
+ * Blocking convenience: request() then poll until granted or timeout (then withdraws
488
+ * from the line and returns { ok:false, granted:false, reason }).
489
+ * heartbeat(resource, holderId) -> { ok } — refresh liveness while holding.
490
+ * pause(resource, holderId) / resume(...) -> { ok } — "alive but going quiet"; a paused
491
+ * holder is exempt from liveness reclaim until resume()/forceBreak().
492
+ * release(resource, holderId) -> { ok, grantedTo } — explicit hand-back; the
493
+ * FIFO head is granted in the same critical section (wake-on-release).
494
+ * forceBreak(resource, { by, reason }) -> { ok, broken, grantedTo } — administrator's
495
+ * escape hatch; `by` is REQUIRED so every break is attributably recorded.
496
+ * withdraw(resource, holderId) -> { ok } — leave the FIFO line voluntarily.
497
+ * status(resource) -> read-only { resource, holder(+stale), waiters }.
498
+ */
499
+ export function createResourceGovernor({
500
+ store = createMemoryGovernorStore(),
501
+ now = Date.now,
502
+ genId = governorGenId,
503
+ config = {},
504
+ } = {}) {
505
+ const cfg = resolveGovernorConfig(config);
506
+ const iso = (ms) => new Date(ms).toISOString();
507
+ const fold = (resource) => foldGovernorResource(store.read(resource), resource);
508
+
509
+ /** The liveness rule, applied. Kept next to its documentation on purpose. */
510
+ function isDead(holder, nowMs) {
511
+ if (!holder) return false;
512
+ if (holder.paused) return false; // a live-but-paused holder is NEVER reclaimed
513
+ return nowMs - Date.parse(holder.livenessAt) > cfg.leaseTtlMs;
514
+ }
515
+
516
+ function grant(st, holderId, nowMs) {
517
+ const leaseId = genId();
518
+ store.append({ type: "gov-granted", resource: st.resource, holderId, leaseId, at: iso(nowMs) });
519
+ st.holder = { holderId, leaseId, grantedAt: iso(nowMs), livenessAt: iso(nowMs), paused: false };
520
+ st.waiters = st.waiters.filter((w) => w.holderId !== holderId);
521
+ return leaseId;
522
+ }
523
+
524
+ /** Reclaim a dead holder (recording the rule it died under), then hand a free resource to the
525
+ * FIFO head. Called inside every mutating critical section. */
526
+ function reclaimAndPromote(st, nowMs) {
527
+ if (st.holder && isDead(st.holder, nowMs)) {
528
+ store.append({
529
+ type: "gov-reclaimed",
530
+ resource: st.resource,
531
+ holderId: st.holder.holderId,
532
+ leaseId: st.holder.leaseId,
533
+ rule: describeLivenessRule(cfg),
534
+ at: iso(nowMs),
535
+ });
536
+ st.holder = null;
537
+ }
538
+ if (!st.holder && st.waiters.length > 0) {
539
+ const head = st.waiters.shift();
540
+ grant(st, head.holderId, nowMs);
541
+ }
542
+ }
543
+
544
+ function request(resource, holderId) {
545
+ if (!resource) return { ok: false, reason: "resource-governor.request: resource is required" };
546
+ if (!holderId) return { ok: false, reason: "resource-governor.request: holderId is required" };
547
+ return store.withLock(resource, () => {
548
+ const nowMs = now();
549
+ const st = fold(resource);
550
+ const wasHolder = st.holder?.holderId === holderId;
551
+ reclaimAndPromote(st, nowMs);
552
+ if (st.holder?.holderId === holderId) {
553
+ // already held, or just promoted from the FIFO head — either way, refresh liveness
554
+ store.append({ type: "gov-heartbeat", resource, holderId, at: iso(nowMs) });
555
+ return { ok: true, granted: true, resource, holderId, leaseId: st.holder.leaseId, alreadyHeld: wasHolder };
556
+ }
557
+ if (!st.holder) {
558
+ // free with nobody ahead (promotion above would have taken the head) — grant directly
559
+ const leaseId = grant(st, holderId, nowMs);
560
+ return { ok: true, granted: true, resource, holderId, leaseId, alreadyHeld: false };
561
+ }
562
+ // held by someone else (live or paused) — wait in the FIFO line, never refuse
563
+ let pos = st.waiters.findIndex((w) => w.holderId === holderId);
564
+ if (pos === -1) {
565
+ store.append({ type: "gov-waiting", resource, holderId, requestId: genId(), at: iso(nowMs) });
566
+ pos = st.waiters.length; // 0-indexed slot of the new tail
567
+ }
568
+ return { ok: true, granted: false, resource, holderId, position: pos + 1, holder: st.holder.holderId };
569
+ });
570
+ }
571
+
572
+ async function acquire(resource, holderId, { timeoutMs = cfg.waitTimeoutMs, pollMs = cfg.wakePollMs, sleep = defaultGovernorSleep } = {}) {
573
+ const deadline = now() + timeoutMs;
574
+ for (;;) {
575
+ const r = request(resource, holderId);
576
+ if (!r.ok || r.granted) return r;
577
+ if (now() >= deadline) {
578
+ withdraw(resource, holderId); // leave the line — never linger as a ghost waiter
579
+ return {
580
+ ok: false,
581
+ granted: false,
582
+ resource,
583
+ holderId,
584
+ position: r.position,
585
+ reason: `resource-governor.acquire: timed out after ${timeoutMs}ms waiting for "${resource}" (held by ${r.holder}, position ${r.position})`,
586
+ };
587
+ }
588
+ await sleep(pollMs);
589
+ }
590
+ }
591
+
592
+ function heartbeat(resource, holderId) {
593
+ return store.withLock(resource, () => {
594
+ const nowMs = now();
595
+ const st = fold(resource);
596
+ if (st.holder?.holderId !== holderId) {
597
+ return { ok: false, reason: `resource-governor.heartbeat: "${holderId}" does not hold "${resource}" (holder: ${st.holder?.holderId ?? "none"})` };
598
+ }
599
+ store.append({ type: "gov-heartbeat", resource, holderId, at: iso(nowMs) });
600
+ return { ok: true, resource, holderId, livenessAt: iso(nowMs) };
601
+ });
602
+ }
603
+
604
+ function pause(resource, holderId) {
605
+ return store.withLock(resource, () => {
606
+ const nowMs = now();
607
+ const st = fold(resource);
608
+ if (st.holder?.holderId !== holderId) {
609
+ return { ok: false, reason: `resource-governor.pause: "${holderId}" does not hold "${resource}" (holder: ${st.holder?.holderId ?? "none"})` };
610
+ }
611
+ store.append({ type: "gov-paused", resource, holderId, at: iso(nowMs) });
612
+ return { ok: true, resource, holderId, paused: true };
613
+ });
614
+ }
615
+
616
+ function resume(resource, holderId) {
617
+ return store.withLock(resource, () => {
618
+ const nowMs = now();
619
+ const st = fold(resource);
620
+ if (st.holder?.holderId !== holderId) {
621
+ return { ok: false, reason: `resource-governor.resume: "${holderId}" does not hold "${resource}" (holder: ${st.holder?.holderId ?? "none"})` };
622
+ }
623
+ store.append({ type: "gov-resumed", resource, holderId, at: iso(nowMs) });
624
+ return { ok: true, resource, holderId, paused: false };
625
+ });
626
+ }
627
+
628
+ function release(resource, holderId) {
629
+ return store.withLock(resource, () => {
630
+ const nowMs = now();
631
+ const st = fold(resource);
632
+ if (st.holder?.holderId !== holderId) {
633
+ return { ok: false, reason: `resource-governor.release: "${holderId}" does not hold "${resource}" (holder: ${st.holder?.holderId ?? "none"})` };
634
+ }
635
+ store.append({ type: "gov-released", resource, holderId, at: iso(nowMs) });
636
+ st.holder = null;
637
+ reclaimAndPromote(st, nowMs); // wake-on-release: FIFO head granted in this same critical section
638
+ return { ok: true, resource, released: holderId, grantedTo: st.holder?.holderId ?? null };
639
+ });
640
+ }
641
+
642
+ function forceBreak(resource, { by, reason = null } = {}) {
643
+ if (!by) {
644
+ return { ok: false, reason: "resource-governor.forceBreak: `by` (the administrator identity) is required — every break is recorded" };
645
+ }
646
+ return store.withLock(resource, () => {
647
+ const nowMs = now();
648
+ const st = fold(resource);
649
+ const broken = st.holder?.holderId ?? null;
650
+ if (st.holder) {
651
+ store.append({ type: "gov-broken", resource, holderId: st.holder.holderId, leaseId: st.holder.leaseId, by, reason, at: iso(nowMs) });
652
+ st.holder = null;
653
+ }
654
+ reclaimAndPromote(st, nowMs);
655
+ return { ok: true, resource, broken, by, grantedTo: st.holder?.holderId ?? null };
656
+ });
657
+ }
658
+
659
+ function withdraw(resource, holderId) {
660
+ return store.withLock(resource, () => {
661
+ const st = fold(resource);
662
+ const w = st.waiters.find((x) => x.holderId === holderId);
663
+ if (!w) return { ok: false, reason: `resource-governor.withdraw: "${holderId}" is not waiting for "${resource}"` };
664
+ store.append({ type: "gov-abandoned", resource, holderId, requestId: w.requestId, at: iso(now()) });
665
+ return { ok: true, resource, holderId };
666
+ });
667
+ }
668
+
669
+ /** Read-only snapshot — never mutates (a dead holder is REPORTED stale here; reclaim happens
670
+ * on the next mutating call, mirroring deriveEffective()'s non-destructive stale reporting). */
671
+ function status(resource) {
672
+ const nowMs = now();
673
+ const st = fold(resource);
674
+ return {
675
+ resource,
676
+ holder: st.holder ? { ...st.holder, stale: isDead(st.holder, nowMs) } : null,
677
+ waiters: st.waiters.map((w, i) => ({ ...w, position: i + 1 })),
678
+ };
679
+ }
680
+
681
+ return { config: cfg, request, acquire, heartbeat, pause, resume, release, forceBreak, withdraw, status };
682
+ }
683
+
684
+ /*
685
+ * ── RESOURCE GOVERNOR (HS-MUJ6, [[HS-AU7I-2U3R-EPIC-resource-governor-leases-and-wait]]) ───────
686
+ *
687
+ * The queue above is a WORK queue: a claim is per-task and terminal (complete/fail are the only
688
+ * exits). The governor below adds the missing WAIT-queue semantics for named, non-forkable
689
+ * resources ("git:repo", "service:spex-console", "machine:specops-gpu0", "mode:night-action", …):
690
+ *
691
+ * one holder · a lease with liveness · a FIFO waiting line · an explicit release ·
692
+ * an administrator's forced-break.
693
+ *
694
+ * It REUSES the atomic-claim discipline already proven above rather than rewriting it: every
695
+ * mutation is a read-decide-append critical section under the same per-key advisory lock
696
+ * (acquireLock/releaseLock), persisted to the same append-only, corrupt-tail-tolerant JSONL shape
697
+ * (appendEvent/readAllEvents) in its own ledger, `.heyspex/resource-governor.jsonl`
698
+ * (override: HEYSPEX_RESOURCE_GOVERNOR_PATH). The ledger doubles as the audit trail — a
699
+ * forced-break or a reclaim is an event line naming who/why/when, never an erased row.
700
+ *
701
+ * THE LIVENESS RULE (explicit, and stamped into every gov-reclaimed event):
702
+ * a holder is DEAD (its lease reclaimable) iff it is NOT paused AND
703
+ * now − lastLivenessAt > leaseTtlMs.
704
+ * A PAUSED holder is NEVER reclaimed by liveness, no matter how old its last heartbeat —
705
+ * pause() is the holder saying "alive, but about to go quiet" (a long tool call, lunch).
706
+ * Only resume() or an administrator's forceBreak() ends a pause. forceBreak is the
707
+ * deliberately-shipped-on-day-one escape hatch for the holder who paused and went home.
708
+ *
709
+ * CONFIGURATION, NOT CONSTANTS ([[HS-DEC-dev-factory-rules-are-configuration-not]]): every
710
+ * tunable is a config parameter with a default — resolveGovernorConfig() layers
711
+ * explicit overrides > HEYSPEX_GOVERNOR_* env vars > GOVERNOR_DEFAULTS. Nothing here is a bare
712
+ * constant an owner cannot change without editing code.
713
+ *
714
+ * PURITY / TESTABILITY: createResourceGovernor() takes an injectable store, clock (`now`),
715
+ * id generator (`genId`), and — for the blocking acquire() — an injectable `sleep`, so every
716
+ * semantic (FIFO order, reclaim-vs-paused, wake-on-release) is testable with a fake clock and
717
+ * no real waiting. Two stores ship here: createMemoryGovernorStore() (pure, in-process — also
718
+ * what the console dispatcher will mount server-side) and createFileGovernorStore() (this
719
+ * machine's durable JSONL mirror; cross-process on one filesystem via the advisory lock).
720
+ *
721
+ * WAKE-ON-RELEASE: release()/forceBreak() promote the FIFO head to holder IN THE SAME critical
722
+ * section — the next waiter is never left un-woken for a drain nobody triggers. In-process
723
+ * blocking waiters (acquire()) observe the grant on their next poll tick (wakePollMs); a waiter
724
+ * that crashed while holding its granted lease is itself reclaimed by the liveness rule, so a
725
+ * dead waiter can never wedge the line. Dead-holder reclaim also runs at the top of every
726
+ * request(), so any poller drives progress even when the holder died silently.
727
+ *
728
+ * ── DESIGN NOTE: how this generalizes fleet-wide, and the multi-machine port ───────────────────
729
+ *
730
+ * This module is deliberately domain-blind: `lane` is a free-form string, `payload` is opaque
731
+ * JSON, and nothing in enqueue/claim/complete/fail/list knows about research, Firecrawl, or
732
+ * Night Action. The first lane ("research-firecrawl") is a CONSUMER of this queue, not part of
733
+ * its design. Any other machine-gated resource in the fleet can stand up its own lane the same
734
+ * way, with zero changes to this file:
735
+ * - build jobs waiting on a busy GPU box -> lane "build-<machine>"
736
+ * - breakdown/critic jobs waiting on the DeepSeek seat -> lane "breakdown-deepseek"
737
+ * - any single-tenant resource with a "one workload at a time" mode lease, mirroring
738
+ * night-action-mode.mjs's Ollama-XOR-Firecrawl shape (mode lease refuses -> caller enqueues
739
+ * instead of failing -> a drain step pops the lane once the resource frees up)
740
+ * The pattern each consumer follows is the same three-step shape night-action-mode.mjs
741
+ * demonstrates: (1) try the gated resource directly, (2) on refusal, enqueue() instead of
742
+ * returning a hard failure, (3) a --drain (or equivalent poll) on the resource-owning side pops
743
+ * nextPending()/list() once the resource is free and dispatches the work.
744
+ *
745
+ * MULTI-MACHINE PORT (done, HS-XOCK phase 4): this file-backed JSONL queue is correct for a
746
+ * SINGLE shared filesystem (one repo checkout, or machines that all see the same network share)
747
+ * — the per-id file lock only provides mutual exclusion within one filesystem. Machines with no
748
+ * shared disk now go through fleet-queue-neon.mjs instead — a Neon control-plane HTTP client
749
+ * against the console's bearer-authed `/api/fleet/queue*` routes (`fleet_tasks` table), with
750
+ * claim mutual-exclusion enforced by the dispatcher (`claimTaskById`) instead of a file lock.
751
+ * fleet-queue.mjs (the façade one level up) selects between this module and
752
+ * fleet-queue-neon.mjs at runtime — callers of the façade do not change; this file stays as the
753
+ * offline / no-dispatcher fallback and is never selected while an explicit `queuePath` is passed
754
+ * (tests keep pointing at a temp file unchanged).
755
+ */
756
+
757
+ // ── self-test (local temp queue file — never touches the real .heyspex/fleet-queue.jsonl) ──────
758
+ if (import.meta.url.endsWith("/" + (process.argv[1]?.split(/[\\/]/).pop() ?? "")) && process.argv.includes("--self-test")) {
759
+ (async () => {
760
+ const { tmpdir } = await import("node:os");
761
+ const { mkdtempSync, rmSync: rm } = await import("node:fs");
762
+ let ok = true;
763
+ const check = (n, c) => {
764
+ console.log(` ${c ? "✓" : "✗"} ${n}`);
765
+ if (!c) ok = false;
766
+ };
767
+ console.log("== fleet-queue-file.mjs self-test ==\n");
768
+
769
+ const dir = mkdtempSync(join(tmpdir(), "hs-fleet-queue-selftest-"));
770
+ const qp = join(dir, "fleet-queue.jsonl");
771
+
772
+ // 1. enqueue: id + 1-indexed position, oldest-first within a lane
773
+ const a = enqueue({ lane: "research-firecrawl", payload: { q: "a" }, requestedBy: "agent-1", queuePath: qp, now: 1000 });
774
+ check("1 first enqueue gets position 1", a.position === 1 && typeof a.id === "string");
775
+ const b = enqueue({ lane: "research-firecrawl", payload: { q: "b" }, requestedBy: "agent-2", queuePath: qp, now: 2000 });
776
+ check("2 second enqueue in same lane gets position 2", b.position === 2);
777
+ const otherLane = enqueue({ lane: "build-gpu0", payload: { job: "x" }, queuePath: qp, now: 2500 });
778
+ check("3 a different lane starts its own position count", otherLane.position === 1);
779
+
780
+ // 2. nextPending returns oldest first, scoped to the lane
781
+ check("4 nextPending returns the oldest pending item in the lane", nextPending("research-firecrawl", { queuePath: qp, now: 3000 })?.id === a.id);
782
+ check("5 nextPending is lane-scoped", nextPending("build-gpu0", { queuePath: qp, now: 3000 })?.id === otherLane.id);
783
+
784
+ // 3. claim -> complete happy path
785
+ const claimA = claim(a.id, "claimant-1", { queuePath: qp, now: 4000 });
786
+ check("6 claim succeeds on a pending item", claimA.ok === true && claimA.reclaimed === false);
787
+ check("7 nextPending skips the now-claimed item", nextPending("research-firecrawl", { queuePath: qp, now: 4500 })?.id === b.id);
788
+ const doneA = complete(a.id, { savedTo: "specs/research/x.md" }, { queuePath: qp, now: 5000 });
789
+ check("8 complete succeeds on a claimed item", doneA.ok === true);
790
+ check("9 list reflects completed status + result", list("research-firecrawl", null, { queuePath: qp, now: 5500 }).find((i) => i.id === a.id)?.status === "completed");
791
+
792
+ // 4. double-claim rejection
793
+ const claimB1 = claim(b.id, "claimant-1", { queuePath: qp, now: 6000 });
794
+ check("10 first claim on b succeeds", claimB1.ok === true);
795
+ const claimB2 = claim(b.id, "claimant-2", { queuePath: qp, now: 6100 });
796
+ check("11 a second claim on the SAME still-live item is rejected", claimB2.ok === false && /already claimed/.test(claimB2.reason));
797
+
798
+ // 5. fail path
799
+ const failB = fail(b.id, "endpoint unreachable", { queuePath: qp, now: 7000 });
800
+ check("12 fail succeeds on a claimed item", failB.ok === true);
801
+ check("13 list reflects failed status + reason", list(null, "failed", { queuePath: qp, now: 7500 }).find((i) => i.id === b.id)?.failReason === "endpoint unreachable");
802
+ check("14 cannot claim a failed (terminal) item", claim(b.id, "claimant-3", { queuePath: qp, now: 7600 }).ok === false);
803
+ check("15 cannot complete an already-completed item", complete(a.id, {}, { queuePath: qp, now: 7700 }).ok === false);
804
+
805
+ // 6. crash-recovery semantics: a claimed-but-stale entry is reclaimable after the TTL
806
+ const c = enqueue({ lane: "research-firecrawl", payload: { q: "c" }, queuePath: qp, now: 10_000 });
807
+ const claimC = claim(c.id, "claimant-crashed", { queuePath: qp, now: 10_100, ttlMs: 1000 });
808
+ check("16 c gets claimed", claimC.ok === true);
809
+ const tooSoon = claim(c.id, "claimant-4", { queuePath: qp, now: 10_500, ttlMs: 1000 }); // 400ms later, still under 1000ms TTL
810
+ check("17 reclaim attempt before TTL expiry is rejected", tooSoon.ok === false);
811
+ const afterTtl = claim(c.id, "claimant-4", { queuePath: qp, now: 11_200, ttlMs: 1000 }); // 1100ms later, TTL exceeded
812
+ check("18 reclaim attempt after TTL expiry succeeds (stale claim reclaimed)", afterTtl.ok === true && afterTtl.reclaimed === true);
813
+ 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");
814
+
815
+ // 7. unknown id handling
816
+ check("20 claim on an unknown id fails cleanly", claim("fq_does_not_exist", "x", { queuePath: qp }).ok === false);
817
+ check("21 complete on an unknown id fails cleanly", complete("fq_does_not_exist", {}, { queuePath: qp }).ok === false);
818
+
819
+ // 8. concurrent double-claim: two "processes" race claim() on the same id — only one wins
820
+ const d = enqueue({ lane: "research-firecrawl", payload: { q: "d" }, queuePath: qp, now: 12_000 });
821
+ const [raceA, raceB] = await Promise.all([
822
+ Promise.resolve().then(() => claim(d.id, "racer-A", { queuePath: qp, now: 12_100 })),
823
+ Promise.resolve().then(() => claim(d.id, "racer-B", { queuePath: qp, now: 12_100 })),
824
+ ]);
825
+ const winners = [raceA, raceB].filter((r) => r.ok);
826
+ check("22 concurrent claim() calls on the same id: exactly one wins", winners.length === 1);
827
+
828
+ // 9. append-only: the file only grows, corrupt trailing line tolerated
829
+ const beforeLines = readFileSync(qp, "utf8").split("\n").filter(Boolean).length;
830
+ const { appendFileSync } = await import("node:fs");
831
+ appendFileSync(qp, '{"type":"claim","id":"broken", partial-json');
832
+ check("23 a corrupt trailing line does not throw when reading state", (() => {
833
+ try {
834
+ list(null, null, { queuePath: qp });
835
+ return true;
836
+ } catch {
837
+ return false;
838
+ }
839
+ })());
840
+ check("24 file only grew (never rewritten) across the whole run", readFileSync(qp, "utf8").split("\n").filter(Boolean).length >= beforeLines);
841
+
842
+ rm(dir, { recursive: true, force: true });
843
+ console.log(`\n${ok ? "✓ self-test PASS" : "✗ self-test FAIL"}`);
844
+ process.exit(ok ? 0 : 1);
845
+ })();
846
+ }