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,1124 @@
1
+ // Claim lifecycle tools: heyspex_claim_spec, report_progress, submit_for_review,
2
+ // release_spec, record_wip, defer_spec, record_alignment, claim_acquire/release/list,
3
+ // claim_file, release_file_claim. Extracted from cli.mjs under
4
+ // [[HS-BLNF-6W61-DIAG-cli-mjs-has-grown-into-a-13-665-line]] (no behavior change).
5
+
6
+ import { findSpecFile, parseFrontmatter, resolveSpecIdViaTier } from './vault-io.mjs';
7
+ import { join } from 'node:path';
8
+ import { readFileSync, writeFileSync } from 'node:fs';
9
+ import { childKindFor, getChildRefs, upsertFrontmatterKeys } from './alignment-gate.mjs';
10
+ import { measureDiskLanding, resolveSpecIdForMutation } from './tool-helpers.mjs';
11
+ import { stampSkippedDespiteFindingsOnBreakdownMarker } from './plan-review.mjs';
12
+ import { resolveNextOnDeckPayload } from './on-deck-brief.mjs';
13
+ import { ConsoleUnreachableCloudModeError, readViaSelector, resolveReadTier } from './read-path.mjs';
14
+ import { detectAgentIdentity, effectiveConsoleUrl } from './mcp-config.mjs';
15
+ import { consoleFetch, controlError, controlOk, enrichConsoleFailurePayloadAsync, postClaimEndpoint } from './console-transport.mjs';
16
+ import { consoleErrorCode, consoleErrorMessage } from './console-fetch.mjs';
17
+ import { executeSubmitForReview } from './submit-for-review.mjs';
18
+
19
+ /**
20
+ * Minimum length for an alignment override reason. Matches the bar the milestone
21
+ * attach override already uses (promote-research.mjs) so the two overrides in
22
+ * this system hold to one standard.
23
+ */
24
+ export const ALIGNMENT_OVERRIDE_REASON_MIN_LENGTH = 40;
25
+
26
+ /**
27
+ * Record that ONE part of a `heyspex_record_alignment` call did not happen, on a
28
+ * call whose main write did ([[HS-IOJW-5YDH-DIAG]] sweep).
29
+ *
30
+ * The old shape put the failure in `payload.skipped_despite_findings_error` and
31
+ * still returned `ok: true` with no `isError`, so a half-happened write read
32
+ * exactly like a write that happened. This marks the payload so
33
+ * `alignmentResult` can flip it to an explicit partial — while keeping every
34
+ * fact that DID land (the read-back alignment stamp, the chained on-deck brief),
35
+ * because the checkpoint is real and must not be re-done.
36
+ *
37
+ * @param {Record<string, unknown>} payload
38
+ * @param {string} part — the sub-write that failed, e.g. 'skipped_despite_findings'
39
+ * @param {unknown} detail — the failure as reported by the writer
40
+ */
41
+ export function markAlignmentPartial(payload, part, detail) {
42
+ payload.ok = false;
43
+ payload.partial = true;
44
+ payload.alignment_recorded = true;
45
+ payload[`${part}_recorded`] = false;
46
+ payload[`${part}_error`] = detail;
47
+ const failed = Array.isArray(payload.failed_parts) ? payload.failed_parts : [];
48
+ failed.push(part);
49
+ payload.failed_parts = failed;
50
+ payload.partial_message =
51
+ `PARTIAL: the alignment checkpoint for ${payload.spec_id ?? 'this spec'} WAS recorded and read back, ` +
52
+ `but ${failed.join(', ')} was NOT. Do not re-run record_alignment to fix this — the checkpoint is already ` +
53
+ 'stamped; act on the per-part error below.';
54
+ return payload;
55
+ }
56
+
57
+ /** Wrap a record_alignment payload, surfacing a partial as an error, not a success. */
58
+ export function alignmentResult(payload) {
59
+ const text = JSON.stringify(payload, null, 2);
60
+ return payload.partial === true
61
+ ? { isError: true, content: [{ type: 'text', text }] }
62
+ : { content: [{ type: 'text', text }] };
63
+ }
64
+
65
+ /**
66
+ * Stamp the alignment checkpoint onto an epic/milestone's frontmatter so the
67
+ * pre-execution gate clears. Writes only `alignment_reviewed_at` (+ optional
68
+ * summary/override); AGV2/DSIG dropped `alignment_signature` because it was
69
+ * non-deterministic across reads. The next /hs-next sees the fresh timestamp
70
+ * and stays quiet until the fuse expires (or corpus drift surfaces).
71
+ *
72
+ * AGV2/286X: after the write, chain into `resolveNextOnDeckPayload` and embed
73
+ * the result as `next_on_deck` so the agent can execute the next brief without
74
+ * a manual `/hs-next` re-invoke. The chain is best-effort — if it errors, we
75
+ * still return `ok: true` so the recorded checkpoint is not lost.
76
+ * [[pre-execution-alignment-gate]] [[HS-TASK-286X]]
77
+ */
78
+ export function handleRecordAlignment(vaultRoot, config, args) {
79
+ const specId = args.spec_id;
80
+ if (!specId) {
81
+ return {
82
+ isError: true,
83
+ content: [{ type: 'text', text: JSON.stringify({ error: 'MISSING_SPEC_ID' }) }],
84
+ };
85
+ }
86
+ const specPath = findSpecFile(vaultRoot, specId);
87
+ if (!specPath) {
88
+ return {
89
+ isError: true,
90
+ content: [{ type: 'text', text: JSON.stringify({ error: 'SPEC_NOT_FOUND', message: `Spec ${specId} not found` }) }],
91
+ };
92
+ }
93
+ const fullPath = join(vaultRoot, specPath);
94
+ const raw = readFileSync(fullPath, 'utf-8');
95
+ const { frontmatter } = parseFrontmatter(raw);
96
+ const grain =
97
+ frontmatter.type === 'milestone' ? 'milestone' : 'epic';
98
+ const childIds = getChildRefs(frontmatter, grain);
99
+ const reviewedAt = new Date().toISOString();
100
+
101
+ const kv = {
102
+ alignment_reviewed_at: reviewedAt,
103
+ };
104
+ if (args.summary) {
105
+ kv.alignment_summary = JSON.stringify(String(args.summary));
106
+ }
107
+ // An override clears a real drift finding, so it has to say something. Any
108
+ // non-empty string used to be enough — "x" cleared the gate — which made the
109
+ // override a formality rather than a decision. Same ≥40-char bar the milestone
110
+ // attach override already carries in promote-research.mjs, and the same reason
111
+ // the plan gate refuses a summary under 80: a question only works when a thin
112
+ // answer is rejected.
113
+ //
114
+ // The `reason.length > 0` guard used to sit on the refusal, which meant a
115
+ // whitespace-only reason trimmed to EMPTY, skipped the refusal entirely, stored
116
+ // nothing, and still came back `overridden: true` — forty spaces cleared a real
117
+ // drift finding and reported that a reason had been recorded
118
+ // ([[HS-N71O-P3XQ-DIAG-six-new-qc-gates-fail-cold-verification]] item 3c).
119
+ // Whitespace-only is a thin answer, not an absent one: pass no
120
+ // `override_reason` at all if the review was clean.
121
+ let storedOverride = false;
122
+ if (args.override_reason !== undefined && args.override_reason !== null) {
123
+ const reason = String(args.override_reason).trim();
124
+ if (reason.length < ALIGNMENT_OVERRIDE_REASON_MIN_LENGTH) {
125
+ return {
126
+ isError: true,
127
+ content: [{
128
+ type: 'text',
129
+ text: JSON.stringify({
130
+ error: 'ALIGNMENT_OVERRIDE_REASON_TOO_THIN',
131
+ message:
132
+ `override_reason is ${reason.length} characters after trimming${reason.length === 0 ? ' (it was empty or whitespace only)' : ''} — it must be at least ${ALIGNMENT_OVERRIDE_REASON_MIN_LENGTH} and say WHY the drift finding is a false positive or an intentional supersession. It is stored on the spec as the audit record of that call, so ${reason.length === 0 ? 'blank space' : `"${reason}"`} tells the next reader nothing. Omit override_reason entirely if the review was clean.`,
133
+ override_reason_length: reason.length,
134
+ minimum: ALIGNMENT_OVERRIDE_REASON_MIN_LENGTH,
135
+ }),
136
+ }],
137
+ };
138
+ }
139
+ kv.alignment_override = JSON.stringify(reason);
140
+ storedOverride = true;
141
+ }
142
+ const updated = upsertFrontmatterKeys(raw, kv);
143
+ try {
144
+ writeFileSync(fullPath, updated, 'utf-8');
145
+ } catch (e) {
146
+ return {
147
+ isError: true,
148
+ content: [{ type: 'text', text: JSON.stringify({ error: 'WRITE_FAILED', message: e.message }) }],
149
+ };
150
+ }
151
+
152
+ // Report on the RESULT, not the request ([[HS-IOJW-5YDH-DIAG]] sweep). This is
153
+ // a frontmatter rewrite, so "the file exists" proves nothing — read it back
154
+ // and confirm the stamp is actually in it. The diagnosis' second-order
155
+ // failure was exactly a status flip that answered success over unchanged
156
+ // frontmatter.
157
+ const alignmentLanding = measureDiskLanding(vaultRoot, fullPath);
158
+ try {
159
+ alignmentLanding.stamp_readback = readFileSync(fullPath, 'utf-8').includes(
160
+ `alignment_reviewed_at: ${reviewedAt}`,
161
+ );
162
+ } catch {
163
+ alignmentLanding.stamp_readback = false;
164
+ }
165
+ if (!alignmentLanding.on_disk || alignmentLanding.stamp_readback !== true) {
166
+ return {
167
+ isError: true,
168
+ content: [{
169
+ type: 'text',
170
+ text: JSON.stringify({
171
+ error: 'WRITE_NOT_ON_DISK',
172
+ message:
173
+ `The write reported success but ${specPath} does not carry alignment_reviewed_at: ${reviewedAt} on read-back. ` +
174
+ 'Treat the checkpoint as NOT recorded.',
175
+ spec_path: specPath,
176
+ landed: alignmentLanding,
177
+ }, null, 2),
178
+ }],
179
+ };
180
+ }
181
+
182
+ const resolvedSpecId = frontmatter.id || specId;
183
+ const payload = {
184
+ ok: true,
185
+ spec_id: resolvedSpecId,
186
+ spec_path: specPath,
187
+ landed: alignmentLanding,
188
+ alignment_reviewed_at: reviewedAt,
189
+ child_count: childIds.length,
190
+ // What was actually WRITTEN to the spec, not what was passed in. The old
191
+ // `Boolean(args.override_reason)` reported `true` for a reason that stored
192
+ // nothing ([[HS-N71O-P3XQ-DIAG-six-new-qc-gates-fail-cold-verification]]).
193
+ overridden: storedOverride,
194
+ message: `Alignment checkpoint recorded for ${resolvedSpecId} (${childIds.length} ${childKindFor(grain)}${childIds.length === 1 ? '' : 's'}). The response includes the next on-deck brief inline.`,
195
+ };
196
+
197
+ if (typeof args.skipped_despite_findings === 'boolean') {
198
+ const stamp = stampSkippedDespiteFindingsOnBreakdownMarker({
199
+ vaultRoot,
200
+ epicId: resolvedSpecId,
201
+ skippedDespiteFindings: args.skipped_despite_findings,
202
+ });
203
+ if (stamp.error) {
204
+ // A FAILURE MUST NOT RIDE ALONG INSIDE A SUCCESS ([[HS-IOJW-5YDH-DIAG]]
205
+ // sweep). This used to set `skipped_despite_findings_error` and still return
206
+ // `ok: true` with no `isError`, so the caller saw a green tool call over a
207
+ // flag that was never stamped. The alignment checkpoint itself DID land (it
208
+ // was read back above), so nothing is re-done and nothing is lost — the
209
+ // payload below carries every recorded fact. What changes is that the caller
210
+ // can now tell a full success from a partial one.
211
+ markAlignmentPartial(payload, 'skipped_despite_findings', stamp);
212
+ } else {
213
+ payload.skipped_despite_findings = stamp.skipped_despite_findings;
214
+ payload.review_marker_path = stamp.review_marker_path;
215
+ }
216
+ }
217
+
218
+ // [[HS-TASK-286X]] — chain into the next on-deck brief so the agent does not
219
+ // need a manual /hs-next re-invoke. The just-stamped checkpoint clears the
220
+ // alignment gate for the same spec; the focus-pinned path picks up next
221
+ // from the children. Best-effort — chain errors do not corrupt the recorded
222
+ // checkpoint, the field is just omitted.
223
+ if (config) {
224
+ try {
225
+ const chained = resolveNextOnDeckPayload(vaultRoot, config, {
226
+ spec_id: resolvedSpecId,
227
+ roadmap_id: args.roadmap_id,
228
+ });
229
+ if (chained && chained.result) {
230
+ payload.next_on_deck = chained.result;
231
+ } else if (chained && chained.error) {
232
+ payload.next_on_deck_error = chained.error;
233
+ }
234
+ } catch (e) {
235
+ payload.next_on_deck_error = { error: 'CHAIN_FAILED', message: e.message };
236
+ }
237
+ }
238
+
239
+ return alignmentResult(payload);
240
+ }
241
+
242
+ /**
243
+ * Cloud-reliant sibling of `handleRecordAlignment` ([[HS-DJWP-MCPC-TASK]]
244
+ * "Spec load + alignment read-back" family, backing `heyspex_record_alignment`).
245
+ *
246
+ * `handleRecordAlignment` itself is left completely untouched — it is
247
+ * imported and called directly (synchronously, no `await`) by
248
+ * `alignment-gate-holes.test.mjs`, so it must keep returning a plain object,
249
+ * never a Promise. This wrapper is a NEW function used only by the live tool
250
+ * dispatch: it resolves the tier ONCE, then either calls the untouched disk
251
+ * path or `handleRecordAlignmentCloud` below. Any thrown error from the cloud
252
+ * branch (network failure, a non-404 non-ok console response) is the ONE
253
+ * decision point (`readViaSelector`) turning it into
254
+ * `CONSOLE_UNREACHABLE_CLOUD_MODE` — never a silent disk fallback.
255
+ */
256
+ export async function handleRecordAlignmentViaTier(vaultRoot, config, args) {
257
+ const specId = args.spec_id;
258
+ if (!specId) {
259
+ return {
260
+ isError: true,
261
+ content: [{ type: 'text', text: JSON.stringify({ error: 'MISSING_SPEC_ID' }) }],
262
+ };
263
+ }
264
+ const tier = await resolveReadTier();
265
+ const consoleUrl = effectiveConsoleUrl();
266
+ try {
267
+ return await readViaSelector({
268
+ tier,
269
+ diskRead: () => handleRecordAlignment(vaultRoot, config, args),
270
+ neonRead: () => handleRecordAlignmentCloud(vaultRoot, config, args, { consoleUrl }),
271
+ routeLabel: 'PUT /api/specs/[id]',
272
+ consoleUrl,
273
+ });
274
+ } catch (err) {
275
+ if (err instanceof ConsoleUnreachableCloudModeError) {
276
+ return {
277
+ isError: true,
278
+ content: [{ type: 'text', text: JSON.stringify({ error: err.name, message: err.message }) }],
279
+ };
280
+ }
281
+ throw err;
282
+ }
283
+ }
284
+
285
+ /**
286
+ * Cloud-tier alignment-checkpoint stamp: `GET /api/specs/resolve` (id →
287
+ * relPath, mirrors `findSpecFile`) + `GET /api/specs/[id]` (load) + `PUT
288
+ * /api/specs/[id]` (write) + a second `GET /api/specs/[id]` (read-back
289
+ * verification — "report on the RESULT, not the request", same discipline
290
+ * the disk path follows). Never touches `readFileSync`/`writeFileSync`.
291
+ *
292
+ * `skipped_despite_findings` and the chained `next_on_deck` brief both stay
293
+ * best-effort / out of scope here: the marker file and the on-deck resolver
294
+ * are disk-based mechanisms this read-path task does not extend to cloud
295
+ * reliance — the chain is already wrapped in try/catch (degrades to
296
+ * `next_on_deck_error`), and the marker request is answered with an explicit
297
+ * "not supported in cloud reliance yet" rather than attempting a local write
298
+ * that would fail with no vault checkout.
299
+ */
300
+ export async function handleRecordAlignmentCloud(vaultRoot, config, args, { consoleUrl }) {
301
+ const specId = args.spec_id;
302
+
303
+ let storedOverride = false;
304
+ let overrideReasonTrimmed;
305
+ if (args.override_reason !== undefined && args.override_reason !== null) {
306
+ overrideReasonTrimmed = String(args.override_reason).trim();
307
+ if (overrideReasonTrimmed.length < ALIGNMENT_OVERRIDE_REASON_MIN_LENGTH) {
308
+ return {
309
+ isError: true,
310
+ content: [{
311
+ type: 'text',
312
+ text: JSON.stringify({
313
+ error: 'ALIGNMENT_OVERRIDE_REASON_TOO_THIN',
314
+ message:
315
+ `override_reason is ${overrideReasonTrimmed.length} characters after trimming${overrideReasonTrimmed.length === 0 ? ' (it was empty or whitespace only)' : ''} — it must be at least ${ALIGNMENT_OVERRIDE_REASON_MIN_LENGTH} and say WHY the drift finding is a false positive or an intentional supersession. It is stored on the spec as the audit record of that call, so ${overrideReasonTrimmed.length === 0 ? 'blank space' : `"${overrideReasonTrimmed}"`} tells the next reader nothing. Omit override_reason entirely if the review was clean.`,
316
+ override_reason_length: overrideReasonTrimmed.length,
317
+ minimum: ALIGNMENT_OVERRIDE_REASON_MIN_LENGTH,
318
+ }),
319
+ }],
320
+ };
321
+ }
322
+ storedOverride = true;
323
+ }
324
+
325
+ const relPath = await resolveSpecIdViaTier(vaultRoot, specId, { tier: 'neon', consoleUrl });
326
+ if (!relPath) {
327
+ return {
328
+ isError: true,
329
+ content: [{ type: 'text', text: JSON.stringify({ error: 'SPEC_NOT_FOUND', message: `Spec ${specId} not found` }) }],
330
+ };
331
+ }
332
+
333
+ const getRes = await consoleFetch(`/api/specs/${encodeURIComponent(specId)}`);
334
+ if (!getRes.ok) {
335
+ throw new Error(`GET /api/specs/${specId} returned ${getRes.status}: ${getRes.error || JSON.stringify(getRes.body ?? {})}`);
336
+ }
337
+ const spec = getRes.body?.spec;
338
+ if (!spec) {
339
+ return {
340
+ isError: true,
341
+ content: [{ type: 'text', text: JSON.stringify({ error: 'SPEC_NOT_FOUND', message: `Spec ${specId} not found` }) }],
342
+ };
343
+ }
344
+
345
+ const frontmatter = spec.frontmatter || {};
346
+ const grain = frontmatter.type === 'milestone' ? 'milestone' : 'epic';
347
+ const childIds = getChildRefs(frontmatter, grain);
348
+ const reviewedAt = new Date().toISOString();
349
+
350
+ const updatedFrontmatter = { ...frontmatter, alignment_reviewed_at: reviewedAt };
351
+ if (args.summary) updatedFrontmatter.alignment_summary = String(args.summary);
352
+ if (storedOverride) updatedFrontmatter.alignment_override = overrideReasonTrimmed;
353
+
354
+ const putRes = await consoleFetch(`/api/specs/${encodeURIComponent(specId)}`, {
355
+ method: 'PUT',
356
+ body: { frontmatter: updatedFrontmatter, body: spec.body ?? '' },
357
+ });
358
+ if (!putRes.ok) {
359
+ return {
360
+ isError: true,
361
+ content: [{
362
+ type: 'text',
363
+ text: JSON.stringify({
364
+ error: 'WRITE_FAILED',
365
+ message: consoleErrorMessage(putRes, `PUT /api/specs/${specId} returned ${putRes.status}`),
366
+ }),
367
+ }],
368
+ };
369
+ }
370
+
371
+ // Re-read for the stamp, independent of the PUT response.
372
+ const verifyRes = await consoleFetch(`/api/specs/${encodeURIComponent(specId)}`);
373
+ const stampReadback =
374
+ verifyRes.ok && verifyRes.body?.spec?.frontmatter?.alignment_reviewed_at === reviewedAt;
375
+ const alignmentLanding = {
376
+ tier: 'neon',
377
+ reported_by: 'measured',
378
+ abs_path: null,
379
+ rel_path: relPath,
380
+ on_disk: null, // not applicable in cloud reliance — see `stamp_readback` instead.
381
+ bytes: null,
382
+ stamp_readback: stampReadback,
383
+ };
384
+ if (!stampReadback) {
385
+ return {
386
+ isError: true,
387
+ content: [{
388
+ type: 'text',
389
+ text: JSON.stringify({
390
+ error: 'WRITE_NOT_ON_DISK',
391
+ message:
392
+ `The write reported success but ${specId} does not carry alignment_reviewed_at: ${reviewedAt} on read-back. ` +
393
+ 'Treat the checkpoint as NOT recorded.',
394
+ spec_path: relPath,
395
+ landed: alignmentLanding,
396
+ }, null, 2),
397
+ }],
398
+ };
399
+ }
400
+
401
+ const resolvedSpecId = frontmatter.id || specId;
402
+ const payload = {
403
+ ok: true,
404
+ spec_id: resolvedSpecId,
405
+ spec_path: relPath,
406
+ landed: alignmentLanding,
407
+ alignment_reviewed_at: reviewedAt,
408
+ child_count: childIds.length,
409
+ overridden: storedOverride,
410
+ message: `Alignment checkpoint recorded for ${resolvedSpecId} (${childIds.length} ${childKindFor(grain)}${childIds.length === 1 ? '' : 's'}). The response includes the next on-deck brief inline.`,
411
+ };
412
+
413
+ if (typeof args.skipped_despite_findings === 'boolean') {
414
+ // Same rule as the disk tier above, and here the failure is UNCONDITIONAL:
415
+ // cloud reliance cannot stamp the local breakdown-review marker at all, so
416
+ // every caller that passes this flag was being told, in a green response,
417
+ // that it had been recorded.
418
+ markAlignmentPartial(payload, 'skipped_despite_findings', {
419
+ error: 'UNSUPPORTED_IN_CLOUD_RELIANCE',
420
+ message:
421
+ 'skipped_despite_findings stamping writes a local breakdown-review marker file and is not yet wired for cloud reliance.',
422
+ });
423
+ }
424
+
425
+ if (config) {
426
+ try {
427
+ const chained = resolveNextOnDeckPayload(vaultRoot, config, {
428
+ spec_id: resolvedSpecId,
429
+ roadmap_id: args.roadmap_id,
430
+ });
431
+ if (chained && chained.result) {
432
+ payload.next_on_deck = chained.result;
433
+ } else if (chained && chained.error) {
434
+ payload.next_on_deck_error = chained.error;
435
+ }
436
+ } catch (e) {
437
+ payload.next_on_deck_error = { error: 'CHAIN_FAILED', message: e.message };
438
+ }
439
+ }
440
+
441
+ return alignmentResult(payload);
442
+ }
443
+
444
+ // ── claim-bound worktree binding ────────────────────────────────────────────
445
+ // [[HS-QDQA]] (epic [[HS-03DO]] "Claim-bound worktree isolation for parallel
446
+ // agent sessions"). A session worktree carries `<root>/.hsewt-owner.json`
447
+ // (`kind: "session"`) and is allocated / released / rebound by
448
+ // dev-factory/lib/worktree-allocator.mjs. The claim lifecycle is where that
449
+ // marker's own lifecycle has to be kept honest:
450
+ //
451
+ // release → mark the worktree RELEASED (never delete — release only makes it
452
+ // eligible for a later `reap`, which still refuses a dirty tree).
453
+ // claim → a BARE spawn keys its marker on `launch-<id>` because no spec was
454
+ // claimed yet; the first real claim REBINDS it to the claimed id
455
+ // (and renames the branch), so the worktree the agent is sitting in
456
+ // is named after the work it is actually doing.
457
+ //
458
+ // Both are BEST-EFFORT by design: the console round trip is the authoritative
459
+ // part of a claim/release, and a missing/broken dev-factory checkout (the MCP
460
+ // server also runs against sibling repos that have no dev-factory at all) must
461
+ // never turn a successful release into a failure. Every outcome — including the
462
+ // failure — is reported under `worktree` in the tool result rather than thrown.
463
+
464
+ /** Real allocator, loaded lazily so a claim never pays for dev-factory. */
465
+ const loadWorktreeAllocator = () => import('../../dev-factory/lib/worktree-allocator.mjs');
466
+
467
+ /**
468
+ * Resolve the injectable allocator. `deps.worktree` may be the module object
469
+ * itself (a test stub) or a factory returning one; absent, the real module is
470
+ * dynamically imported. Kept as ONE resolver so both handlers below inject the
471
+ * same way.
472
+ */
473
+ async function resolveWorktreeAllocator(deps = {}) {
474
+ const injected = deps.worktree;
475
+ if (!injected) return loadWorktreeAllocator();
476
+ return typeof injected === 'function' ? injected() : injected;
477
+ }
478
+
479
+ /** Default owner-marker basename, mirrored only as a fallback for a stub that
480
+ * does not re-export the allocator's own OWNER_FILE. */
481
+ const DEFAULT_OWNER_FILE = '.hsewt-owner.json';
482
+
483
+ /**
484
+ * Best-effort "this claim is over, the worktree may be reclaimed" stamp.
485
+ * @returns {Promise<{found: boolean, alreadyReleased: boolean} | {error: string}>}
486
+ */
487
+ export async function markWorktreeReleased(specId, deps = {}) {
488
+ try {
489
+ const wt = await resolveWorktreeAllocator(deps);
490
+ const ctx = wt.makeCtx();
491
+ const result = wt.markReleased(ctx, { specId });
492
+ return {
493
+ found: Boolean(result?.found),
494
+ alreadyReleased: Boolean(result?.alreadyReleased),
495
+ };
496
+ } catch (e) {
497
+ return { error: e?.message ?? String(e) };
498
+ }
499
+ }
500
+
501
+ /**
502
+ * Read the owner marker of the worktree this MCP call is running inside, if
503
+ * any. `null` whenever there is no `HEYSPEX_WORKTREE_PATH`, no marker file, or
504
+ * the marker is unreadable — every one of which means "nothing to rebind".
505
+ */
506
+ function readCurrentWorktreeMarker(deps = {}, ownerFile = DEFAULT_OWNER_FILE) {
507
+ const env = deps.env ?? process.env;
508
+ const root = typeof env.HEYSPEX_WORKTREE_PATH === 'string' ? env.HEYSPEX_WORKTREE_PATH.trim() : '';
509
+ if (!root) return null;
510
+ const read = deps.readFile ?? ((p) => readFileSync(p, 'utf-8'));
511
+ try {
512
+ const parsed = JSON.parse(read(join(root, ownerFile)));
513
+ return parsed && typeof parsed === 'object' ? parsed : null;
514
+ } catch {
515
+ return null;
516
+ }
517
+ }
518
+
519
+ /**
520
+ * Re-point a BARE-spawn worktree (`launch-<id>`) at the spec that was just
521
+ * claimed. Returns `null` when this session is not in a worktree, or its marker
522
+ * is already bound to a real spec — so the caller only ever reports a `worktree`
523
+ * field when something actually applied.
524
+ *
525
+ * @returns {Promise<{rebound: boolean, branch: string|null} | {error: string} | null>}
526
+ */
527
+ export async function rebindLaunchWorktreeForClaim(specId, deps = {}) {
528
+ const env = deps.env ?? process.env;
529
+ const root = typeof env.HEYSPEX_WORKTREE_PATH === 'string' ? env.HEYSPEX_WORKTREE_PATH.trim() : '';
530
+ if (!root) return null; // not a spawned worktree session — never touch the allocator
531
+ try {
532
+ const wt = await resolveWorktreeAllocator(deps);
533
+ const marker = readCurrentWorktreeMarker(deps, wt.OWNER_FILE ?? DEFAULT_OWNER_FILE);
534
+ const from = typeof marker?.specId === 'string' ? marker.specId : '';
535
+ // Only a BARE spawn is rebound. A marker already bound to a real spec id is
536
+ // somebody's live claim, and renaming its branch out from under it is the
537
+ // class of breakage this epic exists to avoid.
538
+ if (!from.startsWith('launch-')) return null;
539
+ if (from === specId) return null;
540
+ const ctx = wt.makeCtx();
541
+ const result = wt.rebind(ctx, { fromSpecId: from, toSpecId: specId });
542
+ return { rebound: Boolean(result?.found), branch: result?.branch ?? null };
543
+ } catch (e) {
544
+ return { error: e?.message ?? String(e) };
545
+ }
546
+ }
547
+
548
+ export async function handleClaimSpec(args = {}, deps = {}) {
549
+ if (!args.spec_id || typeof args.spec_id !== 'string') {
550
+ return {
551
+ isError: true,
552
+ content: [{ type: 'text', text: JSON.stringify({ error: 'INVALID_ARGS', message: 'spec_id is required' }) }],
553
+ };
554
+ }
555
+ // Resolve the id ONCE ([[HS-05ZW-XHR4-DIAG]]): a bare era-3 file key is what
556
+ // `heyspex_file_spec` hands back, and in cloud reliance only the console can
557
+ // prove it names one spec — so the guard resolves it to the full basename
558
+ // instead of refusing it.
559
+ const claimResolved = await resolveSpecIdForMutation(args.spec_id);
560
+ if (claimResolved.response) return claimResolved.response;
561
+ const claimSpecId = claimResolved.specId;
562
+ const identity = detectAgentIdentity(args.agent, args.session_id);
563
+ const agentProfile =
564
+ typeof args.agent_profile === 'string' && args.agent_profile.trim()
565
+ ? args.agent_profile.trim()
566
+ : undefined;
567
+ const post = deps.postClaimEndpoint ?? postClaimEndpoint;
568
+ const result = await post('POST', claimSpecId, {
569
+ agent: identity.agent,
570
+ sessionId: identity.sessionId,
571
+ ...(agentProfile ? { agent_profile: agentProfile } : {}),
572
+ });
573
+ if (!result.ok) {
574
+ const payload = await enrichConsoleFailurePayloadAsync(result, {
575
+ error: consoleErrorCode(result, 'CLAIM_FAILED'),
576
+ message: consoleErrorMessage(result, `Claim endpoint returned ${result.status}`),
577
+ status: result.status,
578
+ url: result.url,
579
+ console_response: result.body,
580
+ attempted: { spec_id: claimSpecId, agent: identity.agent, session_id: identity.sessionId },
581
+ });
582
+ return {
583
+ isError: true,
584
+ content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
585
+ };
586
+ }
587
+ const consoleBody = result.body && typeof result.body === 'object' ? result.body : {};
588
+ const stompWarnings = Array.isArray(consoleBody.stomp_warnings) ? consoleBody.stomp_warnings : [];
589
+ const identityNudges = Array.isArray(consoleBody.identity_nudges) ? consoleBody.identity_nudges : [];
590
+ // The claim LANDED — everything below is reporting, and must not be able to
591
+ // unland it ([[HS-QDQA]]).
592
+ const worktree = await rebindLaunchWorktreeForClaim(claimSpecId, deps);
593
+ const payload = {
594
+ spec_id: claimSpecId,
595
+ agent: identity.agent,
596
+ session_id: identity.sessionId,
597
+ ...(agentProfile ? { agent_profile: agentProfile } : {}),
598
+ console_url: result.url,
599
+ console_response: result.body,
600
+ ...(stompWarnings.length ? { stomp_warnings: stompWarnings } : {}),
601
+ ...(identityNudges.length ? { identity_nudges: identityNudges } : {}),
602
+ ...(worktree ? { worktree } : {}),
603
+ };
604
+ return {
605
+ content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
606
+ };
607
+ }
608
+
609
+ // Closed work-stage vocabulary. SSOT is spex-console/src/lib/work-stages.ts +
610
+ // the execute procedure; this list is the agent-facing mirror. The console route
611
+ // (PATCH /api/specs/:id/claim/status) is the authoritative validator, so a drift
612
+ // here is caught there rather than silently accepted.
613
+ export const WORK_STAGES = ['Grounding', 'Breaking down', 'Drafting', 'Validating', 'Testing', 'Reviewing'];
614
+
615
+ /**
616
+ * Build the PATCH /api/specs/:id/claim/status request body from
617
+ * heyspex_report_progress args + the resolved caller identity. Pure and
618
+ * exported so the sessionId-forwarding contract is unit-testable without a
619
+ * live console — [[HS-0BQ8-3TY6-TASK-send-the-agent-s-session-with-every]].
620
+ * Every sibling handler in this file already sends `identity.sessionId` on
621
+ * its PATCH/POST body; this brings handleReportProgress in line so the
622
+ * console's claim-fence guard (which only engages when a session rides the
623
+ * request) actually engages on the one path every agent uses.
624
+ */
625
+ export function buildReportProgressBody(args = {}, acProgress, identity) {
626
+ const body = { status: 'working', currentStage: args.current_stage, sessionId: identity.sessionId };
627
+ if (acProgress !== undefined) body.acProgress = acProgress;
628
+ return body;
629
+ }
630
+
631
+ export async function handleReportProgress(args = {}) {
632
+ if (!args.spec_id || typeof args.spec_id !== 'string') {
633
+ return {
634
+ isError: true,
635
+ content: [{ type: 'text', text: JSON.stringify({ error: 'INVALID_ARGS', message: 'spec_id is required' }) }],
636
+ };
637
+ }
638
+ if (typeof args.current_stage !== 'string' || !WORK_STAGES.includes(args.current_stage)) {
639
+ return {
640
+ isError: true,
641
+ content: [
642
+ {
643
+ type: 'text',
644
+ text: JSON.stringify({
645
+ error: 'INVALID_ARGS',
646
+ message: `current_stage must be one of: ${WORK_STAGES.join(', ')}`,
647
+ }),
648
+ },
649
+ ],
650
+ };
651
+ }
652
+ let acProgress;
653
+ if (args.ac_progress !== undefined && args.ac_progress !== null) {
654
+ if (typeof args.ac_progress !== 'number' || !Number.isInteger(args.ac_progress) || args.ac_progress < 0) {
655
+ return {
656
+ isError: true,
657
+ content: [
658
+ {
659
+ type: 'text',
660
+ text: JSON.stringify({ error: 'INVALID_ARGS', message: 'ac_progress must be a non-negative integer' }),
661
+ },
662
+ ],
663
+ };
664
+ }
665
+ acProgress = args.ac_progress;
666
+ }
667
+
668
+ // Same guard as claim/submit/defer ([[HS-05ZW-XHR4-DIAG]] surface sweep): a
669
+ // progress PATCH is a claim-scoped mutation, so an id that names a whole
670
+ // family must not be forwarded to pick an arbitrary member.
671
+ const progressResolved = await resolveSpecIdForMutation(args.spec_id);
672
+ if (progressResolved.response) return progressResolved.response;
673
+ const progressSpecId = progressResolved.specId;
674
+
675
+ const identity = detectAgentIdentity(args.agent, args.session_id);
676
+ const body = buildReportProgressBody(args, acProgress, identity);
677
+
678
+ const result = await consoleFetch(
679
+ `/api/specs/${encodeURIComponent(progressSpecId)}/claim/status`,
680
+ { method: 'PATCH', body },
681
+ );
682
+ if (!result.ok) {
683
+ const payload = {
684
+ error: consoleErrorCode(result, 'REPORT_PROGRESS_FAILED'),
685
+ message: consoleErrorMessage(result, `Claim status endpoint returned ${result.status}`),
686
+ status: result.status,
687
+ url: result.url,
688
+ console_response: result.body,
689
+ attempted: { spec_id: progressSpecId, current_stage: args.current_stage, ac_progress: acProgress },
690
+ };
691
+ return {
692
+ isError: true,
693
+ content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
694
+ };
695
+ }
696
+ return {
697
+ content: [
698
+ {
699
+ type: 'text',
700
+ text: JSON.stringify(
701
+ { spec_id: progressSpecId, current_stage: args.current_stage, ac_progress: acProgress, console_response: result.body },
702
+ null,
703
+ 2,
704
+ ),
705
+ },
706
+ ],
707
+ };
708
+ }
709
+
710
+ export async function handleSubmitForReview(vaultRoot, args = {}) {
711
+ const submitResolved = await resolveSpecIdForMutation(args.spec_id);
712
+ if (submitResolved.response) return submitResolved.response;
713
+ const result = await executeSubmitForReview({
714
+ vaultRoot,
715
+ consoleFetch,
716
+ postClaimEndpoint,
717
+ args: { ...args, spec_id: submitResolved.specId },
718
+ });
719
+ if (result.error) {
720
+ return {
721
+ isError: true,
722
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
723
+ };
724
+ }
725
+ return {
726
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
727
+ };
728
+ }
729
+
730
+ export async function handleReleaseSpec(args = {}, deps = {}) {
731
+ if (!args.spec_id || typeof args.spec_id !== 'string') {
732
+ return {
733
+ isError: true,
734
+ content: [{ type: 'text', text: JSON.stringify({ error: 'INVALID_ARGS', message: 'spec_id is required' }) }],
735
+ };
736
+ }
737
+ // Same guard as claim ([[HS-05ZW-XHR4-DIAG]] surface sweep): DELETE-ing the
738
+ // claim of an arbitrary family member is the same class of harm as claiming
739
+ // one, so release goes through the shared resolver too.
740
+ const releaseResolved = await resolveSpecIdForMutation(args.spec_id);
741
+ if (releaseResolved.response) return releaseResolved.response;
742
+ const releaseSpecId = releaseResolved.specId;
743
+ const post = deps.postClaimEndpoint ?? postClaimEndpoint;
744
+ const result = await post('DELETE', releaseSpecId);
745
+ if (!result.ok) {
746
+ const payload = {
747
+ error: consoleErrorCode(result, 'RELEASE_FAILED'),
748
+ message: consoleErrorMessage(result, `Release endpoint returned ${result.status}`),
749
+ status: result.status,
750
+ url: result.url,
751
+ console_response: result.body,
752
+ attempted: { spec_id: releaseSpecId },
753
+ };
754
+ return {
755
+ isError: true,
756
+ content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
757
+ };
758
+ }
759
+ // The console released the claim. Now (and ONLY now) mark the session
760
+ // worktree eligible for reaping — best-effort, never fatal ([[HS-QDQA]]).
761
+ const worktree = await markWorktreeReleased(releaseSpecId, deps);
762
+ return {
763
+ content: [{
764
+ type: 'text',
765
+ text: JSON.stringify(
766
+ { spec_id: releaseSpecId, released: true, console_response: result.body, worktree },
767
+ null,
768
+ 2,
769
+ ),
770
+ }],
771
+ };
772
+ }
773
+
774
+ /**
775
+ * Record the machine-readable WIP ledger for an epic at session close
776
+ * ([[HS-EPIC-FCSM]] forensic-reconstruction incident). Thin forwarder to
777
+ * console `POST /api/specs/:id/wip`, which captures the touched/untracked file
778
+ * lists from `git status` server-side (code-first — not narrated) and merges
779
+ * the agent-supplied semantic fields. The on-deck brief surfaces the entry so
780
+ * the next agent reads state instead of reconstructing it.
781
+ */
782
+ /** Every argument heyspex_record_wip accepts. Anything else is refused by name. */
783
+ export const RECORD_WIP_ACCEPTED_ARGS = [
784
+ 'spec_id',
785
+ 'specs_filed',
786
+ 'remaining',
787
+ 'verified',
788
+ 'notes',
789
+ 'agent',
790
+ 'session_id',
791
+ ];
792
+
793
+ /**
794
+ * Arguments an agent plausibly reaches for that this tool has never had, and
795
+ * where the content actually belongs. Named in the refusal so the error tells
796
+ * the caller what to do instead of just saying no.
797
+ */
798
+ export const RECORD_WIP_ARG_REDIRECTS = {
799
+ summary: 'this tool has no "summary" field — put the hand-off prose in "notes"',
800
+ handoff: 'no such field — put the hand-off prose in "notes"',
801
+ note: 'did you mean "notes"?',
802
+ current_stage: 'belongs to heyspex_report_progress (currentStage), not the WIP ledger',
803
+ currentStage: 'belongs to heyspex_report_progress (currentStage), not the WIP ledger',
804
+ ac_progress: 'belongs to heyspex_report_progress (acProgress), not the WIP ledger',
805
+ acProgress: 'belongs to heyspex_report_progress (acProgress), not the WIP ledger',
806
+ files_touched: 'captured server-side from git status — do not pass it',
807
+ untracked_artifacts: 'captured server-side from git status — do not pass it',
808
+ filesTouched: 'captured server-side from git status — do not pass it',
809
+ untrackedArtifacts: 'captured server-side from git status — do not pass it',
810
+ };
811
+
812
+ /**
813
+ * Mirror of `MAX_NOTES_CHARS` in `spex-console/src/lib/wip-state.ts` — refused
814
+ * here so an over-long hand-off fails before the round trip, with the same
815
+ * number in the message. Keep the two in sync.
816
+ */
817
+ export const RECORD_WIP_MAX_NOTES_CHARS = 4000;
818
+
819
+ /**
820
+ * Pure verdict on a `heyspex_record_wip` argument bag: either the console body
821
+ * to POST, or the refusal to hand back ([[HS-IRX0-5KFT-DIAG]] AC2).
822
+ *
823
+ * Store it or refuse it. The old code built the body from a fixed whitelist, so
824
+ * an argument the store does not keep (four hand-offs were written into
825
+ * `summary`) vanished with no signal while the call returned success. Anything
826
+ * unlisted, or listed with the wrong type, is now a named refusal — no partial
827
+ * write, no silent drop.
828
+ *
829
+ * Identity (agent / sessionId) is stamped by the caller, so this stays pure.
830
+ */
831
+ export function recordWipArgsVerdict(args = {}) {
832
+ if (!args.spec_id || typeof args.spec_id !== 'string') {
833
+ return { ok: false, error: { error: 'INVALID_ARGS', message: 'spec_id is required' } };
834
+ }
835
+
836
+ const unknown = Object.keys(args).filter((k) => !RECORD_WIP_ACCEPTED_ARGS.includes(k));
837
+ if (unknown.length > 0) {
838
+ const detail = unknown
839
+ .map((k) => (RECORD_WIP_ARG_REDIRECTS[k] ? `${k} (${RECORD_WIP_ARG_REDIRECTS[k]})` : k))
840
+ .join(', ');
841
+ return {
842
+ ok: false,
843
+ error: {
844
+ error: 'UNKNOWN_FIELD',
845
+ message: `heyspex_record_wip cannot store: ${detail}. NOTHING WAS WRITTEN — re-send using only the accepted arguments.`,
846
+ field: unknown[0],
847
+ unknown_fields: unknown,
848
+ accepted_fields: RECORD_WIP_ACCEPTED_ARGS,
849
+ attempted: { spec_id: args.spec_id },
850
+ },
851
+ };
852
+ }
853
+
854
+ const body = {};
855
+ const listArgs = { specs_filed: 'specsFiled', remaining: 'remaining', verified: 'verified' };
856
+ for (const [argName, bodyName] of Object.entries(listArgs)) {
857
+ if (args[argName] === undefined) continue;
858
+ if (!Array.isArray(args[argName]) || args[argName].some((v) => typeof v !== 'string')) {
859
+ return {
860
+ ok: false,
861
+ error: {
862
+ error: 'INVALID_FIELD',
863
+ message: `"${argName}" must be an array of strings. NOTHING WAS WRITTEN.`,
864
+ field: argName,
865
+ accepted_fields: RECORD_WIP_ACCEPTED_ARGS,
866
+ },
867
+ };
868
+ }
869
+ body[bodyName] = args[argName];
870
+ }
871
+ if (args.notes !== undefined) {
872
+ if (typeof args.notes !== 'string') {
873
+ return {
874
+ ok: false,
875
+ error: {
876
+ error: 'INVALID_FIELD',
877
+ message: '"notes" must be a string. NOTHING WAS WRITTEN.',
878
+ field: 'notes',
879
+ accepted_fields: RECORD_WIP_ACCEPTED_ARGS,
880
+ },
881
+ };
882
+ }
883
+ if (args.notes.length > RECORD_WIP_MAX_NOTES_CHARS) {
884
+ return {
885
+ ok: false,
886
+ error: {
887
+ error: 'NOTES_TOO_LONG',
888
+ message: `"notes" is ${args.notes.length} characters, over the ${RECORD_WIP_MAX_NOTES_CHARS} the ledger stores. NOTHING WAS WRITTEN — shorten it, or move the long-form detail into the spec body and leave a pointer here.`,
889
+ field: 'notes',
890
+ accepted_fields: RECORD_WIP_ACCEPTED_ARGS,
891
+ },
892
+ };
893
+ }
894
+ body.notes = args.notes;
895
+ }
896
+
897
+ return { ok: true, body };
898
+ }
899
+
900
+ /**
901
+ * Record the WIP hand-off ledger — POST /api/specs/:id/wip. Refuses what it
902
+ * cannot store, and surfaces the console's measured `stored` receipt on success
903
+ * ([[HS-IRX0-5KFT-DIAG]]).
904
+ */
905
+ export async function handleRecordWip(args = {}) {
906
+ const verdict = recordWipArgsVerdict(args);
907
+ if (!verdict.ok) {
908
+ return {
909
+ isError: true,
910
+ content: [{ type: 'text', text: JSON.stringify(verdict.error, null, 2) }],
911
+ };
912
+ }
913
+
914
+ const identity = detectAgentIdentity(args.agent, args.session_id);
915
+ const body = { ...verdict.body, agent: identity.agent, sessionId: identity.sessionId };
916
+
917
+ const result = await consoleFetch(
918
+ `/api/specs/${encodeURIComponent(args.spec_id)}/wip`,
919
+ { method: 'POST', body },
920
+ );
921
+ if (!result.ok) {
922
+ const payload = {
923
+ error: consoleErrorCode(result, 'WIP_FAILED'),
924
+ message: consoleErrorMessage(result, `WIP endpoint returned ${result.status}`),
925
+ status: result.status,
926
+ url: result.url,
927
+ console_response: result.body,
928
+ attempted: { spec_id: args.spec_id },
929
+ };
930
+ return {
931
+ isError: true,
932
+ content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
933
+ };
934
+ }
935
+ // The console's `stored` block is MEASURED from a re-read of the ledger — lift
936
+ // it to the top level so the receipt is the first thing the agent sees.
937
+ const stored = result.body && typeof result.body === 'object' ? result.body.stored : undefined;
938
+ const warnings = result.body && typeof result.body === 'object' ? result.body.warnings : undefined;
939
+ return {
940
+ content: [{
941
+ type: 'text',
942
+ text: JSON.stringify({
943
+ spec_id: args.spec_id,
944
+ stored,
945
+ ...(Array.isArray(warnings) && warnings.length ? { warnings } : {}),
946
+ console_response: result.body,
947
+ }, null, 2),
948
+ }],
949
+ };
950
+ }
951
+
952
+ /**
953
+ * Delegation defer — POST /api/specs/:id/defer ([[HS-TASK-1U0Z]]).
954
+ * Files split child + handoff envelope; console orchestrates pin / claim release / WIP.
955
+ */
956
+ export async function handleDeferSpec(args = {}) {
957
+ const identity = detectAgentIdentity(args.agent, args.session_id);
958
+ let specId = typeof args.spec_id === 'string' && args.spec_id.trim() ? args.spec_id.trim() : null;
959
+ if (!specId) {
960
+ return {
961
+ isError: true,
962
+ content: [{
963
+ type: 'text',
964
+ text: JSON.stringify({
965
+ error: 'INVALID_ARGS',
966
+ message: 'spec_id is required — pass the claimed parent HS-TASK-* id',
967
+ }),
968
+ }],
969
+ };
970
+ }
971
+ const deferResolved = await resolveSpecIdForMutation(specId);
972
+ if (deferResolved.response) return deferResolved.response;
973
+ specId = deferResolved.specId;
974
+
975
+ const body = {
976
+ reason: args.reason,
977
+ summary_done: args.summary_done,
978
+ remaining: args.remaining,
979
+ success_criteria: args.success_criteria,
980
+ agent: identity.agent,
981
+ session_id: identity.sessionId,
982
+ ...(args.parent_status ? { parent_status: args.parent_status } : {}),
983
+ ...(args.effort !== undefined ? { effort: args.effort } : {}),
984
+ ...(typeof args.child_name === 'string' ? { child_name: args.child_name } : {}),
985
+ ...(typeof args.handoff_id === 'string' && args.handoff_id.trim()
986
+ ? { handoff_id: args.handoff_id.trim() }
987
+ : {}),
988
+ ...(args.pin_on_deck === false ? { pin_on_deck: false } : {}),
989
+ ...(args.release_claim === false ? { release_claim: false } : {}),
990
+ ...(args.record_wip === false ? { record_wip: false } : {}),
991
+ };
992
+
993
+ const result = await consoleFetch(`/api/specs/${encodeURIComponent(specId)}/defer`, {
994
+ method: 'POST',
995
+ body,
996
+ });
997
+ if (!result.ok) {
998
+ const payload = {
999
+ error: consoleErrorCode(result, 'DEFER_FAILED'),
1000
+ message: consoleErrorMessage(result, `Defer endpoint returned ${result.status}`),
1001
+ status: result.status,
1002
+ url: result.url,
1003
+ console_response: result.body,
1004
+ attempted: { spec_id: specId },
1005
+ };
1006
+ return {
1007
+ isError: true,
1008
+ content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
1009
+ };
1010
+ }
1011
+ return {
1012
+ content: [{
1013
+ type: 'text',
1014
+ text: JSON.stringify({ spec_id: specId, ...result.body }, null, 2),
1015
+ }],
1016
+ };
1017
+ }
1018
+
1019
+ /**
1020
+ * Build the POST /api/control/active-claims/acquire request body from
1021
+ * heyspex_claim_acquire args + the resolved caller identity. Pure and exported
1022
+ * so the field-forwarding contract (in particular wait_if_held) is
1023
+ * unit-testable without a live console — [[HS-SESO]] AC3.
1024
+ */
1025
+ export function buildClaimAcquireBody(args = {}, identity) {
1026
+ return {
1027
+ spec_id: args.spec_id,
1028
+ agent: identity.agent,
1029
+ session_id: identity.sessionId,
1030
+ ...(args.ttl_ms !== undefined ? { ttl_ms: args.ttl_ms } : {}),
1031
+ ...(args.wait_if_held !== undefined ? { wait_if_held: args.wait_if_held } : {}),
1032
+ };
1033
+ }
1034
+
1035
+ /**
1036
+ * Atomically acquire a claim — [[HS-EPIC-NCPS]] active_claims /
1037
+ * [[HS-EPIC-CLMW]] `hs claim acquire`. POST /api/control/active-claims/acquire.
1038
+ * A 409 is a legitimate "already claimed" outcome (not a transport error): it is
1039
+ * surfaced as a normal result with acquired:false + the existing claimant.
1040
+ */
1041
+ export async function handleClaimAcquire(args = {}) {
1042
+ const identity = detectAgentIdentity(args.agent, args.session_id);
1043
+ const result = await consoleFetch('/api/control/active-claims/acquire', {
1044
+ method: 'POST',
1045
+ body: buildClaimAcquireBody(args, identity),
1046
+ });
1047
+ if (result.ok) return controlOk(result.body);
1048
+ if (result.status === 409) return controlOk(result.body); // already_claimed + existing
1049
+ return controlError(result, 'CLAIM_ACQUIRE_FAILED', { spec_id: args.spec_id });
1050
+ }
1051
+
1052
+ /**
1053
+ * Release a claim — [[HS-EPIC-NCPS]] active_claims / [[HS-EPIC-CLMW]]
1054
+ * `hs claim release`. DELETE /api/control/active-claims/:spec_id. A 404 is a
1055
+ * legitimate "no active claim" outcome, surfaced as released:false.
1056
+ */
1057
+ export async function handleClaimRelease(args = {}) {
1058
+ const specId = typeof args.spec_id === 'string' ? args.spec_id.trim() : '';
1059
+ const identity = detectAgentIdentity(args.agent, args.session_id);
1060
+ const path =
1061
+ `/api/control/active-claims/${encodeURIComponent(specId)}` +
1062
+ `?agent=${encodeURIComponent(identity.agent)}`;
1063
+ const result = await consoleFetch(path, { method: 'DELETE' });
1064
+ if (result.ok) return controlOk(result.body);
1065
+ if (result.status === 404) return controlOk(result.body); // released:false + not_found
1066
+ return controlError(result, 'CLAIM_RELEASE_FAILED', { spec_id: args.spec_id });
1067
+ }
1068
+
1069
+ /**
1070
+ * List all active claims — [[HS-EPIC-NCPS]] active_claims / [[HS-EPIC-CLMW]]
1071
+ * `hs claim list`. GET /api/control/active-claims → { claims: [] }.
1072
+ */
1073
+ export async function handleClaimList(_args = {}) {
1074
+ const result = await consoleFetch('/api/control/active-claims');
1075
+ if (!result.ok) return controlError(result, 'CLAIM_LIST_FAILED');
1076
+ return controlOk(result.body);
1077
+ }
1078
+
1079
+ /**
1080
+ * [[HS-Y00G-EPIC]] — Claim a file path for exclusive editing.
1081
+ * POST /api/file-claims/acquire. Default TTL 5 minutes.
1082
+ */
1083
+ export async function handleClaimFile(args = {}) {
1084
+ if (!args.file_path || typeof args.file_path !== 'string') {
1085
+ return {
1086
+ isError: true,
1087
+ content: [{ type: 'text', text: JSON.stringify({ error: 'INVALID_ARGS', message: 'file_path is required' }) }],
1088
+ };
1089
+ }
1090
+ const identity = detectAgentIdentity(args.agent, args.session_id);
1091
+ const body = {
1092
+ file_path: args.file_path,
1093
+ agent: identity.agent,
1094
+ session_id: identity.sessionId,
1095
+ };
1096
+ if (args.ttl_ms !== undefined) body.ttl_ms = args.ttl_ms;
1097
+ if (args.working_spec_id && typeof args.working_spec_id === 'string') {
1098
+ body.working_spec_id = args.working_spec_id;
1099
+ }
1100
+ const result = await consoleFetch('/api/file-claims/acquire', { method: 'POST', body });
1101
+ if (result.ok) return controlOk(result.body);
1102
+ if (result.status === 409) return controlOk(result.body); // already_claimed + existing
1103
+ return controlError(result, 'FILE_CLAIM_FAILED', { file_path: args.file_path });
1104
+ }
1105
+
1106
+ /**
1107
+ * [[HS-Y00G-EPIC]] — Release a file claim.
1108
+ * DELETE /api/file-claims/release.
1109
+ */
1110
+ export async function handleReleaseFileClaim(args = {}) {
1111
+ if (!args.file_path || typeof args.file_path !== 'string') {
1112
+ return {
1113
+ isError: true,
1114
+ content: [{ type: 'text', text: JSON.stringify({ error: 'INVALID_ARGS', message: 'file_path is required' }) }],
1115
+ };
1116
+ }
1117
+ const result = await consoleFetch('/api/file-claims/release', {
1118
+ method: 'DELETE',
1119
+ body: { file_path: args.file_path },
1120
+ });
1121
+ if (result.ok) return controlOk(result.body);
1122
+ if (result.status === 404) return controlOk(result.body); // released:false + not_found
1123
+ return controlError(result, 'FILE_CLAIM_RELEASE_FAILED', { file_path: args.file_path });
1124
+ }