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
@@ -114,6 +114,26 @@ export function findReviewDocForSpec(vaultRoot, parentSpecId) {
114
114
  */
115
115
  export const FILE_KEY_RE = /^(?=[0-9]*[A-Z])[A-Z0-9]{4,}$/;
116
116
 
117
+ /**
118
+ * `hs next CJV2` → `HS-CJV2` (owner ruling 2026-09-01): typing the bare file
119
+ * key is the natural gesture — the `HS-` prefix carries no information the
120
+ * CLI needs, so requiring it is friction. Accepts the long display form too
121
+ * (`CJV2-90JL`, `CJV2-90JL-EPIC`) by normalizing on the FIRST segment.
122
+ *
123
+ * Returns the `HS-`-prefixed uppercase id, or `null` when the token is not a
124
+ * spec-id shape at all (so the caller can keep its own "unexpected argument"
125
+ * error). A token that already starts with `HS-` comes back uppercased and
126
+ * otherwise untouched. Mirror: spex-console/src/lib/vault-id.ts
127
+ * `normalizeBareSpecIdToken`.
128
+ */
129
+ export function normalizeBareSpecIdToken(input) {
130
+ const token = String(input ?? '').trim().toUpperCase();
131
+ if (!token) return null;
132
+ if (token.startsWith('HS-')) return token;
133
+ const head = token.split('-')[0];
134
+ return FILE_KEY_RE.test(head) ? `HS-${token}` : null;
135
+ }
136
+
117
137
  /**
118
138
  * File key carried by a spec id/basename in EITHER ordering, or null for
119
139
  * legacy no-file-key names. The file key is the first title-tail token when it
@@ -202,14 +222,36 @@ export function allocateFileKey(occupied, options = {}) {
202
222
  }
203
223
 
204
224
  /**
205
- * One-call mint for filing modules: walk the vault for occupied keys, then
206
- * allocate. `randomUnit` is injectable for tests. Occupancy is the UNION of
207
- * file keys AND family keys: era-3 ids are bare file keys, so a file key that
208
- * equals any family key makes a bare `HS-XXXX` citation ambiguous between one
209
- * file and a whole family ([[HS-SJOT-AEP1-DIAG]]).
225
+ * Mint a FILE KEY against an ALREADY-RESOLVED occupancy set. `randomUnit` is
226
+ * injectable for tests. `occupied` must be the UNION of file keys AND family
227
+ * keys: era-3 ids are bare file keys, so a file key that equals any family key
228
+ * makes a bare `HS-XXXX` citation ambiguous between one file and a whole
229
+ * family ([[HS-SJOT-AEP1-DIAG]]).
230
+ *
231
+ * TAKES THE OCCUPANCY, NOT A VAULT ROOT ([[HS-FJWL]]). This used to be
232
+ * `mintFileKey(vaultRoot)` and resolved occupancy itself, by walking
233
+ * `specs/**` — correct on the disk tier, blind on the cloud tier, where a
234
+ * filed spec has a Neon row and no local `.md` and the stale local clone still
235
+ * answers with a large, plausible-looking set. File keys are the unique
236
+ * per-file identity under Naming V3, so a blind scan there re-issues a live
237
+ * id. Callers now resolve occupancy for THEIR tier —
238
+ * `collectOccupiedKeysEitherKindViaTier` (occupied-keys.mjs) on a tier-aware
239
+ * path, `collectOccupiedKeysEitherKind` below on a path that is disk-only by
240
+ * construction — and hand the set in.
241
+ *
242
+ * The `Set` check is the migration's fail-loud: a call site left passing a
243
+ * vaultRoot string would otherwise mint against `collectOccupiedKeysEitherKind`
244
+ * of nothing, i.e. against an empty set, i.e. duplicates. Refuse instead.
210
245
  */
211
- export function mintFileKey(vaultRoot, options = {}) {
212
- return allocateFileKey(collectOccupiedKeysEitherKind(vaultRoot), options);
246
+ export function mintFileKey(occupied, options = {}) {
247
+ if (!(occupied instanceof Set)) {
248
+ throw new TypeError(
249
+ 'mintFileKey(occupied, options) takes a resolved occupancy Set, not a vaultRoot ' +
250
+ '([[HS-FJWL]]). Use collectOccupiedKeysEitherKindViaTier(vaultRoot, { tier, consoleUrl }) ' +
251
+ 'on a tier-aware path, or collectOccupiedKeysEitherKind(vaultRoot) on a disk-only one.',
252
+ );
253
+ }
254
+ return allocateFileKey(occupied, options);
213
255
  }
214
256
 
215
257
  /**
@@ -267,6 +309,20 @@ export function collectOccupiedKeysEitherKind(vaultRoot) {
267
309
  * machinery as {@link allocateFileKey} (4 chars, at least one letter),
268
310
  * checked against {@link collectOccupiedFamilyKeysFromVault} so a fresh mint
269
311
  * doesn't accidentally collide with an existing unrelated family.
312
+ *
313
+ * DELIBERATELY STILL DISK-ONLY — do not "fix" this into a network call
314
+ * ([[HS-FJWL]]). {@link mintFileKey} was made tier-aware because a file key is
315
+ * the unique per-file identity: re-issuing one is a duplicate id. A FAMILY key
316
+ * is the opposite — any number of specs may legally share one (V3 shared-
317
+ * surname model, see {@link collectOccupiedFamilyKeysFromVault}), so this
318
+ * occupancy scan is a "pick something fresh" heuristic, NOT a uniqueness gate.
319
+ * Missing a cloud-only family key costs at most a family name reused by an
320
+ * unrelated spec, which the model already permits; paying a paged console walk
321
+ * per family mint would buy nothing. The cross-namespace half
322
+ * ([[HS-SJOT-AEP1-DIAG]] — a family key must not equal a FILE key) is carried
323
+ * by the union below on the disk tier and, in cloud reliance, by the console's
324
+ * own `createSpec` mint, which resolves occupancy through `loadAll()` and is
325
+ * tier-correct by construction.
270
326
  */
271
327
  export function mintFamilyKey(vaultRoot, options = {}) {
272
328
  return allocateFileKey(collectOccupiedKeysEitherKind(vaultRoot), options);
@@ -553,21 +609,41 @@ export function isBareFamilyToken(input) {
553
609
  }
554
610
 
555
611
  /**
556
- * Guard for mutating MCP/CLI ops: reject a `spec_id` that is a bare family token
557
- * (ambiguous — resolves to a family, not one spec). Returns an error object
558
- * `{ error, message }` to surface to the caller, or `null` when the id is
559
- * unambiguous enough to act on. Read-only resolvers do NOT call this — they fan
560
- * a family token out to a set or just miss.
612
+ * Guard for mutating MCP/CLI ops: reject a `spec_id` that is a bare `HS-XXXX`
613
+ * token the caller has not proven names exactly one spec. Returns an error
614
+ * object `{ error, message }` to surface to the caller, or `null` when the id
615
+ * is unambiguous enough to act on. Read-only resolvers do NOT call this — they
616
+ * fan a family token out to a set or just miss.
617
+ *
618
+ * WORDING ([[HS-05ZW-XHR4-DIAG-the-capture-dropped-it-warning-blamed]] follow-on):
619
+ * {@link isBareFamilyToken} is SHAPE-ONLY — `HS-05ZW` is exactly as likely to be
620
+ * an era-3 FILE key (the stored `id:` of every newly minted spec) as a family
621
+ * key, and nothing here can tell them apart without a lookup. So the default
622
+ * message states only what was actually established: the token could not be
623
+ * resolved to one spec from this machine, and it may be either. A caller that
624
+ * HAS verified the family reading (e.g. `classifyBareSpecId` found the token
625
+ * among the vault's occupied family keys) passes `confirmedFamily: true` to get
626
+ * the stronger, now-earned wording. The actionable hint is identical either way.
627
+ *
628
+ * @param {string|null|undefined} input
629
+ * @param {{ confirmedFamily?: boolean, detail?: string|null }} [opts]
561
630
  */
562
- export function requireResolvableSpecId(input) {
631
+ export function requireResolvableSpecId(input, opts = {}) {
563
632
  if (isBareFamilyToken(input)) {
564
633
  const base = normalizeIdInput(input);
634
+ const hint =
635
+ `Pass a full basename that includes the type label ` +
636
+ `(e.g. ${base}-TASK-<slug> or HS-TASK-${base.replace(/^HS-/, '')}-<slug>; ` +
637
+ `the era-3 form is ${base}-<FAMILY>-<TYPE>-<slug>).`;
638
+ const claim = opts.confirmedFamily
639
+ ? `"${base}" is a bare family key, which names a whole shared-suffix family ` +
640
+ `rather than one spec.`
641
+ : `"${base}" is a bare token that could not be resolved to exactly one spec ` +
642
+ `from this machine — it may be the file key of a spec with no local copy, ` +
643
+ `or a family key naming a whole shared-suffix family.`;
565
644
  return {
566
645
  error: 'AMBIGUOUS_SPEC_ID',
567
- message:
568
- `"${base}" is a bare family key, which names a whole shared-suffix family ` +
569
- `rather than one spec. Pass a full basename that includes the type label ` +
570
- `(e.g. ${base}-TASK-<slug> or HS-TASK-${base.replace(/^HS-/, '')}-<slug>).`,
646
+ message: opts.detail ? `${claim} ${opts.detail} ${hint}` : `${claim} ${hint}`,
571
647
  };
572
648
  }
573
649
  return null;
@@ -42,8 +42,27 @@ import {
42
42
  mkdirSync,
43
43
  unlinkSync,
44
44
  } from 'node:fs';
45
- import { join } from 'node:path';
46
- import { deriveSlug } from './promotion-helpers.mjs';
45
+ import { join, isAbsolute, resolve, sep } from 'node:path';
46
+ import {
47
+ deriveSlug,
48
+ fetchSpecRecordViaConsole,
49
+ resolveWriteModeFromDeps,
50
+ splitRawSpecForConsole,
51
+ cloudWriteFailureResult,
52
+ } from './promotion-helpers.mjs';
53
+ // Cloud-mode vault WRITES + the cloud-tier read selector
54
+ // ([[HS-BYU4-CWPX-TASK-promotion-writes-go-through-the-console]], HS-KR16).
55
+ // Split has the same create-first, link-second shape as the other promotion
56
+ // flows: each child's "Split-from" callout names its SIBLINGS, and the
57
+ // original's `split_into:` names every child — basenames the console only
58
+ // mints when the POST lands.
59
+ import {
60
+ createSpecViaConsole,
61
+ updateSpecViaConsole,
62
+ stripServerMintedFields,
63
+ } from './cloud-vault-writes.mjs';
64
+ import { readViaSelector } from './read-path.mjs';
65
+
47
66
  import { yamlScalar } from './yaml-scalar.mjs';
48
67
  import {
49
68
  allocateFileKey,
@@ -60,6 +79,14 @@ import {
60
79
  // caller would only learn that reactively when heyspex_promote_research
61
80
  // later refuses a child with RESEARCH_STILL_STUB.
62
81
  import { findUnresolvedResearchPlaceholders } from './promote-research.mjs';
82
+ import { assertPathUnderRoot } from '../../scripts/lib/shared-path-helpers.mjs';
83
+
84
+ // Placeholders the CLOUD path feeds `buildChildResearchContent` in place of a
85
+ // locally-minted id / file key / family key; `stripServerMintedFields` removes
86
+ // them from the POST body (plus an explicit `delete` of an un-inheritable
87
+ // `family:`), so the console never sees them.
88
+ const CLOUD_PENDING_ID = 'HS-PENDING';
89
+ const CLOUD_PENDING_KEY = 'PENDING';
63
90
 
64
91
  // Id validation/classification is via parseSpecId (dual-read: accepts type-first
65
92
  // HS-RES-<key> / HS-MIL-<key> / HS-CAP-<key> and the family-first orderings, plus
@@ -216,7 +243,7 @@ function appendChangeLogRow(body, dateStr, author, message) {
216
243
  return body.replace(/\s*$/, '') + `\n${row}\n`;
217
244
  }
218
245
 
219
- function commitWrites(writes) {
246
+ function commitWrites(writes, vaultRoot) {
220
247
  const snapshots = new Map();
221
248
  const performed = [];
222
249
  try {
@@ -230,7 +257,10 @@ function commitWrites(writes) {
230
257
  0,
231
258
  Math.max(w.absPath.lastIndexOf('/'), w.absPath.lastIndexOf('\\')),
232
259
  );
233
- if (dir) mkdirSync(dir, { recursive: true });
260
+ if (dir) {
261
+ if (vaultRoot) assertPathUnderRoot(vaultRoot, dir, 'split-research write');
262
+ mkdirSync(dir, { recursive: true });
263
+ }
234
264
  writeFileSync(w.absPath, w.content, 'utf8');
235
265
  performed.push(w);
236
266
  } else if (w.kind === 'update') {
@@ -396,6 +426,46 @@ function findCaptureByIdOrBasename(vaultRoot, ref) {
396
426
  return null;
397
427
  }
398
428
 
429
+ // ── Cloud-tier siblings of the three lookups above (HS-KR16 pattern) ────────
430
+ // `tier` is resolved ONCE per call by `executeSplitResearch` from `deps.tier`
431
+ // and threaded down; absent it is `disk`, so every pre-existing caller reads
432
+ // exactly as before. The neon branch reuses `fetchSpecRecordViaConsole` — the
433
+ // same injected-`consoleFetch` reader promote-capture/promote-research use —
434
+ // so a test drives the whole flow with a recorder and never opens a socket.
435
+ // These exist because a cloud WRITE is pointless if the INPUTS cannot be
436
+ // found: the writes below all derive from the original research doc and the
437
+ // captures it names.
438
+
439
+ async function findResearchByIdViaTier(vaultRoot, researchId, { tier, consoleUrl, consoleFetch }) {
440
+ return readViaSelector({
441
+ tier,
442
+ diskRead: () => findResearchByIdLocal(vaultRoot, researchId),
443
+ neonRead: () => fetchSpecRecordViaConsole(consoleFetch, researchId),
444
+ routeLabel: 'GET /api/specs/[id]',
445
+ consoleUrl,
446
+ });
447
+ }
448
+
449
+ async function findMilestoneViaTier(vaultRoot, ref, { tier, consoleUrl, consoleFetch }) {
450
+ return readViaSelector({
451
+ tier,
452
+ diskRead: () => findMilestoneByIdOrBasename(vaultRoot, ref),
453
+ neonRead: () => fetchSpecRecordViaConsole(consoleFetch, ref),
454
+ routeLabel: 'GET /api/specs/[id]',
455
+ consoleUrl,
456
+ });
457
+ }
458
+
459
+ async function findCaptureViaTier(vaultRoot, ref, { tier, consoleUrl, consoleFetch }) {
460
+ return readViaSelector({
461
+ tier,
462
+ diskRead: () => findCaptureByIdOrBasename(vaultRoot, ref),
463
+ neonRead: () => fetchSpecRecordViaConsole(consoleFetch, ref),
464
+ routeLabel: 'GET /api/specs/[id]',
465
+ consoleUrl,
466
+ });
467
+ }
468
+
399
469
  function collectOccupiedResearchIds(vaultRoot) {
400
470
  const occupied = new Set();
401
471
  const dir = join(vaultRoot, 'specs', 'research');
@@ -601,7 +671,20 @@ export async function executeSplitResearch(vaultRoot, args = {}, deps = {}) {
601
671
  const validationError = validateInputShape(args);
602
672
  if (validationError) return validationError;
603
673
 
604
- const research = findResearchByIdLocal(vaultRoot, args.research_id);
674
+ // Tier + write mode are resolved ONCE, here, by the caller and threaded down
675
+ // — never re-resolved inside a helper. Both default to the disk behaviour so
676
+ // every pre-existing caller/test is byte-identical
677
+ // ([[HS-BYU4-CWPX-TASK-promotion-writes-go-through-the-console]]).
678
+ const tierDeps = {
679
+ tier: deps?.tier ?? 'disk',
680
+ consoleUrl: deps?.consoleUrl,
681
+ consoleFetch: deps?.consoleFetch,
682
+ };
683
+ const writeMode = resolveWriteModeFromDeps(deps);
684
+ const cloudDeps = { consoleFetch: deps?.consoleFetch, consoleUrl: deps?.consoleUrl };
685
+ const cloudCompleted = [];
686
+
687
+ const research = await findResearchByIdViaTier(vaultRoot, args.research_id, tierDeps);
605
688
  if (!research) {
606
689
  return {
607
690
  error: 'RESEARCH_NOT_FOUND',
@@ -650,9 +733,16 @@ export async function executeSplitResearch(vaultRoot, args = {}, deps = {}) {
650
733
  // Resolve all splits up-front, before any writes.
651
734
  // One occupied set across the whole loop (owner ruling 2026-07-10) — children
652
735
  // are not on disk yet, so a fresh vault walk per split could repeat a key.
653
- const occupiedFileKeys = collectOccupiedKeysEitherKind(vaultRoot); // union: file+family keys (HS-SJOT)
736
+ //
737
+ // In CLOUD mode nothing is minted here at all: `collectOccupiedKeysEitherKind`
738
+ // is a vault SCAN, and in a file-less vault it returns an EMPTY set — a key
739
+ // drawn against it can collide with one the cloud vault already holds. The
740
+ // console mints every child's file key, family and filename.
741
+ const occupiedFileKeys =
742
+ writeMode === 'cloud' ? new Set() : collectOccupiedKeysEitherKind(vaultRoot); // union: file+family keys (HS-SJOT)
654
743
  const childRecords = [];
655
744
  const captureChildMap = new Map();
745
+ const captureRecords = new Map(); // basename -> record (raw reused by the write phase)
656
746
 
657
747
  for (let i = 0; i < args.splits.length; i++) {
658
748
  const split = args.splits[i];
@@ -661,7 +751,7 @@ export async function executeSplitResearch(vaultRoot, args = {}, deps = {}) {
661
751
  if (split.target_milestone === 'NEW') {
662
752
  targetMilestone = { absent: true, newName: split.new_milestone_name.trim() };
663
753
  } else {
664
- const ms = findMilestoneByIdOrBasename(vaultRoot, split.target_milestone);
754
+ const ms = await findMilestoneViaTier(vaultRoot, split.target_milestone, tierDeps);
665
755
  if (!ms) {
666
756
  return {
667
757
  error: 'MILESTONE_NOT_FOUND',
@@ -673,7 +763,8 @@ export async function executeSplitResearch(vaultRoot, args = {}, deps = {}) {
673
763
 
674
764
  const childCaptureBasenames = [];
675
765
  for (const captureRef of split.sources_captures) {
676
- const cap = findCaptureByIdOrBasename(vaultRoot, captureRef);
766
+ const cap = await findCaptureViaTier(vaultRoot, captureRef, tierDeps);
767
+ if (cap) captureRecords.set(cap.basename, cap);
677
768
  if (!cap) {
678
769
  return {
679
770
  error: 'CAPTURE_NOT_FOUND',
@@ -695,15 +786,18 @@ export async function executeSplitResearch(vaultRoot, args = {}, deps = {}) {
695
786
  // original itself has no derivable family (should not occur for a real
696
787
  // research doc).
697
788
  const childFamilyKey =
698
- originalFamilyKey ?? mintFamilyKey(vaultRoot, { randomUnit: deps?.randomUnit });
789
+ writeMode === 'cloud'
790
+ ? (originalFamilyKey ?? null)
791
+ : (originalFamilyKey ?? mintFamilyKey(vaultRoot, { randomUnit: deps?.randomUnit }));
699
792
  // Per-file FILE KEY (owner ruling 2026-07-10) — filename + `file_id:` SSOT,
700
793
  // and (V3) the anchor for `id:` itself
701
794
  // ([[HS-ZU1Q-TASK-PNAS-flip-new-spec-minting-to-the-file-key]]).
702
- const childFileKey = allocateFileKey(occupiedFileKeys, { randomUnit: deps?.randomUnit });
703
- occupiedFileKeys.add(childFileKey);
704
- const childId = `HS-${childFileKey}`;
795
+ const childFileKey =
796
+ writeMode === 'cloud' ? null : allocateFileKey(occupiedFileKeys, { randomUnit: deps?.randomUnit });
797
+ if (childFileKey) occupiedFileKeys.add(childFileKey);
798
+ const childId = childFileKey ? `HS-${childFileKey}` : null;
705
799
  const slug = deriveSlug(split.name);
706
- const basename = `${childId}-${childFamilyKey}-RES-${slug}`;
800
+ const basename = childFileKey ? `${childId}-${childFamilyKey}-RES-${slug}` : null;
707
801
 
708
802
  childRecords.push({
709
803
  id: childId,
@@ -716,9 +810,13 @@ export async function executeSplitResearch(vaultRoot, args = {}, deps = {}) {
716
810
  bodySlice: split.body_slice,
717
811
  });
718
812
 
813
+ // Hold the child RECORD, not its basename: in cloud mode the basename is
814
+ // still null here and only lands when that child's POST answers, so the
815
+ // capture→children map is read lazily at write time in both modes.
816
+ const childRecord = childRecords[childRecords.length - 1];
719
817
  for (const capBasename of childCaptureBasenames) {
720
818
  if (!captureChildMap.has(capBasename)) captureChildMap.set(capBasename, []);
721
- captureChildMap.get(capBasename).push(basename);
819
+ captureChildMap.get(capBasename).push(childRecord);
722
820
  }
723
821
  }
724
822
 
@@ -727,17 +825,16 @@ export async function executeSplitResearch(vaultRoot, args = {}, deps = {}) {
727
825
  const today = now.slice(0, 10);
728
826
  const author = 'split-research (agent)';
729
827
  const writes = [];
730
- const childBasenames = childRecords.map((c) => c.basename);
731
-
732
- for (const child of childRecords) {
733
- const content = buildChildResearchContent({
734
- id: child.id,
735
- fileKey: child.fileKey,
736
- familyKey: child.familyKey,
737
- basename: child.basename,
828
+
829
+ const buildChildContent = (child, siblingBasenames) =>
830
+ buildChildResearchContent({
831
+ id: child.id ?? CLOUD_PENDING_ID,
832
+ fileKey: child.fileKey ?? CLOUD_PENDING_KEY,
833
+ familyKey: child.familyKey ?? CLOUD_PENDING_KEY,
834
+ basename: child.basename ?? '',
738
835
  name: child.name,
739
836
  originalBasename: research.basename,
740
- siblingBasenames: childBasenames,
837
+ siblingBasenames,
741
838
  targetMilestone: child.targetMilestone,
742
839
  capturesBasenames: child.capturesBasenames,
743
840
  bodySlice: child.bodySlice,
@@ -745,42 +842,186 @@ export async function executeSplitResearch(vaultRoot, args = {}, deps = {}) {
745
842
  today,
746
843
  author,
747
844
  });
748
- child.unresolvedPlaceholderCount = findUnresolvedResearchPlaceholders(content);
749
- writes.push({
750
- kind: 'create',
751
- absPath: join(vaultRoot, 'specs', 'research', `${child.basename}.md`),
752
- content,
845
+
846
+ if (writeMode === 'cloud') {
847
+ // ── CREATE FIRST ────────────────────────────────────────────────────────
848
+ // Each child is POSTed with an EMPTY sibling list (a sibling's basename is
849
+ // minted by the console, so it cannot be known before that sibling's own
850
+ // POST answers), then re-PUT once every basename is in hand. The sibling
851
+ // cross-links live only in the "Split-from:" body callout, so the follow-up
852
+ // PUT replaces the BODY and keeps the frontmatter the console stored.
853
+ for (const child of childRecords) {
854
+ const content = buildChildContent(child, []);
855
+ child.unresolvedPlaceholderCount = findUnresolvedResearchPlaceholders(content);
856
+ const { frontmatter, body } = splitRawSpecForConsole(content);
857
+ const cleaned = stripServerMintedFields(frontmatter);
858
+ if (!child.familyKey) delete cleaned.family;
859
+ let created;
860
+ try {
861
+ created = await createSpecViaConsole({
862
+ ...cloudDeps,
863
+ type: 'research',
864
+ name: child.name,
865
+ frontmatter: cleaned,
866
+ body,
867
+ actingAgent: deps?.actingAgent ?? null,
868
+ });
869
+ } catch (err) {
870
+ return cloudWriteFailureResult(err, {
871
+ step: { kind: 'create', type: 'research', name: child.name },
872
+ completed: cloudCompleted,
873
+ });
874
+ }
875
+ child.id = created.specId;
876
+ child.basename = created.basename;
877
+ child.relPath = created.relPath;
878
+ child.cloudFrontmatter = created.spec?.frontmatter ?? cleaned;
879
+ cloudCompleted.push({
880
+ kind: 'create',
881
+ type: 'research',
882
+ spec_id: created.specId,
883
+ basename: created.basename,
884
+ rel_path: created.relPath,
885
+ });
886
+ }
887
+
888
+ const cloudChildBasenames = childRecords.map((c) => c.basename);
889
+
890
+ // ── …LINK SECOND: sibling callouts, then the original, then the captures.
891
+ if (childRecords.length > 1) {
892
+ for (const child of childRecords) {
893
+ const rebuilt = buildChildContent(child, cloudChildBasenames);
894
+ const { body } = splitRawSpecForConsole(rebuilt);
895
+ try {
896
+ const res = await updateSpecViaConsole({
897
+ ...cloudDeps,
898
+ specId: child.id,
899
+ frontmatter: child.cloudFrontmatter,
900
+ body,
901
+ });
902
+ cloudCompleted.push({
903
+ kind: 'update',
904
+ type: 'research.siblings',
905
+ spec_id: res.specId,
906
+ basename: res.basename ?? child.basename,
907
+ rel_path: res.relPath,
908
+ });
909
+ } catch (err) {
910
+ return cloudWriteFailureResult(err, {
911
+ step: { kind: 'update', type: 'research.siblings', basename: child.basename },
912
+ completed: cloudCompleted,
913
+ });
914
+ }
915
+ }
916
+ }
917
+
918
+ const originalUpdatedCloud = buildUpdatedOriginalContent(research.raw, {
919
+ childBasenames: cloudChildBasenames,
920
+ now,
921
+ today,
922
+ author,
753
923
  });
754
- }
924
+ {
925
+ const { frontmatter, body } = splitRawSpecForConsole(originalUpdatedCloud);
926
+ try {
927
+ const res = await updateSpecViaConsole({
928
+ ...cloudDeps,
929
+ specId: originalId,
930
+ frontmatter,
931
+ body,
932
+ });
933
+ cloudCompleted.push({
934
+ kind: 'update',
935
+ type: 'research.original',
936
+ spec_id: res.specId,
937
+ basename: res.basename ?? research.basename,
938
+ rel_path: res.relPath,
939
+ });
940
+ } catch (err) {
941
+ return cloudWriteFailureResult(err, {
942
+ step: { kind: 'update', type: 'research.original', basename: research.basename },
943
+ completed: cloudCompleted,
944
+ });
945
+ }
946
+ }
755
947
 
756
- const originalUpdated = buildUpdatedOriginalContent(research.raw, {
757
- childBasenames,
758
- now,
759
- today,
760
- author,
761
- });
762
- writes.push({
763
- kind: 'update',
764
- absPath: research.absPath,
765
- content: originalUpdated,
766
- });
948
+ for (const [capBasename, childList] of captureChildMap.entries()) {
949
+ const capRecord = captureRecords.get(capBasename);
950
+ if (!capRecord) continue;
951
+ const capUpdated = buildUpdatedCaptureContent(
952
+ capRecord.raw,
953
+ childList.map((c) => c.basename),
954
+ now,
955
+ );
956
+ if (capUpdated === capRecord.raw) continue;
957
+ const { frontmatter, body } = splitRawSpecForConsole(capUpdated);
958
+ try {
959
+ const res = await updateSpecViaConsole({
960
+ ...cloudDeps,
961
+ specId: getScalarField(getFrontmatterBlock(capRecord.raw)?.block ?? '', 'id') ?? capBasename,
962
+ frontmatter,
963
+ body,
964
+ });
965
+ cloudCompleted.push({
966
+ kind: 'update',
967
+ type: 'capture',
968
+ spec_id: res.specId,
969
+ basename: res.basename ?? capBasename,
970
+ rel_path: res.relPath,
971
+ });
972
+ } catch (err) {
973
+ return cloudWriteFailureResult(err, {
974
+ step: { kind: 'update', type: 'capture', basename: capBasename },
975
+ completed: cloudCompleted,
976
+ });
977
+ }
978
+ }
979
+ } else {
980
+ const childBasenames = childRecords.map((c) => c.basename);
981
+
982
+ for (const child of childRecords) {
983
+ const content = buildChildContent(child, childBasenames);
984
+ child.unresolvedPlaceholderCount = findUnresolvedResearchPlaceholders(content);
985
+ writes.push({
986
+ kind: 'create',
987
+ absPath: join(vaultRoot, 'specs', 'research', `${child.basename}.md`),
988
+ content,
989
+ });
990
+ }
767
991
 
768
- for (const [capBasename, childList] of captureChildMap.entries()) {
769
- const capAbs = join(vaultRoot, 'specs', 'captures', `${capBasename}.md`);
770
- const capRaw = readFileSync(capAbs, 'utf8');
771
- const capUpdated = buildUpdatedCaptureContent(capRaw, childList, now);
772
- if (capUpdated !== capRaw) {
773
- writes.push({ kind: 'update', absPath: capAbs, content: capUpdated });
992
+ const originalUpdated = buildUpdatedOriginalContent(research.raw, {
993
+ childBasenames,
994
+ now,
995
+ today,
996
+ author,
997
+ });
998
+ writes.push({
999
+ kind: 'update',
1000
+ absPath: research.absPath,
1001
+ content: originalUpdated,
1002
+ });
1003
+
1004
+ for (const [capBasename, childList] of captureChildMap.entries()) {
1005
+ const capAbs = join(vaultRoot, 'specs', 'captures', `${capBasename}.md`);
1006
+ const capRaw = readFileSync(capAbs, 'utf8');
1007
+ const capUpdated = buildUpdatedCaptureContent(
1008
+ capRaw,
1009
+ childList.map((c) => c.basename),
1010
+ now,
1011
+ );
1012
+ if (capUpdated !== capRaw) {
1013
+ writes.push({ kind: 'update', absPath: capAbs, content: capUpdated });
1014
+ }
774
1015
  }
775
- }
776
1016
 
777
- const commitResult = commitWrites(writes);
778
- if (!commitResult.ok) {
779
- return {
780
- error: 'WRITE_FAILED',
781
- message: commitResult.error?.message ?? 'unknown write failure',
782
- rollback_error: commitResult.error?.rollbackError,
783
- };
1017
+ const commitResult = commitWrites(writes, vaultRoot);
1018
+ if (!commitResult.ok) {
1019
+ return {
1020
+ error: 'WRITE_FAILED',
1021
+ message: commitResult.error?.message ?? 'unknown write failure',
1022
+ rollback_error: commitResult.error?.rollbackError,
1023
+ };
1024
+ }
784
1025
  }
785
1026
 
786
1027
  const anyChildStillStub = childRecords.some((c) => c.unresolvedPlaceholderCount > 0);
@@ -790,10 +1031,11 @@ export async function executeSplitResearch(vaultRoot, args = {}, deps = {}) {
790
1031
  archive_basename: research.basename,
791
1032
  archive_path: research.relPath,
792
1033
  archive_status_after: getScalarField(fm.block, 'status') ?? 'open',
1034
+ ...(writeMode === 'cloud' ? { write_mode: 'cloud' } : {}),
793
1035
  children: childRecords.map((c) => ({
794
1036
  id: c.id,
795
1037
  basename: c.basename,
796
- path: join('specs', 'research', `${c.basename}.md`),
1038
+ path: c.relPath ?? join('specs', 'research', `${c.basename}.md`),
797
1039
  target_milestone: c.targetMilestone.absent ? 'NEW' : c.targetMilestone.basename,
798
1040
  sources_captures: c.capturesBasenames,
799
1041
  // Same placeholder count RESEARCH_STILL_STUB would later refuse
@@ -802,7 +1044,10 @@ export async function executeSplitResearch(vaultRoot, args = {}, deps = {}) {
802
1044
  research_still_stub: c.unresolvedPlaceholderCount > 0,
803
1045
  unresolved_placeholder_count: c.unresolvedPlaceholderCount,
804
1046
  })),
805
- writes: writes.map((w) => ({ kind: w.kind, path: w.absPath })),
1047
+ writes:
1048
+ writeMode === 'cloud'
1049
+ ? cloudCompleted.map((w) => ({ kind: w.kind, path: w.rel_path ?? null, spec_id: w.spec_id }))
1050
+ : writes.map((w) => ({ kind: w.kind, path: w.absPath })),
806
1051
  // No fallback warning: every child unconditionally inherits the
807
1052
  // original's family key (V3 shared-surname model — see the block comment
808
1053
  // above inheritedFamilyKey).
@@ -0,0 +1,22 @@
1
+ // JSON-RPC writers for the stdio transport — the only place the MCP server
2
+ // writes to stdout. Extracted from cli.mjs under
3
+ // [[HS-BLNF-6W61-DIAG-cli-mjs-has-grown-into-a-13-665-line]] (no behavior change).
4
+
5
+ export function sendResponse(id, result) {
6
+ const response = { jsonrpc: '2.0', id, result };
7
+ process.stdout.write(JSON.stringify(response) + '\n');
8
+ }
9
+
10
+ export function sendError(id, code, message, data) {
11
+ const response = {
12
+ jsonrpc: '2.0',
13
+ id,
14
+ error: { code, message, ...(data && { data }) },
15
+ };
16
+ process.stdout.write(JSON.stringify(response) + '\n');
17
+ }
18
+
19
+ export function sendNotification(method, params) {
20
+ const notification = { jsonrpc: '2.0', method, params };
21
+ process.stdout.write(JSON.stringify(notification) + '\n');
22
+ }