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
@@ -42,7 +42,7 @@ import {
42
42
  writeFileSync,
43
43
  } from 'node:fs';
44
44
  import { createHash } from 'node:crypto';
45
- import { join } from 'node:path';
45
+ import { join, isAbsolute, resolve, sep } from 'node:path';
46
46
  import { allocateRandomVaultId } from '../../scripts/vault-id-lib.mjs';
47
47
  import { yamlScalar } from './yaml-scalar.mjs';
48
48
  import {
@@ -72,7 +72,36 @@ import {
72
72
  bumpUpdated,
73
73
  appendChangeLogRow,
74
74
  commitWrites,
75
+ fetchSpecRecordViaConsole,
76
+ resolveWriteModeFromDeps,
77
+ splitRawSpecForConsole,
78
+ cloudWriteFailureResult,
79
+ cloudModeUnsupportedWrite,
75
80
  } from './promotion-helpers.mjs';
81
+ // Cloud-mode vault WRITES ([[HS-BYU4-CWPX-TASK-promotion-writes-go-through-the-console]]).
82
+ // See the block comment at the head of `runExecutePhase` for the create-first,
83
+ // link-second reorder these three verbs make possible.
84
+ import {
85
+ createSpecViaConsole,
86
+ updateSpecViaConsole,
87
+ listSpecsViaConsole,
88
+ stripServerMintedFields,
89
+ } from './cloud-vault-writes.mjs';
90
+ // Cloud-tier read selector ([[HS-KR16-CWPX-TASK-promotion-reads-through-the-cloud-tier]]) —
91
+ // reused verbatim, never copied. See the module doc above `fetchSpecRecordViaConsole`
92
+ // in promotion-helpers.mjs for why the neon branch goes through an injected
93
+ // consoleFetch instead of read-path.mjs's own transport.
94
+ import { readViaSelector } from './read-path.mjs';
95
+ import { assertPathUnderRoot } from '../../scripts/lib/shared-path-helpers.mjs';
96
+
97
+ // Placeholders the CLOUD path feeds the shared content builders in place of a
98
+ // locally-minted id / file key / family key — stripped from the request by
99
+ // `stripServerMintedFields` (plus an explicit `delete` of an un-inheritable
100
+ // `family:`) before it ever reaches the console. Their only job is to keep the
101
+ // builders' templates well-formed so the BODY stored in the cloud vault is the
102
+ // same markdown the disk path writes.
103
+ const CLOUD_PENDING_ID = 'HS-PENDING';
104
+ const CLOUD_PENDING_KEY = 'PENDING';
76
105
 
77
106
  /** Milestone statuses that allow attaching spawned epics without an override. */
78
107
  export const MILESTONE_ATTACH_DEFAULT_ALLOWED_STATUSES = ['open'];
@@ -177,6 +206,45 @@ function findResearchByIdLocal(vaultRoot, researchId) {
177
206
  }
178
207
  }
179
208
 
