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
@@ -46,16 +46,16 @@
46
46
  * import) so tests can stub scripts/lib/dev-mode.mjs and prove the verdict
47
47
  * comes from `resolveDevMode` itself, not from string-matching this file.
48
48
  */
49
- import { dirname, join } from 'node:path';
50
- import { fileURLToPath, pathToFileURL } from 'node:url';
51
- import { getActiveConfig } from './vault-resolve.mjs';
52
-
53
- const __dirname = dirname(fileURLToPath(import.meta.url));
49
+ import { join } from 'node:path';
50
+ import { importDevModeCore } from './dev-mode-core.mjs';
54
51
  // THIS PACKAGE's own root (vendor/heyspex-mcp/read-path.mjs -> two levels
55
52
  // up) — repo root in the monorepo, `node_modules/heyspex` in a packed
56
53
  // install. Used to locate this package's OWN sibling code (dev-mode.mjs);
57
- // see the module doc above for why this is never the vault root.
58
- const PACKAGE_ROOT = join(__dirname, '..', '..');
54
+ // see the module doc above for why this is never the vault root. Derived in
55
+ // ONE place (package-root.mjs) so a bundler bakes one build-machine path
56
+ // into the console's remote-MCP chunk instead of one per module.
57
+ import { PACKAGE_ROOT } from './package-root.mjs';
58
+ import { getActiveConfig } from './vault-resolve.mjs';
59
59
 
60
60
  /**
61
61
  * Resolve the VAULT root via the same shared 5-tier resolver every other MCP
@@ -78,8 +78,11 @@ export function resolveVaultRoot() {
78
78
 
79
79
  /** Real loader: dynamic-imports the shared dev-mode core from THIS PACKAGE's own root. */
80
80
  export async function loadRealDevMode() {
81
- const coreUrl = pathToFileURL(join(PACKAGE_ROOT, 'scripts', 'lib', 'dev-mode.mjs')).href;
82
- return import(coreUrl);
81
+ // Path candidate first (stdio / packed installs), then the static
82
+ // specifier a bundler can trace — see dev-mode-core.mjs. In a bundled
83
+ // deployment `import.meta.url` no longer points at a real checkout, so the
84
+ // path-built URL misses and the fallback is what actually loads.
85
+ return importDevModeCore([join(PACKAGE_ROOT, 'scripts', 'lib', 'dev-mode.mjs')]);
83
86
  }
84
87
 
