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
@@ -6,7 +6,8 @@
6
6
  */
7
7
 
8
8
  import { readFileSync, writeFileSync, existsSync, readdirSync, mkdirSync } from 'node:fs';
9
- import { join, relative } from 'node:path';
9
+ import { dirname, join, relative } from 'node:path';
10
+ import { fileURLToPath } from 'node:url';
10
11
  import { execFileSync } from 'node:child_process';
11
12
  import { prepareVaultPlanContent } from './plan-ingest.mjs';
12
13
  import { yamlScalar } from './yaml-scalar.mjs';
@@ -22,6 +23,36 @@ import {
22
23
  formatSummaryFrontmatterField,
23
24
  resolveAuthoredSummaryValue,
24
25
  } from './summary-contract.mjs';
26
+ // The cloud (console) write path. Transport-free and side-effect-free, so it
27
+ // is safe to import statically; the actual transport (console-transport.mjs)
28
+ // and the mode resolver (read-path.mjs) are DYNAMIC-imported at call time in
29
+ // {@link resolveCloudDeps} / {@link resolveWriteModeForPlanPush} so a local-mode
30
+ // run — and every existing local-mode test — keeps exactly the module graph it
31
+ // had before cloud mode existed.
32
+ import { cloudWriteFailure, createSpecViaConsole } from './cloud-vault-writes.mjs';
33
+
34
+ // scripts/validate-vault-ids.mjs is a CODE-repo artifact that ships with this
35
+ // package — since the repo split (2026-08-31) the vault root no longer holds
36
+ // it, so resolve it relative to THIS file's checkout first and fall back to
37
+ // the vault root only for pre-split layouts where they were the same repo.
38
+ // `HS_PKG_ROOT` wins when set (same precedence as dev-tools.mjs's
39
+ // CHECKOUT_ROOT / plan-review.mjs's resolveCodeAuditRoots).
40
+ const CHECKOUT_ROOT = resolveCodeRoot({
41
+ packageRoot: dirname(dirname(dirname(fileURLToPath(import.meta.url)))),
42
+ });
43
+
44
+ /**
45
+ * Resolve scripts/validate-vault-ids.mjs against the code checkout first,
46
+ * falling back to `vaultRoot` for pre-split layouts where the vault and the
47
+ * code repo were the same checkout. Returns `null` when it exists in neither
48
+ * (the pre-existing "skipped" behavior).
49
+ */
50
+ function resolveValidateVaultIdsScript(vaultRoot) {
51
+ const local = join(CHECKOUT_ROOT, 'scripts', 'validate-vault-ids.mjs');
52
+ if (existsSync(local)) return local;
53
+ const legacy = join(vaultRoot, 'scripts', 'validate-vault-ids.mjs');
54
+ return existsSync(legacy) ? legacy : null;
55
+ }
25
56
 
26
57
  // ─────────────────────────────────────────────────────────────────────────────
27
58
  // Milestone-parent resolution + bidirectional mirror-update (HS-TASK-T5PX)
@@ -218,20 +249,19 @@ const DESIGN_DECISIONS = {
218
249
  };
219
250
 
220
251
  /**
221
- * Detect if plan content mentions web-app file paths. Matches the current
222
- * `spex-console/` paths and the legacy `cockpit/` ones (legacy-cockpit; older plan text still
223
- * uses the pre-rename paths).
252
+ * Detect if plan content mentions web-app file paths (`spex-console/` and
253
+ * component-shaped sources).
224
254
  * @param {string} content - Plan markdown or epic body
225
255
  * @returns {boolean}
226
256
  */
