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