85
88
  /**
@@ -111,6 +114,23 @@ export async function resolveReadTier({ env, homeDir, loadDevMode = loadRealDevM
111
114
  return resolved.readTier;
112
115
  }
113
116
 
117
+ /**
118
+ * resolveVaultWriteMode({ env, homeDir, loadDevMode? }) -> "local" | "cloud"
119
+ *
120
+ * The write-side twin of {@link resolveReadTier}: whatever
121
+ * `resolveDevMode(...).vaultWriteMode` says — `local` only for dev mode with
122
+ * local reliance, `cloud` for prod and for dev with cloud reliance. A
123
+ * create-flow that writes the vault directly (save_research) consults THIS
124
+ * before touching disk, so a cloud-reliant session never leaves a file the
125
+ * console's index has never heard of (the split-brain a disk write in cloud
126
+ * mode produces). Same injectable loader as the read tier.
127
+ */
128
+ export async function resolveVaultWriteMode({ env, homeDir, loadDevMode = loadRealDevMode } = {}) {
129
+ const mod = await loadDevMode();
130
+ const resolved = mod.resolveDevMode({ env, homeDir });
131
+ return resolved.vaultWriteMode === 'local' ? 'local' : 'cloud';
132
+ }
133
+
114
134
  /**
115
135
  * readViaSelector({ tier, diskRead, neonRead, routeLabel, consoleUrl })
116
136
  *
@@ -0,0 +1,146 @@
1
+ // The read-back receipt envelope for `hs` verbs that live in plain .mjs under
2
+ // vendor/heyspex-mcp/ and so cannot import the TypeScript receipt
3
+ // (spex-console/src/lib/cli/receipt.ts, [[HS-STCY-WW9R-TASK-add-a-shared-receipt-that-reads-back]]).
4
+ // Same envelope, same words: `{ local, cloud, verified, agree, diffs, changed,
5
+ // failed?, retry? }`, a two-column "local (this machine) | cloud (console)"
6
+ // table and one "Result:" line with the same anchors (verified / unverified /
7
+ // disagree / failed). Only the side fields differ per verb, so a verb passes a
8
+ // row table naming which local field and which cloud field each row shows
9
+ // and whether the row must agree. `hs login` is the first user
10
+ // ([[HS-CRNE-WW9R-TASK-give-hs-install-and-hs-login-the]], epic
11
+ // [[HS-L6HW-WW9R-EPIC-one-http-transport-and-measured]] invariant 6).
12
+ //
13
+ // Pure: no IO, never throws.
14
+
15
+ /** `EXIT.ACCOUNT_MISMATCH` in spex-console/src/lib/cli/control-shared.ts — the console confirmed another account. */
16
+ export const EXIT_ACCOUNT_MISMATCH = 20;
17
+ /** `EXIT.RECEIPT_DISAGREE` in control-shared.ts — both sides were read back and disagree. */
18
+ export const EXIT_RECEIPT_DISAGREE = 21;
19
+
20
+ /**
21
+ * @typedef {{ read: boolean, reason?: string, [field: string]: unknown }} ReceiptSide
22
+ * @typedef {{ label: string, local?: string, cloud?: string, compare?: boolean }} ReceiptRow
23
+ * One table row: the local side's field, the cloud side's field, and
24
+ * whether the two must be equal for `agree` (a diff is named by `label`).
25
+ */
26
+
27
+ /** A side that was not read back, with the reason. */
28
+ export function unreadSide(reason) {
29
+ return { read: false, reason };
30
+ }
31
+
32
+ const META = new Set(['read', 'reason']);
33
+
34
+ function valueOf(side, field) {
35
+ if (!side || !field) return null;
36
+ const v = side[field];
37
+ return v === undefined ? null : v;
38
+ }
39
+
40
+ /**
41
+ * Build the receipt from the two read-backs.
42
+ * - `verified`: both sides were read back;
43
+ * - `diffs`: every `compare` row whose two values differ (only when verified);
44
+ * - `agree`: verified with no diffs;
45
+ * - `changed`: per side, every field that differs from the `before` read of
46
+ * that side (`[]` on an unchanged re-run; a side with no before read, or
47
+ * not read after, contributes nothing).
48
+ * @param {{ local: ReceiptSide | null, cloud: ReceiptSide | null, rows: ReceiptRow[],
49
+ * before?: { local?: ReceiptSide | null, cloud?: ReceiptSide | null },
50
+ * failed?: { side: 'local' | 'cloud', reason?: string }, retry?: string }} input
51
+ */
52
+ export function buildSidesReceipt({ local, cloud, rows, before = {}, failed, retry }) {
53
+ const l = local ?? unreadSide('not read back');
54
+ const c = cloud ?? unreadSide('not read back');
55
+ if (failed?.reason) (failed.side === 'cloud' ? c : l).reason = failed.reason;
56
+ const verified = l.read === true && c.read === true;
57
+
58
+ const diffs = [];
59
+ if (verified) {
60
+ for (const row of rows) {
61
+ if (!row.compare) continue;
62
+ const lv = valueOf(l, row.local);
63
+ const cv = valueOf(c, row.cloud);
64
+ if (lv !== cv) diffs.push({ field: row.label, local: lv, cloud: cv });
65
+ }
66
+ }
67
+
68
+ const changed = [];
69
+ for (const [name, after] of [['local', l], ['cloud', c]]) {
70
+ const prior = before[name];
71
+ if (!prior?.read || !after.read) continue;
72
+ const fields = new Set([...Object.keys(prior), ...Object.keys(after)].filter((f) => !META.has(f)));
73
+ for (const field of fields) {
74
+ const from = valueOf(prior, field);
75
+ const to = valueOf(after, field);
76
+ if (from !== to) changed.push({ side: name, field, from, to });
77
+ }
78
+ }
79
+
80
+ return {
81
+ local: l,
82
+ cloud: c,
83
+ verified,
84
+ agree: verified && diffs.length === 0,
85
+ diffs,
86
+ changed,
87
+ ...(failed ? { failed: failed.side } : {}),
88
+ ...(retry ? { retry } : {}),
89
+ };
90
+ }
91
+
92
+ /** The one-word state a script or test can branch on (same as receipt.ts `receiptStatus`). */
93
+ export function sidesReceiptStatus(receipt) {
94
+ if (receipt.failed) return 'failed';
95
+ if (!receipt.verified) return 'unverified';
96
+ if (!receipt.agree) return 'disagree';
97
+ return 'verified';
98
+ }
99
+
100
+ function cell(side, field) {
101
+ if (!field) return '';
102
+ if (!side.read) return '(not read)';
103
+ const v = valueOf(side, field);
104
+ if (v === null) return '(none)';
105
+ if (typeof v === 'boolean') return v ? 'yes' : 'no';
106
+ return String(v);
107
+ }
108
+
109
+ const show = (v) => (v === null || v === undefined ? '(none)' : String(v));
110
+
111
+ /**
112
+ * The text sink: the same two-column table and "Result:" line as receipt.ts
113
+ * `formatReceipt`, rows taken from `rows`.
114
+ * @param {ReturnType<typeof buildSidesReceipt>} receipt
115
+ * @param {ReceiptRow[]} rows
116
+ */
117
+ export function formatSidesReceipt(receipt, rows) {
118
+ const table = [
119
+ ['', 'local (this machine)', 'cloud (console)'],
120
+ ...rows.map((r) => [r.label, cell(receipt.local, r.local), cell(receipt.cloud, r.cloud)]),
121
+ ];
122
+ const w0 = Math.max(...table.map((r) => r[0].length));
123
+ const w1 = Math.max(...table.map((r) => r[1].length));
124
+ const lines = [
125
+ 'Read back after the write:',
126
+ ...table.map(([a, b, c]) => ` ${a.padEnd(w0)} ${b.padEnd(w1)} ${c}`.trimEnd()),
127
+ ];
128
+ const status = sidesReceiptStatus(receipt);
129
+ const retry = receipt.retry ? ` Retry: \`${receipt.retry}\`` : '';
130
+ if (status === 'failed') {
131
+ const reason = receipt[receipt.failed].reason;
132
+ lines.push(`Result: failed -- the ${receipt.failed} half did not land${reason ? ` (${reason})` : ''}.${retry}`);
133
+ } else if (status === 'unverified') {
134
+ const unread = ['local', 'cloud'].filter((s) => !receipt[s].read);
135
+ const why = unread.map((s) => `${s}${receipt[s].reason ? `: ${receipt[s].reason}` : ''}`).join('; ');
136
+ lines.push(`Result: unverified -- the ${unread.join(' and ')} side was not read back (${why}).`);
137
+ } else if (status === 'disagree') {
138
+ lines.push(
139
+ `Result: the two sides disagree on ${receipt.diffs.map((d) => d.field).join(', ')}.${retry}`,
140
+ ...receipt.diffs.map((d) => ` ${d.field}: local ${show(d.local)} | cloud ${show(d.cloud)}`),
141
+ );
142
+ } else {
143
+ lines.push('Result: verified -- both sides were read back and agree.');
144
+ }
145
+ return lines.join('\n');
146
+ }
@@ -0,0 +1,117 @@
1
+ // heyspex_start_recipe and heyspex_list_recipes handlers (recipe-tools.mjs holds the store). Extracted from cli.mjs under
2
+ // [[HS-BLNF-6W61-DIAG-cli-mjs-has-grown-into-a-13-665-line]] (no behavior change).
3
+
4
+ import { wrapDesignResult } from './tool-helpers.mjs';
5
+ import { executeListRecipes, executeStartRecipe } from './recipe-tools.mjs';
6
+ import { detectAgentIdentity } from './mcp-config.mjs';
7
+ import { consoleErrorMessage } from './console-fetch.mjs';
8
+ import { createCaptureSessionOnConsole, writeCaptureFocusVendor } from './capture-tools.mjs';
9
+
10
+ // heyspex_list_recipes — pure catalog scan wrapped for the tool surface.
11
+ export function handleListRecipes(vaultRoot, args) {
12
+ return wrapDesignResult(executeListRecipes(vaultRoot, args));
13
+ }
14
+
15
+ // heyspex_start_recipe — resolve the recipe (pure) then, when the console is
16
+ // reachable, create + pin a capture session tied to it (mirrors
17
+ // handlePinCapture). The recipe start context is returned in every case so the
18
+ // flow can be walked even when the capture could not be created (offline).
19
+ export async function handleStartRecipe(vaultRoot, args) {
20
+ const resolved = executeStartRecipe(vaultRoot, args);
21
+ if (resolved.error) {
22
+ return {
23
+ isError: true,
24
+ content: [{ type: 'text', text: JSON.stringify(resolved, null, 2) }],
25
+ };
26
+ }
27
+ const recipe = resolved.recipe;
28
+ const identity = detectAgentIdentity();
29
+
30
+ const createResult = await createCaptureSessionOnConsole({
31
+ opened_by: 'agent',
32
+ opened_by_session: identity.sessionId,
33
+ mode: 'attended',
34
+ status: 'pending',
35
+ });
36
+
37
+ if (createResult.ok && createResult.session) {
38
+ const session = createResult.session;
39
+ const pin = await writeCaptureFocusVendor(vaultRoot, {
40
+ specId: session.id,
41
+ specPath: session.specPath || '',
42
+ reason: `Started workflow-recipe ${recipe.recipe_id} (${recipe.name})`,
43
+ claim: { claimed_by: identity.agent, claim_session: identity.sessionId },
44
+ });
45
+ if (!pin.pinned) {
46
+ // The session row WAS created and the recipe context is intact — both are
47
+ // returned so nothing is lost and no second session gets minted. But the
48
+ // message may not say "pinned" over a pin that never persisted
49
+ // ([[HS-IOJW-5YDH-DIAG]] sweep: report the result, not the request).
50
+ return {
51
+ isError: true,
52
+ content: [{
53
+ type: 'text',
54
+ text: JSON.stringify({
55
+ started: true,
56
+ pinned: false,
57
+ error: 'CAPTURE_PIN_NOT_WRITTEN',
58
+ source: 'database',
59
+ capture: { spec_id: session.id, spec_path: session.specPath || null },
60
+ pin,
61
+ recipe,
62
+ message:
63
+ `Capture session ${session.id} was created for workflow-recipe ${recipe.recipe_id}, but the ` +
64
+ `capture-on-deck pin was NOT written (${pin.reason}). A later /hs-capture with no id will ` +
65
+ 'resolve to a DIFFERENT capture. Do not re-run heyspex_start_recipe (it would mint a second ' +
66
+ `session) — re-pin with heyspex_pin_capture({ spec_id: "${session.id}" }) once the console is healthy.`,
67
+ }, null, 2),
68
+ }],
69
+ };
70
+ }
71
+ return {
72
+ content: [{
73
+ type: 'text',
74
+ text: JSON.stringify({
75
+ started: true,
76
+ pinned: true,
77
+ pin,
78
+ source: 'database',
79
+ capture: { spec_id: session.id, spec_path: session.specPath || null },
80
+ recipe,
81
+ message: `Capture session ${session.id} created and pinned (${pin.tier}) for workflow-recipe ${recipe.recipe_id}. Walk the capture_sequence, then file the spec pre-filled from spec_template (recipe_id + tool_scope stamp provenance).`,
82
+ }, null, 2),
83
+ }],
84
+ };
85
+ }
86
+
87
+ // The create failed → the recipe context is still fully returned so the flow
88
+ // can be walked / filed later. No divergent local capture file is minted
89
+ // (capture is online-only, [[heyspex-is-online-only-no-offline]]).
90
+ //
91
+ // HS-I6HT: this used to hardcode "HeySpex can't reach the server right now"
92
+ // for EVERY failure, so a sign-in failure printed an outage sentence next to
93
+ // a CONSOLE_NOT_SIGNED_IN code — two contradictory stories in one payload.
94
+ // `consoleErrorMessage` already writes the right sentence per class (sign-in,
95
+ // 401, wrong port, timeout, genuinely down); use it whenever it has one, and
96
+ // keep the old wording only as the last-resort fallback.
97
+ const answered =
98
+ typeof createResult.result?.body?.message === 'string'
99
+ ? createResult.result.body.message
100
+ : null;
101
+ const whatHappened =
102
+ (createResult.result ? consoleErrorMessage(createResult.result, answered) : null)
103
+ || "HeySpex can't reach the server right now.";
104
+ return {
105
+ content: [{
106
+ type: 'text',
107
+ text: JSON.stringify({
108
+ started: false,
109
+ status: 'deferred',
110
+ error: createResult.error || 'CONSOLE_UNREACHABLE',
111
+ recipe,
112
+ message:
113
+ `${whatHappened} The capture wasn't created, but the recipe's steps are returned below and nothing is lost. Fix the cause above, then call again to pin the capture.`,
114
+ }, null, 2),
115
+ }],
116
+ };
117
+ }
@@ -21,7 +21,24 @@
21
21
 
