heyspex 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (352) hide show
  1. package/.claude-plugin/marketplace.json +14 -0
  2. package/README.md +21 -0
  3. package/bin/hs.mjs +1089 -104
  4. package/bin/spexcode.mjs +262 -0
  5. package/dev-factory/engine/worktree.mjs +740 -0
  6. package/dev-factory/lib/code-repo-root.mjs +67 -0
  7. package/dev-factory/lib/context-budget.mjs +84 -0
  8. package/dev-factory/lib/epic-detect.mjs +190 -0
  9. package/dev-factory/lib/fleet-queue-file.mjs +846 -0
  10. package/dev-factory/lib/fleet-queue.mjs +426 -0
  11. package/dev-factory/lib/fleet-state.mjs +14 -18
  12. package/dev-factory/lib/model-catalog.mjs +338 -0
  13. package/dev-factory/lib/seat-context.mjs +199 -0
  14. package/dev-factory/lib/spec-io.mjs +93 -0
  15. package/dev-factory/lib/sufficiency-score.mjs +8 -2
  16. package/dev-factory/lib/worktree-allocator.mjs +744 -0
  17. package/dev-factory/model-catalog.json +222 -0
  18. package/dist/cli/hs-check.mjs +1127 -103
  19. package/dist/cli/hs-claim.mjs +981 -63
  20. package/dist/cli/hs-connect.mjs +10059 -1729
  21. package/dist/cli/hs-defer.mjs +5507 -3072
  22. package/dist/cli/hs-dev.mjs +576 -0
  23. package/dist/cli/hs-env.mjs +416 -302
  24. package/dist/cli/hs-index-rebuild.mjs +7334 -0
  25. package/dist/cli/hs-index-reconcile.mjs +8138 -0
  26. package/dist/cli/hs-init.mjs +2840 -149
  27. package/dist/cli/hs-install.mjs +7214 -594
  28. package/dist/cli/hs-join.mjs +10640 -2421
  29. package/dist/cli/hs-vault-pull.mjs +8157 -6370
  30. package/dist/cli/hs-vault-push.mjs +6433 -0
  31. package/dist/cli/hs-vault.mjs +4029 -1555
  32. package/dist/cli/hs-workspace.mjs +2702 -538
  33. package/dist/cli-templates/customer-vault/specs/entity-model.md +594 -601
  34. package/dist/cli-templates/customer-vault/specs/protocols/canonical-frontmatter-templates.md +110 -177
  35. package/dist/cli-templates/customer-vault/specs/protocols/destructive-action-checklist.md +122 -0
  36. package/dist/cli-templates/customer-vault/specs/protocols/memory-protocol.md +13 -15
  37. package/dist/cli-templates/customer-vault/specs/protocols/session-close-protocol.md +91 -0
  38. package/dist/cli-templates/customer-vault/specs/protocols/spec-hierarchy.md +150 -168
  39. package/dist/cli-templates/customer-vault/specs/protocols/spec-summary-authoring.md +83 -0
  40. package/dist/cli-templates/harness-commands/claude/hs-auto.md +36 -0
  41. package/dist/cli-templates/harness-commands/claude/hs-backfill.md +28 -0
  42. package/dist/cli-templates/harness-commands/claude/hs-capture.md +62 -0
  43. package/dist/cli-templates/harness-commands/claude/hs-defer.md +29 -0
  44. package/dist/cli-templates/harness-commands/claude/hs-dev.md +20 -0
  45. package/dist/cli-templates/harness-commands/claude/hs-doctor.md +32 -0
  46. package/dist/cli-templates/harness-commands/claude/hs-ground.md +30 -0
  47. package/dist/cli-templates/harness-commands/claude/hs-handoff.md +33 -0
  48. package/dist/cli-templates/harness-commands/claude/hs-log.md +27 -0
  49. package/dist/cli-templates/harness-commands/claude/hs-next.md +29 -0
  50. package/dist/cli-templates/harness-commands/claude/hs-promote.md +70 -0
  51. package/dist/cli-templates/harness-commands/claude/hs-push.md +35 -0
  52. package/dist/cli-templates/harness-commands/claude/hs-qc.md +27 -0
  53. package/dist/cli-templates/harness-commands/claude/hs-res.md +63 -0
  54. package/dist/cli-templates/harness-commands/claude/hs-resolve.md +23 -0
  55. package/dist/cli-templates/harness-commands/claude/hs-review.md +23 -0
  56. package/dist/cli-templates/harness-commands/claude/hs-view.md +28 -0
  57. package/dist/cli-templates/harness-commands/claude/kill-ports.md +32 -0
  58. package/dist/cli-templates/harness-commands/claude/prototype.md +91 -0
  59. package/dist/cli-templates/harness-commands/cursor/hs-auto.md +40 -0
  60. package/dist/cli-templates/harness-commands/cursor/hs-backfill.md +31 -0
  61. package/dist/cli-templates/harness-commands/cursor/hs-capture.md +65 -0
  62. package/dist/cli-templates/harness-commands/cursor/hs-defer.md +32 -0
  63. package/dist/cli-templates/harness-commands/cursor/hs-dev.md +24 -0
  64. package/dist/cli-templates/harness-commands/cursor/hs-doctor.md +36 -0
  65. package/dist/cli-templates/harness-commands/cursor/hs-ground.md +33 -0
  66. package/dist/cli-templates/harness-commands/cursor/hs-handoff.md +37 -0
  67. package/dist/cli-templates/harness-commands/cursor/hs-log.md +31 -0
  68. package/dist/cli-templates/harness-commands/cursor/hs-next.md +32 -0
  69. package/dist/cli-templates/harness-commands/cursor/hs-promote.md +73 -0
  70. package/dist/cli-templates/harness-commands/cursor/hs-push.md +38 -0
  71. package/dist/cli-templates/harness-commands/cursor/hs-qc.md +30 -0
  72. package/dist/cli-templates/harness-commands/cursor/hs-res.md +67 -0
  73. package/dist/cli-templates/harness-commands/cursor/hs-resolve.md +26 -0
  74. package/dist/cli-templates/harness-commands/cursor/hs-review.md +26 -0
  75. package/dist/cli-templates/harness-commands/cursor/hs-view.md +32 -0
  76. package/dist/cli-templates/harness-commands/cursor/kill-ports.md +35 -0
  77. package/dist/cli-templates/harness-commands/cursor/prototype.md +95 -0
  78. package/docs/copilot-plugin-install.md +162 -0
  79. package/docs/driving-heyspex-from-copilot.md +165 -0
  80. package/docs/mcp-client-config.md +259 -6
  81. package/package.json +249 -124
  82. package/plugins/heyspex/.claude-plugin/plugin.json +12 -0
  83. package/plugins/heyspex/.cursor-plugin/plugin.json +15 -0
  84. package/plugins/heyspex/.mcp.json +12 -0
  85. package/plugins/heyspex/README.md +309 -0
  86. package/plugins/heyspex/bin/launch-mcp.mjs +95 -0
  87. package/plugins/heyspex/bin/run-hook.mjs +80 -0
  88. package/plugins/heyspex/hooks/cursor-hooks.json +308 -0
  89. package/plugins/heyspex/hooks/hooks.json +418 -0
  90. package/plugins/heyspex/mcp.json +13 -0
  91. package/scripts/check-mcp-freshness.mjs +17 -9
  92. package/scripts/claude-hooks/_env-vault-events.mjs +68 -0
  93. package/scripts/claude-hooks/_guardrail-events.mjs +96 -0
  94. package/scripts/claude-hooks/_guardrail-loader.mjs +494 -0
  95. package/scripts/claude-hooks/_intent-classifier-router.mjs +456 -0
  96. package/scripts/claude-hooks/_intent-classifier-semantic.mjs +356 -0
  97. package/scripts/claude-hooks/_run.mjs +413 -0
  98. package/scripts/claude-hooks/_transcript-text.mjs +60 -0
  99. package/scripts/claude-hooks/_vault-retrieval.mjs +322 -0
  100. package/scripts/claude-hooks/agent-spawn-delegation-gate.mjs +133 -0
  101. package/scripts/claude-hooks/capture-execution-gate.mjs +51 -0
  102. package/scripts/claude-hooks/capture-workflow-gate.mjs +11 -0
  103. package/scripts/claude-hooks/check-auto-memory-budget.mjs +396 -0
  104. package/scripts/claude-hooks/check-claude-md-budget.mjs +327 -0
  105. package/scripts/claude-hooks/check-mcp-freshness-hook.mjs +14 -0
  106. package/scripts/claude-hooks/claim-before-code-gate.mjs +11 -0
  107. package/scripts/claude-hooks/claim-liveness-shared.mjs +577 -0
  108. package/scripts/claude-hooks/close-terminal-on-ship.mjs +64 -0
  109. package/scripts/claude-hooks/coo-denylist-gate.mjs +11 -0
  110. package/scripts/claude-hooks/coo-guard-check.mjs +11 -0
  111. package/scripts/claude-hooks/deep-research-cost-gate.mjs +11 -0
  112. package/scripts/claude-hooks/dev-server-task-reminder.mjs +15 -0
  113. package/scripts/claude-hooks/diagnose-capture-reminder.mjs +11 -0
  114. package/scripts/claude-hooks/directive-obligation-gate.mjs +11 -0
  115. package/scripts/claude-hooks/emit-claim-status.mjs +41 -0
  116. package/scripts/claude-hooks/enforce-session-sequence.mjs +11 -0
  117. package/scripts/claude-hooks/enforce-vault-naming.mjs +11 -0
  118. package/scripts/claude-hooks/env-vault-guard.mjs +11 -0
  119. package/scripts/claude-hooks/epic-milestone-phrasing-gate.mjs +11 -0
  120. package/scripts/claude-hooks/git-identity-guard.mjs +11 -0
  121. package/scripts/claude-hooks/git-reflog-audit.mjs +15 -0
  122. package/scripts/claude-hooks/git-stash-gate.mjs +11 -0
  123. package/scripts/claude-hooks/guard-auto-memory-writes.mjs +137 -0
  124. package/scripts/claude-hooks/id-display-format-gate.mjs +11 -0
  125. package/scripts/claude-hooks/inject-communication-style.mjs +86 -0
  126. package/scripts/claude-hooks/inject-coo-seat.mjs +78 -0
  127. package/scripts/claude-hooks/inject-dev-server-awareness.mjs +11 -0
  128. package/scripts/claude-hooks/inject-doctrine.mjs +13 -0
  129. package/scripts/claude-hooks/inject-vault-context.mjs +14 -0
  130. package/scripts/claude-hooks/intent-classifier-trigger.mjs +279 -0
  131. package/scripts/claude-hooks/log-session-activity.mjs +341 -0
  132. package/scripts/claude-hooks/naughty-list-gate.mjs +11 -0
  133. package/scripts/claude-hooks/notify-status-flip.mjs +72 -0
  134. package/scripts/claude-hooks/plan-exit-ingest-gate.mjs +170 -0
  135. package/scripts/claude-hooks/plan-ingest-close-reminder.mjs +66 -0
  136. package/scripts/claude-hooks/plan-review-gate.mjs +11 -0
  137. package/scripts/claude-hooks/preflight-reminder.mjs +262 -0
  138. package/scripts/claude-hooks/protected-action-intent.mjs +221 -0
  139. package/scripts/claude-hooks/research-method-guard.mjs +398 -0
  140. package/scripts/claude-hooks/research-protocol-gate.mjs +11 -0
  141. package/scripts/claude-hooks/session-end.mjs +451 -0
  142. package/scripts/claude-hooks/settings-write-gate.mjs +11 -0
  143. package/scripts/claude-hooks/settlement-check-gate.mjs +39 -0
  144. package/scripts/claude-hooks/status-flip-detect.mjs +17 -0
  145. package/scripts/claude-hooks/terminal-close-shared.mjs +426 -0
  146. package/scripts/claude-hooks/terminal-close-watcher.mjs +122 -0
  147. package/scripts/claude-hooks/vault-mirror-catchup.mjs +11 -0
  148. package/scripts/claude-hooks/vault-write-tier-guard.mjs +11 -0
  149. package/scripts/claude-hooks/wip-checkpoint.mjs +302 -0
  150. package/scripts/cursor-hooks/manifest.mjs +281 -0
  151. package/scripts/dev-server-status.mjs +34 -13
  152. package/scripts/dev-server-stop.mjs +313 -0
  153. package/scripts/heyspex-mode.mjs +288 -0
  154. package/scripts/hs-auto-resolve.mjs +240 -0
  155. package/scripts/hs-auto-source.mjs +398 -0
  156. package/scripts/hs-auto.mjs +134 -0
  157. package/scripts/hs-capture-cli.mjs +326 -0
  158. package/scripts/hs-classifier.mjs +136 -0
  159. package/scripts/hs-companion.mjs +88 -0
  160. package/scripts/hs-gateway.mjs +115 -0
  161. package/scripts/hs-mode.mjs +125 -0
  162. package/scripts/hs-next.mjs +441 -0
  163. package/scripts/hs-settings.mjs +338 -0
  164. package/scripts/hs-view.mjs +496 -0
  165. package/scripts/hs-worktree.mjs +201 -0
  166. package/scripts/install-claude-code-hooks.mjs +272 -0
  167. package/scripts/install-cursor-hooks.mjs +471 -0
  168. package/scripts/lib/agent-env-markers.mjs +38 -0
  169. package/scripts/lib/console-url-resolver.mjs +199 -61
  170. package/scripts/lib/customer-kit.mjs +1203 -0
  171. package/scripts/lib/dev-mode.mjs +249 -32
  172. package/scripts/lib/directive-obligations.mjs +349 -0
  173. package/scripts/lib/heyspex-home.mjs +72 -0
  174. package/scripts/lib/hook-dispositions.mjs +210 -0
  175. package/scripts/lib/hook-vault-header.mjs +30 -0
  176. package/scripts/lib/hs-command.mjs +277 -0
  177. package/scripts/lib/hs-companion-settings.mjs +472 -0
  178. package/scripts/lib/hs-source-branch.mjs +196 -0
  179. package/scripts/lib/hs-source-commit.mjs +59 -5
  180. package/scripts/lib/hs-source-identity.mjs +526 -0
  181. package/scripts/lib/hs-source-log.mjs +13 -22
  182. package/scripts/lib/hs-source-preflight.mjs +18 -4
  183. package/scripts/lib/hs-source-push.mjs +386 -307
  184. package/scripts/lib/hs-worktree-args.mjs +115 -0
  185. package/scripts/lib/shared-path-helpers.mjs +134 -0
  186. package/scripts/lib/standalone-doctor.mjs +540 -3
  187. package/scripts/lib/subagent-git-lock.mjs +109 -0
  188. package/scripts/lib/template-pin.mjs +34 -0
  189. package/scripts/lib/window-placement.mjs +109 -0
  190. package/scripts/provenance-lib.mjs +91 -0
  191. package/scripts/stamp-provenance-on-edit.mjs +222 -0
  192. package/scripts/wip-ledger-health.mjs +653 -0
  193. package/spexharness/doctrine/baseline/SPEXCODE-DOCTRINE.baseline.json +29 -0
  194. package/spexharness/src/adapters/audit.mjs +197 -0
  195. package/spexharness/src/adapters/claude-code.mjs +449 -0
  196. package/spexharness/src/adapters/copilot.mjs +236 -0
  197. package/spexharness/src/adapters/detect-harness.mjs +34 -0
  198. package/spexharness/src/chat/tui/ansi.mjs +448 -0
  199. package/spexharness/src/chat/tui/palette.mjs +22 -0
  200. package/spexharness/src/chat/tui/select.mjs +208 -0
  201. package/spexharness/src/config-root.mjs +54 -0
  202. package/spexharness/src/context-budget.mjs +141 -0
  203. package/spexharness/src/dev-server-control.mjs +389 -0
  204. package/spexharness/src/doctrine/behaviour.mjs +371 -0
  205. package/spexharness/src/doctrine/corpus.mjs +733 -0
  206. package/spexharness/src/doctrine/inject.mjs +338 -0
  207. package/spexharness/src/doctrine/merge.mjs +184 -0
  208. package/spexharness/src/doctrine/sources.mjs +252 -0
  209. package/spexharness/src/doctrine/synthesize.mjs +401 -0
  210. package/spexharness/src/doctrine/vault-source.mjs +574 -0
  211. package/spexharness/src/doctrine/writer.mjs +176 -0
  212. package/spexharness/src/events.mjs +84 -0
  213. package/spexharness/src/gates/_shell-write-targets.mjs +77 -0
  214. package/spexharness/src/gates/capture-workflow-gate.mjs +202 -0
  215. package/spexharness/src/gates/claim-before-code-gate.decide.mjs +33 -0
  216. package/spexharness/src/gates/claim-before-code-gate.mjs +129 -0
  217. package/spexharness/src/gates/deep-research-cost-gate.mjs +275 -0
  218. package/spexharness/src/gates/diagnose-capture-reminder.mjs +112 -0
  219. package/spexharness/src/gates/directive-obligation-gate.decide.mjs +132 -0
  220. package/spexharness/src/gates/directive-obligation-gate.mjs +183 -0
  221. package/spexharness/src/gates/enforce-session-sequence.mjs +895 -0
  222. package/spexharness/src/gates/enforce-vault-naming.mjs +215 -0
  223. package/spexharness/src/gates/epic-milestone-phrasing-gate.mjs +233 -0
  224. package/spexharness/src/gates/git-identity-guard.decide.mjs +436 -0
  225. package/spexharness/src/gates/git-identity-guard.mjs +61 -0
  226. package/spexharness/src/gates/git-reflog-audit.mjs +151 -0
  227. package/spexharness/src/gates/git-stash-gate.decide.mjs +361 -0
  228. package/spexharness/src/gates/git-stash-gate.mjs +159 -0
  229. package/spexharness/src/gates/id-display-format-gate.mjs +190 -0
  230. package/spexharness/src/gates/inject-doctrine-gate.mjs +89 -0
  231. package/spexharness/src/gates/inject-vault-context.mjs +239 -0
  232. package/spexharness/src/gates/naughty-list-gate.mjs +213 -0
  233. package/spexharness/src/gates/plan-review-gate.mjs +233 -0
  234. package/spexharness/src/gates/preflight-reminder-gate.mjs +265 -0
  235. package/spexharness/src/gates/research-protocol-gate.mjs +175 -0
  236. package/spexharness/src/gates/settings-write-gate.decide.mjs +96 -0
  237. package/spexharness/src/gates/settings-write-gate.mjs +104 -0
  238. package/spexharness/src/gates/vault-mirror-catchup.mjs +124 -0
  239. package/spexharness/src/gates/vault-write-tier-guard.decide.mjs +125 -0
  240. package/spexharness/src/gates/vault-write-tier-guard.mjs +42 -0
  241. package/spexharness/src/git-reflog-audit.mjs +294 -0
  242. package/spexharness/src/intent-classifier.mjs +196 -0
  243. package/spexharness/src/intent-directive.mjs +626 -0
  244. package/spexharness/src/profile/learn.mjs +551 -0
  245. package/spexharness/src/profile/proposals.mjs +357 -0
  246. package/spexharness/src/profile/vault-inclusion.mjs +386 -0
  247. package/spexharness/src/redact.mjs +151 -0
  248. package/spexharness/src/rule-table.mjs +849 -0
  249. package/spexharness/src/seat-context.mjs +122 -0
  250. package/spexharness/src/session-bootstrap.mjs +512 -0
  251. package/spexharness/src/session-record.mjs +815 -0
  252. package/spexharness/src/token-inventory.mjs +207 -0
  253. package/vendor/heyspex-mcp/account-switch.mjs +96 -0
  254. package/vendor/heyspex-mcp/alignment-gate.mjs +315 -1
  255. package/vendor/heyspex-mcp/blocker-resolver.mjs +37 -2
  256. package/vendor/heyspex-mcp/breakdown-ladder.mjs +119 -4
  257. package/vendor/heyspex-mcp/breakdown-source.mjs +271 -0
  258. package/vendor/heyspex-mcp/breakdown-writer.mjs +23 -3
  259. package/vendor/heyspex-mcp/capture-buffer-patches.mjs +77 -8
  260. package/vendor/heyspex-mcp/capture-channel.mjs +55 -5
  261. package/vendor/heyspex-mcp/capture-console-client.mjs +117 -3
  262. package/vendor/heyspex-mcp/capture-focus.mjs +1061 -31
  263. package/vendor/heyspex-mcp/capture-outbox.mjs +15 -5
  264. package/vendor/heyspex-mcp/capture-sources.mjs +195 -0
  265. package/vendor/heyspex-mcp/capture-tools.mjs +1264 -0
  266. package/vendor/heyspex-mcp/claim-tools.mjs +1147 -0
  267. package/vendor/heyspex-mcp/cli-verbs.mjs +2074 -0
  268. package/vendor/heyspex-mcp/cli.mjs +207 -15524
  269. package/vendor/heyspex-mcp/close-branch-report.mjs +102 -0
  270. package/vendor/heyspex-mcp/cloud-tool-surface.mjs +128 -0
  271. package/vendor/heyspex-mcp/cloud-vault-writes.mjs +602 -0
  272. package/vendor/heyspex-mcp/config-seed.yaml +15 -0
  273. package/vendor/heyspex-mcp/config-settings.mjs +290 -7
  274. package/vendor/heyspex-mcp/console-fetch.mjs +822 -17
  275. package/vendor/heyspex-mcp/console-transport.mjs +172 -0
  276. package/vendor/heyspex-mcp/credentials.mjs +585 -0
  277. package/vendor/heyspex-mcp/defer-spec.mjs +44 -4
  278. package/vendor/heyspex-mcp/deferred-reclaim.mjs +29 -3
  279. package/vendor/heyspex-mcp/design-handlers.mjs +31 -0
  280. package/vendor/heyspex-mcp/design-tools.mjs +4 -1
  281. package/vendor/heyspex-mcp/dev-factory-bridge.mjs +4 -4
  282. package/vendor/heyspex-mcp/dev-mode-core.mjs +45 -0
  283. package/vendor/heyspex-mcp/dev-tools.mjs +131 -0
  284. package/vendor/heyspex-mcp/diagnose-handlers.mjs +54 -0
  285. package/vendor/heyspex-mcp/events-tools.mjs +207 -0
  286. package/vendor/heyspex-mcp/filing-tools.mjs +723 -0
  287. package/vendor/heyspex-mcp/fleet-handlers.mjs +37 -0
  288. package/vendor/heyspex-mcp/fleet-queue-tools.mjs +71 -15
  289. package/vendor/heyspex-mcp/focus-tools.mjs +190 -0
  290. package/vendor/heyspex-mcp/folder-trust-store.mjs +60 -0
  291. package/vendor/heyspex-mcp/http-transport.mjs +302 -0
  292. package/vendor/heyspex-mcp/list-tools.mjs +257 -0
  293. package/vendor/heyspex-mcp/login-handoff.mjs +15 -1
  294. package/vendor/heyspex-mcp/mcp-config.mjs +107 -0
  295. package/vendor/heyspex-mcp/memory-tools.mjs +523 -0
  296. package/vendor/heyspex-mcp/mvp-mode.mjs +53 -6
  297. package/vendor/heyspex-mcp/obligation-backstop.mjs +103 -0
  298. package/vendor/heyspex-mcp/occupied-keys.mjs +142 -0
  299. package/vendor/heyspex-mcp/on-deck-brief.mjs +3377 -0
  300. package/vendor/heyspex-mcp/on-deck-scheduler.mjs +1708 -0
  301. package/vendor/heyspex-mcp/package-root.mjs +32 -0
  302. package/vendor/heyspex-mcp/peer-presence.mjs +8 -1
  303. package/vendor/heyspex-mcp/plan-ground.mjs +621 -652
  304. package/vendor/heyspex-mcp/plan-push.mjs +908 -22
  305. package/vendor/heyspex-mcp/plan-review.mjs +124 -30
  306. package/vendor/heyspex-mcp/preflight-gate.mjs +0 -0
  307. package/vendor/heyspex-mcp/procedure-handlers.mjs +327 -0
  308. package/vendor/heyspex-mcp/procedure-tools.mjs +1181 -970
  309. package/vendor/heyspex-mcp/promote-capture.mjs +388 -69
  310. package/vendor/heyspex-mcp/promote-diagnose.mjs +74 -0
  311. package/vendor/heyspex-mcp/promote-research-to-diagnose.mjs +9 -2
  312. package/vendor/heyspex-mcp/promote-research.mjs +666 -53
  313. package/vendor/heyspex-mcp/promotion-helpers.mjs +251 -0
  314. package/vendor/heyspex-mcp/prompts.mjs +1460 -0
  315. package/vendor/heyspex-mcp/read-path.mjs +191 -15
  316. package/vendor/heyspex-mcp/receipt-shape.mjs +146 -0
  317. package/vendor/heyspex-mcp/recipe-handlers.mjs +117 -0
  318. package/vendor/heyspex-mcp/regen-tasks.mjs +210 -3
  319. package/vendor/heyspex-mcp/register-spec.mjs +266 -54
  320. package/vendor/heyspex-mcp/request-context.mjs +83 -0
  321. package/vendor/heyspex-mcp/research-focus.mjs +207 -4
  322. package/vendor/heyspex-mcp/research-handlers.mjs +625 -0
  323. package/vendor/heyspex-mcp/research-tools.mjs +4 -4
  324. package/vendor/heyspex-mcp/response-budget.mjs +348 -0
  325. package/vendor/heyspex-mcp/review-focus.mjs +25 -1
  326. package/vendor/heyspex-mcp/roadmap-default.mjs +70 -0
  327. package/vendor/heyspex-mcp/search-specs.mjs +280 -0
  328. package/vendor/heyspex-mcp/seat-block-ledger.mjs +113 -0
  329. package/vendor/heyspex-mcp/seat-identity.mjs +2 -6
  330. package/vendor/heyspex-mcp/session-owner.mjs +109 -0
  331. package/vendor/heyspex-mcp/session-tools.mjs +162 -0
  332. package/vendor/heyspex-mcp/settings-registry.mjs +232 -0
  333. package/vendor/heyspex-mcp/settings-tools.mjs +85 -12
  334. package/vendor/heyspex-mcp/spec-edit-tools.mjs +453 -0
  335. package/vendor/heyspex-mcp/spec-id-lib.mjs +93 -17
  336. package/vendor/heyspex-mcp/split-research.mjs +304 -56
  337. package/vendor/heyspex-mcp/stdio-rpc.mjs +22 -0
  338. package/vendor/heyspex-mcp/submit-for-review.mjs +150 -7
  339. package/vendor/heyspex-mcp/summary-contract.mjs +105 -0
  340. package/vendor/heyspex-mcp/summary-write-tools.mjs +228 -0
  341. package/vendor/heyspex-mcp/symptom-query.mjs +146 -0
  342. package/vendor/heyspex-mcp/test-helpers.mjs +123 -1
  343. package/vendor/heyspex-mcp/tool-catalog.mjs +2819 -0
  344. package/vendor/heyspex-mcp/tool-dispatch.mjs +657 -0
  345. package/vendor/heyspex-mcp/tool-helpers.mjs +325 -0
  346. package/vendor/heyspex-mcp/validate-linkage.mjs +414 -27
  347. package/vendor/heyspex-mcp/vault-io.mjs +720 -0
  348. package/vendor/heyspex-mcp/vault-less-endpoints.mjs +52 -0
  349. package/vendor/heyspex-mcp/vault-resolve.mjs +554 -68
  350. package/vendor/heyspex-mcp/verify-reproduction.mjs +270 -30
  351. package/vendor/heyspex-mcp/whoami.mjs +92 -0
  352. package/vendor/heyspex-mcp/write-outbox.mjs +512 -0