209
+ /**
210
+ * Cloud-tier sibling of {@link findResearchByIdLocal} (HS-KR16). Same
211
+ * `{ record }` / error-shaped contract; the neon branch fetches the by-id
212
+ * spec route directly and re-derives the same `RESEARCH_NOT_FOUND` /
213
+ * `RESEARCH_TYPE_MISMATCH` shapes the disk branch's `resolveBreakdownSource`
214
+ * call produces, since that shared resolver stays disk-only and untouched.
215
+ * `tier`/`consoleUrl`/`consoleFetch` are resolved ONCE per handler call
216
+ * (`handlePromoteResearch` in research-handlers.mjs) and threaded down here.
217
+ */
218
+ async function findResearchByIdViaTier(vaultRoot, researchId, { tier, consoleUrl, consoleFetch }) {
219
+ return readViaSelector({
220
+ allowDiskFallback: false,
221
+ tier,
222
+ diskRead: () => findResearchByIdLocal(vaultRoot, researchId),
223
+ neonRead: async () => {
224
+ const record = await fetchSpecRecordViaConsole(consoleFetch, researchId);
225
+ if (!record) {
226
+ return {
227
+ error: 'RESEARCH_NOT_FOUND',
228
+ message: `research ${researchId} not found under specs/research/ (tried exact basename, frontmatter id:, and id-prefix match)`,
229
+ };
230
+ }
231
+ const fm = getFrontmatterBlock(record.raw);
232
+ const type = fm ? getScalarField(fm.block, 'type') : null;
233
+ if (type && type !== 'research') {
234
+ return {
235
+ error: 'RESEARCH_TYPE_MISMATCH',
236
+ message: `research_id ${researchId} resolved to ${record.relPath ?? 'a file'}, which is \`type: ${type}\` — not a research doc`,
237
+ resolved_basename: record.basename,
238
+ resolved_type: type,
239
+ };
240
+ }
241
+ return { record };
242
+ },
243
+ routeLabel: 'GET /api/specs/[id]',
244
+ consoleUrl,
245
+ });
246
+ }
247
+
180
248
  function findMilestoneByBasename(vaultRoot, basename) {
181
249
  const absPath = join(vaultRoot, 'specs', 'milestones', `${basename}.md`);
182
250
  if (!existsSync(absPath)) return null;
@@ -201,6 +269,36 @@ function findMilestoneById(vaultRoot, milestoneId) {
201
269
  return null;
202
270
  }
203
271
 
272
+ /**
273
+ * Cloud-tier sibling of {@link findMilestoneByBasename} (HS-KR16). Same
274
+ * bare-record-or-null contract as the disk loader.
275
+ */
276
+ async function findMilestoneByBasenameViaTier(vaultRoot, basename, { tier, consoleUrl, consoleFetch }) {
277
+ return readViaSelector({
278
+ allowDiskFallback: false,
279
+ tier,
280
+ diskRead: () => findMilestoneByBasename(vaultRoot, basename),
281
+ neonRead: () => fetchSpecRecordViaConsole(consoleFetch, basename),
282
+ routeLabel: 'GET /api/specs/[id]',
283
+ consoleUrl,
284
+ });
285
+ }
286
+
287
+ /**
288
+ * Cloud-tier sibling of {@link findMilestoneById} (HS-KR16). Same
289
+ * bare-record-or-null contract as the disk loader.
290
+ */
291
+ async function findMilestoneByIdViaTier(vaultRoot, milestoneId, { tier, consoleUrl, consoleFetch }) {
292
+ return readViaSelector({
293
+ allowDiskFallback: false,
294
+ tier,
295
+ diskRead: () => findMilestoneById(vaultRoot, milestoneId),
296
+ neonRead: () => fetchSpecRecordViaConsole(consoleFetch, milestoneId),
297
+ routeLabel: 'GET /api/specs/[id]',
298
+ consoleUrl,
299
+ });
300
+ }
301
+
204
302
  function findEpicByIdOrBasename(vaultRoot, idOrBasename) {
205
303
  const dir = join(vaultRoot, 'specs', 'epics');
206
304
  if (!existsSync(dir)) return null;
@@ -232,6 +330,22 @@ function findEpicByIdOrBasename(vaultRoot, idOrBasename) {
232
330
  return null;
233
331
  }
234
332
 
333
+ /**
334
+ * Cloud-tier sibling of {@link findEpicByIdOrBasename} (HS-KR16) — the
335
+ * console's by-id route accepts either an id or a basename, matching the
336
+ * disk loader's own two-shape contract. Same bare-record-or-null return.
337
+ */
338
+ async function findEpicByIdOrBasenameViaTier(vaultRoot, idOrBasename, { tier, consoleUrl, consoleFetch }) {
339
+ return readViaSelector({
340
+ allowDiskFallback: false,
341
+ tier,
342
+ diskRead: () => findEpicByIdOrBasename(vaultRoot, idOrBasename),
343
+ neonRead: () => fetchSpecRecordViaConsole(consoleFetch, idOrBasename),
344
+ routeLabel: 'GET /api/specs/[id]',
345
+ consoleUrl,
346
+ });
347
+ }
348
+
235
349
  // ─────────────────────────────────────────────────────────────────────────────
236
350
  // Pillar resolution (epic→pillar hard requirement, [[pillar-assignment-protocol]])
237
351
  // ─────────────────────────────────────────────────────────────────────────────
@@ -295,6 +409,51 @@ export function resolvePillar(vaultRoot, raw) {
295
409
  return { ok: true, basename, frontmatterValue: `'[[${basename}]]'` };
296
410
  }
297
411
 
412
+ /**
413
+ * Cloud sibling of {@link listVaultPillars} ([[HS-BYU4-CWPX-TASK-promotion-writes-go-through-the-console]] AC3).
414
+ *
415
+ * The disk version is a `readdirSync(specs/pillars)` scan, which in a file-less
416
+ * vault returns `[]` — so a `pillar_required` refusal would list NO valid
417
+ * options and read as "there are no pillars" rather than "this machine holds
418
+ * no copy of them". `GET /api/specs?type=pillar` (`listSpecsViaConsole`, which
419
+ * follows the keyset cursor) is the route that answers the same question in
420
+ * cloud mode. Fails SOFT to `[]`, exactly like the disk version: this list only
421
+ * enriches an error message that is being returned either way, and turning a
422
+ * refusal into an unreachable-console error would hide the real cause.
423
+ */
424
+ export async function listVaultPillarsViaWriteMode(vaultRoot, { writeMode, consoleFetch, consoleUrl }) {
425
+ if (writeMode !== 'cloud') return listVaultPillars(vaultRoot);
426
+ try {
427
+ const rows = await listSpecsViaConsole({ consoleFetch, consoleUrl, type: 'pillar' });
428
+ return rows.map((row) => ({
429
+ id: row.basename ?? row.id ?? null,
430
+ name: row.name ?? row.basename ?? row.id ?? null,
431
+ }));
432
+ } catch {
433
+ return [];
434
+ }
435
+ }
436
+
437
+ /**
438
+ * Cloud-tier sibling of {@link resolvePillar} (HS-KR16).
439
+ */
440
+ async function resolvePillarViaTier(vaultRoot, raw, { tier, consoleUrl, consoleFetch }) {
441
+ const basename = normalizePillarRef(raw);
442
+ if (!basename) return { ok: false };
443
+ return readViaSelector({
444
+ allowDiskFallback: false,
445
+ tier,
446
+ diskRead: () => resolvePillar(vaultRoot, raw),
447
+ neonRead: async () => {
448
+ const record = await fetchSpecRecordViaConsole(consoleFetch, basename);
449
+ if (!record) return { ok: false };
450
+ return { ok: true, basename, frontmatterValue: `'[[${basename}]]'` };
451
+ },
452
+ routeLabel: 'GET /api/specs/[id]',
453
+ consoleUrl,
454
+ });
455
+ }
456
+
298
457
  // ─────────────────────────────────────────────────────────────────────────────
299
458
  // AI proposer wiring
300
459
  // ─────────────────────────────────────────────────────────────────────────────
@@ -495,21 +654,32 @@ function validateResearchState(research) {
495
654
  // validateBidirectionalEnhances (RESEARCH_ALREADY_PROMOTED).
496
655
  const status = getScalarField(fm.block, 'status');
497
656
  const enhances = getScalarField(fm.block, 'enhances');
498
- return { fm, status, enhances };
657
+ // [[HS-8XJA-KOGH-DIAG-passing-enhances-to-heyspex-promote]]: `enhances` is
658
+ // ALSO a heyspex_promote_capture argument that writes this same field one
659
+ // stage earlier, before any epic has actually been spawned — forging the
660
+ // "already promoted" signal ahead of the event it's supposed to record. A
661
+ // GENUINE promote-research execution always writes `spawned_epics` and/or
662
+ // `enhances_epics` in the SAME commit as `enhances` (buildUpdatedResearchContent
663
+ // below) — so `enhances` set with BOTH of those empty can only mean it was
664
+ // pre-set at capture-promote time, never backed by a real promotion.
665
+ const spawnedEpics = parseListFieldBasenames(fm.block, 'spawned_epics') ?? [];
666
+ const enhancesEpics = parseListFieldBasenames(fm.block, 'enhances_epics') ?? [];
667
+ const enhancesBacked = spawnedEpics.length > 0 || enhancesEpics.length > 0;
668
+ return { fm, status, enhances, enhancesBacked };
499
669
  }
500
670
 
501
- function resolveTargetMilestone(vaultRoot, args, occupiedMilestoneIds) {
671
+ async function resolveTargetMilestone(vaultRoot, args, { tier, consoleUrl, consoleFetch }) {
502
672
  if (args.target_milestone === 'NEW') {
503
673
  return { mode: 'new', name: args.new_milestone_name.trim() };
504
674
  }
505
675
  // Accept either bare HS-MIL-XXXX, the full basename, or wikilink wrapping.
506
676
  const basenameCandidate = normalizeMilestoneBasename(args.target_milestone);
507
677
  if (basenameCandidate) {
508
- const ms = findMilestoneByBasename(vaultRoot, basenameCandidate);
678
+ const ms = await findMilestoneByBasenameViaTier(vaultRoot, basenameCandidate, { tier, consoleUrl, consoleFetch });
509
679
  if (ms) return { mode: 'existing', milestone: ms };
510
680
  }
511
681
  if (parseSpecId(args.target_milestone)?.typeLabel === 'MIL') {
512
- const ms = findMilestoneById(vaultRoot, args.target_milestone);
682
+ const ms = await findMilestoneByIdViaTier(vaultRoot, args.target_milestone, { tier, consoleUrl, consoleFetch });
513
683
  if (ms) return { mode: 'existing', milestone: ms };
514
684
  }
515
685
  return {
@@ -652,6 +822,7 @@ async function runProposalPhase(args, ctx, deps) {
652
822
  /** Minimum length for an explanation, matching attach_override_reason above. */
653
823
  const EDIT_NOTE_MIN_LENGTH = 40;
654
824
 
825
+
655
826
  /** Marker path for a research doc's proposal turn. Keyed on the research id — the
656
827
  * one value stable across both turns, since the human is expected to EDIT the
657
828
  * stubs in between (so hashing the stub content, as the plan gate does, would
@@ -672,6 +843,7 @@ function normalizeStubName(name) {
672
843
  export function writeStubReviewMarker(vaultRoot, researchId, proposal = {}) {
673
844
  try {
674
845
  const file = stubReviewMarkerPath(vaultRoot, researchId);
846
+ assertPathUnderRoot(vaultRoot, join(file, '..'), 'promote-research write');
675
847
  mkdirSync(join(file, '..'), { recursive: true });
676
848
  writeFileSync(
677
849
  file,
@@ -815,6 +987,62 @@ function resolveOwnerIntent(vaultRoot, researchRaw) {
815
987
  }
816
988
  }
817
989
 
990
+ /**
991
+ * Cloud-tier sibling of {@link resolveOwnerIntent} (HS-KR16) — same
992
+ * best-effort, never-throws contract (a missing/unreachable source capture
993
+ * just means no owner-intent enrichment, not a hard failure). Only the
994
+ * per-basename capture read moves through the tier selector; the
995
+ * sources_captures/related parsing stays identical to the disk version.
996
+ */
997
+ async function resolveOwnerIntentViaTier(vaultRoot, researchRaw, { tier, consoleUrl, consoleFetch }) {
998
+ try {
999
+ const fm = getFrontmatterBlock(researchRaw);
1000
+ if (!fm) return null;
1001
+ let basenames = parseListFieldBasenames(fm.block, 'sources_captures') ?? [];
1002
+ if (basenames.length === 0) {
1003
+ basenames = (parseListFieldBasenames(fm.block, 'related') ?? []).filter((b) =>
1004
+ /(^|-)CAP(-|$)/i.test(b),
1005
+ );
1006
+ }
1007
+ if (basenames.length === 0) return null;
1008
+ for (const basename of basenames) {
1009
+ let raw;
1010
+ try {
1011
+ raw = await readViaSelector({
1012
+ allowDiskFallback: false,
1013
+ tier,
1014
+ diskRead: () => {
1015
+ const capturesDir = join(vaultRoot, 'specs', 'captures');
1016
+ let abs = join(capturesDir, `${basename}.md`);
1017
+ if (!existsSync(abs)) {
1018
+ const byPrefix = findFileByPrefix(capturesDir, basename);
1019
+ if (!byPrefix) return null;
1020
+ abs = join(capturesDir, byPrefix);
1021
+ }
1022
+ return readFileSync(abs, 'utf8');
1023
+ },
1024
+ neonRead: async () => {
1025
+ const record = await fetchSpecRecordViaConsole(consoleFetch, basename);
1026
+ return record ? record.raw : null;
1027
+ },
1028
+ routeLabel: 'GET /api/specs/[id]',
1029
+ consoleUrl,
1030
+ });
1031
+ } catch {
1032
+ continue; // a genuine console failure here is soft — skip, try the next basename.
1033
+ }
1034
+ if (!raw) continue;
1035
+ const body = getFrontmatterBlock(raw)?.body ?? raw;
1036
+ const m = body.match(/#\s*Core Idea\s*\n+([\s\S]*?)(?=\n#{1,6}\s|$)/i);
1037
+ const coreIdea = m ? m[1].trim() : '';
1038
+ if (coreIdea) return { basename, coreIdea };
1039
+ }
1040
+ return null;
1041
+ } catch {
1042
+ return null;
1043
+ }
1044
+ }
1045
+
818
1046
  /**
819
1047
  * Pull the research doc's own `## JTBD (job-to-be-done)` section body, so
820
1048
  * spawned milestone/epic stubs can re-derive their altitude's slice of the
@@ -1018,6 +1246,38 @@ async function runExecutePhase(args, ctx, deps) {
1018
1246
  const now = (deps?.clock ?? isoNow)();
1019
1247
  const today = now.slice(0, 10);
1020
1248
  const author = 'promote-research (agent)';
1249
+ // Same already-resolved tier this call's executePromoteResearch entry point
1250
+ // read from deps.tier ([[HS-KR16-CWPX-TASK-promotion-reads-through-the-cloud-tier]])
1251
+ // — read again here, never re-resolved via resolveReadTier().
1252
+ const tierDeps = { tier: deps?.tier ?? 'disk', consoleUrl: deps?.consoleUrl, consoleFetch: deps?.consoleFetch };
1253
+ // ── Vault WRITE mode ([[HS-BYU4-CWPX-TASK-promotion-writes-go-through-the-console]]) ──
1254
+ //
1255
+ // Resolved ONCE by the caller (`resolveVaultWriteMode()`) and threaded in on
1256
+ // `deps`; absent — every pre-existing caller and test — it is `local`, so the
1257
+ // disk plan-then-`commitWrites` path below is byte-identical to before.
1258
+ //
1259
+ // CREATE FIRST, LINK SECOND. On disk this flow mints every filename itself,
1260
+ // so it can stage all four write groups and commit them as one transaction.
1261
+ // In cloud mode the console mints the id, the family and the filename, and
1262
+ // the dependencies run strictly one way:
1263
+ //
1264
+ // milestone ← links to: research basename (known up front)
1265
+ // epic ← links to: MILESTONE basename (only after its POST)
1266
+ // epic ← links to: SIBLING epic basenames (only after their POSTs)
1267
+ // milestone ← links to: EPIC basenames (`epics:`) (only after their POSTs)
1268
+ // research ← links to: MILESTONE + EPIC basenames (only after their POSTs)
1269
+ //
1270
+ // so the cloud order is: POST milestone → POST every epic (with only the
1271
+ // deps already resolvable) → PUT epics that depend on a sibling → PUT the
1272
+ // milestone's `epics:` mirror → PUT the research doc. Every wikilink written
1273
+ // in a PUT body therefore names a basename a POST actually returned.
1274
+ const writeMode = resolveWriteModeFromDeps(deps);
1275
+ const cloudDeps = { consoleFetch: deps?.consoleFetch, consoleUrl: deps?.consoleUrl };
1276
+ const cloudCompleted = [];
1277
+ const recordCloud = (entry) => {
1278
+ cloudCompleted.push(entry);
1279
+ return entry;
1280
+ };
1021
1281
 
1022
1282
  // Family inheritance ([[HS-GZM9-DIAG-G419]], mirrors promote-capture.mjs /
1023
1283
  // split-research.mjs): the spawned milestone AND every spawned epic inherit
@@ -1041,23 +1301,39 @@ async function runExecutePhase(args, ctx, deps) {
1041
1301
  let existingMilestoneEpics = [];
1042
1302
 
1043
1303
  if (ctx.targetResolution.mode === 'new') {
1044
- // Per-file FILE KEY (owner ruling 2026-07-10) — filename + `file_id:` SSOT,
1045
- // and (V3) the anchor for `id:` itself.
1046
- milestoneFileKey = allocateFileKey(collectOccupiedKeysEitherKind(ctx.vaultRoot), {
1047
- randomUnit: deps?.randomUnit,
1048
- });
1049
- // Family: inherit from the research doc; the legacy allocator's
1050
- // `HS-<family>-MIL` draw is only the fallback family source (never blank —
1051
- // last resort is the fresh file key itself, matching the old chain).
1052
- milestoneFamilyKey =
1053
- inheritedFamilyKey ??
1054
- parseSpecId(allocateMilestoneId(ctx.vaultRoot, { randomUnit: deps?.randomUnit }))?.familyKey ??
1055
- milestoneFileKey;
1056
- milestoneId = `HS-${milestoneFileKey}`;
1057
- const slug = deriveSlug(ctx.targetResolution.name);
1058
- milestoneBasename = `${milestoneId}-${milestoneFamilyKey}-MIL-${slug}`;
1059
- milestoneAbsPath = join(ctx.vaultRoot, 'specs', 'milestones', `${milestoneBasename}.md`);
1060
- milestoneRelPath = join('specs', 'milestones', `${milestoneBasename}.md`);
1304
+ if (writeMode === 'cloud') {
1305
+ // NOTHING is minted locally in cloud mode (AC3): `allocateFileKey`,
1306
+ // `collectOccupiedKeysEitherKind` and `allocateMilestoneId` all derive
1307
+ // their answer from a vault SCAN, and in a file-less vault that scan is
1308
+ // empty — a key drawn against an empty occupied set can collide with one
1309
+ // the cloud vault already holds. `createSpec` mints the file key, the
1310
+ // family and the filename server-side. An INHERITED family is still
1311
+ // passed through: it is read off the source research doc, not minted.
1312
+ milestoneFileKey = null;
1313
+ milestoneFamilyKey = inheritedFamilyKey ?? null;
1314
+ milestoneId = null;
1315
+ milestoneBasename = null;
1316
+ milestoneAbsPath = null;
1317
+ milestoneRelPath = null;
1318
+ } else {
1319
+ // Per-file FILE KEY (owner ruling 2026-07-10) — filename + `file_id:` SSOT,
1320
+ // and (V3) the anchor for `id:` itself.
1321
+ milestoneFileKey = allocateFileKey(collectOccupiedKeysEitherKind(ctx.vaultRoot), {
1322
+ randomUnit: deps?.randomUnit,
1323
+ });
1324
+ // Family: inherit from the research doc; the legacy allocator's
1325
+ // `HS-<family>-MIL` draw is only the fallback family source (never blank —
1326
+ // last resort is the fresh file key itself, matching the old chain).
1327
+ milestoneFamilyKey =
1328
+ inheritedFamilyKey ??
1329
+ parseSpecId(allocateMilestoneId(ctx.vaultRoot, { randomUnit: deps?.randomUnit }))?.familyKey ??
1330
+ milestoneFileKey;
1331
+ milestoneId = `HS-${milestoneFileKey}`;
1332
+ const slug = deriveSlug(ctx.targetResolution.name);
1333
+ milestoneBasename = `${milestoneId}-${milestoneFamilyKey}-MIL-${slug}`;
1334
+ milestoneAbsPath = join(ctx.vaultRoot, 'specs', 'milestones', `${milestoneBasename}.md`);
1335
+ milestoneRelPath = join('specs', 'milestones', `${milestoneBasename}.md`);
1336
+ }
1061
1337
  milestoneCreated = true;
1062
1338
  } else {
1063
1339
  const ms = ctx.targetResolution.milestone;
@@ -1076,7 +1352,7 @@ async function runExecutePhase(args, ctx, deps) {
1076
1352
  const augmentEpics = [];
1077
1353
  if (args.intent === 'augment' || args.intent === 'both') {
1078
1354
  for (const epicRef of args.augment_epic_ids) {
1079
- const found = findEpicByIdOrBasename(ctx.vaultRoot, epicRef);
1355
+ const found = await findEpicByIdOrBasenameViaTier(ctx.vaultRoot, epicRef, tierDeps);
1080
1356
  if (!found) {
1081
1357
  return {
1082
1358
  error: 'AUGMENT_EPIC_NOT_FOUND',
@@ -1106,10 +1382,14 @@ async function runExecutePhase(args, ctx, deps) {
1106
1382
 
1107
1383
  const spawnedEpicSpecs = [];
1108
1384
  if (acceptedStubs.length > 0) {
1109
- const { epics: occupiedEpics } = collectOccupiedVaultIds(ctx.vaultRoot);
1385
+ // Both occupied sets are DISK SCANS — skipped entirely in cloud mode,
1386
+ // where no key is minted client-side (AC3).
1387
+ const { epics: occupiedEpics } =
1388
+ writeMode === 'cloud' ? { epics: new Set() } : collectOccupiedVaultIds(ctx.vaultRoot);
1110
1389
  // One occupied set across the whole loop — files are not on disk yet, so a
1111
1390
  // fresh vault walk per stub would let two stubs draw the same file key.
1112
- const occupiedFileKeys = collectOccupiedKeysEitherKind(ctx.vaultRoot); // union: file+family keys (HS-SJOT)
1391
+ const occupiedFileKeys =
1392
+ writeMode === 'cloud' ? new Set() : collectOccupiedKeysEitherKind(ctx.vaultRoot); // union: file+family keys (HS-SJOT)
1113
1393
  for (const stub of acceptedStubs) {
1114
1394
  if (!stub?.name?.trim()) {
1115
1395
  return {
@@ -1117,23 +1397,32 @@ async function runExecutePhase(args, ctx, deps) {
1117
1397
  message: 'every accepted stub must have a non-empty name',
1118
1398
  };
1119
1399
  }
1120
- // Per-file FILE KEY (owner ruling 2026-07-10) — filename + `file_id:` SSOT,
1121
- // and (V3) the anchor for `id:` itself.
1122
- const fileKey = allocateFileKey(occupiedFileKeys, { randomUnit: deps?.randomUnit });
1123
- occupiedFileKeys.add(fileKey);
1124
- // Family: inherit from the research doc; the legacy allocator's
1125
- // `HS-<family>-EPIC` draw is only the fallback family source.
1400
+ let fileKey = null;
1126
1401
  let epicFamilyKey = inheritedFamilyKey;
1127
- if (!epicFamilyKey) {
1128
- const allocatedEpicFamilyId = allocateRandomVaultId('epic', occupiedEpics, {
1129
- randomUnit: deps?.randomUnit,
1130
- });
1131
- occupiedEpics.add(allocatedEpicFamilyId);
1132
- epicFamilyKey = parseSpecId(allocatedEpicFamilyId)?.familyKey ?? fileKey;
1402
+ let epicId = null;
1403
+ let basename = null;
1404
+ if (writeMode === 'cloud') {
1405
+ // Basename/id stay null until this stub's own POST answers; the
1406
+ // inherited family (read off the research doc, never minted) is the
1407
+ // only naming input that survives into the request.
1408
+ epicFamilyKey = inheritedFamilyKey ?? null;
1409
+ } else {
1410
+ // Per-file FILE KEY (owner ruling 2026-07-10) — filename + `file_id:` SSOT,
1411
+ // and (V3) the anchor for `id:` itself.
1412
+ fileKey = allocateFileKey(occupiedFileKeys, { randomUnit: deps?.randomUnit });
1413
+ occupiedFileKeys.add(fileKey);
1414
+ // Family: inherit from the research doc; the legacy allocator's
1415
+ // `HS-<family>-EPIC` draw is only the fallback family source.
1416
+ if (!epicFamilyKey) {
1417
+ const allocatedEpicFamilyId = allocateRandomVaultId('epic', occupiedEpics, {
1418
+ randomUnit: deps?.randomUnit,
1419
+ });
1420
+ occupiedEpics.add(allocatedEpicFamilyId);
1421
+ epicFamilyKey = parseSpecId(allocatedEpicFamilyId)?.familyKey ?? fileKey;
1422
+ }
1423
+ epicId = `HS-${fileKey}`;
1424
+ basename = `${epicId}-${epicFamilyKey}-EPIC-${deriveSlug(stub.name)}`;
1133
1425
  }
1134
- const epicId = `HS-${fileKey}`;
1135
- const slug = deriveSlug(stub.name);
1136
- const basename = `${epicId}-${epicFamilyKey}-EPIC-${slug}`;
1137
1426
  spawnedEpicSpecs.push({
1138
1427
  id: epicId,
1139
1428
  fileKey,
@@ -1147,8 +1436,8 @@ async function runExecutePhase(args, ctx, deps) {
1147
1436
  // Per-stub pillar override; falls back to the batch-default `args.pillar`
1148
1437
  // in the gate below (stub value wins — [[pillar-assignment-protocol]]).
1149
1438
  pillar: typeof stub.pillar === 'string' ? stub.pillar : undefined,
1150
- relPath: join('specs', 'epics', `${basename}.md`),
1151
- absPath: join(ctx.vaultRoot, 'specs', 'epics', `${basename}.md`),
1439
+ relPath: basename ? join('specs', 'epics', `${basename}.md`) : null,
1440
+ absPath: basename ? join(ctx.vaultRoot, 'specs', 'epics', `${basename}.md`) : null,
1152
1441
  });
1153
1442
  }
1154
1443
  }
@@ -1161,20 +1450,32 @@ async function runExecutePhase(args, ctx, deps) {
1161
1450
  // each entry that matches a sibling stub's `name` with that sibling's
1162
1451
  // freshly-minted basename; else resolve against an existing vault epic by
1163
1452
  // id/basename; else DROP it (warn) rather than write an unresolvable link.
1453
+ //
1454
+ // In CLOUD mode a sibling's basename does not exist yet either (the
1455
+ // console mints it at POST time), so the sibling half of this resolution
1456
+ // is DEFERRED: the entry is parked on `spec.pendingSiblingDeps` and folded
1457
+ // in by a follow-up PUT once every sibling POST has answered. The
1458
+ // externally-resolved half is identical in both modes.
1164
1459
  const depWarnings = [];
1165
1460
  if (spawnedEpicSpecs.length > 0) {
1461
+ const siblingNames = new Set(spawnedEpicSpecs.map((s) => s.name.trim().toLowerCase()));
1166
1462
  const siblingNameToBasename = new Map();
1167
1463
  for (const s of spawnedEpicSpecs) {
1168
1464
  siblingNameToBasename.set(s.name.trim().toLowerCase(), s.basename);
1169
1465
  }
1170
1466
  for (const spec of spawnedEpicSpecs) {
1171
1467
  const resolved = [];
1468
+ const pendingSiblings = [];
1172
1469
  for (const raw of spec.depends_on) {
1173
1470
  const d = typeof raw === 'string' ? raw.trim() : '';
1174
1471
  if (!d) continue;
1472
+ if (writeMode === 'cloud' && siblingNames.has(d.toLowerCase())) {
1473
+ pendingSiblings.push(d.toLowerCase());
1474
+ continue;
1475
+ }
1175
1476
  let target = siblingNameToBasename.get(d.toLowerCase());
1176
1477
  if (!target) {
1177
- const found = findEpicByIdOrBasename(ctx.vaultRoot, d);
1478
+ const found = await findEpicByIdOrBasenameViaTier(ctx.vaultRoot, d, tierDeps);
1178
1479
  if (found) target = found.basename;
1179
1480
  }
1180
1481
  if (target) {
@@ -1184,6 +1485,7 @@ async function runExecutePhase(args, ctx, deps) {
1184
1485
  }
1185
1486
  }
1186
1487
  spec.depends_on = resolved;
1488
+ spec.pendingSiblingDeps = pendingSiblings;
1187
1489
  }
1188
1490
  }
1189
1491
 
@@ -1195,14 +1497,19 @@ async function runExecutePhase(args, ctx, deps) {
1195
1497
  if (spawnedEpicSpecs.length > 0) {
1196
1498
  for (const spec of spawnedEpicSpecs) {
1197
1499
  const pillarRef = spec.pillar ?? args.pillar;
1198
- const resolved = resolvePillar(ctx.vaultRoot, pillarRef);
1500
+ const resolved = await resolvePillarViaTier(ctx.vaultRoot, pillarRef, tierDeps);
1199
1501
  if (!resolved.ok) {
1200
1502
  return {
1201
1503
  error: 'pillar_required',
1202
1504
  message: pillarRef
1203
1505
  ? `epic "${spec.name}" pillar \`${pillarRef}\` does not resolve to a pillar under specs/pillars/ — pick an existing pillar or author a new one first`
1204
1506
  : `epic "${spec.name}" requires a \`pillar:\` wikilink to an HS-PILR theme — pass a top-level \`pillar\` batch default or a per-stub \`pillar\`; pick an existing pillar or author a new one first`,
1205
- existing_pillars: listVaultPillars(ctx.vaultRoot),
1507
+ // AC3: in cloud mode this list comes from `GET /api/specs?type=pillar`,
1508
+ // not from a `readdirSync(specs/pillars)` that would be empty here.
1509
+ existing_pillars: await listVaultPillarsViaWriteMode(ctx.vaultRoot, {
1510
+ writeMode,
1511
+ ...cloudDeps,
1512
+ }),
1206
1513
  create_pillar: {
1207
1514
  tool: 'heyspex_file_spec',
1208
1515
  call: { type: 'pillar', name: '<theme name>' },
@@ -1220,11 +1527,295 @@ async function runExecutePhase(args, ctx, deps) {
1220
1527
  // The owner's own words from the originating capture, carried onto every spec
1221
1528
  // this promotion creates so intent survives the hop into the plan graph.
1222
1529
  // Null when no capture resolves — stubs then render exactly as before.
1223
- const ownerIntent = resolveOwnerIntent(ctx.vaultRoot, ctx.research.raw);
1530
+ const ownerIntent = await resolveOwnerIntentViaTier(ctx.vaultRoot, ctx.research.raw, tierDeps);
1224
1531
  // HS-M1MW: seed spawned stubs' JTBD sections from the research doc's own
1225
1532
  // JTBD section, when present.
1226
1533
  const researchJtbd = extractResearchJtbd(ctx.research.raw);
1227
1534
 
1535
+ // ── CLOUD PATH ────────────────────────────────────────────────────────────
1536
+ // Returns early; everything below this block is the untouched disk
1537
+ // plan-then-`commitWrites` path.
1538
+ if (writeMode === 'cloud') {
1539
+ /** Frontmatter/body the console actually stored, falling back to ours. */
1540
+ const storedOf = (created, fallbackRaw) => {
1541
+ const fallback = splitRawSpecForConsole(fallbackRaw);
1542
+ return {
1543
+ frontmatter: created?.spec?.frontmatter ?? fallback.frontmatter,
1544
+ body: typeof created?.spec?.body === 'string' ? created.spec.body : fallback.body,
1545
+ };
1546
+ };
1547
+
1548
+ // ── 1. POST the milestone FIRST — every epic's `parent:`/`milestone:` and
1549
+ // the research's `enhances:` name the basename it returns.
1550
+ let milestoneStored = null;
1551
+ if (milestoneCreated) {
1552
+ const newContent = buildMilestoneStubContent(
1553
+ CLOUD_PENDING_ID,
1554
+ CLOUD_PENDING_KEY,
1555
+ milestoneFamilyKey ?? CLOUD_PENDING_KEY,
1556
+ ctx.targetResolution.name,
1557
+ ctx.research.basename,
1558
+ now,
1559
+ ownerIntent,
1560
+ researchJtbd,
1561
+ );
1562
+ const { frontmatter, body } = splitRawSpecForConsole(newContent);
1563
+ const cleaned = stripServerMintedFields(frontmatter);
1564
+ if (!milestoneFamilyKey) delete cleaned.family;
1565
+ let created;
1566
+ try {
1567
+ created = await createSpecViaConsole({
1568
+ ...cloudDeps,
1569
+ type: 'milestone',
1570
+ name: ctx.targetResolution.name,
1571
+ frontmatter: cleaned,
1572
+ body,
1573
+ actingAgent: deps?.actingAgent ?? null,
1574
+ });
1575
+ } catch (err) {
1576
+ return cloudWriteFailureResult(err, {
1577
+ step: { kind: 'create', type: 'milestone', name: ctx.targetResolution.name },
1578
+ completed: cloudCompleted,
1579
+ });
1580
+ }
1581
+ milestoneId = created.specId;
1582
+ milestoneBasename = created.basename;
1583
+ milestoneRelPath = created.relPath;
1584
+ milestoneStored = storedOf(created, newContent);
1585
+ recordCloud({
1586
+ kind: 'create',
1587
+ type: 'milestone',
1588
+ spec_id: created.specId,
1589
+ basename: created.basename,
1590
+ rel_path: created.relPath,
1591
+ });
1592
+ }
1593
+
1594
+ // ── 2. POST every epic, now that the milestone basename is real.
1595
+ for (const spec of spawnedEpicSpecs) {
1596
+ const content = buildEpicContent(
1597
+ {
1598
+ ...spec,
1599
+ id: CLOUD_PENDING_ID,
1600
+ fileKey: CLOUD_PENDING_KEY,
1601
+ familyKey: spec.familyKey ?? CLOUD_PENDING_KEY,
1602
+ },
1603
+ milestoneBasename,
1604
+ ctx.research.basename,
1605
+ now,
1606
+ ownerIntent,
1607
+ researchJtbd,
1608
+ );
1609
+ const { frontmatter, body } = splitRawSpecForConsole(content);
1610
+ const cleaned = stripServerMintedFields(frontmatter);
1611
+ if (!spec.familyKey) delete cleaned.family;
1612
+ let created;
1613
+ try {
1614
+ created = await createSpecViaConsole({
1615
+ ...cloudDeps,
1616
+ type: 'epic',
1617
+ name: spec.name,
1618
+ frontmatter: cleaned,
1619
+ body,
1620
+ actingAgent: deps?.actingAgent ?? null,
1621
+ });
1622
+ } catch (err) {
1623
+ return cloudWriteFailureResult(err, {
1624
+ step: { kind: 'create', type: 'epic', name: spec.name },
1625
+ completed: cloudCompleted,
1626
+ });
1627
+ }
1628
+ spec.id = created.specId;
1629
+ spec.basename = created.basename;
1630
+ spec.relPath = created.relPath;
1631
+ spec.cloudStored = storedOf(created, content);
1632
+ recordCloud({
1633
+ kind: 'create',
1634
+ type: 'epic',
1635
+ spec_id: created.specId,
1636
+ basename: created.basename,
1637
+ rel_path: created.relPath,
1638
+ });
1639
+ }
1640
+
1641
+ // ── 3. PUT the epics whose `depends_on` names a SIBLING: that basename
1642
+ // only became knowable in step 2.
1643
+ const siblingBasenameByName = new Map(
1644
+ spawnedEpicSpecs.map((s) => [s.name.trim().toLowerCase(), s.basename]),
1645
+ );
1646
+ for (const spec of spawnedEpicSpecs) {
1647
+ if (!spec.pendingSiblingDeps?.length) continue;
1648
+ const siblingBasenames = [];
1649
+ for (const name of spec.pendingSiblingDeps) {
1650
+ const b = siblingBasenameByName.get(name);
1651
+ if (b) siblingBasenames.push(b);
1652
+ else depWarnings.push({ epic: spec.name, unresolved: name });
1653
+ }
1654
+ if (siblingBasenames.length === 0) continue;
1655
+ spec.depends_on = [...spec.depends_on, ...siblingBasenames];
1656
+ const fm = { ...spec.cloudStored.frontmatter };
1657
+ fm.depends_on = spec.depends_on.map((d) => `[[${d}]]`);
1658
+ try {
1659
+ const res = await updateSpecViaConsole({
1660
+ ...cloudDeps,
1661
+ specId: spec.id,
1662
+ frontmatter: fm,
1663
+ body: spec.cloudStored.body,
1664
+ });
1665
+ recordCloud({
1666
+ kind: 'update',
1667
+ type: 'epic.depends_on',
1668
+ spec_id: res.specId,
1669
+ basename: res.basename ?? spec.basename,
1670
+ rel_path: res.relPath,
1671
+ });
1672
+ } catch (err) {
1673
+ return cloudWriteFailureResult(err, {
1674
+ step: { kind: 'update', type: 'epic.depends_on', basename: spec.basename },
1675
+ completed: cloudCompleted,
1676
+ });
1677
+ }
1678
+ }
1679
+
1680
+ // ── 4. PUT the milestone's `epics:` mirror (the cloud twin of the
1681
+ // post-commit `appendEpicToMilestone` line surgery) + `informed_by:`
1682
+ // on an EXISTING milestone. Both reference basenames from step 2.
1683
+ const spawnedBasenames = spawnedEpicSpecs.map((s) => s.basename);
1684
+ if (milestoneCreated) {
1685
+ if (spawnedBasenames.length > 0) {
1686
+ const fm = { ...milestoneStored.frontmatter };
1687
+ fm.epics = spawnedBasenames.map((b) => `[[${b}]]`);
1688
+ try {
1689
+ const res = await updateSpecViaConsole({
1690
+ ...cloudDeps,
1691
+ specId: milestoneId,
1692
+ frontmatter: fm,
1693
+ body: milestoneStored.body,
1694
+ });
1695
+ recordCloud({
1696
+ kind: 'update',
1697
+ type: 'milestone.epics',
1698
+ spec_id: res.specId,
1699
+ basename: res.basename ?? milestoneBasename,
1700
+ rel_path: res.relPath,
1701
+ });
1702
+ } catch (err) {
1703
+ return cloudWriteFailureResult(err, {
1704
+ step: { kind: 'update', type: 'milestone.epics', basename: milestoneBasename },
1705
+ completed: cloudCompleted,
1706
+ });
1707
+ }
1708
+ }
1709
+ } else {
1710
+ const msRaw = ctx.targetResolution.milestone.raw;
1711
+ let updatedRaw = buildUpdatedMilestoneContent(msRaw, {
1712
+ researchBasename: ctx.research.basename,
1713
+ now,
1714
+ });
1715
+ const fmBlock = getFrontmatterBlock(updatedRaw);
1716
+ if (fmBlock && spawnedBasenames.length > 0) {
1717
+ let block = fmBlock.block;
1718
+ for (const b of spawnedBasenames) block = appendToListField(block, 'epics', b).block;
1719
+ updatedRaw = `${fmBlock.open}${block}${fmBlock.close}${fmBlock.body}`;
1720
+ }
1721
+ if (updatedRaw !== msRaw) {
1722
+ const { frontmatter, body } = splitRawSpecForConsole(updatedRaw);
1723
+ try {
1724
+ const res = await updateSpecViaConsole({
1725
+ ...cloudDeps,
1726
+ specId: milestoneId,
1727
+ frontmatter,
1728
+ body,
1729
+ });
1730
+ recordCloud({
1731
+ kind: 'update',
1732
+ type: 'milestone',
1733
+ spec_id: res.specId,
1734
+ basename: res.basename ?? milestoneBasename,
1735
+ rel_path: res.relPath,
1736
+ });
1737
+ } catch (err) {
1738
+ return cloudWriteFailureResult(err, {
1739
+ step: { kind: 'update', type: 'milestone', basename: milestoneBasename },
1740
+ completed: cloudCompleted,
1741
+ });
1742
+ }
1743
+ }
1744
+ }
1745
+
1746
+ // ── 5. PUT the research doc LAST: `enhances:` + `spawned_epics:` +
1747
+ // `enhances_epics:` all name basenames a POST returned above.
1748
+ const researchContentCloud = buildUpdatedResearchContent(ctx.research.raw, {
1749
+ milestoneBasename,
1750
+ spawnedEpicBasenames: spawnedBasenames,
1751
+ augmentedEpicBasenames: augmentEpics.map((e) => e.basename),
1752
+ now,
1753
+ today,
1754
+ author,
1755
+ });
1756
+ {
1757
+ const { frontmatter, body } = splitRawSpecForConsole(researchContentCloud);
1758
+ try {
1759
+ const res = await updateSpecViaConsole({
1760
+ ...cloudDeps,
1761
+ specId: ctx.research.frontmatter?.id ?? args.research_id,
1762
+ frontmatter,
1763
+ body,
1764
+ });
1765
+ recordCloud({
1766
+ kind: 'update',
1767
+ type: 'research',
1768
+ spec_id: res.specId,
1769
+ basename: res.basename ?? ctx.research.basename,
1770
+ rel_path: res.relPath,
1771
+ });
1772
+ } catch (err) {
1773
+ return cloudWriteFailureResult(err, {
1774
+ step: { kind: 'update', type: 'research', basename: ctx.research.basename },
1775
+ completed: cloudCompleted,
1776
+ });
1777
+ }
1778
+ }
1779
+
1780
+ // ── 6. Roadmap manifest append — EXPLICIT DEGRADE. `registerSpec` edits
1781
+ // `roadmap_manifest.milestones` inside a roadmap doc, and `roadmap`
1782
+ // is not one of the console's creatable/updatable spec types (its
1783
+ // TYPE_TO_FOLDER has no entry for it), so there is no route to make
1784
+ // this write through. Reported, never silently skipped.
1785
+ const roadmapRegistrationCloud = milestoneCreated
1786
+ ? {
1787
+ ok: false,
1788
+ ...cloudModeUnsupportedWrite(
1789
+ `${args.roadmap_id || 'HS-ROADMAP-PRODUCT'} (roadmap_manifest.milestones)`,
1790
+ 'a roadmap doc is not one of the spec types the console can create or update (no TYPE_TO_FOLDER entry for "roadmap"), so there is no POST/PUT route for the manifest append',
1791
+ { milestone_basename: milestoneBasename },
1792
+ ),
1793
+ }
1794
+ : undefined;
1795
+
1796
+ return {
1797
+ phase: 'executed',
1798
+ write_mode: 'cloud',
1799
+ milestone_id: milestoneId,
1800
+ milestone_basename: milestoneBasename,
1801
+ milestone_path: milestoneRelPath,
1802
+ milestone_created: milestoneCreated,
1803
+ spawned_epics: spawnedEpicSpecs.map((s) => ({
1804
+ id: s.id,
1805
+ basename: s.basename,
1806
+ path: s.relPath,
1807
+ })),
1808
+ augmented_epics: augmentEpics.map((e) => e.basename),
1809
+ research_id: ctx.research.frontmatter?.id ?? args.research_id,
1810
+ research_path: ctx.research.relPath,
1811
+ research_status_after: ctx.researchStatus ?? 'open',
1812
+ writes: cloudCompleted.map((w) => ({ kind: w.kind, path: w.rel_path ?? null, spec_id: w.spec_id })),
1813
+ human_review_required: false,
1814
+ ...(depWarnings.length > 0 ? { depends_on_warnings: depWarnings } : {}),
1815
+ ...(roadmapRegistrationCloud ? { roadmap_registration: roadmapRegistrationCloud } : {}),
1816
+ };
1817
+ }
1818
+
1228
1819
  // 1. Milestone (create-if-new, else update later via line-surgical helper).
1229
1820
  // Family inheritance (HS-TASK-QQHP defect #1) is resolved once, up top,
1230
1821
  // where the era-3 basenames/ids are composed.
@@ -1401,10 +1992,20 @@ export async function executePromoteResearch(vaultRoot, args = {}, deps = {}) {
1401
1992
  const validationError = validateInputShape(args);
1402
1993
  if (validationError) return validationError;
1403
1994
 
1995
+ // Cloud-tier reads ([[HS-KR16-CWPX-TASK-promotion-reads-through-the-cloud-tier]]):
1996
+ // `deps.tier` is resolved ONCE by the caller (`handlePromoteResearch` in
1997
+ // research-handlers.mjs, via `resolveReadTier()`) and threaded down —
1998
+ // never re-resolved here. Absent (every pre-existing caller/test) defaults
1999
+ // to `disk`, so disk-tier behavior is byte-identical to before this task.
2000
+ const tier = deps.tier ?? 'disk';
2001
+ const consoleUrl = deps.consoleUrl;
2002
+ const consoleFetchDep = deps.consoleFetch;
2003
+ const tierDeps = { tier, consoleUrl, consoleFetch: consoleFetchDep };
2004
+
1404
2005
  // 1. Load research file. Resolution happens FIRST (exact basename → .md /
1405
2006
  // wikilink-stripped → frontmatter id: → unambiguous prefix); the "is this
1406
2007
  // really research?" check reads the resolved file's `type:`.
1407
- const resolved = findResearchByIdLocal(vaultRoot, args.research_id);
2008
+ const resolved = await findResearchByIdViaTier(vaultRoot, args.research_id, tierDeps);
1408
2009
  if (!resolved || resolved.error) {
1409
2010
  return (
1410
2011
  resolved ?? {
@@ -1451,7 +2052,7 @@ export async function executePromoteResearch(vaultRoot, args = {}, deps = {}) {
1451
2052
  }
1452
2053
 
1453
2054
  // 2. Resolve target milestone.
1454
- const targetResolution = resolveTargetMilestone(vaultRoot, args);
2055
+ const targetResolution = await resolveTargetMilestone(vaultRoot, args, tierDeps);
1455
2056
  if (targetResolution.error) return targetResolution;
1456
2057
 
1457
2058
  if (targetResolution.mode === 'existing') {
@@ -1476,12 +2077,17 @@ export async function executePromoteResearch(vaultRoot, args = {}, deps = {}) {
1476
2077
  );
1477
2078
  if (attachPolicy.error) return attachPolicy;
1478
2079
 
1479
- const conflict = validateBidirectionalEnhances(
1480
- researchState.enhances,
1481
- targetResolution.milestone.basename,
1482
- );
2080
+ // [[HS-8XJA-KOGH-DIAG]]: an `enhances` set with no spawned_epics/enhances_epics
2081
+ // behind it was never actually promoted (see validateResearchState) — treat
2082
+ // it as unset rather than blocking on a signal the capture-promote step
2083
+ // forged one stage early. buildUpdatedResearchContent below overwrites
2084
+ // `enhances` unconditionally on a successful run, so this self-heals the
2085
+ // stray pointer instead of requiring a hand-edit to clear it.
2086
+ const conflict = researchState.enhancesBacked
2087
+ ? validateBidirectionalEnhances(researchState.enhances, targetResolution.milestone.basename)
2088
+ : null;
1483
2089
  if (conflict) return conflict;
1484
- } else if (researchState.enhances) {
2090
+ } else if (researchState.enhances && researchState.enhancesBacked) {
1485
2091
  // Already enhances a milestone but target is NEW — refuse, the human should
1486
2092
  // either split the research or pick the existing milestone.
1487
2093
  return {
@@ -1538,6 +2144,13 @@ export const __testables = {
1538
2144
  validateInputShape,
1539
2145
  normalizeResearchRef,
1540
2146
  findResearchByIdLocal,
2147
+ findResearchByIdViaTier,
2148
+ findMilestoneByBasenameViaTier,
2149
+ findMilestoneByIdViaTier,
2150
+ findEpicByIdOrBasenameViaTier,
2151
+ resolvePillarViaTier,
2152
+ resolveOwnerIntentViaTier,
2153
+ resolveTargetMilestone,
1541
2154
  validateExistingMilestoneAttachable,
1542
2155
  MILESTONE_ATTACH_DEFAULT_ALLOWED_STATUSES,
1543
2156
  resolvePillar,