22
22
  import { execFileSync } from 'node:child_process';
23
23
  import { readFileSync, writeFileSync, mkdirSync, existsSync, renameSync } from 'node:fs';
24
- import { dirname, join } from 'node:path';
24
+ import { dirname, join, isAbsolute, resolve, sep } from 'node:path';
25
+ import { assertPathUnderRoot } from '../../scripts/lib/shared-path-helpers.mjs';
26
+ // Cloud-mode vault WRITES ([[HS-BYU4-CWPX-TASK-promotion-writes-go-through-the-console]]).
27
+ // Regen has no create-first/link-second reorder — nothing it writes links to a
28
+ // basename another write in the same batch mints — but the same rule applies:
29
+ // a `writeFileSync` under `specs/` in cloud mode is a split brain.
30
+ import {
31
+ createSpecViaConsole,
32
+ updateSpecViaConsole,
33
+ stripServerMintedFields,
34
+ } from './cloud-vault-writes.mjs';
35
+ import {
36
+ resolveWriteModeFromDeps,
37
+ splitRawSpecForConsole,
38
+ cloudWriteFailureResult,
39
+ cloudModeUnsupportedWrite,
40
+ } from './promotion-helpers.mjs';
41
+
25
42
 
26
43
  const TERMINAL_STATUSES = new Set([
27
44
  'closed',
@@ -49,6 +66,11 @@ export function softDeleteToTrash(vaultRoot, relPath, bucket = 'misc') {
49
66
  if (!existsSync(abs)) return { ok: false, error: 'not_found', source: relPath };
50
67
  const basename = relPath.split(/[\\/]/).pop() || `${timestamp()}-spec.md`;
51
68
  const trashDir = join(vaultRoot, 'specs', '.trash', bucket);
69
+ try {
70
+ assertPathUnderRoot(vaultRoot, trashDir, 'regen-tasks write');
71
+ } catch (err) {
72
+ return { ok: false, error: 'BAD_VAULT_ROOT', source: relPath, message: err.message };
73
+ }
52
74
  mkdirSync(trashDir, { recursive: true });
53
75
  const dest = join(trashDir, `${timestamp()}-${basename}`);
54
76
  renameSync(abs, dest);
@@ -136,7 +158,77 @@ function epicSlugFromRel(relPath) {
136
158
  *
137
159
  * Both modes return `{ ok, mode, task, epic, brief?, commit_result? }`.
138
160
  */
139
- export function executeRegenTask(vaultRoot, { task_id, commit = false, new_body, retire_reason, loadTask }) {
161
+ /**
162
+ * The cloud twin of the `.trash/` soft-delete — EXPLICIT DEGRADE.
163
+ *
164
+ * `softDeleteToTrash` renames a file into `specs/.trash/<bucket>/`, which is
165
+ * not a spec of any type the console models (no TYPE_TO_FOLDER entry, no id,
166
+ * no frontmatter contract), so there is no POST/PUT route that could carry it.
167
+ * In cloud mode the replacement still happens — `PUT /api/specs/:id` overwrites
168
+ * the body and the console keeps its own version history — but the local
169
+ * `.trash/` copy the [[destructive-action-checklist]] recovery procedure names
170
+ * does not exist. That is REPORTED on every affected entry rather than
171
+ * silently skipped, so the caller knows which recovery path applies.
172
+ */
173
+ function cloudTrashDegrade(relPath) {
174
+ return cloudModeUnsupportedWrite(
175
+ `specs/.trash/… (soft-delete of ${relPath})`,
176
+ 'a trash copy is not a spec of any type the console models, so there is no route to write it; the replacement itself still landed through PUT /api/specs/:id and the console keeps its own version history',
177
+ { recovery: 'Restore the previous body from the console\'s spec history rather than from a local .trash/ file.' },
178
+ );
179
+ }
180
+
181
+ /**
182
+ * Replace one existing spec's body through the console.
183
+ * `new_body` is the agent-authored full markdown (frontmatter + body), the
184
+ * same string the disk path writes verbatim.
185
+ */
186
+ async function putBodyViaConsole(specId, newBody, cloudDeps) {
187
+ const { frontmatter, body } = splitRawSpecForConsole(newBody);
188
+ return updateSpecViaConsole({ ...cloudDeps, specId, frontmatter, body });
189
+ }
190
+
191
+ /**
192
+ * Cloud-mode `executeRegenTask` commit. Same result shape as the disk path
193
+ * plus `write_mode: 'cloud'` and the `.trash/` degrade note.
194
+ */
195
+ async function regenTaskCommitViaConsole({ taskSummary, epicSummary, new_body, retire_reason, cloudDeps }) {
196
+ let res;
197
+ try {
198
+ res = await putBodyViaConsole(taskSummary.id, new_body, cloudDeps);
199
+ } catch (err) {
200
+ return {
201
+ ok: false,
202
+ ...cloudWriteFailureResult(err, {
203
+ step: { kind: 'update', type: 'task', spec_id: taskSummary.id },
204
+ completed: [],
205
+ }),
206
+ };
207
+ }
208
+ return {
209
+ ok: true,
210
+ mode: 'commit',
211
+ write_mode: 'cloud',
212
+ task: { id: res.specId ?? taskSummary.id, rel_path: res.relPath ?? taskSummary.rel_path },
213
+ retired_to: null,
214
+ retire_reason: retire_reason || null,
215
+ epic: epicSummary ? { id: epicSummary.id } : null,
216
+ cloud_mode_unsupported_writes: [cloudTrashDegrade(taskSummary.rel_path ?? taskSummary.id)],
217
+ message:
218
+ `Replaced the body of ${taskSummary.id} through the console (cloud write mode — nothing was written to local disk). ` +
219
+ 'No local .trash/ copy was made; recover the previous body from the console\'s spec history.',
220
+ };
221
+ }
222
+
223
+ export function executeRegenTask(vaultRoot, { task_id, commit = false, new_body, retire_reason, loadTask, writeMode, consoleFetch, consoleUrl }) {
224
+ // Vault WRITE mode ([[HS-BYU4-CWPX-TASK-promotion-writes-go-through-the-console]]),
225
+ // resolved ONCE by the caller and defaulting to `local`. This function stays
226
+ // SYNCHRONOUS on the local path on purpose — `spec-edit-tools.mjs` consumes
227
+ // its return value synchronously — and returns a Promise only in cloud mode,
228
+ // which a cloud-aware caller awaits (awaiting the plain object the local
229
+ // path returns is a no-op, so one call site serves both).
230
+ const cloudWriteMode = resolveWriteModeFromDeps({ writeMode });
231
+ const cloudDeps = { consoleFetch, consoleUrl };
140
232
  if (!task_id) {
141
233
  return { ok: false, error: 'MISSING_TASK_ID', message: 'task_id is required' };
142
234
  }
@@ -159,6 +251,15 @@ export function executeRegenTask(vaultRoot, { task_id, commit = false, new_body,
159
251
  message: 'commit: true requires non-empty new_body',
160
252
  };
161
253
  }
254
+ if (cloudWriteMode === 'cloud') {
255
+ return regenTaskCommitViaConsole({
256
+ taskSummary,
257
+ epicSummary,
258
+ new_body,
259
+ retire_reason,
260
+ cloudDeps,
261
+ });
262
+ }
162
263
  if (!taskSummary.rel_path) {
163
264
  return { ok: false, error: 'NO_REL_PATH', task_id };
164
265
  }
@@ -172,6 +273,11 @@ export function executeRegenTask(vaultRoot, { task_id, commit = false, new_body,
172
273
  // it — that's an agent error and surfaces as a malformed spec at the
173
274
  // next /hs-next.
174
275
  const destAbs = join(vaultRoot, taskSummary.rel_path);
276
+ try {
277
+ assertPathUnderRoot(vaultRoot, dirname(destAbs), 'regen-tasks write');
278
+ } catch (err) {
279
+ return { ok: false, error: 'BAD_VAULT_ROOT', task_id, message: err.message };
280
+ }
175
281
  mkdirSync(dirname(destAbs), { recursive: true });
176
282
  writeFileSync(destAbs, new_body, 'utf8');
177
283
  return {
@@ -222,7 +328,93 @@ export function executeRegenTask(vaultRoot, { task_id, commit = false, new_body,
222
328
  * The tool is deliberately deterministic — the LLM thinking happens between
223
329
  * brief mode and commit mode in the calling agent.
224
330
  */
225
- export function executeRegenTasksForEpic(vaultRoot, { epic_id, commit = false, task_specs, loadTask }) {
331
+ /**
332
+ * Cloud-mode `executeRegenTasksForEpic` commit — the same two entry shapes the
333
+ * disk path takes, routed through the console:
334
+ *
335
+ * `{ id, new_body, retire_reason }` → `PUT /api/specs/:id` (replace in place;
336
+ * no local `.trash/` copy — reported, see {@link cloudTrashDegrade}).
337
+ * `{ new_id, body, rel_path }` → `POST /api/specs` (the caller-supplied
338
+ * `new_id`/`rel_path` are IGNORED: the console mints the id, the family
339
+ * and the filename, so nothing is hand-picked — HS-BYU4 AC3. The minted
340
+ * id is reported back in `wrote[]`.)
341
+ */
342
+ async function regenTasksForEpicCommitViaConsole({ epicSummary, task_specs, loadTask, cloudDeps }) {
343
+ const retired = [];
344
+ const wrote = [];
345
+ const errors = [];
346
+ const unsupported = [];
347
+ for (const entry of task_specs) {
348
+ if (entry && typeof entry === 'object' && entry.id && typeof entry.new_body === 'string') {
349
+ const target = loadSpecRecord(loadTask, entry.id);
350
+ const specId = target?.id || target?.frontmatter?.id || entry.id;
351
+ try {
352
+ const res = await putBodyViaConsole(specId, entry.new_body, cloudDeps);
353
+ retired.push({ id: entry.id, trash_path: null, retire_reason: entry.retire_reason || null });
354
+ unsupported.push(cloudTrashDegrade(target?.relPath || target?.rel_path || entry.id));
355
+ wrote.push({ id: res.specId ?? specId, rel_path: res.relPath ?? null });
356
+ } catch (err) {
357
+ errors.push({
358
+ id: entry.id,
359
+ ...cloudWriteFailureResult(err, {
360
+ step: { kind: 'update', type: 'task', spec_id: specId },
361
+ completed: wrote,
362
+ }),
363
+ });
364
+ }
365
+ } else if (entry && typeof entry === 'object' && entry.new_id && typeof entry.body === 'string') {
366
+ const { frontmatter, body } = splitRawSpecForConsole(entry.body);
367
+ const cleaned = stripServerMintedFields(frontmatter);
368
+ try {
369
+ const created = await createSpecViaConsole({
370
+ ...cloudDeps,
371
+ type: cleaned.type ?? frontmatter.type ?? 'task',
372
+ name: cleaned.name ?? frontmatter.name ?? String(entry.new_id),
373
+ summary: cleaned.summary ?? null,
374
+ frontmatter: cleaned,
375
+ body,
376
+ });
377
+ wrote.push({
378
+ id: created.specId,
379
+ rel_path: created.relPath,
380
+ requested_id: entry.new_id,
381
+ note:
382
+ 'The console minted this id/filename server-side; the caller-supplied `new_id`/`rel_path` were not used (file keys are never hand-picked).',
383
+ });
384
+ } catch (err) {
385
+ errors.push({
386
+ new_id: entry.new_id,
387
+ ...cloudWriteFailureResult(err, {
388
+ step: { kind: 'create', type: 'task', name: entry.new_id },
389
+ completed: wrote,
390
+ }),
391
+ });
392
+ }
393
+ } else {
394
+ errors.push({ entry, error: 'INVALID_SHAPE' });
395
+ }
396
+ }
397
+ return {
398
+ ok: errors.length === 0,
399
+ mode: 'commit',
400
+ write_mode: 'cloud',
401
+ epic: { id: epicSummary.id },
402
+ retired,
403
+ wrote,
404
+ errors,
405
+ bucket: null,
406
+ ...(unsupported.length > 0 ? { cloud_mode_unsupported_writes: unsupported } : {}),
407
+ message:
408
+ `Stage-1-2-3 commit for ${epicSummary.id} through the console: replaced ${retired.length}, wrote ${wrote.length}, errors ${errors.length}. ` +
409
+ 'Cloud write mode — nothing was written to local disk and no .trash/ copies were made; recover previous bodies from the console\'s spec history.',
410
+ };
411
+ }
412
+
413
+ export function executeRegenTasksForEpic(vaultRoot, { epic_id, commit = false, task_specs, loadTask, writeMode, consoleFetch, consoleUrl }) {
414
+ // Same once-per-call resolution + sync-unless-cloud contract as
415
+ // {@link executeRegenTask} above.
416
+ const cloudWriteMode = resolveWriteModeFromDeps({ writeMode });
417
+ const cloudDeps = { consoleFetch, consoleUrl };
226
418
  if (!epic_id) {
227
419
  return { ok: false, error: 'MISSING_EPIC_ID', message: 'epic_id is required' };
228
420
  }
@@ -255,6 +447,9 @@ export function executeRegenTasksForEpic(vaultRoot, { epic_id, commit = false, t
255
447
  message: 'commit: true requires task_specs: [{ id, new_body, retire_reason } | { new_id, body, rel_path }]',
256
448
  };
257
449
  }
450
+ if (cloudWriteMode === 'cloud') {
451
+ return regenTasksForEpicCommitViaConsole({ epicSummary, task_specs, loadTask, cloudDeps });
452
+ }
258
453
  const bucket = epicSlugFromRel(epicSummary.rel_path);
259
454
  const retired = [];
260
455
  const wrote = [];
@@ -273,6 +468,12 @@ export function executeRegenTasksForEpic(vaultRoot, { epic_id, commit = false, t
273
468
  continue;
274
469
  }
275
470
  const abs = join(vaultRoot, rel);
471
+ try {
472
+ assertPathUnderRoot(vaultRoot, dirname(abs), 'regen-tasks write');
473
+ } catch (err) {
474
+ errors.push({ id: entry.id, error: 'BAD_VAULT_ROOT', message: err.message });
475
+ continue;
476
+ }
276
477
  mkdirSync(dirname(abs), { recursive: true });
277
478
  writeFileSync(abs, entry.new_body, 'utf8');
278
479
  retired.push({ id: entry.id, trash_path: del.trash_path, retire_reason: entry.retire_reason || null });
@@ -283,6 +484,12 @@ export function executeRegenTasksForEpic(vaultRoot, { epic_id, commit = false, t
283
484
  errors.push({ new_id: entry.new_id, error: 'CONFLICT', rel_path: entry.rel_path });
284
485
  continue;
285
486
  }
487
+ try {
488
+ assertPathUnderRoot(vaultRoot, dirname(abs), 'regen-tasks write');
489
+ } catch (err) {
490
+ errors.push({ new_id: entry.new_id, error: 'BAD_VAULT_ROOT', message: err.message });
491
+ continue;
492
+ }
286
493
  mkdirSync(dirname(abs), { recursive: true });
287
494
  writeFileSync(abs, entry.body, 'utf8');
288
495
  wrote.push({ id: entry.new_id, rel_path: entry.rel_path });