@@ -6,10 +6,12 @@
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';
14
+ import { resolveDefaultRoadmap } from './roadmap-default.mjs';
13
15
  import {
14
16
  allocateFileKey,
15
17
  collectOccupiedFileKeysFromVault,
@@ -22,6 +24,36 @@ import {
22
24
  formatSummaryFrontmatterField,
23
25
  resolveAuthoredSummaryValue,
24
26
  } from './summary-contract.mjs';
27
+ // The cloud (console) write path. Transport-free and side-effect-free, so it
28
+ // is safe to import statically; the actual transport (console-transport.mjs)
29
+ // and the mode resolver (read-path.mjs) are DYNAMIC-imported at call time in
30
+ // {@link resolveCloudDeps} / {@link resolveWriteModeForPlanPush} so a local-mode
31
+ // run — and every existing local-mode test — keeps exactly the module graph it
32
+ // had before cloud mode existed.
33
+ import { cloudWriteFailure, createSpecViaConsole } from './cloud-vault-writes.mjs';
34
+
35
+ // scripts/validate-vault-ids.mjs is a CODE-repo artifact that ships with this
36
+ // package — since the repo split (2026-08-31) the vault root no longer holds
37
+ // it, so resolve it relative to THIS file's checkout first and fall back to
38
+ // the vault root only for pre-split layouts where they were the same repo.
39
+ // `HS_PKG_ROOT` wins when set (same precedence as dev-tools.mjs's
40
+ // CHECKOUT_ROOT / plan-review.mjs's resolveCodeAuditRoots).
41
+ const CHECKOUT_ROOT = resolveCodeRoot({
42
+ packageRoot: dirname(dirname(dirname(fileURLToPath(import.meta.url)))),
43
+ });
44
+
45
+ /**
46
+ * Resolve scripts/validate-vault-ids.mjs against the code checkout first,
47
+ * falling back to `vaultRoot` for pre-split layouts where the vault and the
48
+ * code repo were the same checkout. Returns `null` when it exists in neither
49
+ * (the pre-existing "skipped" behavior).
50
+ */
51
+ function resolveValidateVaultIdsScript(vaultRoot) {
52
+ const local = join(CHECKOUT_ROOT, 'scripts', 'validate-vault-ids.mjs');
53
+ if (existsSync(local)) return local;
54
+ const legacy = join(vaultRoot, 'scripts', 'validate-vault-ids.mjs');
55
+ return existsSync(legacy) ? legacy : null;
56
+ }
25
57
 
26
58
  // ─────────────────────────────────────────────────────────────────────────────
27
59
  // Milestone-parent resolution + bidirectional mirror-update (HS-TASK-T5PX)
@@ -218,20 +250,19 @@ const DESIGN_DECISIONS = {
218
250
  };
219
251
 
220
252
  /**
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).
253
+ * Detect if plan content mentions web-app file paths (`spex-console/` and
254
+ * component-shaped sources).
224
255
  * @param {string} content - Plan markdown or epic body
225
256
  * @returns {boolean}
226
257
  */
227
258
  function detectConsolePaths(content) {
228
- // Look for spex-console (and legacy-cockpit) file path patterns
259
+ // Look for spex-console file path patterns
229
260
  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
261
+ /spex-console\/src\//i,
262
+ /spex-console\/components\//i,
263
+ /spex-console\/app\//i,
264
+ /spex-console.*\.tsx/i,
265
+ /spex-console.*\.ts/i,
235
266
  /\bsurface\b.*\b(tsx?|component)\b/i,
236
267
  ];
237
268
 
@@ -843,6 +874,7 @@ const VAULT_ID_MIN_SUFFIX_LENGTH = 4;
843
874
  const KIND_TYPE_LABEL = { task: 'TASK', epic: 'EPIC' };
844
875
 
845
876
  import { deriveSlug } from './promotion-helpers.mjs';
877
+ import { resolveCodeRoot } from '../../scripts/lib/shared-path-helpers.mjs';
846
878
  export { deriveSlug };
847
879
 
848
880
  function randomSuffix(length, charset = VAULT_ID_CHARSET) {
@@ -1181,7 +1213,22 @@ function buildOnDeckBanner(reason) {
1181
1213
  // / `focus-history.jsonl` writer here anymore ([[no-fallbacks-by-default]]; the
1182
1214
  // focus.yaml-fallback incident).
1183
1215
 
1184
- export function executePushPlan(vaultRoot, args) {
1216
+ /**
1217
+ * `heyspex_push_plan`.
1218
+ *
1219
+ * WRITE MODE ([[HS-BYU4-CWPX-TASK-promotion-writes-go-through-the-console]]):
1220
+ * `options.vaultWriteMode` selects where the epic lands and defaults to
1221
+ * `'local'`, so every pre-existing two-argument caller keeps the byte-identical
1222
+ * synchronous disk path below. `'cloud'` delegates to
1223
+ * {@link pushPlanViaConsole} and therefore returns a PROMISE — resolve the mode
1224
+ * once at the handler entry ({@link executePushPlanCloudAware}) and `await` the
1225
+ * result, which is correct for both modes (awaiting the sync local object is a
1226
+ * no-op).
1227
+ */
1228
+ export function executePushPlan(vaultRoot, args, options = {}) {
1229
+ if ((options.vaultWriteMode ?? 'local') === 'cloud') {
1230
+ return pushPlanViaConsole(vaultRoot, args, options);
1231
+ }
1185
1232
  const {
1186
1233
  epic_name: epicName,
1187
1234
  summary: epicSummaryArg,
@@ -1377,13 +1424,14 @@ ${epicBody}
1377
1424
 
1378
1425
  let validateOk = null;
1379
1426
  let validateMessage = null;
1380
- const validateScript = join(vaultRoot, 'scripts', 'validate-vault-ids.mjs');
1381
- if (existsSync(validateScript)) {
1427
+ const validateScript = resolveValidateVaultIdsScript(vaultRoot);
1428
+ if (validateScript) {
1382
1429
  try {
1383
1430
  execFileSync(process.execPath, [validateScript], {
1384
- cwd: vaultRoot,
1431
+ cwd: dirname(validateScript),
1385
1432
  encoding: 'utf8',
1386
1433
  stdio: ['ignore', 'pipe', 'pipe'],
1434
+ env: { ...process.env, HEYSPEX_VALIDATE_ROOT: vaultRoot },
1387
1435
  });
1388
1436
  validateOk = true;
1389
1437
  } catch (e) {
@@ -1392,7 +1440,7 @@ ${epicBody}
1392
1440
  }
1393
1441
  } else {
1394
1442
  validateOk = null;
1395
- validateMessage = 'skipped (scripts/validate-vault-ids.mjs not in vault root)';
1443
+ validateMessage = 'skipped (scripts/validate-vault-ids.mjs not found in the code root or vault root)';
1396
1444
  }
1397
1445
 
1398
1446
  // Reuse the pre-flight conflict detection result computed before file writes
@@ -1415,7 +1463,13 @@ ${epicBody}
1415
1463
  spec_path: epicRelPath,
1416
1464
  },
1417
1465
  children: [],
1418
- focus_written: onDeck,
1466
+ // NOT a claim that the pin landed — this function is sync and cannot write to
1467
+ // Neon (see the note above). It reports the REQUEST (`on_deck`), and the async
1468
+ // caller (`handlePushPlan`) overwrites this with the measured result of
1469
+ // `writeFocusSelectionVendor`. `null` = "not attempted here / unknown", so a
1470
+ // path that somehow skips the caller cannot read as a successful pin
1471
+ // ([[HS-IOJW-5YDH-DIAG]] sweep idiom: report the result, never the request).
1472
+ focus_written: onDeck ? null : false,
1419
1473
  focus_reason: computedFocusReason,
1420
1474
  validate_vault_ids: validateOk,
1421
1475
  validate_message: validateMessage,
@@ -1459,8 +1513,15 @@ ${epicBody}
1459
1513
  /**
1460
1514
  * Retroactively create an epic + child tasks for completed work.
1461
1515
  * All specs are created with status: in_review and a review doc is auto-generated.
1516
+ *
1517
+ * Same write-mode contract as {@link executePushPlan}: `options.vaultWriteMode`
1518
+ * defaults to `'local'` (byte-identical synchronous disk path), `'cloud'`
1519
+ * delegates to {@link shipEpicViaConsole} and returns a Promise.
1462
1520
  */
1463
- export function executeShipEpic(vaultRoot, args) {
1521
+ export function executeShipEpic(vaultRoot, args, options = {}) {
1522
+ if ((options.vaultWriteMode ?? 'local') === 'cloud') {
1523
+ return shipEpicViaConsole(vaultRoot, args, options);
1524
+ }
1464
1525
  const {
1465
1526
  epic_name: epicName,
1466
1527
  summary = '',
@@ -1471,7 +1532,7 @@ export function executeShipEpic(vaultRoot, args) {
1471
1532
  effort = 3,
1472
1533
  tags = [],
1473
1534
  related = [],
1474
- roadmap_id: roadmapId = 'HS-ROADMAP-PRODUCT',
1535
+ roadmap_id: roadmapId = resolveDefaultRoadmap(vaultRoot).id,
1475
1536
  roadmap_phase: roadmapPhase = 'P0',
1476
1537
  skip_roadmap_registration: skipRoadmapRegistration = false,
1477
1538
  milestone,
@@ -1718,13 +1779,14 @@ _Space for reviewer comments._
1718
1779
  // Validate vault IDs
1719
1780
  let validateOk = null;
1720
1781
  let validateMessage = null;
1721
- const validateScript = join(vaultRoot, 'scripts', 'validate-vault-ids.mjs');
1722
- if (existsSync(validateScript)) {
1782
+ const validateScript = resolveValidateVaultIdsScript(vaultRoot);
1783
+ if (validateScript) {
1723
1784
  try {
1724
1785
  execFileSync(process.execPath, [validateScript], {
1725
- cwd: vaultRoot,
1786
+ cwd: dirname(validateScript),
1726
1787
  encoding: 'utf8',
1727
1788
  stdio: ['ignore', 'pipe', 'pipe'],
1789
+ env: { ...process.env, HEYSPEX_VALIDATE_ROOT: vaultRoot },
1728
1790
  });
1729
1791
  validateOk = true;
1730
1792
  } catch (e) {
@@ -1733,7 +1795,7 @@ _Space for reviewer comments._
1733
1795
  }
1734
1796
  } else {
1735
1797
  validateOk = null;
1736
- validateMessage = 'skipped (scripts/validate-vault-ids.mjs not in vault root)';
1798
+ validateMessage = 'skipped (scripts/validate-vault-ids.mjs not found in the code root or vault root)';
1737
1799
  }
1738
1800
 
1739
1801
  return {
@@ -1824,3 +1886,827 @@ export function buildShipWorkflowMarkdown(options = {}) {
1824
1886
 
1825
1887
  return lines.join('\n');
1826
1888
  }
1889
+
1890
+ // ─────────────────────────────────────────────────────────────────────────────
1891
+ // CLOUD WRITE MODE — `resolveVaultWriteMode() === 'cloud'`
1892
+ // [[HS-BYU4-CWPX-TASK-promotion-writes-go-through-the-console]]
1893
+ //
1894
+ // In cloud reliance the vault is NOT on this machine's disk: a `writeFileSync`
1895
+ // here is a split brain — a markdown file the console's index has never heard
1896
+ // of, possibly on a machine with no clone of the vault at all. So every write
1897
+ // `push_plan` / `backfill_epic` makes goes through the console instead
1898
+ // (`POST /api/specs`, via cloud-vault-writes.mjs), and every id/file-key mint
1899
+ // that used to SCAN THE VAULT ON DISK is skipped — the console mints the id,
1900
+ // the family and the filename server-side.
1901
+ //
1902
+ // The structural consequence is CREATE-FIRST / LINK-SECOND. On disk the epic
1903
+ // basename was computable before any write, so children could be named in the
1904
+ // epic's own frontmatter. In cloud mode the basename only exists once
1905
+ // `POST /api/specs` has answered, so the order is: create the epic -> read back
1906
+ // its basename -> create each task with `parent: "[[<epic basename>]]"` ->
1907
+ // create the review doc, which cites both. Nothing is hand-linked in the
1908
+ // reverse direction: `createSpec` back-links `children:` (task->epic) and
1909
+ // `epics:` (epic->milestone) itself at create time (KIND_LINKAGE in
1910
+ // spex-console's vault.ts), which is exactly what `appendEpicToMilestone` did
1911
+ // on disk.
1912
+ //
1913
+ // There is NO rollback (the console has no un-create), so each entry point
1914
+ // stops at the first failure and reports which step failed and which documents
1915
+ // already landed — never an implied clean abort.
1916
+ // ─────────────────────────────────────────────────────────────────────────────
1917
+
1918
+ /**
1919
+ * The one document these tools write that has NO console route, described once
1920
+ * so the degrade message and any report of it can never drift apart.
1921
+ *
1922
+ * The console's create route accepts only the types in its `TYPE_TO_FOLDER`
1923
+ * map (task, epic, milestone, diagnose, capture, planning-flow, memory-*,
1924
+ * protocol, workflow-recipe, research, review, pillar, agent, golden-case,
1925
+ * adr, draft, daily). A roadmap manifest is none of those, and its update is
1926
+ * not a spec create at all: `addEpicToRoadmapPhase` scans `specs/roadmaps/`
1927
+ * for the file whose frontmatter carries `id: <roadmapId>` and then performs
1928
+ * line-surgical YAML editing inside a `roadmap_manifest` phase block. Borrowing
1929
+ * some other `type` would mint a NEW document in the wrong folder rather than
1930
+ * amend the existing manifest, so in cloud mode the write is skipped and said
1931
+ * out loud.
1932
+ */
1933
+ const CLOUD_UNSUPPORTED_ROADMAP_WRITE = Object.freeze({
1934
+ error: 'CLOUD_MODE_UNSUPPORTED_WRITE',
1935
+ document: 'specs/roadmaps/<manifest>.md',
1936
+ write: 'addEpicToRoadmapPhase — append the epic basename to a roadmap_manifest phase',
1937
+ why:
1938
+ 'There is no console route for a roadmap manifest: POST /api/specs only accepts the types in TYPE_TO_FOLDER '
1939
+ + '(roadmap is not one of them), and this is an in-place edit of an existing YAML phase block, not a spec create. '
1940
+ + 'Writing it to local disk in cloud mode would be a split brain, and minting it under a borrowed type would '
1941
+ + 'create a new document instead of amending the manifest.',
1942
+ });
1943
+
1944
+ /**
1945
+ * Resolve the console transport + URL for a cloud-mode run.
1946
+ *
1947
+ * Both are DEPENDENCIES with real defaults: a test injects a recorder and never
1948
+ * opens a socket, while production gets `consoleFetch` from
1949
+ * ./console-transport.mjs and `getConsoleUrl()` from ./mcp-config.mjs. The
1950
+ * imports are dynamic so a local-mode run never loads the transport at all and
1951
+ * the pre-existing local tests keep exactly the module graph they had.
1952
+ */
1953
+ async function resolveCloudDeps(options = {}) {
1954
+ let consoleFetch = options.consoleFetch;
1955
+ if (typeof consoleFetch !== 'function') {
1956
+ ({ consoleFetch } = await import('./console-transport.mjs'));
1957
+ }
1958
+ let consoleUrl = options.consoleUrl ?? null;
1959
+ if (!consoleUrl) {
1960
+ try {
1961
+ const { getConsoleUrl } = await import('./mcp-config.mjs');
1962
+ consoleUrl = getConsoleUrl();
1963
+ } catch {
1964
+ consoleUrl = null;
1965
+ }
1966
+ }
1967
+ return { consoleFetch, consoleUrl, actingAgent: options.actingAgent ?? null };
1968
+ }
1969
+
1970
+ /** `resolveVaultWriteMode()` with the same injectable seam the handlers use. */
1971
+ async function resolveWriteModeForPlanPush(options = {}) {
1972
+ if (options.vaultWriteMode === 'local' || options.vaultWriteMode === 'cloud') {
1973
+ return options.vaultWriteMode;
1974
+ }
1975
+ if (typeof options.resolveVaultWriteMode === 'function') {
1976
+ return (await options.resolveVaultWriteMode()) ?? 'local';
1977
+ }
1978
+ const { resolveVaultWriteMode } = await import('./read-path.mjs');
1979
+ return (await resolveVaultWriteMode()) ?? 'local';
1980
+ }
1981
+
1982
+ /**
1983
+ * `heyspex_push_plan` HANDLER ENTRY: resolve the write mode ONCE, then run.
1984
+ * `deps` is the same object threaded down to {@link executePushPlan} — pass
1985
+ * `{ consoleFetch }` in tests, nothing in production.
1986
+ */
1987
+ export async function executePushPlanCloudAware(vaultRoot, args, deps = {}) {
1988
+ const vaultWriteMode = await resolveWriteModeForPlanPush(deps);
1989
+ return executePushPlan(vaultRoot, args, { ...deps, vaultWriteMode });
1990
+ }
1991
+
1992
+ /** `heyspex_backfill_epic` HANDLER ENTRY — see {@link executePushPlanCloudAware}. */
1993
+ export async function executeShipEpicCloudAware(vaultRoot, args, deps = {}) {
1994
+ const vaultWriteMode = await resolveWriteModeForPlanPush(deps);
1995
+ return executeShipEpic(vaultRoot, args, { ...deps, vaultWriteMode });
1996
+ }
1997
+
1998
+ /**
1999
+ * `GET /api/specs/:id` — the cloud twin of reading a vault file off disk. The
2000
+ * route resolves an `id` OR a wikilink basename, which is exactly the two
2001
+ * shapes the disk readers here resolve. Returns `null` for a genuine 404 (the
2002
+ * spec is not in this caller's vault — the same "skip it" answer
2003
+ * `resolveWikilink` gives); every other failure throws the canonical
2004
+ * `CONSOLE_UNREACHABLE_CLOUD_MODE` error.
2005
+ */
2006
+ async function readSpecViaConsole({ consoleFetch, consoleUrl, ref }) {
2007
+ const routeLabel = `GET /api/specs/${ref}`;
2008
+ if (typeof consoleFetch !== 'function') {
2009
+ throw cloudWriteFailure(routeLabel, consoleUrl, 'no consoleFetch dependency was injected');
2010
+ }
2011
+ let result;
2012
+ try {
2013
+ result = await consoleFetch(`/api/specs/${encodeURIComponent(ref)}`);
2014
+ } catch (err) {
2015
+ throw cloudWriteFailure(routeLabel, consoleUrl, String(err?.message ?? err));
2016
+ }
2017
+ if (result?.status === 404) return null;
2018
+ if (!result?.ok) {
2019
+ throw cloudWriteFailure(
2020
+ routeLabel,
2021
+ result?.url ?? consoleUrl,
2022
+ `${result?.status ?? '?'}${result?.error ? ` ${result.error}` : ''}`,
2023
+ { status: result?.status ?? null, console_response: result?.body ?? null },
2024
+ );
2025
+ }
2026
+ return result.body?.spec ?? null;
2027
+ }
2028
+
2029
+ /**
2030
+ * Cloud twin of {@link resolveMilestoneArg}. The shape checks (missing /
2031
+ * not-an-HS-MIL) are pure and stay identical; only the EXISTENCE check moves
2032
+ * from `existsSync(specs/milestones/<b>.md)` to the console. Also returns the
2033
+ * milestone's `pillar:` so the epic can inherit it (the disk path reads the
2034
+ * same field via {@link resolveMilestonePillar}).
2035
+ *
2036
+ * `family:` is deliberately NOT returned: in cloud mode `createSpec` inherits
2037
+ * the family from the resolved structural parent itself, so minting or echoing
2038
+ * one here would only give the server a chance to disagree with itself.
2039
+ */
2040
+ async function resolveMilestoneArgViaConsole(raw, { consoleFetch, consoleUrl }) {
2041
+ if (!raw?.trim?.()) {
2042
+ return {
2043
+ error: 'MISSING_MILESTONE',
2044
+ message:
2045
+ 'milestone is required — wikilink to parent HS-MIL-* (e.g. "[[HS-MIL-HMNJ-status-and-roadmap-surface-refactor]]"). See [[HS-TASK-T5PX]].',
2046
+ };
2047
+ }
2048
+ const basename = normalizeMilestoneBasename(raw);
2049
+ if (!basename) {
2050
+ return {
2051
+ error: 'INVALID_MILESTONE',
2052
+ message: `milestone is not a valid HS-MIL wikilink: ${raw}`,
2053
+ };
2054
+ }
2055
+ const spec = await readSpecViaConsole({ consoleFetch, consoleUrl, ref: basename });
2056
+ if (!spec) {
2057
+ return {
2058
+ error: 'MILESTONE_NOT_FOUND',
2059
+ message: `milestone does not exist in the console vault: ${basename} (cloud write mode — checked via GET /api/specs/${basename}, not local disk)`,
2060
+ };
2061
+ }
2062
+ const resolvedBasename =
2063
+ typeof spec.basename === 'string' && spec.basename ? spec.basename : basename;
2064
+ return {
2065
+ basename: resolvedBasename,
2066
+ wikilink: `[[${resolvedBasename}]]`,
2067
+ pillar: normalizePillarRef(spec.frontmatter?.pillar),
2068
+ };
2069
+ }
2070
+
2071
+ /**
2072
+ * Cloud twin of {@link detectSpecConflicts}. Same claim extraction and the same
2073
+ * comparison — only the per-link body read moves from `resolveWikilink` +
2074
+ * `readFileSync` to `GET /api/specs/:id`. A link the console cannot serve is
2075
+ * SKIPPED and named in `unreachable`, mirroring the disk path's "skip files
2076
+ * that can't be read" rather than failing a pre-flight advisory.
2077
+ */
2078
+ async function detectSpecConflictsViaConsole(epicContent, relatedBasenames, deps) {
2079
+ const epicClaims = extractBehavioralClaims(extractConflictCheckContent(epicContent), 'epic');
2080
+ if (epicClaims.length === 0) {
2081
+ return { conflicts: [], linked_specs_checked: [], unreachable: [] };
2082
+ }
2083
+ const conflicts = [];
2084
+ const linkedSpecsChecked = [];
2085
+ const unreachable = [];
2086
+ for (const basename of relatedBasenames) {
2087
+ let spec;
2088
+ try {
2089
+ spec = await readSpecViaConsole({ ...deps, ref: basename });
2090
+ } catch (err) {
2091
+ unreachable.push({ basename, message: String(err?.message ?? err) });
2092
+ continue;
2093
+ }
2094
+ if (!spec) continue;
2095
+ linkedSpecsChecked.push(basename);
2096
+ conflicts.push(
2097
+ ...findConflicts(epicClaims, extractBehavioralClaims(String(spec.body ?? ''), basename)),
2098
+ );
2099
+ }
2100
+ return { conflicts, linked_specs_checked: linkedSpecsChecked, unreachable };
2101
+ }
2102
+
2103
+ /** The `related:` wikilink array both cloud writers build (user links + defaults). */
2104
+ function normalizeRelatedWikilinks(related, defaults = []) {
2105
+ const list = (Array.isArray(related) ? related : []).map(
2106
+ (r) => `[[${String(r).replace(/^\[\[|\]\]$/g, '')}]]`,
2107
+ );
2108
+ return [...new Set([...list, ...defaults])];
2109
+ }
2110
+
2111
+ /** The `landed` block every cloud result carries — the twin of research-handlers'. */
2112
+ function cloudLandedBlock({ consoleUrl, relPath, consoleWrite = null, vaultRoot = null }) {
2113
+ return {
2114
+ tier: 'cloud',
2115
+ reported_by: consoleWrite ? 'console' : 'inferred_from_console_url',
2116
+ console_url: consoleUrl ?? null,
2117
+ rel_path: relPath ?? null,
2118
+ abs_path: null,
2119
+ on_disk: false,
2120
+ in_local_vault: false,
2121
+ vault_root: vaultRoot ?? null,
2122
+ console_write: consoleWrite,
2123
+ };
2124
+ }
2125
+
2126
+ /**
2127
+ * The one failure envelope both cloud entry points return. Names the step that
2128
+ * failed AND everything that already landed — there is no rollback, so a caller
2129
+ * must be able to say what state the vault is actually in.
2130
+ */
2131
+ function cloudWriteFailureResult(err, { step, landed = [], extra = {} }) {
2132
+ return {
2133
+ error: 'CONSOLE_UNREACHABLE_CLOUD_MODE',
2134
+ success: false,
2135
+ write_mode: 'cloud',
2136
+ failed_step: step,
2137
+ message: String(err?.message ?? err),
2138
+ console_url: err?.console_url ?? null,
2139
+ ...(err?.status != null ? { status: err.status } : {}),
2140
+ ...(err?.console_response ? { console_response: err.console_response } : {}),
2141
+ ...(err?.hint ? { hint: err.hint } : {}),
2142
+ already_landed: landed,
2143
+ rollback:
2144
+ 'none — the console has no un-create; the documents in `already_landed` are real and must not be re-created on a retry',
2145
+ ...extra,
2146
+ };
2147
+ }
2148
+
2149
+ /** In cloud mode the vault is not on this machine, so the id validator cannot run. */
2150
+ const CLOUD_VALIDATE_SKIPPED =
2151
+ '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)';
2152
+
2153
+ /**
2154
+ * `push_plan` in cloud write mode. Mirrors {@link executePushPlan}'s result
2155
+ * shape so callers read ONE shape in both modes, minus everything that only
2156
+ * exists on disk (the `file_id`/`family` mints, the milestone mirror-update,
2157
+ * the id-validator subprocess).
2158
+ */
2159
+ async function pushPlanViaConsole(vaultRoot, args, options = {}) {
2160
+ const {
2161
+ epic_name: epicName,
2162
+ summary: epicSummaryArg,
2163
+ plan_markdown: planMarkdown = '',
2164
+ implementation_prompt: implementationPrompt,
2165
+ source_plan_paths: sourcePlanPaths = [],
2166
+ children = [],
2167
+ on_deck: onDeck = false,
2168
+ focus_reason: focusReason,
2169
+ kind = 'feature',
2170
+ effort = 3,
2171
+ tags = [],
2172
+ related = [],
2173
+ skip_conflict_check: skipConflictCheck = false,
2174
+ milestone,
2175
+ // Cloud-only: POST /api/specs HARD-REQUIRES `pillar:` on an epic (route.ts
2176
+ // `pillar_required`), which the disk path never emitted because nothing on
2177
+ // disk enforced it. Honoured only here, so the local branch stays
2178
+ // byte-identical.
2179
+ pillar,
2180
+ } = args;
2181
+
2182
+ if (!epicName?.trim()) {
2183
+ return { error: 'MISSING_EPIC_NAME', message: 'epic_name is required' };
2184
+ }
2185
+ const epicSummary = resolveAuthoredSummaryValue(epicSummaryArg);
2186
+ if (!epicSummary) {
2187
+ return buildSummaryRequiredMcpError('epic');
2188
+ }
2189
+ if (Array.isArray(children) && children.some((c) => c?.name?.trim())) {
2190
+ return {
2191
+ error: 'CHILDREN_NOT_SUPPORTED',
2192
+ message:
2193
+ '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).',
2194
+ procedure: 'spec-hierarchy',
2195
+ };
2196
+ }
2197
+
2198
+ const { consoleFetch, consoleUrl, actingAgent } = await resolveCloudDeps(options);
2199
+
2200
+ let milestoneResolved;
2201
+ try {
2202
+ milestoneResolved = await resolveMilestoneArgViaConsole(milestone, {
2203
+ consoleFetch,
2204
+ consoleUrl,
2205
+ });
2206
+ } catch (err) {
2207
+ return cloudWriteFailureResult(err, { step: 'resolve_milestone' });
2208
+ }
2209
+ if (milestoneResolved.error) return milestoneResolved;
2210
+
2211
+ const resolvedPillar = normalizePillarRef(pillar) ?? milestoneResolved.pillar ?? null;
2212
+ if (!resolvedPillar) {
2213
+ return {
2214
+ error: 'PILLAR_REQUIRED',
2215
+ message:
2216
+ `Cannot resolve a pillar for this epic: parent milestone "${milestoneResolved.basename}" has no \`pillar:\` and none was passed. ` +
2217
+ 'Cloud write mode files the epic through POST /api/specs, which hard-blocks an epic with no resolvable `pillar:`. ' +
2218
+ 'Pass `pillar: "[[HS-PILR-<slug>]]"`, or assign the milestone a pillar first. ' +
2219
+ 'Use heyspex_suggest_pillar to pick one against each pillar\'s written charter — never by name ([[pillar-assignment-protocol]]).',
2220
+ };
2221
+ }
2222
+
2223
+ // Reads LOCAL plan source files (Cursor `*.plan.md` etc.) into the epic body.
2224
+ // Those are this machine's own files, not vault content, and this makes no
2225
+ // vault write — so it is unchanged in cloud mode.
2226
+ const prepared = prepareVaultPlanContent(vaultRoot, {
2227
+ plan_markdown: planMarkdown,
2228
+ implementation_prompt: implementationPrompt,
2229
+ source_plan_paths: sourcePlanPaths,
2230
+ });
2231
+
2232
+ let epicBody =
2233
+ prepared.plan_markdown.trim() || `# ${epicName}\n\n## Goal\n\n_Describe outcome._\n`;
2234
+ const preparedPrompt = prepared.implementation_prompt;
2235
+ if (onDeck) {
2236
+ epicBody = `${buildOnDeckBanner(focusReason)}\n${epicBody}`;
2237
+ if (preparedPrompt?.trim()) {
2238
+ epicBody += buildNextStepSection(preparedPrompt);
2239
+ }
2240
+ }
2241
+
2242
+ const userRelatedLinks = Array.isArray(related) ? related : [];
2243
+ const uniqueRelated = normalizeRelatedWikilinks(userRelatedLinks, [
2244
+ '[[plan-ingest-agent-workflow]]',
2245
+ '[[vault-id-assignment-protocol]]',
2246
+ ]);
2247
+
2248
+ // Pre-flight conflict detection, BEFORE anything is created — same blocking
2249
+ // contract as the disk path (an on_deck push with conflicts writes nothing).
2250
+ let preflightConflicts = [];
2251
+ let preflightLinkedSpecs = [];
2252
+ let preflightUnreachable = [];
2253
+ if (!skipConflictCheck && userRelatedLinks.length > 0) {
2254
+ const fullEpicContent = [epicBody, preparedPrompt || ''].join('\n');
2255
+ const relatedBasenames = userRelatedLinks.map((r) => String(r).replace(/^\[\[|\]\]$/g, ''));
2256
+ const conflictResult = await detectSpecConflictsViaConsole(fullEpicContent, relatedBasenames, {
2257
+ consoleFetch,
2258
+ consoleUrl,
2259
+ });
2260
+ preflightConflicts = conflictResult.conflicts;
2261
+ preflightLinkedSpecs = conflictResult.linked_specs_checked;
2262
+ preflightUnreachable = conflictResult.unreachable;
2263
+
2264
+ if (onDeck && preflightConflicts.length > 0) {
2265
+ return {
2266
+ error: 'CONFLICTS_DETECTED',
2267
+ success: false,
2268
+ on_deck: true,
2269
+ blocked: true,
2270
+ write_mode: 'cloud',
2271
+ message: `${preflightConflicts.length} behavioral conflict(s) detected against linked spec(s). Resolve them or pass skip_conflict_check: true to force the push.`,
2272
+ conflicts: preflightConflicts,
2273
+ linked_specs_checked: preflightLinkedSpecs,
2274
+ has_conflicts: true,
2275
+ next_steps: [
2276
+ 'Review the conflicts below — each shows a behavioral claim in this plan that contradicts a linked spec.',
2277
+ 'Resolution options:',
2278
+ ' • Edit the NEXT STEP / acceptance criteria in plan_markdown to match the linked spec, then re-run heyspex_push_plan.',
2279
+ ' • Edit the linked spec (its Interaction model section) to match this plan, then re-run heyspex_push_plan.',
2280
+ ' • Pass skip_conflict_check: true to force the push (a conflict_override row can be recorded later via the console Dismiss-with-reason action).',
2281
+ ],
2282
+ };
2283
+ }
2284
+ }
2285
+
2286
+ // No `id`/`file_id`/`family`/`created`/`updated`: every one of those is
2287
+ // server-minted (cloud-vault-writes.mjs SERVER_MINTED_FIELDS), and `family`
2288
+ // is inherited from the milestone by `createSpec` itself.
2289
+ const epicFrontmatter = {
2290
+ kind,
2291
+ effort,
2292
+ requires_user_testing: false,
2293
+ status: onDeck ? 'in_progress' : 'open',
2294
+ milestone: milestoneResolved.wikilink,
2295
+ pillar: `[[${resolvedPillar}]]`,
2296
+ tags: tags.length > 0 ? [...tags] : ['epic'],
2297
+ // Left empty ON PURPOSE — push_plan never files children, and the console
2298
+ // back-links any later task into this array at THAT task's create time.
2299
+ children: [],
2300
+ related: uniqueRelated,
2301
+ };
2302
+ const epicMarkdown = `${epicBody}
2303
+
2304
+ ## Change log
2305
+
2306
+ | Date | Author | Change |
2307
+ |------|--------|--------|
2308
+ | ${todayDate()} | pwher (with agent via heyspex_push_plan) | Epic created via hs-push${onDeck ? '-next' : ''}. |
2309
+ `;
2310
+
2311
+ let createdEpic;
2312
+ try {
2313
+ createdEpic = await createSpecViaConsole({
2314
+ consoleFetch,
2315
+ consoleUrl,
2316
+ type: 'epic',
2317
+ name: epicName,
2318
+ summary: epicSummary,
2319
+ frontmatter: epicFrontmatter,
2320
+ body: epicMarkdown,
2321
+ actingAgent,
2322
+ });
2323
+ } catch (err) {
2324
+ return cloudWriteFailureResult(err, { step: 'create_epic' });
2325
+ }
2326
+
2327
+ const computedFocusReason = onDeck ? focusReason || `On deck: ${epicName} (hs-push-next)` : null;
2328
+ const designDecisions = getApplicableDesignDecisions(
2329
+ [epicBody, preparedPrompt || ''].join('\n'),
2330
+ );
2331
+
2332
+ const result = {
2333
+ success: true,
2334
+ write_mode: 'cloud',
2335
+ on_deck: onDeck,
2336
+ epic: {
2337
+ id: createdEpic.specId,
2338
+ basename: createdEpic.basename,
2339
+ spec_path: createdEpic.relPath,
2340
+ },
2341
+ children: [],
2342
+ landed: cloudLandedBlock({
2343
+ consoleUrl: createdEpic.consoleUrl ?? consoleUrl,
2344
+ relPath: createdEpic.relPath,
2345
+ consoleWrite: createdEpic.consoleWrite,
2346
+ vaultRoot,
2347
+ }),
2348
+ // The console back-links `epics:` on the milestone at create time
2349
+ // (KIND_LINKAGE.epic.parentArray), which is what appendEpicToMilestone did
2350
+ // on disk — so this tool does NOT hand-write the parent array in cloud mode.
2351
+ milestone_backlink: {
2352
+ milestone: milestoneResolved.basename,
2353
+ by: 'console (createSpec KIND_LINKAGE.epic.parentArray = "epics")',
2354
+ },
2355
+ focus_written: onDeck ? null : false,
2356
+ focus_reason: computedFocusReason,
2357
+ validate_vault_ids: null,
2358
+ validate_message: CLOUD_VALIDATE_SKIPPED,
2359
+ conflicts: preflightConflicts,
2360
+ linked_specs_checked: preflightLinkedSpecs,
2361
+ has_conflicts: preflightConflicts.length > 0,
2362
+ design_decisions: designDecisions,
2363
+ has_design_decisions: designDecisions.length > 0,
2364
+ plans_ingested: prepared.plans_ingested,
2365
+ ingested_plans: prepared.ingested_plans,
2366
+ missing_plans: prepared.missing_plans,
2367
+ ...(Array.isArray(createdEpic.warnings) && createdEpic.warnings.length > 0
2368
+ ? { console_warnings: createdEpic.warnings }
2369
+ : {}),
2370
+ ...(preflightUnreachable.length > 0
2371
+ ? { conflict_check_unreachable: preflightUnreachable }
2372
+ : {}),
2373
+ next_steps: [
2374
+ 'heyspex_validate_linkage',
2375
+ onDeck
2376
+ ? 'heyspex_next_on_deck or /hs-next — break into tasks via the breakdown brief (heyspex_file_spec with summary)'
2377
+ : 'Optional: heyspex_push_plan on_deck after review',
2378
+ 'heyspex_register_spec if epic should appear in roadmap manifest',
2379
+ 'Record the WIP ledger (heyspex_record_wip) for the epic if this is the new focus (next-session-handoff.md retired — HS-EPIC-HNDR)',
2380
+ ],
2381
+ };
2382
+
2383
+ if (result.conflicts.length > 0) {
2384
+ result.next_steps.unshift(
2385
+ `⚠️ ${result.conflicts.length} spec conflict(s) detected — review before implementation`,
2386
+ );
2387
+ }
2388
+ if (designDecisions.length > 0) {
2389
+ result.next_steps.unshift(
2390
+ `📐 ${designDecisions.length} design decision(s) apply — review before implementation`,
2391
+ );
2392
+ }
2393
+ return result;
2394
+ }
2395
+
2396
+ /**
2397
+ * `backfill_epic` in cloud write mode — the CREATE-FIRST / LINK-SECOND flow.
2398
+ *
2399
+ * Order is load-bearing: the epic must exist before a task can name it as
2400
+ * `parent:`, and the review doc must come last because it cites both the epic
2401
+ * and every task by basename — and in cloud mode a basename is only knowable
2402
+ * once the console has answered.
2403
+ *
2404
+ * The `children:` array is NOT hand-written: `createSpec` back-links each task
2405
+ * into its epic at the task's own create time. The roadmap manifest has no
2406
+ * console route at all and degrades explicitly (see
2407
+ * {@link CLOUD_UNSUPPORTED_ROADMAP_WRITE}).
2408
+ */
2409
+ async function shipEpicViaConsole(vaultRoot, args, options = {}) {
2410
+ const {
2411
+ epic_name: epicName,
2412
+ summary = '',
2413
+ plan_markdown: planMarkdown = '',
2414
+ children = [],
2415
+ files_changed: filesChanged = [],
2416
+ kind = 'feature',
2417
+ effort = 3,
2418
+ tags = [],
2419
+ related = [],
2420
+ roadmap_id: roadmapId = resolveDefaultRoadmap(vaultRoot).id,
2421
+ roadmap_phase: roadmapPhase = 'P0',
2422
+ skip_roadmap_registration: skipRoadmapRegistration = false,
2423
+ milestone,
2424
+ pillar,
2425
+ } = args;
2426
+
2427
+ if (!epicName?.trim()) {
2428
+ return { error: 'MISSING_EPIC_NAME', message: 'epic_name is required' };
2429
+ }
2430
+ const epicSummary = resolveAuthoredSummaryValue(summary);
2431
+ if (!epicSummary) {
2432
+ return buildSummaryRequiredMcpError('epic');
2433
+ }
2434
+
2435
+ const { consoleFetch, consoleUrl, actingAgent } = await resolveCloudDeps(options);
2436
+
2437
+ let milestoneResolved;
2438
+ try {
2439
+ milestoneResolved = await resolveMilestoneArgViaConsole(milestone, {
2440
+ consoleFetch,
2441
+ consoleUrl,
2442
+ });
2443
+ } catch (err) {
2444
+ return cloudWriteFailureResult(err, { step: 'resolve_milestone' });
2445
+ }
2446
+ if (milestoneResolved.error) return milestoneResolved;
2447
+
2448
+ const resolvedPillar = normalizePillarRef(pillar) ?? milestoneResolved.pillar ?? null;
2449
+ if (!resolvedPillar) {
2450
+ return {
2451
+ error: 'PILLAR_REQUIRED',
2452
+ message:
2453
+ `Cannot resolve a pillar for this epic: parent milestone "${milestoneResolved.basename}" has no \`pillar:\` and none was passed. ` +
2454
+ 'Pass `pillar: "[[HS-PILR-<slug>]]"`, or assign the milestone a pillar first. ' +
2455
+ 'Use heyspex_suggest_pillar to pick one against each pillar\'s written charter — never by name ([[pillar-assignment-protocol]]).',
2456
+ };
2457
+ }
2458
+
2459
+ // Validate EVERY child before creating anything: a summary-less child must
2460
+ // not surface after half the vault has already been written.
2461
+ const plannedChildren = [];
2462
+ for (const child of children) {
2463
+ if (!child?.name?.trim()) continue;
2464
+ const childSummary = resolveAuthoredSummaryValue(child.summary);
2465
+ if (!childSummary) {
2466
+ return {
2467
+ ...buildSummaryRequiredMcpError('task'),
2468
+ message: `Child task "${child.name}" requires a plain-language \`summary\` when backfilled — pass \`summary\` on each child per spec-summary-authoring.`,
2469
+ };
2470
+ }
2471
+ plannedChildren.push({ name: child.name, summary: childSummary });
2472
+ }
2473
+
2474
+ const relatedWikilinks = normalizeRelatedWikilinks(related);
2475
+ const epicBody = planMarkdown.trim() || `# ${epicName}\n\n${epicSummary}\n`;
2476
+ const epicFrontmatter = {
2477
+ kind,
2478
+ effort,
2479
+ requires_user_testing: false,
2480
+ status: 'in_review',
2481
+ milestone: milestoneResolved.wikilink,
2482
+ pillar: `[[${resolvedPillar}]]`,
2483
+ tags: tags.length > 0 ? [...tags] : ['epic'],
2484
+ // Empty at create time BY CONSTRUCTION — the task basenames do not exist
2485
+ // yet. Each task back-links itself into this array when the console creates
2486
+ // it (KIND_LINKAGE.task.parentArray = "children").
2487
+ children: [],
2488
+ related: relatedWikilinks,
2489
+ };
2490
+
2491
+ const landed = [];
2492
+
2493
+ // ── Step 1: the epic (create-first) ────────────────────────────────────────
2494
+ let createdEpic;
2495
+ try {
2496
+ createdEpic = await createSpecViaConsole({
2497
+ consoleFetch,
2498
+ consoleUrl,
2499
+ type: 'epic',
2500
+ name: epicName,
2501
+ summary: epicSummary,
2502
+ frontmatter: epicFrontmatter,
2503
+ body: `${epicBody}
2504
+
2505
+ ## Change log
2506
+
2507
+ | Date | Author | Change |
2508
+ |------|--------|--------|
2509
+ | ${todayDate()} | pwher (with agent via heyspex_backfill_epic) | Epic created retroactively, backfilled to review. |
2510
+ `,
2511
+ actingAgent,
2512
+ });
2513
+ } catch (err) {
2514
+ return cloudWriteFailureResult(err, { step: 'create_epic', landed });
2515
+ }
2516
+ const epicBasename = createdEpic.basename;
2517
+ landed.push({
2518
+ step: 'create_epic',
2519
+ type: 'epic',
2520
+ id: createdEpic.specId,
2521
+ basename: epicBasename,
2522
+ rel_path: createdEpic.relPath,
2523
+ });
2524
+
2525
+ // ── Step 2: the child tasks (link-second — the parent is now knowable) ─────
2526
+ const childSpecs = [];
2527
+ const childWikilinks = [];
2528
+ for (const child of plannedChildren) {
2529
+ let createdTask;
2530
+ try {
2531
+ createdTask = await createSpecViaConsole({
2532
+ consoleFetch,
2533
+ consoleUrl,
2534
+ type: 'task',
2535
+ name: child.name,
2536
+ summary: child.summary,
2537
+ frontmatter: {
2538
+ kind,
2539
+ effort,
2540
+ requires_user_testing: false,
2541
+ status: 'in_review',
2542
+ tags: ['shipped'],
2543
+ parent: `[[${epicBasename}]]`,
2544
+ },
2545
+ body: `# ${child.name}
2546
+
2547
+ _Completed as part of [[${epicBasename}]]._
2548
+
2549
+ ## Change log
2550
+
2551
+ | Date | Author | Change |
2552
+ |------|--------|--------|
2553
+ | ${todayDate()} | pwher (with agent via heyspex_backfill_epic) | Task backfilled to review as part of [[${epicBasename}]]. |
2554
+ `,
2555
+ actingAgent,
2556
+ });
2557
+ } catch (err) {
2558
+ return cloudWriteFailureResult(err, {
2559
+ step: 'create_task',
2560
+ landed,
2561
+ extra: { failed_task: child.name },
2562
+ });
2563
+ }
2564
+ landed.push({
2565
+ step: 'create_task',
2566
+ type: 'task',
2567
+ id: createdTask.specId,
2568
+ basename: createdTask.basename,
2569
+ rel_path: createdTask.relPath,
2570
+ });
2571
+ childWikilinks.push(`[[${createdTask.basename}]]`);
2572
+ childSpecs.push({
2573
+ id: createdTask.specId,
2574
+ basename: createdTask.basename,
2575
+ relPath: createdTask.relPath,
2576
+ name: child.name,
2577
+ summary: child.summary,
2578
+ });
2579
+ }
2580
+
2581
+ // ── Step 3: the review doc (cites the epic AND every task — so, last) ─────
2582
+ const filesTable =
2583
+ filesChanged.length > 0
2584
+ ? filesChanged.map((f) => `| ${f} | Modified |`).join('\n')
2585
+ : '| _No files listed_ | - |';
2586
+ const reviewBody = `# Review: ${epicName}
2587
+
2588
+ ## Pre-Review Checklist
2589
+
2590
+ - [ ] Code changes reviewed
2591
+ - [ ] Tests passing (if applicable)
2592
+ - [ ] No regressions introduced
2593
+ - [ ] Documentation updated (if applicable)
2594
+
2595
+ ## Files Changed
2596
+
2597
+ | File | Change Type |
2598
+ |------|-------------|
2599
+ ${filesTable}
2600
+
2601
+ ## Summary
2602
+
2603
+ ${epicSummary}
2604
+
2605
+ ## Child Tasks
2606
+
2607
+ ${childWikilinks.length > 0 ? childWikilinks.map((c) => `- ${c}`).join('\n') : '_No child tasks._'}
2608
+
2609
+ ## Notes
2610
+
2611
+ _Space for reviewer comments._
2612
+ `;
2613
+ let createdReview;
2614
+ try {
2615
+ createdReview = await createSpecViaConsole({
2616
+ consoleFetch,
2617
+ consoleUrl,
2618
+ type: 'review',
2619
+ name: `Review: ${epicName}`,
2620
+ summary: epicSummary,
2621
+ frontmatter: {
2622
+ // `parent:` is REQUIRED for a review by the console's linkage rules
2623
+ // (KIND_LINKAGE.review.parentRequired); `for:` preserves the field the
2624
+ // on-disk `<epic>.review.md` carried so existing readers still resolve.
2625
+ parent: `[[${epicBasename}]]`,
2626
+ for: `[[${epicBasename}]]`,
2627
+ },
2628
+ body: reviewBody,
2629
+ actingAgent,
2630
+ });
2631
+ } catch (err) {
2632
+ return cloudWriteFailureResult(err, { step: 'create_review_doc', landed });
2633
+ }
2634
+ landed.push({
2635
+ step: 'create_review_doc',
2636
+ type: 'review',
2637
+ id: createdReview.specId,
2638
+ basename: createdReview.basename,
2639
+ rel_path: createdReview.relPath,
2640
+ });
2641
+
2642
+ // ── Step 4: the roadmap manifest — NO CLOUD PATH, degrade out loud ────────
2643
+ const roadmapRegistration = skipRoadmapRegistration
2644
+ ? { success: true, message: 'Skipped (skip_roadmap_registration: true)' }
2645
+ : {
2646
+ success: false,
2647
+ ...CLOUD_UNSUPPORTED_ROADMAP_WRITE,
2648
+ roadmap_id: roadmapId,
2649
+ roadmap_phase: roadmapPhase,
2650
+ epic_basename: epicBasename,
2651
+ message:
2652
+ `Roadmap registration was SKIPPED in cloud write mode: ${CLOUD_UNSUPPORTED_ROADMAP_WRITE.why} `
2653
+ + 'Nothing was written to local disk and no substitute document was minted. '
2654
+ + `To land it, add "${epicBasename}" to phase ${roadmapPhase} of ${roadmapId} by hand, or run this backfill in local write mode.`,
2655
+ };
2656
+
2657
+ return {
2658
+ success: true,
2659
+ write_mode: 'cloud',
2660
+ shipped_to_review: true,
2661
+ epic: {
2662
+ id: createdEpic.specId,
2663
+ basename: epicBasename,
2664
+ spec_path: createdEpic.relPath,
2665
+ },
2666
+ children: childSpecs,
2667
+ review_doc: createdReview.relPath,
2668
+ landed: cloudLandedBlock({
2669
+ consoleUrl: createdEpic.consoleUrl ?? consoleUrl,
2670
+ relPath: createdEpic.relPath,
2671
+ consoleWrite: createdEpic.consoleWrite,
2672
+ vaultRoot,
2673
+ }),
2674
+ console_writes: landed,
2675
+ children_backlink: {
2676
+ by: 'console (createSpec KIND_LINKAGE.task.parentArray = "children")',
2677
+ 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',
2678
+ },
2679
+ milestone_backlink: {
2680
+ milestone: milestoneResolved.basename,
2681
+ by: 'console (createSpec KIND_LINKAGE.epic.parentArray = "epics")',
2682
+ },
2683
+ roadmap_registration: roadmapRegistration,
2684
+ ...(skipRoadmapRegistration
2685
+ ? {}
2686
+ : { cloud_mode_unsupported_writes: [CLOUD_UNSUPPORTED_ROADMAP_WRITE] }),
2687
+ validate_vault_ids: null,
2688
+ validate_message: CLOUD_VALIDATE_SKIPPED,
2689
+ next_steps: [
2690
+ roadmapRegistration.success
2691
+ ? `Roadmap registration: ${roadmapRegistration.message}`
2692
+ : `⚠️ 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`,
2693
+ 'Human reviews the epic and child tasks',
2694
+ 'Move to status: closed after verification',
2695
+ 'Record the WIP ledger (heyspex_record_wip) for the epic if needed (next-session-handoff.md retired — HS-EPIC-HNDR)',
2696
+ ],
2697
+ };
2698
+ }
2699
+
2700
+ /** Internals exposed for tests only. */
2701
+ export const __testables = {
2702
+ CLOUD_UNSUPPORTED_ROADMAP_WRITE,
2703
+ CLOUD_VALIDATE_SKIPPED,
2704
+ detectSpecConflictsViaConsole,
2705
+ normalizeRelatedWikilinks,
2706
+ pushPlanViaConsole,
2707
+ readSpecViaConsole,
2708
+ resolveCloudDeps,
2709
+ resolveMilestoneArgViaConsole,
2710
+ resolveWriteModeForPlanPush,
2711
+ shipEpicViaConsole,
2712
+ };