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
@@ -52,6 +52,7 @@ import { scoreSufficiency } from '../../dev-factory/lib/sufficiency-score.mjs';
52
52
  // capture-focus.mjs deep-dive reader rather than a third regex copy.
53
53
  import { extractCaptureExpandedConceptFromBody } from './capture-focus.mjs';
54
54
  import {
55
+ collectOccupiedKeysEitherKind,
55
56
  mintFileKey,
56
57
  mintFamilyKey,
57
58
  parseSpecId,
@@ -82,7 +83,27 @@ import {
82
83
  bumpUpdated,
83
84
  appendChangeLogRow,
84
85
  commitWrites,
86
+ fetchSpecRecordViaConsole,
87
+ resolveWriteModeFromDeps,
88
+ splitRawSpecForConsole,
89
+ cloudWriteFailureResult,
85
90
  } from './promotion-helpers.mjs';
91
+ // Cloud-mode vault WRITES ([[HS-BYU4-CWPX-TASK-promotion-writes-go-through-the-console]]).
92
+ // The write-side twin of read-path.mjs: in `cloud` write mode a create becomes
93
+ // POST /api/specs (server-minted id + family + filename) and an edit becomes
94
+ // PUT /api/specs/:id. Nothing here opens a socket itself — `consoleFetch` is
95
+ // the same injected dep the cloud-tier READS already use.
96
+ import {
97
+ createSpecViaConsole,
98
+ updateSpecViaConsole,
99
+ stripServerMintedFields,
100
+ } from './cloud-vault-writes.mjs';
101
+ // Cloud-tier read selector ([[HS-KR16-CWPX-TASK-promotion-reads-through-the-cloud-tier]]) —
102
+ // reused verbatim, never copied. `readViaSelector` picks disk vs neon; the
103
+ // neon branch below fetches through `fetchSpecRecordViaConsole` (an injected
104
+ // consoleFetch, not read-path.mjs's own transport) so tests never open a
105
+ // socket, matching this file's existing deps-injection convention.
106
+ import { readViaSelector } from './read-path.mjs';
86
107
  // Reuse the exact same placeholder-count logic RESEARCH_STILL_STUB uses to
87
108
  // refuse `heyspex_promote_research` on a stub research doc — proactively
88
109
  // surfaced here at creation/extension time instead of only reactively at
@@ -99,6 +120,15 @@ import { findUnresolvedResearchPlaceholders } from './promote-research.mjs';
99
120
  // vendor/heyspex-mcp/spec-id-lib.mjs (`parseSpecId`).
100
121
  // ─────────────────────────────────────────────────────────────────────────────
101
122
 
123
+ // Placeholders the CLOUD path feeds the shared content builders in place of a
124
+ // locally-minted id / file key / family key. They never reach the console:
125
+ // `stripServerMintedFields` removes `id:` and `file_id:` from the request, and
126
+ // an un-inheritable `family:` is deleted alongside them. Their only job is to
127
+ // keep the builder's template well-formed so its BODY is byte-for-byte the
128
+ // markdown the disk path would have written.
129
+ const CLOUD_PENDING_ID = 'HS-PENDING';
130
+ const CLOUD_PENDING_KEY = 'PENDING';
131
+
102
132
  /** True when `id` (either ordering) parses to a spec with the given type label. */
103
133
  function hasTypeLabel(id, label) {
104
134
  const p = parseSpecId(id);
@@ -295,6 +325,36 @@ function findResearchByIdLocal(vaultRoot, researchId) {
295
325
  return null;
296
326
  }
297
327
 
328
+ /**
329
+ * Cloud-tier sibling of {@link findCaptureByIdLocal} (HS-KR16). `tier`,
330
+ * `consoleUrl` and `consoleFetch` are resolved ONCE per handler call
331
+ * (`handlePromoteCapture` in capture-tools.mjs) and threaded down here —
332
+ * never re-resolved inside this helper.
333
+ */
334
+ async function findCaptureByIdViaTier(vaultRoot, captureId, { tier, consoleUrl, consoleFetch }) {
335
+ return readViaSelector({
336
+ tier,
337
+ diskRead: () => findCaptureByIdLocal(vaultRoot, captureId),
338
+ neonRead: () => fetchSpecRecordViaConsole(consoleFetch, captureId),
339
+ routeLabel: 'GET /api/specs/[id]',
340
+ consoleUrl,
341
+ });
342
+ }
343
+
344
+ /**
345
+ * Cloud-tier sibling of {@link findResearchByIdLocal} (HS-KR16). Same
346
+ * contract: a bare record, or `null` on a genuine not-found.
347
+ */
348
+ async function findResearchByIdViaTier(vaultRoot, researchId, { tier, consoleUrl, consoleFetch }) {
349
+ return readViaSelector({
350
+ tier,
351
+ diskRead: () => findResearchByIdLocal(vaultRoot, researchId),
352
+ neonRead: () => fetchSpecRecordViaConsole(consoleFetch, researchId),
353
+ routeLabel: 'GET /api/specs/[id]',
354
+ consoleUrl,
355
+ });
356
+ }
357
+
298
358
  function findResearchByBasename(vaultRoot, basename) {
299
359
  const absPath = join(vaultRoot, 'specs', 'research', `${basename}.md`);
300
360
  if (!existsSync(absPath)) return null;
@@ -652,6 +712,26 @@ async function runExecutePhase(args, ctx, deps) {
652
712
  const now = (deps?.clock ?? isoNow)();
653
713
  const today = now.slice(0, 10);
654
714
  const author = 'promote-capture (agent)';
715
+ // Vault WRITE mode ([[HS-BYU4-CWPX-TASK-promotion-writes-go-through-the-console]]).
716
+ // Resolved ONCE by the caller (`resolveVaultWriteMode()`) and threaded in on
717
+ // `deps`; absent (every pre-existing caller and test) it is `local`, so the
718
+ // disk path below is byte-identical to before this task.
719
+ //
720
+ // CREATE FIRST, LINK SECOND: on disk the flow stages every write and commits
721
+ // them as one transaction, because it mints the research filename itself. In
722
+ // cloud mode the console mints the id, the family and the filename, so the
723
+ // basename each capture's `promoted_to:` / `informs_research:` links to is
724
+ // only knowable AFTER `POST /api/specs` returns. The order therefore
725
+ // inverts: POST the research doc, take its basename, then PUT each capture.
726
+ const writeMode = resolveWriteModeFromDeps(deps);
727
+ const cloudDeps = { consoleFetch: deps?.consoleFetch, consoleUrl: deps?.consoleUrl };
728
+ // Steps that have ALREADY landed in the cloud vault, in order. There is no
729
+ // rollback (the console has no un-create) — on a mid-sequence failure this
730
+ // list is what the caller is told actually happened.
731
+ const cloudCompleted = [];
732
+ // PUTs queued while the primary create is being resolved; flushed at the
733
+ // commit point below, in order.
734
+ const cloudUpdates = [];
655
735
 
656
736
  // ── Resolve / allocate target research basename + path ────────────────────
657
737
  let researchBasename;
@@ -672,28 +752,49 @@ async function runExecutePhase(args, ctx, deps) {
672
752
  // (ctx.captures[0]) just below. No fallback: era-3 `id:` keys onto the
673
753
  // file key alone, so a shared family can never collide.
674
754
  const primaryCapture = ctx.captures[0] ?? null;
675
- researchFamilyKey =
676
- (primaryCapture
677
- ? inheritedFamilyKey(
678
- primaryCapture.frontmatter?.id ?? null,
679
- primaryCapture.frontmatter,
680
- primaryCapture.basename,
681
- )
682
- : null) ?? mintFamilyKey(ctx.vaultRoot, { randomUnit: deps?.randomUnit });
683
- // Per-file FILE KEY (owner ruling 2026-07-10) — filename + `file_id:` SSOT,
684
- // and (V3) the anchor for `id:` itself ([[HS-ZU1Q-TASK-PNAS-flip-new-spec-minting-to-the-file-key]]).
685
- researchFileKey = mintFileKey(ctx.vaultRoot, { randomUnit: deps?.randomUnit });
686
- researchId = `HS-${researchFileKey}`;
687
- const outlineName = args.accepted_outline?.name ?? args.new_research_name;
688
- const slug = deriveSlug(outlineName);
689
- researchBasename = `${researchId}-${researchFamilyKey}-RES-${slug}`;
690
- researchAbsPath = join(
691
- ctx.vaultRoot,
692
- 'specs',
693
- 'research',
694
- `${researchBasename}.md`,
695
- );
696
- researchRelPath = join('specs', 'research', `${researchBasename}.md`);
755
+ const inherited = primaryCapture
756
+ ? inheritedFamilyKey(
757
+ primaryCapture.frontmatter?.id ?? null,
758
+ primaryCapture.frontmatter,
759
+ primaryCapture.basename,
760
+ )
761
+ : null;
762
+ if (writeMode === 'cloud') {
763
+ // NOTHING is minted locally in cloud mode (AC3): `mintFileKey` /
764
+ // `mintFamilyKey` both derive their answer by scanning the vault for
765
+ // occupied keys, and in a file-less vault that scan sees an EMPTY set —
766
+ // so a key minted here can collide with one the cloud vault already
767
+ // holds. The console mints the file key, the family and the filename
768
+ // inside `createSpec`. An INHERITED family is still passed through (it
769
+ // is read off the source capture, not minted, and `createSpec` honours
770
+ // an explicit `family:` over parent inheritance); with no inheritable
771
+ // family the field is simply omitted and the server picks one.
772
+ researchFamilyKey = inherited ?? null;
773
+ researchFileKey = null;
774
+ researchId = null;
775
+ researchBasename = null;
776
+ researchAbsPath = null;
777
+ researchRelPath = null;
778
+ } else {
779
+ researchFamilyKey =
780
+ inherited ?? mintFamilyKey(ctx.vaultRoot, { randomUnit: deps?.randomUnit });
781
+ // Per-file FILE KEY (owner ruling 2026-07-10) — filename + `file_id:` SSOT,
782
+ // and (V3) the anchor for `id:` itself ([[HS-ZU1Q-TASK-PNAS-flip-new-spec-minting-to-the-file-key]]).
783
+ // Disk occupancy ([[HS-FJWL]]): the `writeMode === 'cloud'` branch above
784
+ // mints NOTHING locally, so this is the local-vault branch only.
785
+ researchFileKey = mintFileKey(collectOccupiedKeysEitherKind(ctx.vaultRoot), { randomUnit: deps?.randomUnit });
786
+ researchId = `HS-${researchFileKey}`;
787
+ const outlineName = args.accepted_outline?.name ?? args.new_research_name;
788
+ const slug = deriveSlug(outlineName);
789
+ researchBasename = `${researchId}-${researchFamilyKey}-RES-${slug}`;
790
+ researchAbsPath = join(
791
+ ctx.vaultRoot,
792
+ 'specs',
793
+ 'research',
794
+ `${researchBasename}.md`,
795
+ );
796
+ researchRelPath = join('specs', 'research', `${researchBasename}.md`);
797
+ }
697
798
  researchCreated = true;
698
799
 
699
800
  // Sanity: accepted_outline is required to actually create the file.
@@ -775,10 +876,14 @@ async function runExecutePhase(args, ctx, deps) {
775
876
  // interim `JTBD (job-to-be-done):` leading block, when present.
776
877
  const jtbdSeed =
777
878
  ctx.captures.length > 0 ? extractCaptureJtbd(ctx.captures[0].raw) : '';
879
+ // In cloud mode the id/file key/family placeholders below are stripped
880
+ // before the request goes out (`stripServerMintedFields`) — the builder is
881
+ // reused verbatim rather than forked so the BODY the console stores is the
882
+ // same markdown the disk path writes.
778
883
  const newContent = buildResearchStubContent(
779
- researchId,
780
- researchFileKey,
781
- researchFamilyKey,
884
+ researchId ?? CLOUD_PENDING_ID,
885
+ researchFileKey ?? CLOUD_PENDING_KEY,
886
+ researchFamilyKey ?? CLOUD_PENDING_KEY,
782
887
  outlineForCreate,
783
888
  captureBasenames,
784
889
  enhancesBasename,
@@ -786,7 +891,42 @@ async function runExecutePhase(args, ctx, deps) {
786
891
  suggestedType,
787
892
  jtbdSeed,
788
893
  );
789
- writes.push({ kind: 'create', absPath: researchAbsPath, content: newContent });
894
+ if (writeMode === 'cloud') {
895
+ // ── CREATE FIRST ────────────────────────────────────────────────────
896
+ const { frontmatter, body } = splitRawSpecForConsole(newContent);
897
+ const cleaned = stripServerMintedFields(frontmatter);
898
+ if (!researchFamilyKey) delete cleaned.family;
899
+ let created;
900
+ try {
901
+ created = await createSpecViaConsole({
902
+ ...cloudDeps,
903
+ type: 'research',
904
+ name: outlineForCreate.name,
905
+ frontmatter: cleaned,
906
+ body,
907
+ actingAgent: deps?.actingAgent ?? null,
908
+ });
909
+ } catch (err) {
910
+ return cloudWriteFailureResult(err, {
911
+ step: { kind: 'create', type: 'research', name: outlineForCreate.name },
912
+ completed: cloudCompleted,
913
+ });
914
+ }
915
+ // ── …LINK SECOND: every sibling write below now has a real basename.
916
+ researchId = created.specId;
917
+ researchBasename = created.basename;
918
+ researchRelPath = created.relPath;
919
+ researchAbsPath = null;
920
+ cloudCompleted.push({
921
+ kind: 'create',
922
+ type: 'research',
923
+ spec_id: created.specId,
924
+ basename: created.basename,
925
+ rel_path: created.relPath,
926
+ });
927
+ } else {
928
+ writes.push({ kind: 'create', absPath: researchAbsPath, content: newContent });
929
+ }
790
930
  finalResearchContent = newContent;
791
931
  } else {
792
932
  const updated = buildUpdatedResearchContent(
@@ -800,11 +940,23 @@ async function runExecutePhase(args, ctx, deps) {
800
940
  },
801
941
  );
802
942
  if (updated.changed) {
803
- writes.push({
804
- kind: 'update',
805
- absPath: researchAbsPath,
806
- content: updated.content,
807
- });
943
+ if (writeMode === 'cloud') {
944
+ // The target research already exists, so its basename is already
945
+ // known — this PUT carries no create-first dependency and is simply
946
+ // queued with the capture PUTs below.
947
+ cloudUpdates.push({
948
+ label: 'research',
949
+ specId: researchId,
950
+ basename: researchBasename,
951
+ content: updated.content,
952
+ });
953
+ } else {
954
+ writes.push({
955
+ kind: 'update',
956
+ absPath: researchAbsPath,
957
+ content: updated.content,
958
+ });
959
+ }
808
960
  }
809
961
  finalResearchContent = updated.changed ? updated.content : ctx.targetResolution.research.raw;
810
962
  }
@@ -842,15 +994,28 @@ async function runExecutePhase(args, ctx, deps) {
842
994
  promoted_to_set: updated.setPromotedTo,
843
995
  });
844
996
  if (updated.changed) {
845
- writes.push({
846
- kind: 'update',
847
- absPath: c.absPath,
848
- content: updated.content,
849
- });
997
+ if (writeMode === 'cloud') {
998
+ cloudUpdates.push({
999
+ label: 'capture',
1000
+ specId: c.frontmatter?.id ?? c.basename,
1001
+ basename: c.basename,
1002
+ content: updated.content,
1003
+ });
1004
+ } else {
1005
+ writes.push({
1006
+ kind: 'update',
1007
+ absPath: c.absPath,
1008
+ content: updated.content,
1009
+ });
1010
+ }
850
1011
  }
851
1012
  }
852
1013
 
853
- if (writes.length === 0) {
1014
+ const nothingToWrite =
1015
+ writeMode === 'cloud'
1016
+ ? cloudCompleted.length === 0 && cloudUpdates.length === 0
1017
+ : writes.length === 0;
1018
+ if (nothingToWrite) {
854
1019
  // Idempotent no-op: research already references every input capture and
855
1020
  // each capture already references the research. Surface this clearly.
856
1021
  return {
@@ -871,13 +1036,41 @@ async function runExecutePhase(args, ctx, deps) {
871
1036
  }
872
1037
 
873
1038
  // ── Commit ────────────────────────────────────────────────────────────────
874
- const commitResult = commitWrites(writes);
875
- if (!commitResult.ok) {
876
- return {
877
- error: 'WRITE_FAILED',
878
- message: commitResult.error?.message ?? 'unknown write failure',
879
- rollback_error: commitResult.error?.rollbackError,
880
- };
1039
+ if (writeMode === 'cloud') {
1040
+ // Flush the queued PUTs IN ORDER, stopping at the first failure. No
1041
+ // rollback: the error result names the failing step and lists what landed.
1042
+ for (const u of cloudUpdates) {
1043
+ const { frontmatter, body } = splitRawSpecForConsole(u.content);
1044
+ try {
1045
+ const res = await updateSpecViaConsole({
1046
+ ...cloudDeps,
1047
+ specId: u.specId,
1048
+ frontmatter,
1049
+ body,
1050
+ });
1051
+ cloudCompleted.push({
1052
+ kind: 'update',
1053
+ type: u.label,
1054
+ spec_id: res.specId,
1055
+ basename: res.basename ?? u.basename,
1056
+ rel_path: res.relPath,
1057
+ });
1058
+ } catch (err) {
1059
+ return cloudWriteFailureResult(err, {
1060
+ step: { kind: 'update', type: u.label, spec_id: u.specId, basename: u.basename },
1061
+ completed: cloudCompleted,
1062
+ });
1063
+ }
1064
+ }
1065
+ } else {
1066
+ const commitResult = commitWrites(writes);
1067
+ if (!commitResult.ok) {
1068
+ return {
1069
+ error: 'WRITE_FAILED',
1070
+ message: commitResult.error?.message ?? 'unknown write failure',
1071
+ rollback_error: commitResult.error?.rollbackError,
1072
+ };
1073
+ }
881
1074
  }
882
1075
 
883
1076
  return {
@@ -887,7 +1080,11 @@ async function runExecutePhase(args, ctx, deps) {
887
1080
  research_path: researchRelPath,
888
1081
  research_created: researchCreated,
889
1082
  captures: capturePromotionResults,
890
- writes: writes.map((w) => ({ kind: w.kind, path: w.absPath })),
1083
+ ...(writeMode === 'cloud' ? { write_mode: 'cloud' } : {}),
1084
+ writes:
1085
+ writeMode === 'cloud'
1086
+ ? cloudCompleted.map((w) => ({ kind: w.kind, path: w.rel_path ?? null, spec_id: w.spec_id }))
1087
+ : writes.map((w) => ({ kind: w.kind, path: w.absPath })),
891
1088
  // TARGET_RESEARCH_CLOSED only occurs on the existing-research branch
892
1089
  // (researchCreated === false). Family inheritance on the NEW-research
893
1090
  // branch no longer has a fallback case to warn about (era-3 minting —
@@ -919,6 +1116,19 @@ export async function executePromoteCapture(vaultRoot, args = {}, deps = {}) {
919
1116
  const validationError = validateInputShape(args);
920
1117
  if (validationError) return validationError;
921
1118
 
1119
+ // Cloud-tier reads ([[HS-KR16-CWPX-TASK-promotion-reads-through-the-cloud-tier]]):
1120
+ // `deps.tier` is resolved ONCE by the caller (`handlePromoteCapture` in
1121
+ // capture-tools.mjs, via `resolveReadTier()`) and threaded down — never
1122
+ // re-resolved here. Absent (every pre-existing caller/test) defaults to
1123
+ // `disk`, so disk-tier behavior is byte-identical to before this task.
1124
+ // Cloud-mode vault WRITES ([[HS-BYU4-CWPX-TASK-promotion-writes-go-through-the-console]]):
1125
+ // `deps.writeMode` is resolved ONCE by the caller (`resolveVaultWriteMode()`)
1126
+ // exactly like `deps.tier`, and defaults to `local` so every pre-existing
1127
+ // caller/test writes to disk unchanged. It is consumed in `runExecutePhase`.
1128
+ const tier = deps.tier ?? 'disk';
1129
+ const consoleUrl = deps.consoleUrl;
1130
+ const consoleFetchDep = deps.consoleFetch;
1131
+
922
1132
  // 1. Load every input capture; fail on first miss. Ids collapse to the
923
1133
  // short stored form first (display long `HS-XV4W-HV6H` → `HS-XV4W`) so the
924
1134
  // basename-prefix and frontmatter-id matches inside the loader hit for any
@@ -926,7 +1136,11 @@ export async function executePromoteCapture(vaultRoot, args = {}, deps = {}) {
926
1136
  const captures = [];
927
1137
  for (const rawCaptureId of args.capture_ids) {
928
1138
  const captureId = shortLookupIdOf(rawCaptureId) ?? rawCaptureId;
929
- const capture = findCaptureByIdLocal(vaultRoot, captureId);
1139
+ const capture = await findCaptureByIdViaTier(vaultRoot, captureId, {
1140
+ tier,
1141
+ consoleUrl,
1142
+ consoleFetch: consoleFetchDep,
1143
+ });
930
1144
  if (!capture) {
931
1145
  return {
932
1146
  error: 'CAPTURE_NOT_FOUND',
@@ -1008,9 +1222,10 @@ export async function executePromoteCapture(vaultRoot, args = {}, deps = {}) {
1008
1222
  if (args.target_research === 'NEW') {
1009
1223
  targetResolution = { mode: 'new' };
1010
1224
  } else {
1011
- const research = findResearchByIdLocal(
1225
+ const research = await findResearchByIdViaTier(
1012
1226
  vaultRoot,
1013
1227
  shortLookupIdOf(args.target_research) ?? args.target_research,
1228
+ { tier, consoleUrl, consoleFetch: consoleFetchDep },
1014
1229
  );
1015
1230
  if (!research) {
1016
1231
  return {
@@ -1190,7 +1405,19 @@ export async function executePromoteCaptureToDiagnose(vaultRoot, args = {}, deps
1190
1405
  const severityRaw = typeof args.severity === 'string' ? args.severity.trim().toLowerCase() : 'warning';
1191
1406
  const severity = VALID_DIAGNOSE_SEVERITIES.has(severityRaw) ? severityRaw : 'warning';
1192
1407
 
1193
- const capture = findCaptureByIdLocal(vaultRoot, captureId);
1408
+ // Same once-per-call resolution rule as executePromoteCapture: `tier` and
1409
+ // `writeMode` come in on `deps`, already resolved, and default to
1410
+ // `disk`/`local` so every pre-existing caller is byte-identical.
1411
+ const tier = deps?.tier ?? 'disk';
1412
+ const writeMode = resolveWriteModeFromDeps(deps);
1413
+ const cloudDeps = { consoleFetch: deps?.consoleFetch, consoleUrl: deps?.consoleUrl };
1414
+ const cloudCompleted = [];
1415
+
1416
+ const capture = await findCaptureByIdViaTier(vaultRoot, captureId, {
1417
+ tier,
1418
+ consoleUrl: deps?.consoleUrl,
1419
+ consoleFetch: deps?.consoleFetch,
1420
+ });
1194
1421
  if (!capture) {
1195
1422
  return { error: 'CAPTURE_NOT_FOUND', message: `capture ${captureId} not found under specs/captures/` };
1196
1423
  }
@@ -1207,23 +1434,73 @@ export async function executePromoteCaptureToDiagnose(vaultRoot, args = {}, deps
1207
1434
 
1208
1435
  // A capture→diagnose promotion has no documented family-inheritance hop
1209
1436
  // (unlike capture→research) — mints a fresh family, same as any other
1210
- // parentless spec.
1211
- const familyKey = mintFamilyKey(vaultRoot, { randomUnit: deps?.randomUnit });
1437
+ // parentless spec. In CLOUD mode nothing is minted here at all: the console
1438
+ // mints the file key, the family and the filename (AC3).
1439
+ const familyKey =
1440
+ writeMode === 'cloud' ? null : mintFamilyKey(vaultRoot, { randomUnit: deps?.randomUnit });
1212
1441
  // Per-file FILE KEY (owner ruling 2026-07-10) — filename + `file_id:` SSOT,
1213
1442
  // and (V3) the anchor for `id:` itself.
1214
- const fileKey = mintFileKey(vaultRoot, { randomUnit: deps?.randomUnit });
1215
- const diagnoseId = `HS-${fileKey}`;
1443
+ // Disk occupancy ([[HS-FJWL]]): guarded by the same `writeMode === 'cloud'`
1444
+ // ternary as the family above — in cloud mode nothing is minted here at all.
1445
+ const fileKey =
1446
+ writeMode === 'cloud'
1447
+ ? null
1448
+ : mintFileKey(collectOccupiedKeysEitherKind(vaultRoot), { randomUnit: deps?.randomUnit });
1216
1449
  const slug = deriveSlug(name);
1217
- const diagnoseBasename = `${diagnoseId}-${familyKey}-DIAG-${slug}`;
1218
- const diagnoseRelPath = `specs/diagnoses/${diagnoseBasename}.md`;
1219
- const diagnoseAbsPath = join(vaultRoot, 'specs', 'diagnoses', `${diagnoseBasename}.md`);
1450
+ let diagnoseId = fileKey ? `HS-${fileKey}` : null;
1451
+ let diagnoseBasename = fileKey ? `${diagnoseId}-${familyKey}-DIAG-${slug}` : null;
1452
+ let diagnoseRelPath = fileKey ? `specs/diagnoses/${diagnoseBasename}.md` : null;
1453
+ const diagnoseAbsPath = fileKey
1454
+ ? join(vaultRoot, 'specs', 'diagnoses', `${diagnoseBasename}.md`)
1455
+ : null;
1456
+
1457
+ const diagnoseContent = buildDiagnoseContent(
1458
+ diagnoseId ?? CLOUD_PENDING_ID,
1459
+ fileKey ?? CLOUD_PENDING_KEY,
1460
+ familyKey ?? CLOUD_PENDING_KEY,
1461
+ name,
1462
+ severity,
1463
+ capture.basename,
1464
+ coreIdea,
1465
+ now,
1466
+ );
1220
1467
 
1221
1468
  const writes = [];
1222
- writes.push({
1223
- kind: 'create',
1224
- absPath: diagnoseAbsPath,
1225
- content: buildDiagnoseContent(diagnoseId, fileKey, familyKey, name, severity, capture.basename, coreIdea, now),
1226
- });
1469
+ if (writeMode === 'cloud') {
1470
+ // CREATE FIRST — the capture's `promoted_to:` links to the basename this
1471
+ // POST returns, so it cannot be built until the POST has answered.
1472
+ const { frontmatter, body } = splitRawSpecForConsole(diagnoseContent);
1473
+ const cleaned = stripServerMintedFields(frontmatter);
1474
+ delete cleaned.family; // never inherited on this hop; let the server mint it
1475
+ let created;
1476
+ try {
1477
+ created = await createSpecViaConsole({
1478
+ ...cloudDeps,
1479
+ type: 'diagnose',
1480
+ name,
1481
+ frontmatter: cleaned,
1482
+ body,
1483
+ actingAgent: deps?.actingAgent ?? null,
1484
+ });
1485
+ } catch (err) {
1486
+ return cloudWriteFailureResult(err, {
1487
+ step: { kind: 'create', type: 'diagnose', name },
1488
+ completed: cloudCompleted,
1489
+ });
1490
+ }
1491
+ diagnoseId = created.specId;
1492
+ diagnoseBasename = created.basename;
1493
+ diagnoseRelPath = created.relPath;
1494
+ cloudCompleted.push({
1495
+ kind: 'create',
1496
+ type: 'diagnose',
1497
+ spec_id: created.specId,
1498
+ basename: created.basename,
1499
+ rel_path: created.relPath,
1500
+ });
1501
+ } else {
1502
+ writes.push({ kind: 'create', absPath: diagnoseAbsPath, content: diagnoseContent });
1503
+ }
1227
1504
 
1228
1505
  const captureUpdate = buildUpdatedCaptureContentForDiagnose(capture.raw, {
1229
1506
  diagnoseBasename,
@@ -1232,16 +1509,50 @@ export async function executePromoteCaptureToDiagnose(vaultRoot, args = {}, deps
1232
1509
  author: 'promote-capture (agent)',
1233
1510
  });
1234
1511
  if (captureUpdate.changed) {
1235
- writes.push({ kind: 'update', absPath: capture.absPath, content: captureUpdate.content });
1512
+ if (writeMode === 'cloud') {
1513
+ // …LINK SECOND.
1514
+ const { frontmatter, body } = splitRawSpecForConsole(captureUpdate.content);
1515
+ try {
1516
+ const res = await updateSpecViaConsole({
1517
+ ...cloudDeps,
1518
+ specId: getScalarField(getFrontmatterBlock(capture.raw)?.block ?? '', 'id') ?? capture.basename,
1519
+ frontmatter,
1520
+ body,
1521
+ });
1522
+ cloudCompleted.push({
1523
+ kind: 'update',
1524
+ type: 'capture',
1525
+ spec_id: res.specId,
1526
+ basename: res.basename ?? capture.basename,
1527
+ rel_path: res.relPath,
1528
+ });
1529
+ } catch (err) {
1530
+ return cloudWriteFailureResult(err, {
1531
+ step: { kind: 'update', type: 'capture', basename: capture.basename },
1532
+ completed: cloudCompleted,
1533
+ });
1534
+ }
1535
+ } else {
1536
+ writes.push({ kind: 'update', absPath: capture.absPath, content: captureUpdate.content });
1537
+ }
1236
1538
  }
1237
1539
 
1238
- const commitResult = commitWrites(writes);
1239
- if (!commitResult.ok) {
1240
- return {
1241
- error: 'WRITE_FAILED',
1242
- message: commitResult.error?.message ?? 'unknown write failure',
1243
- rollback_error: commitResult.error?.rollbackError,
1244
- };
1540
+ if (writeMode !== 'cloud') {
1541
+ const commitResult = commitWrites(writes);
1542
+ if (!commitResult.ok) {
1543
+ return {
1544
+ error: 'WRITE_FAILED',
1545
+ message: commitResult.error?.message ?? 'unknown write failure',
1546
+ rollback_error: commitResult.error?.rollbackError,
1547
+ };
1548
+ }
1549
+ }
1550
+
1551
+ // By this point every path (local mint or cloud create-first) has replaced
1552
+ // the transitional fileKey-pending null with a real id — narrow it here so
1553
+ // callers (e.g. `promote-diagnose.ts`) see `string`, not `string | null`.
1554
+ if (!diagnoseId || !diagnoseBasename || !diagnoseRelPath) {
1555
+ throw new Error('promoteCaptureToDiagnose: missing diagnose id after promotion (unexpected)');
1245
1556
  }
1246
1557
 
1247
1558
  return {
@@ -1257,7 +1568,11 @@ export async function executePromoteCaptureToDiagnose(vaultRoot, args = {}, deps
1257
1568
  informs_diagnose_added: captureUpdate.changed,
1258
1569
  promoted_to_set: captureUpdate.setPromotedTo,
1259
1570
  },
1260
- writes: writes.map((w) => ({ kind: w.kind, path: w.absPath })),
1571
+ ...(writeMode === 'cloud' ? { write_mode: 'cloud' } : {}),
1572
+ writes:
1573
+ writeMode === 'cloud'
1574
+ ? cloudCompleted.map((w) => ({ kind: w.kind, path: w.rel_path ?? null, spec_id: w.spec_id }))
1575
+ : writes.map((w) => ({ kind: w.kind, path: w.absPath })),
1261
1576
  human_review_required: false,
1262
1577
  };
1263
1578
  }
@@ -1266,6 +1581,8 @@ export const __testables = {
1266
1581
  validateInputShape,
1267
1582
  findCaptureByIdLocal,
1268
1583
  findResearchByIdLocal,
1584
+ findCaptureByIdViaTier,
1585
+ findResearchByIdViaTier,
1269
1586
  findResearchByBasename,
1270
1587
  collectOccupiedResearchIds,
1271
1588
  collectOccupiedDiagnoseIds,