227
257
  function detectConsolePaths(content) {
228
- // Look for spex-console (and legacy-cockpit) file path patterns
258
+ // Look for spex-console file path patterns
229
259
  const patterns = [
230
- /(spex-console|cockpit)\/src\//i, // legacy-cockpit
231
- /(spex-console|cockpit)\/components\//i, // legacy-cockpit
232
- /(spex-console|cockpit)\/app\//i, // legacy-cockpit
233
- /(spex-console|cockpit).*\.tsx/i, // legacy-cockpit
234
- /(spex-console|cockpit).*\.ts/i, // legacy-cockpit
260
+ /spex-console\/src\//i,
261
+ /spex-console\/components\//i,
262
+ /spex-console\/app\//i,
263
+ /spex-console.*\.tsx/i,
264
+ /spex-console.*\.ts/i,
235
265
  /\bsurface\b.*\b(tsx?|component)\b/i,
236
266
  ];
237
267
 
@@ -843,6 +873,7 @@ const VAULT_ID_MIN_SUFFIX_LENGTH = 4;
843
873
  const KIND_TYPE_LABEL = { task: 'TASK', epic: 'EPIC' };
844
874
 
845
875
  import { deriveSlug } from './promotion-helpers.mjs';
876
+ import { resolveCodeRoot } from '../../scripts/lib/shared-path-helpers.mjs';
846
877
  export { deriveSlug };
847
878
 
848
879
  function randomSuffix(length, charset = VAULT_ID_CHARSET) {
@@ -1181,7 +1212,22 @@ function buildOnDeckBanner(reason) {
1181
1212
  // / `focus-history.jsonl` writer here anymore ([[no-fallbacks-by-default]]; the
1182
1213
  // focus.yaml-fallback incident).
1183
1214
 
1184
- export function executePushPlan(vaultRoot, args) {
1215
+ /**
1216
+ * `heyspex_push_plan`.
1217
+ *
1218
+ * WRITE MODE ([[HS-BYU4-CWPX-TASK-promotion-writes-go-through-the-console]]):
1219
+ * `options.vaultWriteMode` selects where the epic lands and defaults to
1220
+ * `'local'`, so every pre-existing two-argument caller keeps the byte-identical
1221
+ * synchronous disk path below. `'cloud'` delegates to
1222
+ * {@link pushPlanViaConsole} and therefore returns a PROMISE — resolve the mode
1223
+ * once at the handler entry ({@link executePushPlanCloudAware}) and `await` the
1224
+ * result, which is correct for both modes (awaiting the sync local object is a
1225
+ * no-op).
1226
+ */
1227
+ export function executePushPlan(vaultRoot, args, options = {}) {
1228
+ if ((options.vaultWriteMode ?? 'local') === 'cloud') {
1229
+ return pushPlanViaConsole(vaultRoot, args, options);
1230
+ }
1185
1231
  const {
1186
1232
  epic_name: epicName,
1187
1233
  summary: epicSummaryArg,
@@ -1377,13 +1423,14 @@ ${epicBody}
1377
1423
 
1378
1424
  let validateOk = null;
1379
1425
  let validateMessage = null;
1380
- const validateScript = join(vaultRoot, 'scripts', 'validate-vault-ids.mjs');
1381
- if (existsSync(validateScript)) {
1426
+ const validateScript = resolveValidateVaultIdsScript(vaultRoot);
1427
+ if (validateScript) {
1382
1428
  try {
1383
1429
  execFileSync(process.execPath, [validateScript], {
1384
- cwd: vaultRoot,
1430
+ cwd: dirname(validateScript),
1385
1431
  encoding: 'utf8',
1386
1432
  stdio: ['ignore', 'pipe', 'pipe'],
1433
+ env: { ...process.env, HEYSPEX_VALIDATE_ROOT: vaultRoot },
1387
1434
  });
1388
1435
  validateOk = true;
1389
1436
  } catch (e) {
@@ -1392,7 +1439,7 @@ ${epicBody}
1392
1439
  }
1393
1440
  } else {
1394
1441
  validateOk = null;
1395
- validateMessage = 'skipped (scripts/validate-vault-ids.mjs not in vault root)';
1442
+ validateMessage = 'skipped (scripts/validate-vault-ids.mjs not found in the code root or vault root)';
1396
1443
  }
1397
1444
 
1398
1445
  // Reuse the pre-flight conflict detection result computed before file writes
@@ -1415,7 +1462,13 @@ ${epicBody}
1415
1462
  spec_path: epicRelPath,
1416
1463
  },
1417
1464
  children: [],
1418
- focus_written: onDeck,
1465
+ // NOT a claim that the pin landed — this function is sync and cannot write to
1466
+ // Neon (see the note above). It reports the REQUEST (`on_deck`), and the async
1467
+ // caller (`handlePushPlan`) overwrites this with the measured result of
1468
+ // `writeFocusSelectionVendor`. `null` = "not attempted here / unknown", so a
1469
+ // path that somehow skips the caller cannot read as a successful pin
1470
+ // ([[HS-IOJW-5YDH-DIAG]] sweep idiom: report the result, never the request).
1471
+ focus_written: onDeck ? null : false,
1419
1472
  focus_reason: computedFocusReason,
1420
1473
  validate_vault_ids: validateOk,
1421
1474
  validate_message: validateMessage,
@@ -1459,8 +1512,15 @@ ${epicBody}
1459
1512
  /**
1460
1513
  * Retroactively create an epic + child tasks for completed work.
1461
1514
  * All specs are created with status: in_review and a review doc is auto-generated.
1515
+ *
1516
+ * Same write-mode contract as {@link executePushPlan}: `options.vaultWriteMode`
1517
+ * defaults to `'local'` (byte-identical synchronous disk path), `'cloud'`
1518
+ * delegates to {@link shipEpicViaConsole} and returns a Promise.
1462
1519
  */
1463
- export function executeShipEpic(vaultRoot, args) {
1520
+ export function executeShipEpic(vaultRoot, args, options = {}) {
1521
+ if ((options.vaultWriteMode ?? 'local') === 'cloud') {
1522
+ return shipEpicViaConsole(vaultRoot, args, options);
1523
+ }
1464
1524
  const {
1465
1525
  epic_name: epicName,
1466
1526
  summary = '',
@@ -1718,13 +1778,14 @@ _Space for reviewer comments._
1718
1778
  // Validate vault IDs
1719
1779
  let validateOk = null;
1720
1780
  let validateMessage = null;
1721
- const validateScript = join(vaultRoot, 'scripts', 'validate-vault-ids.mjs');
1722
- if (existsSync(validateScript)) {
1781
+ const validateScript = resolveValidateVaultIdsScript(vaultRoot);
1782
+ if (validateScript) {
1723
1783
  try {
1724
1784
  execFileSync(process.execPath, [validateScript], {
1725
- cwd: vaultRoot,
1785
+ cwd: dirname(validateScript),
1726
1786
  encoding: 'utf8',
1727
1787
  stdio: ['ignore', 'pipe', 'pipe'],
1788
+ env: { ...process.env, HEYSPEX_VALIDATE_ROOT: vaultRoot },
1728
1789
  });
1729
1790
  validateOk = true;
1730
1791
  } catch (e) {
@@ -1733,7 +1794,7 @@ _Space for reviewer comments._
1733
1794
  }
1734
1795
  } else {
1735
1796
  validateOk = null;
1736
- validateMessage = 'skipped (scripts/validate-vault-ids.mjs not in vault root)';
1797
+ validateMessage = 'skipped (scripts/validate-vault-ids.mjs not found in the code root or vault root)';
1737
1798
  }
1738
1799
 
1739
1800
  return {
@@ -1824,3 +1885,827 @@ export function buildShipWorkflowMarkdown(options = {}) {
1824
1885
 
1825
1886
  return lines.join('\n');
1826
1887
  }
1888
+
1889
+ // ─────────────────────────────────────────────────────────────────────────────
1890
+ // CLOUD WRITE MODE — `resolveVaultWriteMode() === 'cloud'`
1891
+ // [[HS-BYU4-CWPX-TASK-promotion-writes-go-through-the-console]]
1892
+ //
1893
+ // In cloud reliance the vault is NOT on this machine's disk: a `writeFileSync`
1894
+ // here is a split brain — a markdown file the console's index has never heard
1895
+ // of, possibly on a machine with no clone of the vault at all. So every write
1896
+ // `push_plan` / `backfill_epic` makes goes through the console instead
1897
+ // (`POST /api/specs`, via cloud-vault-writes.mjs), and every id/file-key mint
1898
+ // that used to SCAN THE VAULT ON DISK is skipped — the console mints the id,
1899
+ // the family and the filename server-side.
1900
+ //
1901
+ // The structural consequence is CREATE-FIRST / LINK-SECOND. On disk the epic
1902
+ // basename was computable before any write, so children could be named in the
1903
+ // epic's own frontmatter. In cloud mode the basename only exists once
1904
+ // `POST /api/specs` has answered, so the order is: create the epic -> read back
1905
+ // its basename -> create each task with `parent: "[[<epic basename>]]"` ->
1906
+ // create the review doc, which cites both. Nothing is hand-linked in the
1907
+ // reverse direction: `createSpec` back-links `children:` (task->epic) and
1908
+ // `epics:` (epic->milestone) itself at create time (KIND_LINKAGE in
1909
+ // spex-console's vault.ts), which is exactly what `appendEpicToMilestone` did
1910
+ // on disk.
1911
+ //
1912
+ // There is NO rollback (the console has no un-create), so each entry point
1913
+ // stops at the first failure and reports which step failed and which documents
1914
+ // already landed — never an implied clean abort.
1915
+ // ─────────────────────────────────────────────────────────────────────────────
1916
+
1917
+ /**
1918
+ * The one document these tools write that has NO console route, described once
1919
+ * so the degrade message and any report of it can never drift apart.
1920
+ *
1921
+ * The console's create route accepts only the types in its `TYPE_TO_FOLDER`
1922
+ * map (task, epic, milestone, diagnose, capture, planning-flow, memory-*,
1923
+ * protocol, workflow-recipe, research, review, pillar, agent, golden-case,
1924
+ * adr, draft, daily). A roadmap manifest is none of those, and its update is
1925
+ * not a spec create at all: `addEpicToRoadmapPhase` scans `specs/roadmaps/`
1926
+ * for the file whose frontmatter carries `id: <roadmapId>` and then performs
1927
+ * line-surgical YAML editing inside a `roadmap_manifest` phase block. Borrowing
1928
+ * some other `type` would mint a NEW document in the wrong folder rather than
1929
+ * amend the existing manifest, so in cloud mode the write is skipped and said
1930
+ * out loud.
1931
+ */
1932
+ const CLOUD_UNSUPPORTED_ROADMAP_WRITE = Object.freeze({
1933
+ error: 'CLOUD_MODE_UNSUPPORTED_WRITE',
1934
+ document: 'specs/roadmaps/<manifest>.md',
1935
+ write: 'addEpicToRoadmapPhase — append the epic basename to a roadmap_manifest phase',
1936
+ why:
1937
+ 'There is no console route for a roadmap manifest: POST /api/specs only accepts the types in TYPE_TO_FOLDER '
1938
+ + '(roadmap is not one of them), and this is an in-place edit of an existing YAML phase block, not a spec create. '
1939
+ + 'Writing it to local disk in cloud mode would be a split brain, and minting it under a borrowed type would '
1940
+ + 'create a new document instead of amending the manifest.',
1941
+ });
1942
+
1943
+ /**
1944
+ * Resolve the console transport + URL for a cloud-mode run.
1945
+ *
1946
+ * Both are DEPENDENCIES with real defaults: a test injects a recorder and never
1947
+ * opens a socket, while production gets `consoleFetch` from
1948
+ * ./console-transport.mjs and `getConsoleUrl()` from ./mcp-config.mjs. The
1949
+ * imports are dynamic so a local-mode run never loads the transport at all and
1950
+ * the pre-existing local tests keep exactly the module graph they had.
1951
+ */
1952
+ async function resolveCloudDeps(options = {}) {
1953
+ let consoleFetch = options.consoleFetch;
1954
+ if (typeof consoleFetch !== 'function') {
1955
+ ({ consoleFetch } = await import('./console-transport.mjs'));
1956
+ }
1957
+ let consoleUrl = options.consoleUrl ?? null;
1958
+ if (!consoleUrl) {
1959
+ try {
1960
+ const { getConsoleUrl } = await import('./mcp-config.mjs');
1961
+ consoleUrl = getConsoleUrl();
1962
+ } catch {
1963
+ consoleUrl = null;
1964
+ }
1965
+ }
1966
+ return { consoleFetch, consoleUrl, actingAgent: options.actingAgent ?? null };
1967
+ }
1968
+
1969
+ /** `resolveVaultWriteMode()` with the same injectable seam the handlers use. */
1970
+ async function resolveWriteModeForPlanPush(options = {}) {
1971
+ if (options.vaultWriteMode === 'local' || options.vaultWriteMode === 'cloud') {
1972
+ return options.vaultWriteMode;
1973
+ }
1974
+ if (typeof options.resolveVaultWriteMode === 'function') {
1975
+ return (await options.resolveVaultWriteMode()) ?? 'local';
1976
+ }
1977
+ const { resolveVaultWriteMode } = await import('./read-path.mjs');
1978
+ return (await resolveVaultWriteMode()) ?? 'local';
1979
+ }
1980
+
1981
+ /**
1982
+ * `heyspex_push_plan` HANDLER ENTRY: resolve the write mode ONCE, then run.
1983
+ * `deps` is the same object threaded down to {@link executePushPlan} — pass
1984
+ * `{ consoleFetch }` in tests, nothing in production.
1985
+ */
1986
+ export async function executePushPlanCloudAware(vaultRoot, args, deps = {}) {
1987
+ const vaultWriteMode = await resolveWriteModeForPlanPush(deps);
1988
+ return executePushPlan(vaultRoot, args, { ...deps, vaultWriteMode });
1989
+ }
1990
+
1991
+ /** `heyspex_backfill_epic` HANDLER ENTRY — see {@link executePushPlanCloudAware}. */
1992
+ export async function executeShipEpicCloudAware(vaultRoot, args, deps = {}) {
1993
+ const vaultWriteMode = await resolveWriteModeForPlanPush(deps);
1994
+ return executeShipEpic(vaultRoot, args, { ...deps, vaultWriteMode });
1995
+ }
1996
+
1997
+ /**
1998
+ * `GET /api/specs/:id` — the cloud twin of reading a vault file off disk. The
1999
+ * route resolves an `id` OR a wikilink basename, which is exactly the two
2000
+ * shapes the disk readers here resolve. Returns `null` for a genuine 404 (the
2001
+ * spec is not in this caller's vault — the same "skip it" answer
2002
+ * `resolveWikilink` gives); every other failure throws the canonical
2003
+ * `CONSOLE_UNREACHABLE_CLOUD_MODE` error.
2004
+ */
2005
+ async function readSpecViaConsole({ consoleFetch, consoleUrl, ref }) {
2006
+ const routeLabel = `GET /api/specs/${ref}`;
2007
+ if (typeof consoleFetch !== 'function') {
2008
+ throw cloudWriteFailure(routeLabel, consoleUrl, 'no consoleFetch dependency was injected');
2009
+ }
2010
+ let result;
2011
+ try {
2012
+ result = await consoleFetch(`/api/specs/${encodeURIComponent(ref)}`);
2013
+ } catch (err) {
2014
+ throw cloudWriteFailure(routeLabel, consoleUrl, String(err?.message ?? err));
2015
+ }
2016
+ if (result?.status === 404) return null;
2017
+ if (!result?.ok) {
2018
+ throw cloudWriteFailure(
2019
+ routeLabel,
2020
+ result?.url ?? consoleUrl,
2021
+ `${result?.status ?? '?'}${result?.error ? ` ${result.error}` : ''}`,
2022
+ { status: result?.status ?? null, console_response: result?.body ?? null },
2023
+ );
2024
+ }
2025
+ return result.body?.spec ?? null;
2026
+ }
2027
+
2028
+ /**
2029
+ * Cloud twin of {@link resolveMilestoneArg}. The shape checks (missing /
2030
+ * not-an-HS-MIL) are pure and stay identical; only the EXISTENCE check moves
2031
+ * from `existsSync(specs/milestones/<b>.md)` to the console. Also returns the
2032
+ * milestone's `pillar:` so the epic can inherit it (the disk path reads the
2033
+ * same field via {@link resolveMilestonePillar}).
2034
+ *
2035
+ * `family:` is deliberately NOT returned: in cloud mode `createSpec` inherits
2036
+ * the family from the resolved structural parent itself, so minting or echoing
2037
+ * one here would only give the server a chance to disagree with itself.
2038
+ */
2039
+ async function resolveMilestoneArgViaConsole(raw, { consoleFetch, consoleUrl }) {
2040
+ if (!raw?.trim?.()) {
2041
+ return {
2042
+ error: 'MISSING_MILESTONE',
2043
+ message:
2044
+ 'milestone is required — wikilink to parent HS-MIL-* (e.g. "[[HS-MIL-HMNJ-status-and-roadmap-surface-refactor]]"). See [[HS-TASK-T5PX]].',
2045
+ };
2046
+ }
2047
+ const basename = normalizeMilestoneBasename(raw);
2048
+ if (!basename) {
2049
+ return {
2050
+ error: 'INVALID_MILESTONE',
2051
+ message: `milestone is not a valid HS-MIL wikilink: ${raw}`,
2052
+ };
2053
+ }
2054
+ const spec = await readSpecViaConsole({ consoleFetch, consoleUrl, ref: basename });
2055
+ if (!spec) {
2056
+ return {
2057
+ error: 'MILESTONE_NOT_FOUND',
2058
+ message: `milestone does not exist in the console vault: ${basename} (cloud write mode — checked via GET /api/specs/${basename}, not local disk)`,
2059
+ };
2060
+ }
2061
+ const resolvedBasename =
2062
+ typeof spec.basename === 'string' && spec.basename ? spec.basename : basename;
2063
+ return {
2064
+ basename: resolvedBasename,
2065
+ wikilink: `[[${resolvedBasename}]]`,
2066
+ pillar: normalizePillarRef(spec.frontmatter?.pillar),
2067
+ };
2068
+ }
2069
+
2070
+ /**
2071
+ * Cloud twin of {@link detectSpecConflicts}. Same claim extraction and the same
2072
+ * comparison — only the per-link body read moves from `resolveWikilink` +
2073
+ * `readFileSync` to `GET /api/specs/:id`. A link the console cannot serve is
2074
+ * SKIPPED and named in `unreachable`, mirroring the disk path's "skip files
2075
+ * that can't be read" rather than failing a pre-flight advisory.
2076
+ */
2077
+ async function detectSpecConflictsViaConsole(epicContent, relatedBasenames, deps) {
2078
+ const epicClaims = extractBehavioralClaims(extractConflictCheckContent(epicContent), 'epic');
2079
+ if (epicClaims.length === 0) {
2080
+ return { conflicts: [], linked_specs_checked: [], unreachable: [] };
2081
+ }
2082
+ const conflicts = [];
2083
+ const linkedSpecsChecked = [];
2084
+ const unreachable = [];
2085
+ for (const basename of relatedBasenames) {
2086
+ let spec;
2087
+ try {
2088
+ spec = await readSpecViaConsole({ ...deps, ref: basename });
2089
+ } catch (err) {
2090
+ unreachable.push({ basename, message: String(err?.message ?? err) });
2091
+ continue;
2092
+ }
2093
+ if (!spec) continue;
2094
+ linkedSpecsChecked.push(basename);
2095
+ conflicts.push(
2096
+ ...findConflicts(epicClaims, extractBehavioralClaims(String(spec.body ?? ''), basename)),
2097
+ );
2098
+ }
2099
+ return { conflicts, linked_specs_checked: linkedSpecsChecked, unreachable };
2100
+ }
2101
+
2102
+ /** The `related:` wikilink array both cloud writers build (user links + defaults). */
2103
+ function normalizeRelatedWikilinks(related, defaults = []) {
2104
+ const list = (Array.isArray(related) ? related : []).map(
2105
+ (r) => `[[${String(r).replace(/^\[\[|\]\]$/g, '')}]]`,
2106
+ );
2107
+ return [...new Set([...list, ...defaults])];
2108
+ }
2109
+
2110
+ /** The `landed` block every cloud result carries — the twin of research-handlers'. */
2111
+ function cloudLandedBlock({ consoleUrl, relPath, consoleWrite = null, vaultRoot = null }) {
2112
+ return {
2113
+ tier: 'cloud',
2114
+ reported_by: consoleWrite ? 'console' : 'inferred_from_console_url',
2115
+ console_url: consoleUrl ?? null,
2116
+ rel_path: relPath ?? null,
2117
+ abs_path: null,
2118
+ on_disk: false,
2119
+ in_local_vault: false,
2120
+ vault_root: vaultRoot ?? null,
2121
+ console_write: consoleWrite,
2122
+ };
2123
+ }
2124
+
2125
+ /**
2126
+ * The one failure envelope both cloud entry points return. Names the step that
2127
+ * failed AND everything that already landed — there is no rollback, so a caller
2128
+ * must be able to say what state the vault is actually in.
2129
+ */
2130
+ function cloudWriteFailureResult(err, { step, landed = [], extra = {} }) {
2131
+ return {
2132
+ error: 'CONSOLE_UNREACHABLE_CLOUD_MODE',
2133
+ success: false,
2134
+ write_mode: 'cloud',
2135
+ failed_step: step,
2136
+ message: String(err?.message ?? err),
2137
+ console_url: err?.console_url ?? null,
2138
+ ...(err?.status != null ? { status: err.status } : {}),
2139
+ ...(err?.console_response ? { console_response: err.console_response } : {}),
2140
+ ...(err?.hint ? { hint: err.hint } : {}),
2141
+ already_landed: landed,
2142
+ rollback:
2143
+ 'none — the console has no un-create; the documents in `already_landed` are real and must not be re-created on a retry',
2144
+ ...extra,
2145
+ };
2146
+ }
2147
+
2148
+ /** In cloud mode the vault is not on this machine, so the id validator cannot run. */
2149
+ const CLOUD_VALIDATE_SKIPPED =
2150
+ 'skipped (cloud write mode — scripts/validate-vault-ids.mjs scans the vault on disk and this machine may hold no clone; the console validates ids server-side at create time)';
2151
+
2152
+ /**
2153
+ * `push_plan` in cloud write mode. Mirrors {@link executePushPlan}'s result
2154
+ * shape so callers read ONE shape in both modes, minus everything that only
2155
+ * exists on disk (the `file_id`/`family` mints, the milestone mirror-update,
2156
+ * the id-validator subprocess).
2157
+ */
2158
+ async function pushPlanViaConsole(vaultRoot, args, options = {}) {
2159
+ const {
2160
+ epic_name: epicName,
2161
+ summary: epicSummaryArg,
2162
+ plan_markdown: planMarkdown = '',
2163
+ implementation_prompt: implementationPrompt,
2164
+ source_plan_paths: sourcePlanPaths = [],
2165
+ children = [],
2166
+ on_deck: onDeck = false,
2167
+ focus_reason: focusReason,
2168
+ kind = 'feature',
2169
+ effort = 3,
2170
+ tags = [],
2171
+ related = [],
2172
+ skip_conflict_check: skipConflictCheck = false,
2173
+ milestone,
2174
+ // Cloud-only: POST /api/specs HARD-REQUIRES `pillar:` on an epic (route.ts
2175
+ // `pillar_required`), which the disk path never emitted because nothing on
2176
+ // disk enforced it. Honoured only here, so the local branch stays
2177
+ // byte-identical.
2178
+ pillar,
2179
+ } = args;
2180
+
2181
+ if (!epicName?.trim()) {
2182
+ return { error: 'MISSING_EPIC_NAME', message: 'epic_name is required' };
2183
+ }
2184
+ const epicSummary = resolveAuthoredSummaryValue(epicSummaryArg);
2185
+ if (!epicSummary) {
2186
+ return buildSummaryRequiredMcpError('epic');
2187
+ }
2188
+ if (Array.isArray(children) && children.some((c) => c?.name?.trim())) {
2189
+ return {
2190
+ error: 'CHILDREN_NOT_SUPPORTED',
2191
+ message:
2192
+ 'heyspex_push_plan files the epic only (`children: []`). File tasks just-in-time from the What\'s Next `/hs-next` breakdown via `heyspex_file_spec` with `summary` — not via push_plan ([[spec-hierarchy]] §empty-state grain-split).',
2193
+ procedure: 'spec-hierarchy',
2194
+ };
2195
+ }
2196
+
2197
+ const { consoleFetch, consoleUrl, actingAgent } = await resolveCloudDeps(options);
2198
+
2199
+ let milestoneResolved;
2200
+ try {
2201
+ milestoneResolved = await resolveMilestoneArgViaConsole(milestone, {
2202
+ consoleFetch,
2203
+ consoleUrl,
2204
+ });
2205
+ } catch (err) {
2206
+ return cloudWriteFailureResult(err, { step: 'resolve_milestone' });
2207
+ }
2208
+ if (milestoneResolved.error) return milestoneResolved;
2209
+
2210
+ const resolvedPillar = normalizePillarRef(pillar) ?? milestoneResolved.pillar ?? null;
2211
+ if (!resolvedPillar) {
2212
+ return {
2213
+ error: 'PILLAR_REQUIRED',
2214
+ message:
2215
+ `Cannot resolve a pillar for this epic: parent milestone "${milestoneResolved.basename}" has no \`pillar:\` and none was passed. ` +
2216
+ 'Cloud write mode files the epic through POST /api/specs, which hard-blocks an epic with no resolvable `pillar:`. ' +
2217
+ 'Pass `pillar: "[[HS-PILR-<slug>]]"`, or assign the milestone a pillar first. ' +
2218
+ 'Use heyspex_suggest_pillar to pick one against each pillar\'s written charter — never by name ([[pillar-assignment-protocol]]).',
2219
+ };
2220
+ }
2221
+
2222
+ // Reads LOCAL plan source files (Cursor `*.plan.md` etc.) into the epic body.
2223
+ // Those are this machine's own files, not vault content, and this makes no
2224
+ // vault write — so it is unchanged in cloud mode.
2225
+ const prepared = prepareVaultPlanContent(vaultRoot, {
2226
+ plan_markdown: planMarkdown,
2227
+ implementation_prompt: implementationPrompt,
2228
+ source_plan_paths: sourcePlanPaths,
2229
+ });
2230
+
2231
+ let epicBody =
2232
+ prepared.plan_markdown.trim() || `# ${epicName}\n\n## Goal\n\n_Describe outcome._\n`;
2233
+ const preparedPrompt = prepared.implementation_prompt;
2234
+ if (onDeck) {
2235
+ epicBody = `${buildOnDeckBanner(focusReason)}\n${epicBody}`;
2236
+ if (preparedPrompt?.trim()) {
2237
+ epicBody += buildNextStepSection(preparedPrompt);
2238
+ }
2239
+ }
2240
+
2241
+ const userRelatedLinks = Array.isArray(related) ? related : [];
2242
+ const uniqueRelated = normalizeRelatedWikilinks(userRelatedLinks, [
2243
+ '[[plan-ingest-agent-workflow]]',
2244
+ '[[vault-id-assignment-protocol]]',
2245
+ ]);
2246
+
2247
+ // Pre-flight conflict detection, BEFORE anything is created — same blocking
2248
+ // contract as the disk path (an on_deck push with conflicts writes nothing).
2249
+ let preflightConflicts = [];
2250
+ let preflightLinkedSpecs = [];
2251
+ let preflightUnreachable = [];
2252
+ if (!skipConflictCheck && userRelatedLinks.length > 0) {
2253
+ const fullEpicContent = [epicBody, preparedPrompt || ''].join('\n');
2254
+ const relatedBasenames = userRelatedLinks.map((r) => String(r).replace(/^\[\[|\]\]$/g, ''));
2255
+ const conflictResult = await detectSpecConflictsViaConsole(fullEpicContent, relatedBasenames, {
2256
+ consoleFetch,
2257
+ consoleUrl,
2258
+ });
2259
+ preflightConflicts = conflictResult.conflicts;
2260
+ preflightLinkedSpecs = conflictResult.linked_specs_checked;
2261
+ preflightUnreachable = conflictResult.unreachable;
2262
+
2263
+ if (onDeck && preflightConflicts.length > 0) {
2264
+ return {
2265
+ error: 'CONFLICTS_DETECTED',
2266
+ success: false,
2267
+ on_deck: true,
2268
+ blocked: true,
2269
+ write_mode: 'cloud',
2270
+ message: `${preflightConflicts.length} behavioral conflict(s) detected against linked spec(s). Resolve them or pass skip_conflict_check: true to force the push.`,
2271
+ conflicts: preflightConflicts,
2272
+ linked_specs_checked: preflightLinkedSpecs,
2273
+ has_conflicts: true,
2274
+ next_steps: [
2275
+ 'Review the conflicts below — each shows a behavioral claim in this plan that contradicts a linked spec.',
2276
+ 'Resolution options:',
2277
+ ' • Edit the NEXT STEP / acceptance criteria in plan_markdown to match the linked spec, then re-run heyspex_push_plan.',
2278
+ ' • Edit the linked spec (its Interaction model section) to match this plan, then re-run heyspex_push_plan.',
2279
+ ' • Pass skip_conflict_check: true to force the push (a conflict_override row can be recorded later via the console Dismiss-with-reason action).',
2280
+ ],
2281
+ };
2282
+ }
2283
+ }
2284
+
2285
+ // No `id`/`file_id`/`family`/`created`/`updated`: every one of those is
2286
+ // server-minted (cloud-vault-writes.mjs SERVER_MINTED_FIELDS), and `family`
2287
+ // is inherited from the milestone by `createSpec` itself.
2288
+ const epicFrontmatter = {
2289
+ kind,
2290
+ effort,
2291
+ requires_user_testing: false,
2292
+ status: onDeck ? 'in_progress' : 'open',
2293
+ milestone: milestoneResolved.wikilink,
2294
+ pillar: `[[${resolvedPillar}]]`,
2295
+ tags: tags.length > 0 ? [...tags] : ['epic'],
2296
+ // Left empty ON PURPOSE — push_plan never files children, and the console
2297
+ // back-links any later task into this array at THAT task's create time.
2298
+ children: [],
2299
+ related: uniqueRelated,
2300
+ };
2301
+ const epicMarkdown = `${epicBody}
2302
+
2303
+ ## Change log
2304
+
2305
+ | Date | Author | Change |
2306
+ |------|--------|--------|
2307
+ | ${todayDate()} | pwher (with agent via heyspex_push_plan) | Epic created via hs-push${onDeck ? '-next' : ''}. |
2308
+ `;
2309
+
2310
+ let createdEpic;
2311
+ try {
2312
+ createdEpic = await createSpecViaConsole({
2313
+ consoleFetch,
2314
+ consoleUrl,
2315
+ type: 'epic',
2316
+ name: epicName,
2317
+ summary: epicSummary,
2318
+ frontmatter: epicFrontmatter,
2319
+ body: epicMarkdown,
2320
+ actingAgent,
2321
+ });
2322
+ } catch (err) {
2323
+ return cloudWriteFailureResult(err, { step: 'create_epic' });
2324
+ }
2325
+
2326
+ const computedFocusReason = onDeck ? focusReason || `On deck: ${epicName} (hs-push-next)` : null;
2327
+ const designDecisions = getApplicableDesignDecisions(
2328
+ [epicBody, preparedPrompt || ''].join('\n'),
2329
+ );
2330
+
2331
+ const result = {
2332
+ success: true,
2333
+ write_mode: 'cloud',
2334
+ on_deck: onDeck,
2335
+ epic: {
2336
+ id: createdEpic.specId,
2337
+ basename: createdEpic.basename,
2338
+ spec_path: createdEpic.relPath,
2339
+ },
2340
+ children: [],
2341
+ landed: cloudLandedBlock({
2342
+ consoleUrl: createdEpic.consoleUrl ?? consoleUrl,
2343
+ relPath: createdEpic.relPath,
2344
+ consoleWrite: createdEpic.consoleWrite,
2345
+ vaultRoot,
2346
+ }),
2347
+ // The console back-links `epics:` on the milestone at create time
2348
+ // (KIND_LINKAGE.epic.parentArray), which is what appendEpicToMilestone did
2349
+ // on disk — so this tool does NOT hand-write the parent array in cloud mode.
2350
+ milestone_backlink: {
2351
+ milestone: milestoneResolved.basename,
2352
+ by: 'console (createSpec KIND_LINKAGE.epic.parentArray = "epics")',
2353
+ },
2354
+ focus_written: onDeck ? null : false,
2355
+ focus_reason: computedFocusReason,
2356
+ validate_vault_ids: null,
2357
+ validate_message: CLOUD_VALIDATE_SKIPPED,
2358
+ conflicts: preflightConflicts,
2359
+ linked_specs_checked: preflightLinkedSpecs,
2360
+ has_conflicts: preflightConflicts.length > 0,
2361
+ design_decisions: designDecisions,
2362
+ has_design_decisions: designDecisions.length > 0,
2363
+ plans_ingested: prepared.plans_ingested,
2364
+ ingested_plans: prepared.ingested_plans,
2365
+ missing_plans: prepared.missing_plans,
2366
+ ...(Array.isArray(createdEpic.warnings) && createdEpic.warnings.length > 0
2367
+ ? { console_warnings: createdEpic.warnings }
2368
+ : {}),
2369
+ ...(preflightUnreachable.length > 0
2370
+ ? { conflict_check_unreachable: preflightUnreachable }
2371
+ : {}),
2372
+ next_steps: [
2373
+ 'heyspex_validate_linkage',
2374
+ onDeck
2375
+ ? 'heyspex_next_on_deck or /hs-next — break into tasks via the breakdown brief (heyspex_file_spec with summary)'
2376
+ : 'Optional: heyspex_push_plan on_deck after review',
2377
+ 'heyspex_register_spec if epic should appear in roadmap manifest',
2378
+ 'Record the WIP ledger (heyspex_record_wip) for the epic if this is the new focus (next-session-handoff.md retired — HS-EPIC-HNDR)',
2379
+ ],
2380
+ };
2381
+
2382
+ if (result.conflicts.length > 0) {
2383
+ result.next_steps.unshift(
2384
+ `⚠️ ${result.conflicts.length} spec conflict(s) detected — review before implementation`,
2385
+ );
2386
+ }
2387
+ if (designDecisions.length > 0) {
2388
+ result.next_steps.unshift(
2389
+ `📐 ${designDecisions.length} design decision(s) apply — review before implementation`,
2390
+ );
2391
+ }
2392
+ return result;
2393
+ }
2394
+
2395
+ /**
2396
+ * `backfill_epic` in cloud write mode — the CREATE-FIRST / LINK-SECOND flow.
2397
+ *
2398
+ * Order is load-bearing: the epic must exist before a task can name it as
2399
+ * `parent:`, and the review doc must come last because it cites both the epic
2400
+ * and every task by basename — and in cloud mode a basename is only knowable
2401
+ * once the console has answered.
2402
+ *
2403
+ * The `children:` array is NOT hand-written: `createSpec` back-links each task
2404
+ * into its epic at the task's own create time. The roadmap manifest has no
2405
+ * console route at all and degrades explicitly (see
2406
+ * {@link CLOUD_UNSUPPORTED_ROADMAP_WRITE}).
2407
+ */
2408
+ async function shipEpicViaConsole(vaultRoot, args, options = {}) {
2409
+ const {
2410
+ epic_name: epicName,
2411
+ summary = '',
2412
+ plan_markdown: planMarkdown = '',
2413
+ children = [],
2414
+ files_changed: filesChanged = [],
2415
+ kind = 'feature',
2416
+ effort = 3,
2417
+ tags = [],
2418
+ related = [],
2419
+ roadmap_id: roadmapId = 'HS-ROADMAP-PRODUCT',
2420
+ roadmap_phase: roadmapPhase = 'P0',
2421
+ skip_roadmap_registration: skipRoadmapRegistration = false,
2422
+ milestone,
2423
+ pillar,
2424
+ } = args;
2425
+
2426
+ if (!epicName?.trim()) {
2427
+ return { error: 'MISSING_EPIC_NAME', message: 'epic_name is required' };
2428
+ }
2429
+ const epicSummary = resolveAuthoredSummaryValue(summary);
2430
+ if (!epicSummary) {
2431
+ return buildSummaryRequiredMcpError('epic');
2432
+ }
2433
+
2434
+ const { consoleFetch, consoleUrl, actingAgent } = await resolveCloudDeps(options);
2435
+
2436
+ let milestoneResolved;
2437
+ try {
2438
+ milestoneResolved = await resolveMilestoneArgViaConsole(milestone, {
2439
+ consoleFetch,
2440
+ consoleUrl,
2441
+ });
2442
+ } catch (err) {
2443
+ return cloudWriteFailureResult(err, { step: 'resolve_milestone' });
2444
+ }
2445
+ if (milestoneResolved.error) return milestoneResolved;
2446
+
2447
+ const resolvedPillar = normalizePillarRef(pillar) ?? milestoneResolved.pillar ?? null;
2448
+ if (!resolvedPillar) {
2449
+ return {
2450
+ error: 'PILLAR_REQUIRED',
2451
+ message:
2452
+ `Cannot resolve a pillar for this epic: parent milestone "${milestoneResolved.basename}" has no \`pillar:\` and none was passed. ` +
2453
+ 'Pass `pillar: "[[HS-PILR-<slug>]]"`, or assign the milestone a pillar first. ' +
2454
+ 'Use heyspex_suggest_pillar to pick one against each pillar\'s written charter — never by name ([[pillar-assignment-protocol]]).',
2455
+ };
2456
+ }
2457
+
2458
+ // Validate EVERY child before creating anything: a summary-less child must
2459
+ // not surface after half the vault has already been written.
2460
+ const plannedChildren = [];
2461
+ for (const child of children) {
2462
+ if (!child?.name?.trim()) continue;
2463
+ const childSummary = resolveAuthoredSummaryValue(child.summary);
2464
+ if (!childSummary) {
2465
+ return {
2466
+ ...buildSummaryRequiredMcpError('task'),
2467
+ message: `Child task "${child.name}" requires a plain-language \`summary\` when backfilled — pass \`summary\` on each child per spec-summary-authoring.`,
2468
+ };
2469
+ }
2470
+ plannedChildren.push({ name: child.name, summary: childSummary });
2471
+ }
2472
+
2473
+ const relatedWikilinks = normalizeRelatedWikilinks(related);
2474
+ const epicBody = planMarkdown.trim() || `# ${epicName}\n\n${epicSummary}\n`;
2475
+ const epicFrontmatter = {
2476
+ kind,
2477
+ effort,
2478
+ requires_user_testing: false,
2479
+ status: 'in_review',
2480
+ milestone: milestoneResolved.wikilink,
2481
+ pillar: `[[${resolvedPillar}]]`,
2482
+ tags: tags.length > 0 ? [...tags] : ['epic'],
2483
+ // Empty at create time BY CONSTRUCTION — the task basenames do not exist
2484
+ // yet. Each task back-links itself into this array when the console creates
2485
+ // it (KIND_LINKAGE.task.parentArray = "children").
2486
+ children: [],
2487
+ related: relatedWikilinks,
2488
+ };
2489
+
2490
+ const landed = [];
2491
+
2492
+ // ── Step 1: the epic (create-first) ────────────────────────────────────────
2493
+ let createdEpic;
2494
+ try {
2495
+ createdEpic = await createSpecViaConsole({
2496
+ consoleFetch,
2497
+ consoleUrl,
2498
+ type: 'epic',
2499
+ name: epicName,
2500
+ summary: epicSummary,
2501
+ frontmatter: epicFrontmatter,
2502
+ body: `${epicBody}
2503
+
2504
+ ## Change log
2505
+
2506
+ | Date | Author | Change |
2507
+ |------|--------|--------|
2508
+ | ${todayDate()} | pwher (with agent via heyspex_backfill_epic) | Epic created retroactively, backfilled to review. |
2509
+ `,
2510
+ actingAgent,
2511
+ });
2512
+ } catch (err) {
2513
+ return cloudWriteFailureResult(err, { step: 'create_epic', landed });
2514
+ }
2515
+ const epicBasename = createdEpic.basename;
2516
+ landed.push({
2517
+ step: 'create_epic',
2518
+ type: 'epic',
2519
+ id: createdEpic.specId,
2520
+ basename: epicBasename,
2521
+ rel_path: createdEpic.relPath,
2522
+ });
2523
+
2524
+ // ── Step 2: the child tasks (link-second — the parent is now knowable) ─────
2525
+ const childSpecs = [];
2526
+ const childWikilinks = [];
2527
+ for (const child of plannedChildren) {
2528
+ let createdTask;
2529
+ try {
2530
+ createdTask = await createSpecViaConsole({
2531
+ consoleFetch,
2532
+ consoleUrl,
2533
+ type: 'task',
2534
+ name: child.name,
2535
+ summary: child.summary,
2536
+ frontmatter: {
2537
+ kind,
2538
+ effort,
2539
+ requires_user_testing: false,
2540
+ status: 'in_review',
2541
+ tags: ['shipped'],
2542
+ parent: `[[${epicBasename}]]`,
2543
+ },
2544
+ body: `# ${child.name}
2545
+
2546
+ _Completed as part of [[${epicBasename}]]._
2547
+
2548
+ ## Change log
2549
+
2550
+ | Date | Author | Change |
2551
+ |------|--------|--------|
2552
+ | ${todayDate()} | pwher (with agent via heyspex_backfill_epic) | Task backfilled to review as part of [[${epicBasename}]]. |
2553
+ `,
2554
+ actingAgent,
2555
+ });
2556
+ } catch (err) {
2557
+ return cloudWriteFailureResult(err, {
2558
+ step: 'create_task',
2559
+ landed,
2560
+ extra: { failed_task: child.name },
2561
+ });
2562
+ }
2563
+ landed.push({
2564
+ step: 'create_task',
2565
+ type: 'task',
2566
+ id: createdTask.specId,
2567
+ basename: createdTask.basename,
2568
+ rel_path: createdTask.relPath,
2569
+ });
2570
+ childWikilinks.push(`[[${createdTask.basename}]]`);
2571
+ childSpecs.push({
2572
+ id: createdTask.specId,
2573
+ basename: createdTask.basename,
2574
+ relPath: createdTask.relPath,
2575
+ name: child.name,
2576
+ summary: child.summary,
2577
+ });
2578
+ }
2579
+
2580
+ // ── Step 3: the review doc (cites the epic AND every task — so, last) ─────
2581
+ const filesTable =
2582
+ filesChanged.length > 0
2583
+ ? filesChanged.map((f) => `| ${f} | Modified |`).join('\n')
2584
+ : '| _No files listed_ | - |';
2585
+ const reviewBody = `# Review: ${epicName}
2586
+
2587
+ ## Pre-Review Checklist
2588
+
2589
+ - [ ] Code changes reviewed
2590
+ - [ ] Tests passing (if applicable)
2591
+ - [ ] No regressions introduced
2592
+ - [ ] Documentation updated (if applicable)
2593
+
2594
+ ## Files Changed
2595
+
2596
+ | File | Change Type |
2597
+ |------|-------------|
2598
+ ${filesTable}
2599
+
2600
+ ## Summary
2601
+
2602
+ ${epicSummary}
2603
+
2604
+ ## Child Tasks
2605
+
2606
+ ${childWikilinks.length > 0 ? childWikilinks.map((c) => `- ${c}`).join('\n') : '_No child tasks._'}
2607
+
2608
+ ## Notes
2609
+
2610
+ _Space for reviewer comments._
2611
+ `;
2612
+ let createdReview;
2613
+ try {
2614
+ createdReview = await createSpecViaConsole({
2615
+ consoleFetch,
2616
+ consoleUrl,
2617
+ type: 'review',
2618
+ name: `Review: ${epicName}`,
2619
+ summary: epicSummary,
2620
+ frontmatter: {
2621
+ // `parent:` is REQUIRED for a review by the console's linkage rules
2622
+ // (KIND_LINKAGE.review.parentRequired); `for:` preserves the field the
2623
+ // on-disk `<epic>.review.md` carried so existing readers still resolve.
2624
+ parent: `[[${epicBasename}]]`,
2625
+ for: `[[${epicBasename}]]`,
2626
+ },
2627
+ body: reviewBody,
2628
+ actingAgent,
2629
+ });
2630
+ } catch (err) {
2631
+ return cloudWriteFailureResult(err, { step: 'create_review_doc', landed });
2632
+ }
2633
+ landed.push({
2634
+ step: 'create_review_doc',
2635
+ type: 'review',
2636
+ id: createdReview.specId,
2637
+ basename: createdReview.basename,
2638
+ rel_path: createdReview.relPath,
2639
+ });
2640
+
2641
+ // ── Step 4: the roadmap manifest — NO CLOUD PATH, degrade out loud ────────
2642
+ const roadmapRegistration = skipRoadmapRegistration
2643
+ ? { success: true, message: 'Skipped (skip_roadmap_registration: true)' }
2644
+ : {
2645
+ success: false,
2646
+ ...CLOUD_UNSUPPORTED_ROADMAP_WRITE,
2647
+ roadmap_id: roadmapId,
2648
+ roadmap_phase: roadmapPhase,
2649
+ epic_basename: epicBasename,
2650
+ message:
2651
+ `Roadmap registration was SKIPPED in cloud write mode: ${CLOUD_UNSUPPORTED_ROADMAP_WRITE.why} `
2652
+ + 'Nothing was written to local disk and no substitute document was minted. '
2653
+ + `To land it, add "${epicBasename}" to phase ${roadmapPhase} of ${roadmapId} by hand, or run this backfill in local write mode.`,
2654
+ };
2655
+
2656
+ return {
2657
+ success: true,
2658
+ write_mode: 'cloud',
2659
+ shipped_to_review: true,
2660
+ epic: {
2661
+ id: createdEpic.specId,
2662
+ basename: epicBasename,
2663
+ spec_path: createdEpic.relPath,
2664
+ },
2665
+ children: childSpecs,
2666
+ review_doc: createdReview.relPath,
2667
+ landed: cloudLandedBlock({
2668
+ consoleUrl: createdEpic.consoleUrl ?? consoleUrl,
2669
+ relPath: createdEpic.relPath,
2670
+ consoleWrite: createdEpic.consoleWrite,
2671
+ vaultRoot,
2672
+ }),
2673
+ console_writes: landed,
2674
+ children_backlink: {
2675
+ by: 'console (createSpec KIND_LINKAGE.task.parentArray = "children")',
2676
+ note: 'each task back-links itself into the epic `children:` array at its own create time — this tool does not hand-write the array in cloud mode',
2677
+ },
2678
+ milestone_backlink: {
2679
+ milestone: milestoneResolved.basename,
2680
+ by: 'console (createSpec KIND_LINKAGE.epic.parentArray = "epics")',
2681
+ },
2682
+ roadmap_registration: roadmapRegistration,
2683
+ ...(skipRoadmapRegistration
2684
+ ? {}
2685
+ : { cloud_mode_unsupported_writes: [CLOUD_UNSUPPORTED_ROADMAP_WRITE] }),
2686
+ validate_vault_ids: null,
2687
+ validate_message: CLOUD_VALIDATE_SKIPPED,
2688
+ next_steps: [
2689
+ roadmapRegistration.success
2690
+ ? `Roadmap registration: ${roadmapRegistration.message}`
2691
+ : `⚠️ Roadmap registration has NO cloud path — add "${epicBasename}" to ${roadmapPhase} of ${roadmapId} by hand, or the epic will not appear in the kanban Review column`,
2692
+ 'Human reviews the epic and child tasks',
2693
+ 'Move to status: closed after verification',
2694
+ 'Record the WIP ledger (heyspex_record_wip) for the epic if needed (next-session-handoff.md retired — HS-EPIC-HNDR)',
2695
+ ],
2696
+ };
2697
+ }
2698
+
2699
+ /** Internals exposed for tests only. */
2700
+ export const __testables = {
2701
+ CLOUD_UNSUPPORTED_ROADMAP_WRITE,
2702
+ CLOUD_VALIDATE_SKIPPED,
2703
+ detectSpecConflictsViaConsole,
2704
+ normalizeRelatedWikilinks,
2705
+ pushPlanViaConsole,
2706
+ readSpecViaConsole,
2707
+ resolveCloudDeps,
2708
+ resolveMilestoneArgViaConsole,
2709
+ resolveWriteModeForPlanPush,
2710
+ shipEpicViaConsole,
2711
+ };