heyspex 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (322) hide show
  1. package/README.md +21 -0
  2. package/bin/hs.mjs +931 -98
  3. package/bin/spexcode.mjs +262 -0
  4. package/dev-factory/engine/worktree.mjs +740 -0
  5. package/dev-factory/lib/code-repo-root.mjs +67 -0
  6. package/dev-factory/lib/context-budget.mjs +84 -0
  7. package/dev-factory/lib/epic-detect.mjs +190 -0
  8. package/dev-factory/lib/fleet-queue-file.mjs +846 -0
  9. package/dev-factory/lib/fleet-queue.mjs +426 -0
  10. package/dev-factory/lib/fleet-state.mjs +14 -18
  11. package/dev-factory/lib/model-catalog.mjs +338 -0
  12. package/dev-factory/lib/seat-context.mjs +199 -0
  13. package/dev-factory/lib/spec-io.mjs +93 -0
  14. package/dev-factory/lib/sufficiency-score.mjs +8 -2
  15. package/dev-factory/lib/worktree-allocator.mjs +744 -0
  16. package/dev-factory/model-catalog.json +222 -0
  17. package/dist/cli/hs-check.mjs +1085 -103
  18. package/dist/cli/hs-claim.mjs +936 -63
  19. package/dist/cli/hs-connect.mjs +9786 -1910
  20. package/dist/cli/hs-defer.mjs +5267 -3053
  21. package/dist/cli/hs-dev.mjs +564 -0
  22. package/dist/cli/hs-env.mjs +385 -299
  23. package/dist/cli/hs-index-rebuild.mjs +7129 -0
  24. package/dist/cli/hs-index-reconcile.mjs +7852 -0
  25. package/dist/cli/hs-init.mjs +777 -138
  26. package/dist/cli/hs-install.mjs +6583 -485
  27. package/dist/cli/hs-join.mjs +10137 -2368
  28. package/dist/cli/hs-vault-pull.mjs +4659 -5882
  29. package/dist/cli/hs-vault.mjs +3793 -1537
  30. package/dist/cli/hs-workspace.mjs +2550 -524
  31. package/dist/cli-templates/customer-vault/specs/entity-model.md +594 -601
  32. package/dist/cli-templates/customer-vault/specs/protocols/canonical-frontmatter-templates.md +110 -177
  33. package/dist/cli-templates/customer-vault/specs/protocols/destructive-action-checklist.md +122 -0
  34. package/dist/cli-templates/customer-vault/specs/protocols/memory-protocol.md +13 -15
  35. package/dist/cli-templates/customer-vault/specs/protocols/session-close-protocol.md +91 -0
  36. package/dist/cli-templates/customer-vault/specs/protocols/spec-hierarchy.md +150 -168
  37. package/dist/cli-templates/customer-vault/specs/protocols/spec-summary-authoring.md +83 -0
  38. package/dist/cli-templates/harness-commands/claude/hs-auto.md +36 -0
  39. package/dist/cli-templates/harness-commands/claude/hs-backfill.md +28 -0
  40. package/dist/cli-templates/harness-commands/claude/hs-capture.md +62 -0
  41. package/dist/cli-templates/harness-commands/claude/hs-defer.md +29 -0
  42. package/dist/cli-templates/harness-commands/claude/hs-dev.md +20 -0
  43. package/dist/cli-templates/harness-commands/claude/hs-doctor.md +32 -0
  44. package/dist/cli-templates/harness-commands/claude/hs-ground.md +28 -0
  45. package/dist/cli-templates/harness-commands/claude/hs-handoff.md +33 -0
  46. package/dist/cli-templates/harness-commands/claude/hs-log.md +27 -0
  47. package/dist/cli-templates/harness-commands/claude/hs-next.md +29 -0
  48. package/dist/cli-templates/harness-commands/claude/hs-promote.md +70 -0
  49. package/dist/cli-templates/harness-commands/claude/hs-push.md +35 -0
  50. package/dist/cli-templates/harness-commands/claude/hs-qc.md +27 -0
  51. package/dist/cli-templates/harness-commands/claude/hs-res.md +63 -0
  52. package/dist/cli-templates/harness-commands/claude/hs-resolve.md +23 -0
  53. package/dist/cli-templates/harness-commands/claude/hs-review.md +23 -0
  54. package/dist/cli-templates/harness-commands/claude/hs-view.md +26 -0
  55. package/dist/cli-templates/harness-commands/claude/kill-ports.md +32 -0
  56. package/dist/cli-templates/harness-commands/claude/prototype.md +91 -0
  57. package/dist/cli-templates/harness-commands/cursor/hs-auto.md +40 -0
  58. package/dist/cli-templates/harness-commands/cursor/hs-backfill.md +31 -0
  59. package/dist/cli-templates/harness-commands/cursor/hs-capture.md +65 -0
  60. package/dist/cli-templates/harness-commands/cursor/hs-defer.md +32 -0
  61. package/dist/cli-templates/harness-commands/cursor/hs-dev.md +24 -0
  62. package/dist/cli-templates/harness-commands/cursor/hs-doctor.md +36 -0
  63. package/dist/cli-templates/harness-commands/cursor/hs-ground.md +31 -0
  64. package/dist/cli-templates/harness-commands/cursor/hs-handoff.md +37 -0
  65. package/dist/cli-templates/harness-commands/cursor/hs-log.md +31 -0
  66. package/dist/cli-templates/harness-commands/cursor/hs-next.md +32 -0
  67. package/dist/cli-templates/harness-commands/cursor/hs-promote.md +73 -0
  68. package/dist/cli-templates/harness-commands/cursor/hs-push.md +38 -0
  69. package/dist/cli-templates/harness-commands/cursor/hs-qc.md +30 -0
  70. package/dist/cli-templates/harness-commands/cursor/hs-res.md +67 -0
  71. package/dist/cli-templates/harness-commands/cursor/hs-resolve.md +26 -0
  72. package/dist/cli-templates/harness-commands/cursor/hs-review.md +26 -0
  73. package/dist/cli-templates/harness-commands/cursor/hs-view.md +30 -0
  74. package/dist/cli-templates/harness-commands/cursor/kill-ports.md +35 -0
  75. package/dist/cli-templates/harness-commands/cursor/prototype.md +95 -0
  76. package/docs/mcp-client-config.md +259 -6
  77. package/package.json +231 -124
  78. package/plugins/heyspex/.claude-plugin/plugin.json +12 -0
  79. package/plugins/heyspex/.cursor-plugin/plugin.json +15 -0
  80. package/plugins/heyspex/.mcp.json +12 -0
  81. package/plugins/heyspex/bin/launch-mcp.mjs +95 -0
  82. package/plugins/heyspex/bin/run-hook.mjs +62 -0
  83. package/plugins/heyspex/hooks/cursor-hooks.json +227 -0
  84. package/plugins/heyspex/hooks/hooks.json +361 -0
  85. package/plugins/heyspex/mcp.json +13 -0
  86. package/scripts/check-mcp-freshness.mjs +17 -9
  87. package/scripts/claude-hooks/_env-vault-events.mjs +68 -0
  88. package/scripts/claude-hooks/_guardrail-events.mjs +96 -0
  89. package/scripts/claude-hooks/_guardrail-loader.mjs +494 -0
  90. package/scripts/claude-hooks/_intent-classifier-router.mjs +410 -0
  91. package/scripts/claude-hooks/_intent-classifier-semantic.mjs +351 -0
  92. package/scripts/claude-hooks/_run.mjs +120 -0
  93. package/scripts/claude-hooks/_transcript-text.mjs +60 -0
  94. package/scripts/claude-hooks/_vault-retrieval.mjs +322 -0
  95. package/scripts/claude-hooks/agent-spawn-delegation-gate.mjs +133 -0
  96. package/scripts/claude-hooks/capture-workflow-gate.mjs +11 -0
  97. package/scripts/claude-hooks/check-auto-memory-budget.mjs +396 -0
  98. package/scripts/claude-hooks/check-claude-md-budget.mjs +327 -0
  99. package/scripts/claude-hooks/check-mcp-freshness-hook.mjs +14 -0
  100. package/scripts/claude-hooks/claim-before-code-gate.mjs +11 -0
  101. package/scripts/claude-hooks/claim-liveness-shared.mjs +575 -0
  102. package/scripts/claude-hooks/close-terminal-on-ship.mjs +64 -0
  103. package/scripts/claude-hooks/coo-denylist-gate.mjs +11 -0
  104. package/scripts/claude-hooks/coo-guard-check.mjs +11 -0
  105. package/scripts/claude-hooks/deep-research-cost-gate.mjs +11 -0
  106. package/scripts/claude-hooks/dev-server-task-reminder.mjs +15 -0
  107. package/scripts/claude-hooks/emit-claim-status.mjs +41 -0
  108. package/scripts/claude-hooks/enforce-session-sequence.mjs +11 -0
  109. package/scripts/claude-hooks/enforce-vault-naming.mjs +11 -0
  110. package/scripts/claude-hooks/env-vault-guard.mjs +11 -0
  111. package/scripts/claude-hooks/epic-milestone-phrasing-gate.mjs +11 -0
  112. package/scripts/claude-hooks/git-identity-guard.mjs +11 -0
  113. package/scripts/claude-hooks/git-reflog-audit.mjs +15 -0
  114. package/scripts/claude-hooks/git-stash-gate.mjs +11 -0
  115. package/scripts/claude-hooks/guard-auto-memory-writes.mjs +137 -0
  116. package/scripts/claude-hooks/id-display-format-gate.mjs +11 -0
  117. package/scripts/claude-hooks/inject-communication-style.mjs +66 -0
  118. package/scripts/claude-hooks/inject-coo-seat.mjs +78 -0
  119. package/scripts/claude-hooks/inject-dev-server-awareness.mjs +11 -0
  120. package/scripts/claude-hooks/inject-doctrine.mjs +13 -0
  121. package/scripts/claude-hooks/inject-vault-context.mjs +14 -0
  122. package/scripts/claude-hooks/intent-classifier-trigger.mjs +247 -0
  123. package/scripts/claude-hooks/log-session-activity.mjs +341 -0
  124. package/scripts/claude-hooks/naughty-list-gate.mjs +11 -0
  125. package/scripts/claude-hooks/notify-status-flip.mjs +72 -0
  126. package/scripts/claude-hooks/plan-exit-ingest-gate.mjs +170 -0
  127. package/scripts/claude-hooks/plan-ingest-close-reminder.mjs +66 -0
  128. package/scripts/claude-hooks/plan-review-gate.mjs +11 -0
  129. package/scripts/claude-hooks/preflight-reminder.mjs +262 -0
  130. package/scripts/claude-hooks/protected-action-intent.mjs +221 -0
  131. package/scripts/claude-hooks/research-method-guard.mjs +398 -0
  132. package/scripts/claude-hooks/research-protocol-gate.mjs +11 -0
  133. package/scripts/claude-hooks/session-end.mjs +449 -0
  134. package/scripts/claude-hooks/settings-write-gate.mjs +11 -0
  135. package/scripts/claude-hooks/settlement-check-gate.mjs +39 -0
  136. package/scripts/claude-hooks/status-flip-detect.mjs +17 -0
  137. package/scripts/claude-hooks/terminal-close-shared.mjs +426 -0
  138. package/scripts/claude-hooks/terminal-close-watcher.mjs +122 -0
  139. package/scripts/claude-hooks/wip-checkpoint.mjs +298 -0
  140. package/scripts/cursor-hooks/manifest.mjs +235 -0
  141. package/scripts/dev-server-status.mjs +34 -13
  142. package/scripts/dev-server-stop.mjs +313 -0
  143. package/scripts/heyspex-mode.mjs +268 -0
  144. package/scripts/hs-auto-resolve.mjs +240 -0
  145. package/scripts/hs-auto-source.mjs +398 -0
  146. package/scripts/hs-auto.mjs +134 -0
  147. package/scripts/hs-capture-cli.mjs +326 -0
  148. package/scripts/hs-classifier.mjs +136 -0
  149. package/scripts/hs-companion.mjs +88 -0
  150. package/scripts/hs-gateway.mjs +115 -0
  151. package/scripts/hs-mode.mjs +125 -0
  152. package/scripts/hs-next.mjs +441 -0
  153. package/scripts/hs-settings.mjs +338 -0
  154. package/scripts/hs-view.mjs +333 -0
  155. package/scripts/hs-worktree.mjs +201 -0
  156. package/scripts/install-claude-code-hooks.mjs +270 -0
  157. package/scripts/install-cursor-hooks.mjs +471 -0
  158. package/scripts/lib/agent-env-markers.mjs +38 -0
  159. package/scripts/lib/console-url-resolver.mjs +197 -60
  160. package/scripts/lib/customer-kit.mjs +886 -0
  161. package/scripts/lib/dev-mode.mjs +244 -28
  162. package/scripts/lib/heyspex-home.mjs +72 -0
  163. package/scripts/lib/hs-command.mjs +277 -0
  164. package/scripts/lib/hs-companion-settings.mjs +472 -0
  165. package/scripts/lib/hs-source-commit.mjs +59 -5
  166. package/scripts/lib/hs-source-identity.mjs +526 -0
  167. package/scripts/lib/hs-source-log.mjs +11 -21
  168. package/scripts/lib/hs-source-push.mjs +107 -58
  169. package/scripts/lib/hs-worktree-args.mjs +115 -0
  170. package/scripts/lib/shared-path-helpers.mjs +134 -0
  171. package/scripts/lib/standalone-doctor.mjs +540 -3
  172. package/scripts/lib/subagent-git-lock.mjs +109 -0
  173. package/scripts/lib/template-pin.mjs +34 -0
  174. package/scripts/provenance-lib.mjs +91 -0
  175. package/scripts/stamp-provenance-on-edit.mjs +222 -0
  176. package/scripts/wip-ledger-health.mjs +653 -0
  177. package/spexharness/doctrine/baseline/SPEXCODE-DOCTRINE.baseline.json +29 -0
  178. package/spexharness/src/adapters/audit.mjs +197 -0
  179. package/spexharness/src/adapters/claude-code.mjs +441 -0
  180. package/spexharness/src/chat/tui/ansi.mjs +448 -0
  181. package/spexharness/src/chat/tui/palette.mjs +22 -0
  182. package/spexharness/src/chat/tui/select.mjs +208 -0
  183. package/spexharness/src/config-root.mjs +54 -0
  184. package/spexharness/src/context-budget.mjs +141 -0
  185. package/spexharness/src/dev-server-control.mjs +389 -0
  186. package/spexharness/src/doctrine/behaviour.mjs +371 -0
  187. package/spexharness/src/doctrine/corpus.mjs +733 -0
  188. package/spexharness/src/doctrine/inject.mjs +338 -0
  189. package/spexharness/src/doctrine/merge.mjs +184 -0
  190. package/spexharness/src/doctrine/sources.mjs +252 -0
  191. package/spexharness/src/doctrine/synthesize.mjs +401 -0
  192. package/spexharness/src/doctrine/vault-source.mjs +574 -0
  193. package/spexharness/src/doctrine/writer.mjs +176 -0
  194. package/spexharness/src/events.mjs +84 -0
  195. package/spexharness/src/gates/capture-workflow-gate.mjs +202 -0
  196. package/spexharness/src/gates/claim-before-code-gate.decide.mjs +33 -0
  197. package/spexharness/src/gates/claim-before-code-gate.mjs +129 -0
  198. package/spexharness/src/gates/deep-research-cost-gate.mjs +274 -0
  199. package/spexharness/src/gates/enforce-session-sequence.mjs +895 -0
  200. package/spexharness/src/gates/enforce-vault-naming.mjs +215 -0
  201. package/spexharness/src/gates/epic-milestone-phrasing-gate.mjs +233 -0
  202. package/spexharness/src/gates/git-identity-guard.decide.mjs +436 -0
  203. package/spexharness/src/gates/git-identity-guard.mjs +61 -0
  204. package/spexharness/src/gates/git-reflog-audit.mjs +151 -0
  205. package/spexharness/src/gates/git-stash-gate.decide.mjs +361 -0
  206. package/spexharness/src/gates/git-stash-gate.mjs +159 -0
  207. package/spexharness/src/gates/id-display-format-gate.mjs +190 -0
  208. package/spexharness/src/gates/inject-doctrine-gate.mjs +89 -0
  209. package/spexharness/src/gates/inject-vault-context.mjs +239 -0
  210. package/spexharness/src/gates/naughty-list-gate.mjs +213 -0
  211. package/spexharness/src/gates/plan-review-gate.mjs +233 -0
  212. package/spexharness/src/gates/preflight-reminder-gate.mjs +265 -0
  213. package/spexharness/src/gates/research-protocol-gate.mjs +175 -0
  214. package/spexharness/src/gates/settings-write-gate.decide.mjs +96 -0
  215. package/spexharness/src/gates/settings-write-gate.mjs +104 -0
  216. package/spexharness/src/git-reflog-audit.mjs +294 -0
  217. package/spexharness/src/intent-classifier.mjs +194 -0
  218. package/spexharness/src/intent-directive.mjs +541 -0
  219. package/spexharness/src/profile/learn.mjs +551 -0
  220. package/spexharness/src/profile/proposals.mjs +357 -0
  221. package/spexharness/src/profile/vault-inclusion.mjs +386 -0
  222. package/spexharness/src/redact.mjs +151 -0
  223. package/spexharness/src/rule-table.mjs +819 -0
  224. package/spexharness/src/seat-context.mjs +122 -0
  225. package/spexharness/src/session-bootstrap.mjs +512 -0
  226. package/spexharness/src/session-record.mjs +815 -0
  227. package/spexharness/src/token-inventory.mjs +207 -0
  228. package/vendor/heyspex-mcp/account-switch.mjs +95 -0
  229. package/vendor/heyspex-mcp/alignment-gate.mjs +315 -1
  230. package/vendor/heyspex-mcp/blocker-resolver.mjs +37 -2
  231. package/vendor/heyspex-mcp/breakdown-ladder.mjs +119 -4
  232. package/vendor/heyspex-mcp/breakdown-source.mjs +271 -0
  233. package/vendor/heyspex-mcp/breakdown-writer.mjs +23 -3
  234. package/vendor/heyspex-mcp/capture-buffer-patches.mjs +46 -5
  235. package/vendor/heyspex-mcp/capture-channel.mjs +55 -5
  236. package/vendor/heyspex-mcp/capture-console-client.mjs +80 -1
  237. package/vendor/heyspex-mcp/capture-focus.mjs +1035 -29
  238. package/vendor/heyspex-mcp/capture-outbox.mjs +5 -1
  239. package/vendor/heyspex-mcp/capture-tools.mjs +1219 -0
  240. package/vendor/heyspex-mcp/claim-tools.mjs +1124 -0
  241. package/vendor/heyspex-mcp/cli-verbs.mjs +2076 -0
  242. package/vendor/heyspex-mcp/cli.mjs +201 -15524
  243. package/vendor/heyspex-mcp/cloud-tool-surface.mjs +128 -0
  244. package/vendor/heyspex-mcp/cloud-vault-writes.mjs +412 -0
  245. package/vendor/heyspex-mcp/config-seed.yaml +15 -0
  246. package/vendor/heyspex-mcp/config-settings.mjs +290 -7
  247. package/vendor/heyspex-mcp/console-fetch.mjs +657 -16
  248. package/vendor/heyspex-mcp/console-transport.mjs +166 -0
  249. package/vendor/heyspex-mcp/credentials.mjs +584 -0
  250. package/vendor/heyspex-mcp/defer-spec.mjs +44 -4
  251. package/vendor/heyspex-mcp/deferred-reclaim.mjs +29 -3
  252. package/vendor/heyspex-mcp/design-handlers.mjs +31 -0
  253. package/vendor/heyspex-mcp/design-tools.mjs +4 -1
  254. package/vendor/heyspex-mcp/dev-factory-bridge.mjs +4 -4
  255. package/vendor/heyspex-mcp/dev-mode-core.mjs +45 -0
  256. package/vendor/heyspex-mcp/dev-tools.mjs +124 -0
  257. package/vendor/heyspex-mcp/diagnose-handlers.mjs +54 -0
  258. package/vendor/heyspex-mcp/events-tools.mjs +207 -0
  259. package/vendor/heyspex-mcp/filing-tools.mjs +688 -0
  260. package/vendor/heyspex-mcp/fleet-handlers.mjs +37 -0
  261. package/vendor/heyspex-mcp/fleet-queue-tools.mjs +69 -14
  262. package/vendor/heyspex-mcp/focus-tools.mjs +190 -0
  263. package/vendor/heyspex-mcp/folder-trust-store.mjs +59 -0
  264. package/vendor/heyspex-mcp/http-transport.mjs +300 -0
  265. package/vendor/heyspex-mcp/list-tools.mjs +257 -0
  266. package/vendor/heyspex-mcp/login-handoff.mjs +15 -1
  267. package/vendor/heyspex-mcp/mcp-config.mjs +94 -0
  268. package/vendor/heyspex-mcp/memory-tools.mjs +523 -0
  269. package/vendor/heyspex-mcp/mvp-mode.mjs +53 -6
  270. package/vendor/heyspex-mcp/occupied-keys.mjs +141 -0
  271. package/vendor/heyspex-mcp/on-deck-brief.mjs +3360 -0
  272. package/vendor/heyspex-mcp/on-deck-scheduler.mjs +1708 -0
  273. package/vendor/heyspex-mcp/package-root.mjs +32 -0
  274. package/vendor/heyspex-mcp/peer-presence.mjs +8 -1
  275. package/vendor/heyspex-mcp/plan-ground.mjs +621 -652
  276. package/vendor/heyspex-mcp/plan-push.mjs +906 -21
  277. package/vendor/heyspex-mcp/plan-review.mjs +124 -30
  278. package/vendor/heyspex-mcp/preflight-gate.mjs +0 -0
  279. package/vendor/heyspex-mcp/procedure-handlers.mjs +325 -0
  280. package/vendor/heyspex-mcp/procedure-tools.mjs +540 -351
  281. package/vendor/heyspex-mcp/promote-capture.mjs +386 -69
  282. package/vendor/heyspex-mcp/promote-diagnose.mjs +74 -0
  283. package/vendor/heyspex-mcp/promote-research-to-diagnose.mjs +9 -2
  284. package/vendor/heyspex-mcp/promote-research.mjs +660 -53
  285. package/vendor/heyspex-mcp/promotion-helpers.mjs +251 -0
  286. package/vendor/heyspex-mcp/prompts.mjs +1460 -0
  287. package/vendor/heyspex-mcp/read-path.mjs +29 -9
  288. package/vendor/heyspex-mcp/receipt-shape.mjs +146 -0
  289. package/vendor/heyspex-mcp/recipe-handlers.mjs +117 -0
  290. package/vendor/heyspex-mcp/regen-tasks.mjs +210 -3
  291. package/vendor/heyspex-mcp/register-spec.mjs +266 -54
  292. package/vendor/heyspex-mcp/request-context.mjs +59 -0
  293. package/vendor/heyspex-mcp/research-focus.mjs +207 -4
  294. package/vendor/heyspex-mcp/research-handlers.mjs +623 -0
  295. package/vendor/heyspex-mcp/research-tools.mjs +4 -4
  296. package/vendor/heyspex-mcp/response-budget.mjs +348 -0
  297. package/vendor/heyspex-mcp/review-focus.mjs +25 -1
  298. package/vendor/heyspex-mcp/search-specs.mjs +280 -0
  299. package/vendor/heyspex-mcp/seat-block-ledger.mjs +113 -0
  300. package/vendor/heyspex-mcp/seat-identity.mjs +2 -6
  301. package/vendor/heyspex-mcp/session-owner.mjs +108 -0
  302. package/vendor/heyspex-mcp/session-tools.mjs +152 -0
  303. package/vendor/heyspex-mcp/settings-registry.mjs +232 -0
  304. package/vendor/heyspex-mcp/settings-tools.mjs +85 -12
  305. package/vendor/heyspex-mcp/spec-edit-tools.mjs +446 -0
  306. package/vendor/heyspex-mcp/spec-id-lib.mjs +93 -17
  307. package/vendor/heyspex-mcp/split-research.mjs +301 -56
  308. package/vendor/heyspex-mcp/stdio-rpc.mjs +22 -0
  309. package/vendor/heyspex-mcp/submit-for-review.mjs +132 -6
  310. package/vendor/heyspex-mcp/summary-contract.mjs +105 -0
  311. package/vendor/heyspex-mcp/summary-write-tools.mjs +224 -0
  312. package/vendor/heyspex-mcp/symptom-query.mjs +146 -0
  313. package/vendor/heyspex-mcp/test-helpers.mjs +108 -1
  314. package/vendor/heyspex-mcp/tool-catalog.mjs +2813 -0
  315. package/vendor/heyspex-mcp/tool-dispatch.mjs +639 -0
  316. package/vendor/heyspex-mcp/tool-helpers.mjs +325 -0
  317. package/vendor/heyspex-mcp/validate-linkage.mjs +414 -27
  318. package/vendor/heyspex-mcp/vault-io.mjs +724 -0
  319. package/vendor/heyspex-mcp/vault-less-endpoints.mjs +52 -0
  320. package/vendor/heyspex-mcp/vault-resolve.mjs +366 -68
  321. package/vendor/heyspex-mcp/verify-reproduction.mjs +270 -30
  322. package/vendor/heyspex-mcp/whoami.mjs +91 -0
@@ -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,44 @@ 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
+ tier,
221
+ diskRead: () => findResearchByIdLocal(vaultRoot, researchId),
222
+ neonRead: async () => {
223
+ const record = await fetchSpecRecordViaConsole(consoleFetch, researchId);
224
+ if (!record) {
225
+ return {
226
+ error: 'RESEARCH_NOT_FOUND',
227
+ message: `research ${researchId} not found under specs/research/ (tried exact basename, frontmatter id:, and id-prefix match)`,
228
+ };
229
+ }
230
+ const fm = getFrontmatterBlock(record.raw);
231
+ const type = fm ? getScalarField(fm.block, 'type') : null;
232
+ if (type && type !== 'research') {
233
+ return {
234
+ error: 'RESEARCH_TYPE_MISMATCH',
235
+ message: `research_id ${researchId} resolved to ${record.relPath ?? 'a file'}, which is \`type: ${type}\` — not a research doc`,
236
+ resolved_basename: record.basename,
237
+ resolved_type: type,
238
+ };
239
+ }
240
+ return { record };
241
+ },
242
+ routeLabel: 'GET /api/specs/[id]',
243
+ consoleUrl,
244
+ });
245
+ }
246
+
180
247
  function findMilestoneByBasename(vaultRoot, basename) {
181
248
  const absPath = join(vaultRoot, 'specs', 'milestones', `${basename}.md`);
182
249
  if (!existsSync(absPath)) return null;
@@ -201,6 +268,34 @@ function findMilestoneById(vaultRoot, milestoneId) {
201
268
  return null;
202
269
  }
203
270
 
271
+ /**
272
+ * Cloud-tier sibling of {@link findMilestoneByBasename} (HS-KR16). Same
273
+ * bare-record-or-null contract as the disk loader.
274
+ */
275
+ async function findMilestoneByBasenameViaTier(vaultRoot, basename, { tier, consoleUrl, consoleFetch }) {
276
+ return readViaSelector({
277
+ tier,
278
+ diskRead: () => findMilestoneByBasename(vaultRoot, basename),
279
+ neonRead: () => fetchSpecRecordViaConsole(consoleFetch, basename),
280
+ routeLabel: 'GET /api/specs/[id]',
281
+ consoleUrl,
282
+ });
283
+ }
284
+
285
+ /**
286
+ * Cloud-tier sibling of {@link findMilestoneById} (HS-KR16). Same
287
+ * bare-record-or-null contract as the disk loader.
288
+ */
289
+ async function findMilestoneByIdViaTier(vaultRoot, milestoneId, { tier, consoleUrl, consoleFetch }) {
290
+ return readViaSelector({
291
+ tier,
292
+ diskRead: () => findMilestoneById(vaultRoot, milestoneId),
293
+ neonRead: () => fetchSpecRecordViaConsole(consoleFetch, milestoneId),
294
+ routeLabel: 'GET /api/specs/[id]',
295
+ consoleUrl,
296
+ });
297
+ }
298
+
204
299
  function findEpicByIdOrBasename(vaultRoot, idOrBasename) {
205
300
  const dir = join(vaultRoot, 'specs', 'epics');
206
301
  if (!existsSync(dir)) return null;
@@ -232,6 +327,21 @@ function findEpicByIdOrBasename(vaultRoot, idOrBasename) {
232
327
  return null;
233
328
  }
234
329
 
330
+ /**
331
+ * Cloud-tier sibling of {@link findEpicByIdOrBasename} (HS-KR16) — the
332
+ * console's by-id route accepts either an id or a basename, matching the
333
+ * disk loader's own two-shape contract. Same bare-record-or-null return.
334
+ */
335
+ async function findEpicByIdOrBasenameViaTier(vaultRoot, idOrBasename, { tier, consoleUrl, consoleFetch }) {
336
+ return readViaSelector({
337
+ tier,
338
+ diskRead: () => findEpicByIdOrBasename(vaultRoot, idOrBasename),
339
+ neonRead: () => fetchSpecRecordViaConsole(consoleFetch, idOrBasename),
340
+ routeLabel: 'GET /api/specs/[id]',
341
+ consoleUrl,
342
+ });
343
+ }
344
+
235
345
  // ─────────────────────────────────────────────────────────────────────────────
236
346
  // Pillar resolution (epic→pillar hard requirement, [[pillar-assignment-protocol]])
237
347
  // ─────────────────────────────────────────────────────────────────────────────
@@ -295,6 +405,50 @@ export function resolvePillar(vaultRoot, raw) {
295
405
  return { ok: true, basename, frontmatterValue: `'[[${basename}]]'` };
296
406
  }
297
407
 
408
+ /**
409
+ * Cloud sibling of {@link listVaultPillars} ([[HS-BYU4-CWPX-TASK-promotion-writes-go-through-the-console]] AC3).
410
+ *
411
+ * The disk version is a `readdirSync(specs/pillars)` scan, which in a file-less
412
+ * vault returns `[]` — so a `pillar_required` refusal would list NO valid
413
+ * options and read as "there are no pillars" rather than "this machine holds
414
+ * no copy of them". `GET /api/specs?type=pillar` (`listSpecsViaConsole`, which
415
+ * follows the keyset cursor) is the route that answers the same question in
416
+ * cloud mode. Fails SOFT to `[]`, exactly like the disk version: this list only
417
+ * enriches an error message that is being returned either way, and turning a
418
+ * refusal into an unreachable-console error would hide the real cause.
419
+ */
420
+ export async function listVaultPillarsViaWriteMode(vaultRoot, { writeMode, consoleFetch, consoleUrl }) {
421
+ if (writeMode !== 'cloud') return listVaultPillars(vaultRoot);
422
+ try {
423
+ const rows = await listSpecsViaConsole({ consoleFetch, consoleUrl, type: 'pillar' });
424
+ return rows.map((row) => ({
425
+ id: row.basename ?? row.id ?? null,
426
+ name: row.name ?? row.basename ?? row.id ?? null,
427
+ }));
428
+ } catch {
429
+ return [];
430
+ }
431
+ }
432
+
433
+ /**
434
+ * Cloud-tier sibling of {@link resolvePillar} (HS-KR16).
435
+ */
436
+ async function resolvePillarViaTier(vaultRoot, raw, { tier, consoleUrl, consoleFetch }) {
437
+ const basename = normalizePillarRef(raw);
438
+ if (!basename) return { ok: false };
439
+ return readViaSelector({
440
+ tier,
441
+ diskRead: () => resolvePillar(vaultRoot, raw),
442
+ neonRead: async () => {
443
+ const record = await fetchSpecRecordViaConsole(consoleFetch, basename);
444
+ if (!record) return { ok: false };
445
+ return { ok: true, basename, frontmatterValue: `'[[${basename}]]'` };
446
+ },
447
+ routeLabel: 'GET /api/specs/[id]',
448
+ consoleUrl,
449
+ });
450
+ }
451
+
298
452
  // ─────────────────────────────────────────────────────────────────────────────
299
453
  // AI proposer wiring
300
454
  // ─────────────────────────────────────────────────────────────────────────────
@@ -495,21 +649,32 @@ function validateResearchState(research) {
495
649
  // validateBidirectionalEnhances (RESEARCH_ALREADY_PROMOTED).
496
650
  const status = getScalarField(fm.block, 'status');
497
651
  const enhances = getScalarField(fm.block, 'enhances');
498
- return { fm, status, enhances };
652
+ // [[HS-8XJA-KOGH-DIAG-passing-enhances-to-heyspex-promote]]: `enhances` is
653
+ // ALSO a heyspex_promote_capture argument that writes this same field one
654
+ // stage earlier, before any epic has actually been spawned — forging the
655
+ // "already promoted" signal ahead of the event it's supposed to record. A
656
+ // GENUINE promote-research execution always writes `spawned_epics` and/or
657
+ // `enhances_epics` in the SAME commit as `enhances` (buildUpdatedResearchContent
658
+ // below) — so `enhances` set with BOTH of those empty can only mean it was
659
+ // pre-set at capture-promote time, never backed by a real promotion.
660
+ const spawnedEpics = parseListFieldBasenames(fm.block, 'spawned_epics') ?? [];
661
+ const enhancesEpics = parseListFieldBasenames(fm.block, 'enhances_epics') ?? [];
662
+ const enhancesBacked = spawnedEpics.length > 0 || enhancesEpics.length > 0;
663
+ return { fm, status, enhances, enhancesBacked };
499
664
  }
500
665
 
501
- function resolveTargetMilestone(vaultRoot, args, occupiedMilestoneIds) {
666
+ async function resolveTargetMilestone(vaultRoot, args, { tier, consoleUrl, consoleFetch }) {
502
667
  if (args.target_milestone === 'NEW') {
503
668
  return { mode: 'new', name: args.new_milestone_name.trim() };
504
669
  }
505
670
  // Accept either bare HS-MIL-XXXX, the full basename, or wikilink wrapping.
506
671
  const basenameCandidate = normalizeMilestoneBasename(args.target_milestone);
507
672
  if (basenameCandidate) {
508
- const ms = findMilestoneByBasename(vaultRoot, basenameCandidate);
673
+ const ms = await findMilestoneByBasenameViaTier(vaultRoot, basenameCandidate, { tier, consoleUrl, consoleFetch });
509
674
  if (ms) return { mode: 'existing', milestone: ms };
510
675
  }
511
676
  if (parseSpecId(args.target_milestone)?.typeLabel === 'MIL') {
512
- const ms = findMilestoneById(vaultRoot, args.target_milestone);
677
+ const ms = await findMilestoneByIdViaTier(vaultRoot, args.target_milestone, { tier, consoleUrl, consoleFetch });
513
678
  if (ms) return { mode: 'existing', milestone: ms };
514
679
  }
515
680
  return {
@@ -652,6 +817,7 @@ async function runProposalPhase(args, ctx, deps) {
652
817
  /** Minimum length for an explanation, matching attach_override_reason above. */
653
818
  const EDIT_NOTE_MIN_LENGTH = 40;
654
819
 
820
+
655
821
  /** Marker path for a research doc's proposal turn. Keyed on the research id — the
656
822
  * one value stable across both turns, since the human is expected to EDIT the
657
823
  * stubs in between (so hashing the stub content, as the plan gate does, would
@@ -672,6 +838,7 @@ function normalizeStubName(name) {
672
838
  export function writeStubReviewMarker(vaultRoot, researchId, proposal = {}) {
673
839
  try {
674
840
  const file = stubReviewMarkerPath(vaultRoot, researchId);
841
+ assertPathUnderRoot(vaultRoot, join(file, '..'), 'promote-research write');
675
842
  mkdirSync(join(file, '..'), { recursive: true });
676
843
  writeFileSync(
677
844
  file,
@@ -815,6 +982,61 @@ function resolveOwnerIntent(vaultRoot, researchRaw) {
815
982
  }
816
983
  }
817
984
 
985
+ /**
986
+ * Cloud-tier sibling of {@link resolveOwnerIntent} (HS-KR16) — same
987
+ * best-effort, never-throws contract (a missing/unreachable source capture
988
+ * just means no owner-intent enrichment, not a hard failure). Only the
989
+ * per-basename capture read moves through the tier selector; the
990
+ * sources_captures/related parsing stays identical to the disk version.
991
+ */
992
+ async function resolveOwnerIntentViaTier(vaultRoot, researchRaw, { tier, consoleUrl, consoleFetch }) {
993
+ try {
994
+ const fm = getFrontmatterBlock(researchRaw);
995
+ if (!fm) return null;
996
+ let basenames = parseListFieldBasenames(fm.block, 'sources_captures') ?? [];
997
+ if (basenames.length === 0) {
998
+ basenames = (parseListFieldBasenames(fm.block, 'related') ?? []).filter((b) =>
999
+ /(^|-)CAP(-|$)/i.test(b),
1000
+ );
1001
+ }
1002
+ if (basenames.length === 0) return null;
1003
+ for (const basename of basenames) {
1004
+ let raw;
1005
+ try {
1006
+ raw = await readViaSelector({
1007
+ tier,
1008
+ diskRead: () => {
1009
+ const capturesDir = join(vaultRoot, 'specs', 'captures');
1010
+ let abs = join(capturesDir, `${basename}.md`);
1011
+ if (!existsSync(abs)) {
1012
+ const byPrefix = findFileByPrefix(capturesDir, basename);
1013
+ if (!byPrefix) return null;
1014
+ abs = join(capturesDir, byPrefix);
1015
+ }
1016
+ return readFileSync(abs, 'utf8');
1017
+ },
1018
+ neonRead: async () => {
1019
+ const record = await fetchSpecRecordViaConsole(consoleFetch, basename);
1020
+ return record ? record.raw : null;
1021
+ },
1022
+ routeLabel: 'GET /api/specs/[id]',
1023
+ consoleUrl,
1024
+ });
1025
+ } catch {
1026
+ continue; // a genuine console failure here is soft — skip, try the next basename.
1027
+ }
1028
+ if (!raw) continue;
1029
+ const body = getFrontmatterBlock(raw)?.body ?? raw;
1030
+ const m = body.match(/#\s*Core Idea\s*\n+([\s\S]*?)(?=\n#{1,6}\s|$)/i);
1031
+ const coreIdea = m ? m[1].trim() : '';
1032
+ if (coreIdea) return { basename, coreIdea };
1033
+ }
1034
+ return null;
1035
+ } catch {
1036
+ return null;
1037
+ }
1038
+ }
1039
+
818
1040
  /**
819
1041
  * Pull the research doc's own `## JTBD (job-to-be-done)` section body, so
820
1042
  * spawned milestone/epic stubs can re-derive their altitude's slice of the
@@ -1018,6 +1240,38 @@ async function runExecutePhase(args, ctx, deps) {
1018
1240
  const now = (deps?.clock ?? isoNow)();
1019
1241
  const today = now.slice(0, 10);
1020
1242
  const author = 'promote-research (agent)';
1243
+ // Same already-resolved tier this call's executePromoteResearch entry point
1244
+ // read from deps.tier ([[HS-KR16-CWPX-TASK-promotion-reads-through-the-cloud-tier]])
1245
+ // — read again here, never re-resolved via resolveReadTier().
1246
+ const tierDeps = { tier: deps?.tier ?? 'disk', consoleUrl: deps?.consoleUrl, consoleFetch: deps?.consoleFetch };
1247
+ // ── Vault WRITE mode ([[HS-BYU4-CWPX-TASK-promotion-writes-go-through-the-console]]) ──
1248
+ //
1249
+ // Resolved ONCE by the caller (`resolveVaultWriteMode()`) and threaded in on
1250
+ // `deps`; absent — every pre-existing caller and test — it is `local`, so the
1251
+ // disk plan-then-`commitWrites` path below is byte-identical to before.
1252
+ //
1253
+ // CREATE FIRST, LINK SECOND. On disk this flow mints every filename itself,
1254
+ // so it can stage all four write groups and commit them as one transaction.
1255
+ // In cloud mode the console mints the id, the family and the filename, and
1256
+ // the dependencies run strictly one way:
1257
+ //
1258
+ // milestone ← links to: research basename (known up front)
1259
+ // epic ← links to: MILESTONE basename (only after its POST)
1260
+ // epic ← links to: SIBLING epic basenames (only after their POSTs)
1261
+ // milestone ← links to: EPIC basenames (`epics:`) (only after their POSTs)
1262
+ // research ← links to: MILESTONE + EPIC basenames (only after their POSTs)
1263
+ //
1264
+ // so the cloud order is: POST milestone → POST every epic (with only the
1265
+ // deps already resolvable) → PUT epics that depend on a sibling → PUT the
1266
+ // milestone's `epics:` mirror → PUT the research doc. Every wikilink written
1267
+ // in a PUT body therefore names a basename a POST actually returned.
1268
+ const writeMode = resolveWriteModeFromDeps(deps);
1269
+ const cloudDeps = { consoleFetch: deps?.consoleFetch, consoleUrl: deps?.consoleUrl };
1270
+ const cloudCompleted = [];
1271
+ const recordCloud = (entry) => {
1272
+ cloudCompleted.push(entry);
1273
+ return entry;
1274
+ };
1021
1275
 
1022
1276
  // Family inheritance ([[HS-GZM9-DIAG-G419]], mirrors promote-capture.mjs /
1023
1277
  // split-research.mjs): the spawned milestone AND every spawned epic inherit
@@ -1041,23 +1295,39 @@ async function runExecutePhase(args, ctx, deps) {
1041
1295
  let existingMilestoneEpics = [];
1042
1296
 
1043
1297
  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`);
1298
+ if (writeMode === 'cloud') {
1299
+ // NOTHING is minted locally in cloud mode (AC3): `allocateFileKey`,
1300
+ // `collectOccupiedKeysEitherKind` and `allocateMilestoneId` all derive
1301
+ // their answer from a vault SCAN, and in a file-less vault that scan is
1302
+ // empty — a key drawn against an empty occupied set can collide with one
1303
+ // the cloud vault already holds. `createSpec` mints the file key, the
1304
+ // family and the filename server-side. An INHERITED family is still
1305
+ // passed through: it is read off the source research doc, not minted.
1306
+ milestoneFileKey = null;
1307
+ milestoneFamilyKey = inheritedFamilyKey ?? null;
1308
+ milestoneId = null;
1309
+ milestoneBasename = null;
1310
+ milestoneAbsPath = null;
1311
+ milestoneRelPath = null;
1312
+ } else {
1313
+ // Per-file FILE KEY (owner ruling 2026-07-10) — filename + `file_id:` SSOT,
1314
+ // and (V3) the anchor for `id:` itself.
1315
+ milestoneFileKey = allocateFileKey(collectOccupiedKeysEitherKind(ctx.vaultRoot), {
1316
+ randomUnit: deps?.randomUnit,
1317
+ });
1318
+ // Family: inherit from the research doc; the legacy allocator's
1319
+ // `HS-<family>-MIL` draw is only the fallback family source (never blank —
1320
+ // last resort is the fresh file key itself, matching the old chain).
1321
+ milestoneFamilyKey =
1322
+ inheritedFamilyKey ??
1323
+ parseSpecId(allocateMilestoneId(ctx.vaultRoot, { randomUnit: deps?.randomUnit }))?.familyKey ??
1324
+ milestoneFileKey;
1325
+ milestoneId = `HS-${milestoneFileKey}`;
1326
+ const slug = deriveSlug(ctx.targetResolution.name);
1327
+ milestoneBasename = `${milestoneId}-${milestoneFamilyKey}-MIL-${slug}`;
1328
+ milestoneAbsPath = join(ctx.vaultRoot, 'specs', 'milestones', `${milestoneBasename}.md`);
1329
+ milestoneRelPath = join('specs', 'milestones', `${milestoneBasename}.md`);
1330
+ }
1061
1331
  milestoneCreated = true;
1062
1332
  } else {
1063
1333
  const ms = ctx.targetResolution.milestone;
@@ -1076,7 +1346,7 @@ async function runExecutePhase(args, ctx, deps) {
1076
1346
  const augmentEpics = [];
1077
1347
  if (args.intent === 'augment' || args.intent === 'both') {
1078
1348
  for (const epicRef of args.augment_epic_ids) {
1079
- const found = findEpicByIdOrBasename(ctx.vaultRoot, epicRef);
1349
+ const found = await findEpicByIdOrBasenameViaTier(ctx.vaultRoot, epicRef, tierDeps);
1080
1350
  if (!found) {
1081
1351
  return {
1082
1352
  error: 'AUGMENT_EPIC_NOT_FOUND',
@@ -1106,10 +1376,14 @@ async function runExecutePhase(args, ctx, deps) {
1106
1376
 
1107
1377
  const spawnedEpicSpecs = [];
1108
1378
  if (acceptedStubs.length > 0) {
1109
- const { epics: occupiedEpics } = collectOccupiedVaultIds(ctx.vaultRoot);
1379
+ // Both occupied sets are DISK SCANS — skipped entirely in cloud mode,
1380
+ // where no key is minted client-side (AC3).
1381
+ const { epics: occupiedEpics } =
1382
+ writeMode === 'cloud' ? { epics: new Set() } : collectOccupiedVaultIds(ctx.vaultRoot);
1110
1383
  // One occupied set across the whole loop — files are not on disk yet, so a
1111
1384
  // 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)
1385
+ const occupiedFileKeys =
1386
+ writeMode === 'cloud' ? new Set() : collectOccupiedKeysEitherKind(ctx.vaultRoot); // union: file+family keys (HS-SJOT)
1113
1387
  for (const stub of acceptedStubs) {
1114
1388
  if (!stub?.name?.trim()) {
1115
1389
  return {
@@ -1117,23 +1391,32 @@ async function runExecutePhase(args, ctx, deps) {
1117
1391
  message: 'every accepted stub must have a non-empty name',
1118
1392
  };
1119
1393
  }
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.
1394
+ let fileKey = null;
1126
1395
  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;
1396
+ let epicId = null;
1397
+ let basename = null;
1398
+ if (writeMode === 'cloud') {
1399
+ // Basename/id stay null until this stub's own POST answers; the
1400
+ // inherited family (read off the research doc, never minted) is the
1401
+ // only naming input that survives into the request.
1402
+ epicFamilyKey = inheritedFamilyKey ?? null;
1403
+ } else {
1404
+ // Per-file FILE KEY (owner ruling 2026-07-10) — filename + `file_id:` SSOT,
1405
+ // and (V3) the anchor for `id:` itself.
1406
+ fileKey = allocateFileKey(occupiedFileKeys, { randomUnit: deps?.randomUnit });
1407
+ occupiedFileKeys.add(fileKey);
1408
+ // Family: inherit from the research doc; the legacy allocator's
1409
+ // `HS-<family>-EPIC` draw is only the fallback family source.
1410
+ if (!epicFamilyKey) {
1411
+ const allocatedEpicFamilyId = allocateRandomVaultId('epic', occupiedEpics, {
1412
+ randomUnit: deps?.randomUnit,
1413
+ });
1414
+ occupiedEpics.add(allocatedEpicFamilyId);
1415
+ epicFamilyKey = parseSpecId(allocatedEpicFamilyId)?.familyKey ?? fileKey;
1416
+ }
1417
+ epicId = `HS-${fileKey}`;
1418
+ basename = `${epicId}-${epicFamilyKey}-EPIC-${deriveSlug(stub.name)}`;
1133
1419
  }
1134
- const epicId = `HS-${fileKey}`;
1135
- const slug = deriveSlug(stub.name);
1136
- const basename = `${epicId}-${epicFamilyKey}-EPIC-${slug}`;
1137
1420
  spawnedEpicSpecs.push({
1138
1421
  id: epicId,
1139
1422
  fileKey,
@@ -1147,8 +1430,8 @@ async function runExecutePhase(args, ctx, deps) {
1147
1430
  // Per-stub pillar override; falls back to the batch-default `args.pillar`
1148
1431
  // in the gate below (stub value wins — [[pillar-assignment-protocol]]).
1149
1432
  pillar: typeof stub.pillar === 'string' ? stub.pillar : undefined,
1150
- relPath: join('specs', 'epics', `${basename}.md`),
1151
- absPath: join(ctx.vaultRoot, 'specs', 'epics', `${basename}.md`),
1433
+ relPath: basename ? join('specs', 'epics', `${basename}.md`) : null,
1434
+ absPath: basename ? join(ctx.vaultRoot, 'specs', 'epics', `${basename}.md`) : null,
1152
1435
  });
1153
1436
  }
1154
1437
  }
@@ -1161,20 +1444,32 @@ async function runExecutePhase(args, ctx, deps) {
1161
1444
  // each entry that matches a sibling stub's `name` with that sibling's
1162
1445
  // freshly-minted basename; else resolve against an existing vault epic by
1163
1446
  // id/basename; else DROP it (warn) rather than write an unresolvable link.
1447
+ //
1448
+ // In CLOUD mode a sibling's basename does not exist yet either (the
1449
+ // console mints it at POST time), so the sibling half of this resolution
1450
+ // is DEFERRED: the entry is parked on `spec.pendingSiblingDeps` and folded
1451
+ // in by a follow-up PUT once every sibling POST has answered. The
1452
+ // externally-resolved half is identical in both modes.
1164
1453
  const depWarnings = [];
1165
1454
  if (spawnedEpicSpecs.length > 0) {
1455
+ const siblingNames = new Set(spawnedEpicSpecs.map((s) => s.name.trim().toLowerCase()));
1166
1456
  const siblingNameToBasename = new Map();
1167
1457
  for (const s of spawnedEpicSpecs) {
1168
1458
  siblingNameToBasename.set(s.name.trim().toLowerCase(), s.basename);
1169
1459
  }
1170
1460
  for (const spec of spawnedEpicSpecs) {
1171
1461
  const resolved = [];
1462
+ const pendingSiblings = [];
1172
1463
  for (const raw of spec.depends_on) {
1173
1464
  const d = typeof raw === 'string' ? raw.trim() : '';
1174
1465
  if (!d) continue;
1466
+ if (writeMode === 'cloud' && siblingNames.has(d.toLowerCase())) {
1467
+ pendingSiblings.push(d.toLowerCase());
1468
+ continue;
1469
+ }
1175
1470
  let target = siblingNameToBasename.get(d.toLowerCase());
1176
1471
  if (!target) {
1177
- const found = findEpicByIdOrBasename(ctx.vaultRoot, d);
1472
+ const found = await findEpicByIdOrBasenameViaTier(ctx.vaultRoot, d, tierDeps);
1178
1473
  if (found) target = found.basename;
1179
1474
  }
1180
1475
  if (target) {
@@ -1184,6 +1479,7 @@ async function runExecutePhase(args, ctx, deps) {
1184
1479
  }
1185
1480
  }
1186
1481
  spec.depends_on = resolved;
1482
+ spec.pendingSiblingDeps = pendingSiblings;
1187
1483
  }
1188
1484
  }
1189
1485
 
@@ -1195,14 +1491,19 @@ async function runExecutePhase(args, ctx, deps) {
1195
1491
  if (spawnedEpicSpecs.length > 0) {
1196
1492
  for (const spec of spawnedEpicSpecs) {
1197
1493
  const pillarRef = spec.pillar ?? args.pillar;
1198
- const resolved = resolvePillar(ctx.vaultRoot, pillarRef);
1494
+ const resolved = await resolvePillarViaTier(ctx.vaultRoot, pillarRef, tierDeps);
1199
1495
  if (!resolved.ok) {
1200
1496
  return {
1201
1497
  error: 'pillar_required',
1202
1498
  message: pillarRef
1203
1499
  ? `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
1500
  : `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),
1501
+ // AC3: in cloud mode this list comes from `GET /api/specs?type=pillar`,
1502
+ // not from a `readdirSync(specs/pillars)` that would be empty here.
1503
+ existing_pillars: await listVaultPillarsViaWriteMode(ctx.vaultRoot, {
1504
+ writeMode,
1505
+ ...cloudDeps,
1506
+ }),
1206
1507
  create_pillar: {
1207
1508
  tool: 'heyspex_file_spec',
1208
1509
  call: { type: 'pillar', name: '<theme name>' },
@@ -1220,11 +1521,295 @@ async function runExecutePhase(args, ctx, deps) {
1220
1521
  // The owner's own words from the originating capture, carried onto every spec
1221
1522
  // this promotion creates so intent survives the hop into the plan graph.
1222
1523
  // Null when no capture resolves — stubs then render exactly as before.
1223
- const ownerIntent = resolveOwnerIntent(ctx.vaultRoot, ctx.research.raw);
1524
+ const ownerIntent = await resolveOwnerIntentViaTier(ctx.vaultRoot, ctx.research.raw, tierDeps);
1224
1525
  // HS-M1MW: seed spawned stubs' JTBD sections from the research doc's own
1225
1526
  // JTBD section, when present.
1226
1527
  const researchJtbd = extractResearchJtbd(ctx.research.raw);
1227
1528
 
1529
+ // ── CLOUD PATH ────────────────────────────────────────────────────────────
1530
+ // Returns early; everything below this block is the untouched disk
1531
+ // plan-then-`commitWrites` path.
1532
+ if (writeMode === 'cloud') {
1533
+ /** Frontmatter/body the console actually stored, falling back to ours. */
1534
+ const storedOf = (created, fallbackRaw) => {
1535
+ const fallback = splitRawSpecForConsole(fallbackRaw);
1536
+ return {
1537
+ frontmatter: created?.spec?.frontmatter ?? fallback.frontmatter,
1538
+ body: typeof created?.spec?.body === 'string' ? created.spec.body : fallback.body,
1539
+ };
1540
+ };
1541
+
1542
+ // ── 1. POST the milestone FIRST — every epic's `parent:`/`milestone:` and
1543
+ // the research's `enhances:` name the basename it returns.
1544
+ let milestoneStored = null;
1545
+ if (milestoneCreated) {
1546
+ const newContent = buildMilestoneStubContent(
1547
+ CLOUD_PENDING_ID,
1548
+ CLOUD_PENDING_KEY,
1549
+ milestoneFamilyKey ?? CLOUD_PENDING_KEY,
1550
+ ctx.targetResolution.name,
1551
+ ctx.research.basename,
1552
+ now,
1553
+ ownerIntent,
1554
+ researchJtbd,
1555
+ );
1556
+ const { frontmatter, body } = splitRawSpecForConsole(newContent);
1557
+ const cleaned = stripServerMintedFields(frontmatter);
1558
+ if (!milestoneFamilyKey) delete cleaned.family;
1559
+ let created;
1560
+ try {
1561
+ created = await createSpecViaConsole({
1562
+ ...cloudDeps,
1563
+ type: 'milestone',
1564
+ name: ctx.targetResolution.name,
1565
+ frontmatter: cleaned,
1566
+ body,
1567
+ actingAgent: deps?.actingAgent ?? null,
1568
+ });
1569
+ } catch (err) {
1570
+ return cloudWriteFailureResult(err, {
1571
+ step: { kind: 'create', type: 'milestone', name: ctx.targetResolution.name },
1572
+ completed: cloudCompleted,
1573
+ });
1574
+ }
1575
+ milestoneId = created.specId;
1576
+ milestoneBasename = created.basename;
1577
+ milestoneRelPath = created.relPath;
1578
+ milestoneStored = storedOf(created, newContent);
1579
+ recordCloud({
1580
+ kind: 'create',
1581
+ type: 'milestone',
1582
+ spec_id: created.specId,
1583
+ basename: created.basename,
1584
+ rel_path: created.relPath,
1585
+ });
1586
+ }
1587
+
1588
+ // ── 2. POST every epic, now that the milestone basename is real.
1589
+ for (const spec of spawnedEpicSpecs) {
1590
+ const content = buildEpicContent(
1591
+ {
1592
+ ...spec,
1593
+ id: CLOUD_PENDING_ID,
1594
+ fileKey: CLOUD_PENDING_KEY,
1595
+ familyKey: spec.familyKey ?? CLOUD_PENDING_KEY,
1596
+ },
1597
+ milestoneBasename,
1598
+ ctx.research.basename,
1599
+ now,
1600
+ ownerIntent,
1601
+ researchJtbd,
1602
+ );
1603
+ const { frontmatter, body } = splitRawSpecForConsole(content);
1604
+ const cleaned = stripServerMintedFields(frontmatter);
1605
+ if (!spec.familyKey) delete cleaned.family;
1606
+ let created;
1607
+ try {
1608
+ created = await createSpecViaConsole({
1609
+ ...cloudDeps,
1610
+ type: 'epic',
1611
+ name: spec.name,
1612
+ frontmatter: cleaned,
1613
+ body,
1614
+ actingAgent: deps?.actingAgent ?? null,
1615
+ });
1616
+ } catch (err) {
1617
+ return cloudWriteFailureResult(err, {
1618
+ step: { kind: 'create', type: 'epic', name: spec.name },
1619
+ completed: cloudCompleted,
1620
+ });
1621
+ }
1622
+ spec.id = created.specId;
1623
+ spec.basename = created.basename;
1624
+ spec.relPath = created.relPath;
1625
+ spec.cloudStored = storedOf(created, content);
1626
+ recordCloud({
1627
+ kind: 'create',
1628
+ type: 'epic',
1629
+ spec_id: created.specId,
1630
+ basename: created.basename,
1631
+ rel_path: created.relPath,
1632
+ });
1633
+ }
1634
+
1635
+ // ── 3. PUT the epics whose `depends_on` names a SIBLING: that basename
1636
+ // only became knowable in step 2.
1637
+ const siblingBasenameByName = new Map(
1638
+ spawnedEpicSpecs.map((s) => [s.name.trim().toLowerCase(), s.basename]),
1639
+ );
1640
+ for (const spec of spawnedEpicSpecs) {
1641
+ if (!spec.pendingSiblingDeps?.length) continue;
1642
+ const siblingBasenames = [];
1643
+ for (const name of spec.pendingSiblingDeps) {
1644
+ const b = siblingBasenameByName.get(name);
1645
+ if (b) siblingBasenames.push(b);
1646
+ else depWarnings.push({ epic: spec.name, unresolved: name });
1647
+ }
1648
+ if (siblingBasenames.length === 0) continue;
1649
+ spec.depends_on = [...spec.depends_on, ...siblingBasenames];
1650
+ const fm = { ...spec.cloudStored.frontmatter };
1651
+ fm.depends_on = spec.depends_on.map((d) => `[[${d}]]`);
1652
+ try {
1653
+ const res = await updateSpecViaConsole({
1654
+ ...cloudDeps,
1655
+ specId: spec.id,
1656
+ frontmatter: fm,
1657
+ body: spec.cloudStored.body,
1658
+ });
1659
+ recordCloud({
1660
+ kind: 'update',
1661
+ type: 'epic.depends_on',
1662
+ spec_id: res.specId,
1663
+ basename: res.basename ?? spec.basename,
1664
+ rel_path: res.relPath,
1665
+ });
1666
+ } catch (err) {
1667
+ return cloudWriteFailureResult(err, {
1668
+ step: { kind: 'update', type: 'epic.depends_on', basename: spec.basename },
1669
+ completed: cloudCompleted,
1670
+ });
1671
+ }
1672
+ }
1673
+
1674
+ // ── 4. PUT the milestone's `epics:` mirror (the cloud twin of the
1675
+ // post-commit `appendEpicToMilestone` line surgery) + `informed_by:`
1676
+ // on an EXISTING milestone. Both reference basenames from step 2.
1677
+ const spawnedBasenames = spawnedEpicSpecs.map((s) => s.basename);
1678
+ if (milestoneCreated) {
1679
+ if (spawnedBasenames.length > 0) {
1680
+ const fm = { ...milestoneStored.frontmatter };
1681
+ fm.epics = spawnedBasenames.map((b) => `[[${b}]]`);
1682
+ try {
1683
+ const res = await updateSpecViaConsole({
1684
+ ...cloudDeps,
1685
+ specId: milestoneId,
1686
+ frontmatter: fm,
1687
+ body: milestoneStored.body,
1688
+ });
1689
+ recordCloud({
1690
+ kind: 'update',
1691
+ type: 'milestone.epics',
1692
+ spec_id: res.specId,
1693
+ basename: res.basename ?? milestoneBasename,
1694
+ rel_path: res.relPath,
1695
+ });
1696
+ } catch (err) {
1697
+ return cloudWriteFailureResult(err, {
1698
+ step: { kind: 'update', type: 'milestone.epics', basename: milestoneBasename },
1699
+ completed: cloudCompleted,
1700
+ });
1701
+ }
1702
+ }
1703
+ } else {
1704
+ const msRaw = ctx.targetResolution.milestone.raw;
1705
+ let updatedRaw = buildUpdatedMilestoneContent(msRaw, {
1706
+ researchBasename: ctx.research.basename,
1707
+ now,
1708
+ });
1709
+ const fmBlock = getFrontmatterBlock(updatedRaw);
1710
+ if (fmBlock && spawnedBasenames.length > 0) {
1711
+ let block = fmBlock.block;
1712
+ for (const b of spawnedBasenames) block = appendToListField(block, 'epics', b).block;
1713
+ updatedRaw = `${fmBlock.open}${block}${fmBlock.close}${fmBlock.body}`;
1714
+ }
1715
+ if (updatedRaw !== msRaw) {
1716
+ const { frontmatter, body } = splitRawSpecForConsole(updatedRaw);
1717
+ try {
1718
+ const res = await updateSpecViaConsole({
1719
+ ...cloudDeps,
1720
+ specId: milestoneId,
1721
+ frontmatter,
1722
+ body,
1723
+ });
1724
+ recordCloud({
1725
+ kind: 'update',
1726
+ type: 'milestone',
1727
+ spec_id: res.specId,
1728
+ basename: res.basename ?? milestoneBasename,
1729
+ rel_path: res.relPath,
1730
+ });
1731
+ } catch (err) {
1732
+ return cloudWriteFailureResult(err, {
1733
+ step: { kind: 'update', type: 'milestone', basename: milestoneBasename },
1734
+ completed: cloudCompleted,
1735
+ });
1736
+ }
1737
+ }
1738
+ }
1739
+
1740
+ // ── 5. PUT the research doc LAST: `enhances:` + `spawned_epics:` +
1741
+ // `enhances_epics:` all name basenames a POST returned above.
1742
+ const researchContentCloud = buildUpdatedResearchContent(ctx.research.raw, {
1743
+ milestoneBasename,
1744
+ spawnedEpicBasenames: spawnedBasenames,
1745
+ augmentedEpicBasenames: augmentEpics.map((e) => e.basename),
1746
+ now,
1747
+ today,
1748
+ author,
1749
+ });
1750
+ {
1751
+ const { frontmatter, body } = splitRawSpecForConsole(researchContentCloud);
1752
+ try {
1753
+ const res = await updateSpecViaConsole({
1754
+ ...cloudDeps,
1755
+ specId: ctx.research.frontmatter?.id ?? args.research_id,
1756
+ frontmatter,
1757
+ body,
1758
+ });
1759
+ recordCloud({
1760
+ kind: 'update',
1761
+ type: 'research',
1762
+ spec_id: res.specId,
1763
+ basename: res.basename ?? ctx.research.basename,
1764
+ rel_path: res.relPath,
1765
+ });
1766
+ } catch (err) {
1767
+ return cloudWriteFailureResult(err, {
1768
+ step: { kind: 'update', type: 'research', basename: ctx.research.basename },
1769
+ completed: cloudCompleted,
1770
+ });
1771
+ }
1772
+ }
1773
+
1774
+ // ── 6. Roadmap manifest append — EXPLICIT DEGRADE. `registerSpec` edits
1775
+ // `roadmap_manifest.milestones` inside a roadmap doc, and `roadmap`
1776
+ // is not one of the console's creatable/updatable spec types (its
1777
+ // TYPE_TO_FOLDER has no entry for it), so there is no route to make
1778
+ // this write through. Reported, never silently skipped.
1779
+ const roadmapRegistrationCloud = milestoneCreated
1780
+ ? {
1781
+ ok: false,
1782
+ ...cloudModeUnsupportedWrite(
1783
+ `${args.roadmap_id || 'HS-ROADMAP-PRODUCT'} (roadmap_manifest.milestones)`,
1784
+ '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',
1785
+ { milestone_basename: milestoneBasename },
1786
+ ),
1787
+ }
1788
+ : undefined;
1789
+
1790
+ return {
1791
+ phase: 'executed',
1792
+ write_mode: 'cloud',
1793
+ milestone_id: milestoneId,
1794
+ milestone_basename: milestoneBasename,
1795
+ milestone_path: milestoneRelPath,
1796
+ milestone_created: milestoneCreated,
1797
+ spawned_epics: spawnedEpicSpecs.map((s) => ({
1798
+ id: s.id,
1799
+ basename: s.basename,
1800
+ path: s.relPath,
1801
+ })),
1802
+ augmented_epics: augmentEpics.map((e) => e.basename),
1803
+ research_id: ctx.research.frontmatter?.id ?? args.research_id,
1804
+ research_path: ctx.research.relPath,
1805
+ research_status_after: ctx.researchStatus ?? 'open',
1806
+ writes: cloudCompleted.map((w) => ({ kind: w.kind, path: w.rel_path ?? null, spec_id: w.spec_id })),
1807
+ human_review_required: false,
1808
+ ...(depWarnings.length > 0 ? { depends_on_warnings: depWarnings } : {}),
1809
+ ...(roadmapRegistrationCloud ? { roadmap_registration: roadmapRegistrationCloud } : {}),
1810
+ };
1811
+ }
1812
+
1228
1813
  // 1. Milestone (create-if-new, else update later via line-surgical helper).
1229
1814
  // Family inheritance (HS-TASK-QQHP defect #1) is resolved once, up top,
1230
1815
  // where the era-3 basenames/ids are composed.
@@ -1401,10 +1986,20 @@ export async function executePromoteResearch(vaultRoot, args = {}, deps = {}) {
1401
1986
  const validationError = validateInputShape(args);
1402
1987
  if (validationError) return validationError;
1403
1988
 
1989
+ // Cloud-tier reads ([[HS-KR16-CWPX-TASK-promotion-reads-through-the-cloud-tier]]):
1990
+ // `deps.tier` is resolved ONCE by the caller (`handlePromoteResearch` in
1991
+ // research-handlers.mjs, via `resolveReadTier()`) and threaded down —
1992
+ // never re-resolved here. Absent (every pre-existing caller/test) defaults
1993
+ // to `disk`, so disk-tier behavior is byte-identical to before this task.
1994
+ const tier = deps.tier ?? 'disk';
1995
+ const consoleUrl = deps.consoleUrl;
1996
+ const consoleFetchDep = deps.consoleFetch;
1997
+ const tierDeps = { tier, consoleUrl, consoleFetch: consoleFetchDep };
1998
+
1404
1999
  // 1. Load research file. Resolution happens FIRST (exact basename → .md /
1405
2000
  // wikilink-stripped → frontmatter id: → unambiguous prefix); the "is this
1406
2001
  // really research?" check reads the resolved file's `type:`.
1407
- const resolved = findResearchByIdLocal(vaultRoot, args.research_id);
2002
+ const resolved = await findResearchByIdViaTier(vaultRoot, args.research_id, tierDeps);
1408
2003
  if (!resolved || resolved.error) {
1409
2004
  return (
1410
2005
  resolved ?? {
@@ -1451,7 +2046,7 @@ export async function executePromoteResearch(vaultRoot, args = {}, deps = {}) {
1451
2046
  }
1452
2047
 
1453
2048
  // 2. Resolve target milestone.
1454
- const targetResolution = resolveTargetMilestone(vaultRoot, args);
2049
+ const targetResolution = await resolveTargetMilestone(vaultRoot, args, tierDeps);
1455
2050
  if (targetResolution.error) return targetResolution;
1456
2051
 
1457
2052
  if (targetResolution.mode === 'existing') {
@@ -1476,12 +2071,17 @@ export async function executePromoteResearch(vaultRoot, args = {}, deps = {}) {
1476
2071
  );
1477
2072
  if (attachPolicy.error) return attachPolicy;
1478
2073
 
1479
- const conflict = validateBidirectionalEnhances(
1480
- researchState.enhances,
1481
- targetResolution.milestone.basename,
1482
- );
2074
+ // [[HS-8XJA-KOGH-DIAG]]: an `enhances` set with no spawned_epics/enhances_epics
2075
+ // behind it was never actually promoted (see validateResearchState) — treat
2076
+ // it as unset rather than blocking on a signal the capture-promote step
2077
+ // forged one stage early. buildUpdatedResearchContent below overwrites
2078
+ // `enhances` unconditionally on a successful run, so this self-heals the
2079
+ // stray pointer instead of requiring a hand-edit to clear it.
2080
+ const conflict = researchState.enhancesBacked
2081
+ ? validateBidirectionalEnhances(researchState.enhances, targetResolution.milestone.basename)
2082
+ : null;
1483
2083
  if (conflict) return conflict;
1484
- } else if (researchState.enhances) {
2084
+ } else if (researchState.enhances && researchState.enhancesBacked) {
1485
2085
  // Already enhances a milestone but target is NEW — refuse, the human should
1486
2086
  // either split the research or pick the existing milestone.
1487
2087
  return {
@@ -1538,6 +2138,13 @@ export const __testables = {
1538
2138
  validateInputShape,
1539
2139
  normalizeResearchRef,
1540
2140
  findResearchByIdLocal,
2141
+ findResearchByIdViaTier,
2142
+ findMilestoneByBasenameViaTier,
2143
+ findMilestoneByIdViaTier,
2144
+ findEpicByIdOrBasenameViaTier,
2145
+ resolvePillarViaTier,
2146
+ resolveOwnerIntentViaTier,
2147
+ resolveTargetMilestone,
1541
2148
  validateExistingMilestoneAttachable,
1542
2149
  MILESTONE_ATTACH_DEFAULT_ALLOWED_STATUSES,
1543
2150
  resolvePillar,