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
@@ -9,10 +9,17 @@
9
9
  */
10
10
 
11
11
  import { readFileSync, writeFileSync, existsSync, readdirSync, mkdirSync, renameSync } from 'node:fs';
12
- import { join } from 'node:path';
13
- import { mintFileKey, mintFamilyKey, parseSpecId } from './spec-id-lib.mjs';
12
+ import { join, isAbsolute, resolve, sep } from 'node:path';
13
+ import { collectOccupiedKeysEitherKind, mintFileKey, mintFamilyKey, parseSpecId } from './spec-id-lib.mjs';
14
+ import { consoleFetch as realConsoleFetch } from './console-transport.mjs';
15
+ import { getConsoleUrl } from './mcp-config.mjs';
16
+ import { resolveVaultWriteMode } from './read-path.mjs';
17
+ import { cloudWriteFailure, createSpecViaConsole, updateSpecViaConsole } from './cloud-vault-writes.mjs';
14
18
  import { yamlScalar } from './yaml-scalar.mjs';
19
+ import { ownerFrontmatterEntry, resolveSessionOwner } from './session-owner.mjs';
15
20
  import { scoreSufficiency, SUFFICIENCY_THRESHOLD } from '../../dev-factory/lib/sufficiency-score.mjs';
21
+ import { assertPathUnderRoot } from '../../scripts/lib/shared-path-helpers.mjs';
22
+
16
23
 
17
24
  export const CAPTURE_CORE_SOFT_MAX = 200;
18
25
  export const CAPTURE_CORE_HARD_MAX = 500;
@@ -38,6 +45,68 @@ export const CAPTURE_SUMMARY_CHAR_HARD_MAX = 1500;
38
45
  */
39
46
  export const CAPTURE_PIN_FUSE_MS = 7 * 24 * 60 * 60 * 1000;
40
47
 
48
+ /**
49
+ * Staleness limit for the OFFLINE pin file, checked against its own `pinned_at`.
50
+ * [[HS-O4U6-2ZRO-DIAG-the-offline-capture-pin-file-is-months]].
51
+ *
52
+ * On a console-backed vault the pin lives in Neon and `.heyspex/capture-focus.yaml`
53
+ * is only the fallback for when the console cannot be reached — so the file stops
54
+ * being written and quietly rots. This repo's file held a 25 June pin for over two
55
+ * months while three captures were created and promoted against Neon. The failure
56
+ * is NOT the staleness: it is that a stale read and a genuine "nothing is pinned"
57
+ * answer were the same answer, so an offline capture attached to a two-month-old
58
+ * wrong pin and reported success. `loadCaptureFocusOffline` refuses past this age
59
+ * and says so, instead of returning a wrong answer quietly.
60
+ *
61
+ * WHY 7 DAYS, and not shorter or longer:
62
+ * - It is the number this codebase already ruled correct for "this capture pin is
63
+ * abandoned" — `CAPTURE_PIN_FUSE_MS` above, which mirrors the epic-pin silent-fuse
64
+ * convention. One concept, one number; a second, different threshold for the same
65
+ * judgement would be the thing that drifts.
66
+ * - It is ~28× the 6h `RECENT_PROMOTION_LINEAGE_MS` window that bounds "the same
67
+ * working session". A capture is a fast brain-offload artifact, opened and promoted
68
+ * within a session, so a genuinely live pin is hours old, never days — 7 days clears
69
+ * a real pin by a wide margin and cannot false-positive on a weekend or a holiday.
70
+ * - Shorter (say 24h) would start refusing legitimate free-tier pins, where this file
71
+ * IS the system of record and a pin may legitimately sit over a long weekend.
72
+ * Tunable here; deliberately derived from the fuse so the two cannot diverge.
73
+ */
74
+ export const CAPTURE_FOCUS_FILE_STALE_MS = CAPTURE_PIN_FUSE_MS;
75
+
76
+ /**
77
+ * Age-check a loaded pin against its own `pinned_at`.
78
+ *
79
+ * A pin with a missing or unparsable `pinned_at` counts as STALE: we cannot prove
80
+ * it is current, and "cannot prove" must not read as "is fine" — that equivalence
81
+ * is the bug this whole guard exists to close.
82
+ *
83
+ * @returns {{ stale: boolean, ageMs: number|null, pinnedAt: string|null }}
84
+ */
85
+ export function captureFocusStaleness(focus, { now = Date.now(), maxAgeMs = CAPTURE_FOCUS_FILE_STALE_MS } = {}) {
86
+ const pinnedAt = typeof focus?.pinned_at === 'string' ? focus.pinned_at.trim() : '';
87
+ if (!pinnedAt) return { stale: true, ageMs: null, pinnedAt: null };
88
+ const ts = Date.parse(pinnedAt);
89
+ if (!Number.isFinite(ts)) return { stale: true, ageMs: null, pinnedAt };
90
+ const ageMs = now - ts;
91
+ return { stale: ageMs > maxAgeMs, ageMs, pinnedAt };
92
+ }
93
+
94
+ /** One warning per distinct (path, pinned_at) per process — loud, not spam. */
95
+ const warnedStalePins = new Set();
96
+
97
+ function warnStalePin(path, pinnedAt, ageMs) {
98
+ const key = `${path} ${pinnedAt ?? '<none>'}`;
99
+ if (warnedStalePins.has(key)) return;
100
+ warnedStalePins.add(key);
101
+ const age = ageMs == null ? 'unknown age' : `${Math.floor(ageMs / 86400000)}d old`;
102
+ process.stderr.write?.(
103
+ `[heyspex-mcp] STALE capture pin REFUSED: ${path} pinned_at=${pinnedAt ?? '<missing>'} (${age}, ` +
104
+ `limit ${Math.floor(CAPTURE_FOCUS_FILE_STALE_MS / 86400000)}d). The console is the system of record for ` +
105
+ `this pin; the offline file has rotted. Nothing is being treated as on deck — re-pin with /hs-capture. ` +
106
+ `[[HS-O4U6-2ZRO-DIAG-the-offline-capture-pin-file-is-months]]\n`,
107
+ );
108
+ }
109
+
41
110
  /** Frontmatter statuses that mean a capture is no longer on deck. */
42
111
  const RETIRED_CAPTURE_STATUSES = new Set(['closed', 'superseded', 'abandoned', 'done']);
43
112
 
@@ -139,8 +208,17 @@ export function migrateLegacyIdeaFocus(vaultRoot) {
139
208
  if (state.claimed_by) lines.push(`claimed_by: ${JSON.stringify(state.claimed_by)}`);
140
209
  if (state.claim_session) lines.push(`claim_session: ${JSON.stringify(state.claim_session)}`);
141
210
 
142
- mkdirSync(join(vaultRoot, '.heyspex'), { recursive: true });
143
- writeFileSync(next, `${lines.join('\n')}\n`, 'utf8');
211
+ // Best-effort: this migration runs opportunistically (on boot, and at the
212
+ // top of every loadCaptureFocus) and must never abort the caller's real
213
+ // work over a bad vaultRoot — refuse and report rather than throw.
214
+ try {
215
+ const dir = join(vaultRoot, '.heyspex');
216
+ assertPathUnderRoot(vaultRoot, dir, 'capture-focus write');
217
+ mkdirSync(dir, { recursive: true });
218
+ writeFileSync(next, `${lines.join('\n')}\n`, 'utf8');
219
+ } catch (err) {
220
+ return { migrated: false, reason: 'write_failed', error: String(err?.message ?? err) };
221
+ }
144
222
 
145
223
  try {
146
224
  const stamped = `${legacy}.migrated-${new Date().toISOString().replace(/[:.]/g, '-')}`;
@@ -157,7 +235,21 @@ export function migrateLegacyIdeaFocus(vaultRoot) {
157
235
  return { migrated: true, from: legacy, to: next };
158
236
  }
159
237
 
160
- export function loadCaptureFocus(vaultRoot) {
238
+ /**
239
+ * Read the offline pin file as-is.
240
+ *
241
+ * Unchanged contract (`focus | null`) — on the FREE tier this file is still the
242
+ * real system of record, so a pin is never dropped here on age alone. What is new
243
+ * (HS-O4U6-2ZRO): the returned object is ANNOTATED with `stale` / `stale_age_ms`
244
+ * so no caller has to guess whether it is holding a current answer. Callers for
245
+ * whom this file is only the offline fallback should use `loadCaptureFocusOffline`,
246
+ * which refuses a stale pin outright.
247
+ *
248
+ * The annotation is read-only metadata — every writer here (`writeCaptureFocus`,
249
+ * `updateCaptureFocusClaim`, `clearCaptureFocusExpiry`) enumerates the fields it
250
+ * persists, so `stale` can never be written back into the YAML.
251
+ */
252
+ export function loadCaptureFocus(vaultRoot, { now = Date.now() } = {}) {
161
253
  migrateLegacyIdeaFocus(vaultRoot);
162
254
  const p = captureFocusPath(vaultRoot);
163
255
  if (!existsSync(p)) return null;
@@ -165,16 +257,48 @@ export function loadCaptureFocus(vaultRoot) {
165
257
  const focus = parseSimpleFocusYaml(readFileSync(p, 'utf-8'));
166
258
  if (focus.expires) {
167
259
  const expiry = new Date(focus.expires);
168
- if (expiry < new Date()) return null;
260
+ if (expiry < new Date(now)) return null;
169
261
  }
262
+ const { stale, ageMs } = captureFocusStaleness(focus, { now });
263
+ focus.stale = stale;
264
+ focus.stale_age_ms = ageMs;
170
265
  return focus;
171
266
  } catch {
172
267
  return null;
173
268
  }
174
269
  }
175
270
 
271
+ /**
272
+ * Read the pin file as an OFFLINE FALLBACK — the seam used when the console (the
273
+ * system of record on a console-backed vault) could not be reached.
274
+ *
275
+ * This is the read that must not lie. A pin older than `CAPTURE_FOCUS_FILE_STALE_MS`
276
+ * is REFUSED (`focus: null`) and a loud one-time warning goes to stderr, because the
277
+ * alternative — handing back a two-month-old pin — makes an offline capture attach to
278
+ * the wrong capture and report success. The result distinguishes the three outcomes
279
+ * the old `focus | null` could not:
280
+ *
281
+ * { focus: {...}, stale: false } → a fresh pin, safe to use
282
+ * { focus: null, stale: false, reason: 'no_pin' } → genuinely nothing pinned
283
+ * { focus: null, stale: true, reason: 'stale', ageMs } → a pin existed and was refused
284
+ *
285
+ * @returns {{ focus: object|null, stale: boolean, ageMs: number|null, pinnedAt: string|null, reason: string }}
286
+ */
287
+ export function loadCaptureFocusOffline(
288
+ vaultRoot,
289
+ { now = Date.now(), maxAgeMs = CAPTURE_FOCUS_FILE_STALE_MS } = {},
290
+ ) {
291
+ const focus = loadCaptureFocus(vaultRoot, { now });
292
+ if (!focus) return { focus: null, stale: false, ageMs: null, pinnedAt: null, reason: 'no_pin' };
293
+ const { stale, ageMs, pinnedAt } = captureFocusStaleness(focus, { now, maxAgeMs });
294
+ if (!stale) return { focus, stale: false, ageMs, pinnedAt, reason: 'fresh' };
295
+ warnStalePin(captureFocusPath(vaultRoot), pinnedAt, ageMs);
296
+ return { focus: null, stale: true, ageMs, pinnedAt, reason: 'stale' };
297
+ }
298
+
176
299
  export function writeCaptureFocus(vaultRoot, specPath, reason, claim, opts = {}) {
177
300
  const dir = join(vaultRoot, '.heyspex');
301
+ assertPathUnderRoot(vaultRoot, dir, 'capture-focus write');
178
302
  mkdirSync(dir, { recursive: true });
179
303
  // Mirror the pinned capture's frontmatter `kind:` into the pin so the on-deck
180
304
  // pin reflects the real kind (idea | bug-fix | clip | reference | question |
@@ -204,7 +328,6 @@ export function updateCaptureFocusClaim(vaultRoot, claim) {
204
328
  const focus = loadCaptureFocus(vaultRoot);
205
329
  if (!focus?.spec_path) return false;
206
330
  const dir = join(vaultRoot, '.heyspex');
207
- mkdirSync(dir, { recursive: true });
208
331
  const lines = [
209
332
  '# HeySpex capture-on-deck — MCP /hs-capture ([[idea-capture-guardrails]])',
210
333
  `kind: ${focus.kind || 'idea'}`,
@@ -218,7 +341,19 @@ export function updateCaptureFocusClaim(vaultRoot, claim) {
218
341
  if (focus.expires) lines.push(`expires: ${focus.expires}`);
219
342
  if (claim?.claimed_by) lines.push(`claimed_by: ${JSON.stringify(claim.claimed_by)}`);
220
343
  if (claim?.claim_session) lines.push(`claim_session: ${JSON.stringify(claim.claim_session)}`);
221
- writeFileSync(captureFocusPath(vaultRoot), `${lines.join('\n')}\n`, 'utf8');
344
+ // Best-effort: callers fire this as a claim-metadata refresh AFTER their
345
+ // primary work already succeeded, and none of them check the return value —
346
+ // a bad vaultRoot here must not blow up an otherwise-successful tool call.
347
+ try {
348
+ assertPathUnderRoot(vaultRoot, dir, 'capture-focus write');
349
+ mkdirSync(dir, { recursive: true });
350
+ writeFileSync(captureFocusPath(vaultRoot), `${lines.join('\n')}\n`, 'utf8');
351
+ } catch (err) {
352
+ process.stderr.write?.(
353
+ `[heyspex-mcp] updateCaptureFocusClaim refused/failed: ${err?.message ?? err}\n`,
354
+ );
355
+ return false;
356
+ }
222
357
  return true;
223
358
  }
224
359
 
@@ -324,8 +459,18 @@ export const EXPANDED_IDEA_DETAILS_RE = DETAILS_BLOCK_RE;
324
459
  */
325
460
  export function extractAgentExpansionFromBody(body) {
326
461
  const normalized = body.replace(/\r\n/g, '\n');
462
+ // Stop only at a KNOWN sibling section heading (or a top-level `#` / end of
463
+ // body) — not at any `##`. Agent grounding is normally structured prose
464
+ // that opens with its own level-2 heading (e.g. "## Grounding — …"), and
465
+ // the old any-`##` lookahead matched that as the section boundary,
466
+ // silently returning "" for a fully expanded capture. That, in turn,
467
+ // defeated the HS-55YM already-expanded promote bypass (which reads this
468
+ // extractor to decide whether to skip the sufficiency gate) for the normal
469
+ // shape of its own input. Mirrors capture-vault.ts's
470
+ // extractAgentExpansionFromBody byte-for-byte — no drift between the two
471
+ // implementations. [[HS-NHPA-3EFV-DIAG-already-expanded-promote-bypass-is]]
327
472
  const section = normalized.match(
328
- /##[ \t]*Expanded[ \t]+Concept[ \t]*\n([\s\S]*?)(?=\n##\s|\n#\s|$)/i,
473
+ /##[ \t]*Expanded[ \t]+Concept[ \t]*\n([\s\S]*?)(?=\n##[ \t]*(?:Source Material|Notes|Example|User Stories|Done When)[ \t]*\n|\n#[ \t]|$)/i,
329
474
  );
330
475
  if (section) return stripLeadingHtmlComments(section[1]);
331
476
  const m = normalized.match(DETAILS_BLOCK_RE);
@@ -467,6 +612,127 @@ export function extractSourceMaterialFromBody(body) {
467
612
  return fence ? fence[1] : stripLeadingHtmlComments(section[1]);
468
613
  }
469
614
 
615
+ /* ------------------------------------------------------------------------ *
616
+ * Source Material — list form (many sources per capture). Mirrors the
617
+ * console `spex-console/src/lib/capture-vault.ts` implementation byte-for-
618
+ * byte (hand-kept in sync, no shared import — see the "3 mirrored builders"
619
+ * lesson). [[HS-9GGC-U1DO-EPIC-source-list-schema-and-one-capture]]
620
+ * ------------------------------------------------------------------------ */
621
+
622
+ export const SOURCE_MATERIAL_GRAINS = ['tab', 'window', 'column', 'folder'];
623
+
624
+ const SOURCE_LIST_ITEM_RE = /^-\s*url:\s*(.+)$/;
625
+ const SOURCE_LIST_FIELD_RE = /^\s+([a-zA-Z_]+):\s*(.*)$/;
626
+
627
+ function parseBoardRefInline(value) {
628
+ const wrapped = value.match(/^\{\s*([\s\S]*?)\s*\}$/);
629
+ if (!wrapped) return undefined;
630
+ const fields = {};
631
+ for (const part of wrapped[1].split(',')) {
632
+ const kv = part.trim().match(/^([a-zA-Z_]+):\s*(.+)$/);
633
+ if (kv) fields[kv[1]] = kv[2].trim();
634
+ }
635
+ if (!fields.board || !fields.column) return undefined;
636
+ return { board: fields.board, column: fields.column, ...(fields.folder ? { folder: fields.folder } : {}) };
637
+ }
638
+
639
+ function parseSourceMaterialListText(text) {
640
+ const entries = [];
641
+ let current = null;
642
+ const flush = () => {
643
+ if (current?.url && current.grain && current.added_at) entries.push(current);
644
+ current = null;
645
+ };
646
+ for (const line of text.split('\n')) {
647
+ const itemMatch = line.match(SOURCE_LIST_ITEM_RE);
648
+ if (itemMatch) {
649
+ flush();
650
+ current = { url: itemMatch[1].trim() };
651
+ continue;
652
+ }
653
+ if (!current) continue;
654
+ const fieldMatch = line.match(SOURCE_LIST_FIELD_RE);
655
+ if (!fieldMatch) continue;
656
+ const [, key, rawValue] = fieldMatch;
657
+ const value = rawValue.trim();
658
+ switch (key) {
659
+ case 'title':
660
+ current.title = value;
661
+ break;
662
+ case 'favicon':
663
+ current.favicon = value;
664
+ break;
665
+ case 'added_at':
666
+ current.added_at = value;
667
+ break;
668
+ case 'grain':
669
+ if (SOURCE_MATERIAL_GRAINS.includes(value)) current.grain = value;
670
+ break;
671
+ case 'board_ref': {
672
+ const ref = parseBoardRefInline(value);
673
+ if (ref) current.board_ref = ref;
674
+ break;
675
+ }
676
+ case 'tab_id':
677
+ case 'window_id': {
678
+ const n = Number(value);
679
+ if (Number.isFinite(n)) current[key] = n;
680
+ break;
681
+ }
682
+ default:
683
+ break;
684
+ }
685
+ }
686
+ flush();
687
+ return entries;
688
+ }
689
+
690
+ function parseSourceMaterialSection(sectionText) {
691
+ const fence = sectionText.match(SOURCE_FENCE_RE);
692
+ if (fence) return { sources: [], blob: fence[1] };
693
+ const trimmed = sectionText.trim();
694
+ if (!trimmed) return { sources: [], blob: '' };
695
+ if (!/^-\s*url:\s*/m.test(trimmed)) {
696
+ return { sources: [], blob: stripLeadingHtmlComments(sectionText) };
697
+ }
698
+ return { sources: parseSourceMaterialListText(trimmed), blob: '' };
699
+ }
700
+
701
+ /**
702
+ * Structured read of `## Source Material` — parses BOTH the legacy single-blob
703
+ * fence and the list form. Use this (not `extractSourceMaterialFromBody`) for
704
+ * any read-then-rebuild path so a list-form section survives an unrelated
705
+ * body rewrite (e.g. `heyspex_update_capture` touching Notes/Core Idea).
706
+ */
707
+ export function extractSourceMaterialStructured(body) {
708
+ const withoutDetails = stripAgentExpansionFromBody(body);
709
+ const section = withoutDetails.match(
710
+ /##[ \t]*Source[ \t]+Material[ \t]*\n([\s\S]*?)(?=\n##\s|$)/i,
711
+ );
712
+ if (!section) return { sources: [], blob: '' };
713
+ return parseSourceMaterialSection(section[1]);
714
+ }
715
+
716
+ export function formatSourceMaterialEntry(entry) {
717
+ const lines = [`- url: ${entry.url}`];
718
+ if (entry.title) lines.push(` title: ${entry.title}`);
719
+ if (entry.favicon) lines.push(` favicon: ${entry.favicon}`);
720
+ lines.push(` added_at: ${entry.added_at}`);
721
+ lines.push(` grain: ${entry.grain}`);
722
+ if (entry.board_ref) {
723
+ const parts = [`board: ${entry.board_ref.board}`, `column: ${entry.board_ref.column}`];
724
+ if (entry.board_ref.folder) parts.push(`folder: ${entry.board_ref.folder}`);
725
+ lines.push(` board_ref: { ${parts.join(', ')} }`);
726
+ }
727
+ if (typeof entry.tab_id === 'number') lines.push(` tab_id: ${entry.tab_id}`);
728
+ if (typeof entry.window_id === 'number') lines.push(` window_id: ${entry.window_id}`);
729
+ return lines.join('\n');
730
+ }
731
+
732
+ export function buildSourceMaterialList(entries) {
733
+ return entries.map(formatSourceMaterialEntry).join('\n');
734
+ }
735
+
470
736
  /**
471
737
  * Build the capture body in summaries-first order:
472
738
  * **Core Idea → Summary → Notes → Example → User Stories → JTBD → Done When →
@@ -488,6 +754,7 @@ export function buildCaptureBody(
488
754
  userStories = '',
489
755
  doneWhen = '',
490
756
  jtbd = '',
757
+ sources = [],
491
758
  ) {
492
759
  const looksLikeDetails =
493
760
  typeof summaryOrAgentExpansion === 'string' && /<details>/i.test(summaryOrAgentExpansion);
@@ -543,8 +810,11 @@ export function buildCaptureBody(
543
810
  const expandedBlock = buildAgentExpansionBlock(expandedContent);
544
811
  if (expandedBlock) out += expandedBlock.startsWith('\n') ? expandedBlock : `\n${expandedBlock}`;
545
812
 
546
- // Source Material — conditional, LAST.
547
- if (sourceTrim) {
813
+ // Source Material — conditional, LAST. List form wins when `sources` is
814
+ // populated (many-sources-per-capture); otherwise the legacy fenced blob.
815
+ if (Array.isArray(sources) && sources.length > 0) {
816
+ out += `\n## Source Material\n\n${buildSourceMaterialList(sources)}\n`;
817
+ } else if (sourceTrim) {
548
818
  out += `\n## Source Material\n\n\`\`\`source\n${sourceTrim}\n\`\`\`\n`;
549
819
  }
550
820
  return out;
@@ -1083,6 +1353,50 @@ export function executeUpdateCapture(vaultRoot, parseFrontmatter, args = {}, dep
1083
1353
  return { error: 'CAPTURE_FILE_MISSING', message: specPath };
1084
1354
  }
1085
1355
 
1356
+ const content = readFileSync(fullPath, 'utf-8');
1357
+ const { frontmatter, body } = parseFrontmatter(content);
1358
+ const computed = computeCaptureUpdate(frontmatter, body, args);
1359
+ if (computed.error) return computed;
1360
+ const { fm, newBody, core, notes, summaryWarning, hasContent } = computed;
1361
+
1362
+ writeFileSync(fullPath, stringifyCaptureSpec(fm, newBody), 'utf8');
1363
+
1364
+ // Real content just landed → drop the empty-scaffold fuse so this capture is
1365
+ // no longer treated as an abandonable zombie. Runs regardless of whether a
1366
+ // claim refresh follows (a content write without a claim must still defuse).
1367
+ if (hasContent) {
1368
+ clearCaptureFocusExpiry(vaultRoot);
1369
+ }
1370
+
1371
+ if (deps.claim) {
1372
+ updateCaptureFocusClaim(vaultRoot, deps.claim);
1373
+ }
1374
+
1375
+ return buildUpdateCaptureResult({
1376
+ fm,
1377
+ newBody,
1378
+ specPath,
1379
+ core,
1380
+ notes,
1381
+ args,
1382
+ autoCreated,
1383
+ continuationOf,
1384
+ summaryWarning,
1385
+ });
1386
+ }
1387
+
1388
+ /**
1389
+ * The PURE middle of `heyspex_update_capture`: given the capture's current
1390
+ * `{ frontmatter, body }` and the tool args, produce the post-edit
1391
+ * `{ fm, newBody }` pair plus everything the result needs. No I/O at all —
1392
+ * which is what lets the local path `writeFileSync` it and the cloud path hand
1393
+ * the very same pair to `PUT /api/specs/:id` ({@link updateSpecViaConsole})
1394
+ * without two copies of this logic drifting apart.
1395
+ *
1396
+ * Returns a `{ error, message }` result (the shape the tool already returns)
1397
+ * for every rejection.
1398
+ */
1399
+ function computeCaptureUpdate(frontmatter, body, args = {}) {
1086
1400
  // `summary` is the canonical input; `concept` is the deprecated alias (HS-EPIC-OED2).
1087
1401
  const summaryArg = args.summary ?? args.concept;
1088
1402
 
@@ -1099,8 +1413,6 @@ export function executeUpdateCapture(vaultRoot, parseFrontmatter, args = {}, dep
1099
1413
  if (blocked) return blocked;
1100
1414
  }
1101
1415
 
1102
- const content = readFileSync(fullPath, 'utf-8');
1103
- const { frontmatter, body } = parseFrontmatter(content);
1104
1416
  let core = extractCaptureCoreFromBody(body);
1105
1417
  let notes = extractCaptureNotesFromBody(body);
1106
1418
  let summary = extractCaptureSummaryFromBody(body);
@@ -1190,8 +1502,22 @@ export function executeUpdateCapture(vaultRoot, parseFrontmatter, args = {}, dep
1190
1502
 
1191
1503
  // Preserve any existing `## Source Material` across the rebuild (MCP args don't
1192
1504
  // carry source material, but it must survive an update — it used to be dropped).
1193
- const sourceMaterial = extractSourceMaterialFromBody(body);
1194
- const newBody = buildCaptureBody(core, notes, summary, agentExpansion, sourceMaterial, example, userStories, doneWhen, jtbd);
1505
+ // Structured read so a LIST-form section (many sources per capture,
1506
+ // [[HS-9GGC-U1DO-EPIC-source-list-schema-and-one-capture]]) survives too —
1507
+ // the plain-string reader would flatten it into a re-fenced blob.
1508
+ const existingSourceMaterial = extractSourceMaterialStructured(body);
1509
+ const newBody = buildCaptureBody(
1510
+ core,
1511
+ notes,
1512
+ summary,
1513
+ agentExpansion,
1514
+ existingSourceMaterial.blob,
1515
+ example,
1516
+ userStories,
1517
+ doneWhen,
1518
+ jtbd,
1519
+ existingSourceMaterial.sources,
1520
+ );
1195
1521
  const updated = new Date().toISOString().slice(0, 10);
1196
1522
  const fm = { ...frontmatter, updated };
1197
1523
  // SV2K: assert type: capture going forward; legacy type: idea is tolerated until E2.
@@ -1211,11 +1537,9 @@ export function executeUpdateCapture(vaultRoot, parseFrontmatter, args = {}, dep
1211
1537
  if (firstLine) fm.name = firstLine.replace(/^[-*>\s"]+|["]+$/g, '').slice(0, 80);
1212
1538
  }
1213
1539
 
1214
- writeFileSync(fullPath, stringifyCaptureSpec(fm, newBody), 'utf8');
1215
-
1216
- // Real content just landed → drop the empty-scaffold fuse so this capture is
1217
- // no longer treated as an abandonable zombie. Runs regardless of whether a
1218
- // claim refresh follows (a content write without a claim must still defuse).
1540
+ // Real content just landed → the caller drops the empty-scaffold fuse so this
1541
+ // capture is no longer treated as an abandonable zombie. Computed here (pure)
1542
+ // and acted on by whichever persistence path ran.
1219
1543
  const hasContent =
1220
1544
  core.trim() !== '' ||
1221
1545
  notes.trim() !== '' ||
@@ -1223,14 +1547,27 @@ export function executeUpdateCapture(vaultRoot, parseFrontmatter, args = {}, dep
1223
1547
  example.trim() !== '' ||
1224
1548
  doneWhen.trim() !== '' ||
1225
1549
  String(agentExpansion ?? '').trim() !== '';
1226
- if (hasContent) {
1227
- clearCaptureFocusExpiry(vaultRoot);
1228
- }
1229
1550
 
1230
- if (deps.claim) {
1231
- updateCaptureFocusClaim(vaultRoot, deps.claim);
1232
- }
1551
+ return { fm, newBody, core, notes, summaryWarning, hasContent };
1552
+ }
1233
1553
 
1554
+ /**
1555
+ * Shape the `heyspex_update_capture` success payload. Shared verbatim by the
1556
+ * local (disk) and cloud (console) persistence paths so the two can never
1557
+ * report different shapes for the same write.
1558
+ */
1559
+ function buildUpdateCaptureResult({
1560
+ fm,
1561
+ newBody,
1562
+ specPath,
1563
+ core,
1564
+ notes,
1565
+ args = {},
1566
+ autoCreated,
1567
+ continuationOf,
1568
+ summaryWarning,
1569
+ extra = null,
1570
+ }) {
1234
1571
  const result = {
1235
1572
  success: true,
1236
1573
  spec_id: fm.id,
@@ -1264,6 +1601,9 @@ export function executeUpdateCapture(vaultRoot, parseFrontmatter, args = {}, dep
1264
1601
  // the agent elicits missing elements in this pass instead of bouncing at
1265
1602
  // promote time. Read-only — never blocks the update or triggers a promote.
1266
1603
  result.sufficiency = computeCaptureSufficiency(fm, newBody);
1604
+ // Cloud-only additions (`write_mode` / `on_disk` / `console_url`). Null on the
1605
+ // local path, so that payload stays byte-identical to what it always was.
1606
+ if (extra) Object.assign(result, extra);
1267
1607
  return result;
1268
1608
  }
1269
1609
 
@@ -1280,7 +1620,13 @@ function unwrapWikilink(s) {
1280
1620
  return m ? m[1] : trimmed;
1281
1621
  }
1282
1622
 
1283
- export function createFreshCaptureSpec(vaultRoot, allocateRandomVaultId, occupiedCaptureIds, lineage = null) {
1623
+ export function createFreshCaptureSpec(
1624
+ vaultRoot,
1625
+ allocateRandomVaultId,
1626
+ occupiedCaptureIds,
1627
+ lineage = null,
1628
+ deps = {},
1629
+ ) {
1284
1630
  // Era-3 file-key-first ([[naming-v3-7-rulings-hs-namespace-kept]]): the
1285
1631
  // FAMILY token still comes from the legacy allocator's `HS-<family>-CAP`
1286
1632
  // draw (parentless capture — no family to inherit); `id:` keys onto the
@@ -1289,12 +1635,17 @@ export function createFreshCaptureSpec(vaultRoot, allocateRandomVaultId, occupie
1289
1635
  const familyKey = parseSpecId(allocatedFamilyId)?.familyKey ?? mintFamilyKey(vaultRoot);
1290
1636
  // Per-file FILE KEY (owner ruling 2026-07-10) — filename + `file_id:` SSOT,
1291
1637
  // and (V3) the anchor for `id:` itself.
1292
- const fileKey = mintFileKey(vaultRoot);
1638
+ // Disk occupancy ([[HS-FJWL]]): this is the LOCAL branch of
1639
+ // `createFreshCaptureSpecViaMode` — cloud reliance routes to
1640
+ // `createFreshCaptureSpecViaConsole`, where the console mints the id — so
1641
+ // the vault walk is complete here by construction.
1642
+ const fileKey = mintFileKey(collectOccupiedKeysEitherKind(vaultRoot));
1293
1643
  const newId = `HS-${fileKey}`;
1294
1644
  const today = new Date().toISOString().slice(0, 10);
1295
1645
  const slug = `idea-${today}`;
1296
1646
  const filename = `${newId}-${familyKey}-CAP-${slug}.md`;
1297
1647
  const dir = join(vaultRoot, 'specs', 'captures');
1648
+ assertPathUnderRoot(vaultRoot, dir, 'capture-focus write');
1298
1649
  mkdirSync(dir, { recursive: true });
1299
1650
  const iso = new Date().toISOString();
1300
1651
  const frontmatter = {
@@ -1305,7 +1656,9 @@ export function createFreshCaptureSpec(vaultRoot, allocateRandomVaultId, occupie
1305
1656
  type: 'capture',
1306
1657
  kind: 'idea',
1307
1658
  status: 'open',
1308
- owner: 'pwher',
1659
+ // `owner:` is the paired session account, OMITTED when none resolves —
1660
+ // never a literal handle ([[HS-WE87-WW9R-TASK-thread-the-gated-caller-through-every]]).
1661
+ ...ownerFrontmatterEntry({ resolve: deps.resolveSessionOwner ?? resolveSessionOwner }),
1309
1662
  created: iso,
1310
1663
  updated: iso,
1311
1664
  tags: ['idea', 'capture'],
@@ -1364,3 +1717,656 @@ export function executePinCapture(vaultRoot, parseFrontmatter, loadSpec, args =
1364
1717
  message: `Capture ${specId} is now on deck for /hs-capture`,
1365
1718
  };
1366
1719
  }
1720
+
1721
+ // ---------------------------------------------------------------------------
1722
+ // Cloud vault-write mode
1723
+ // ([[HS-BYU4-CWPX-TASK-promotion-writes-go-through-the-console]])
1724
+ //
1725
+ // Everything above this line is the LOCAL write path and is untouched: the
1726
+ // markdown goes to `specs/captures/` and the pin goes to the YAML pin file,
1727
+ // exactly as before.
1728
+ //
1729
+ // When `resolveVaultWriteMode()` says `cloud`, neither may touch this machine:
1730
+ //
1731
+ // 1. The capture MARKDOWN (the document itself) is created with
1732
+ // `POST /api/specs` (`type: 'capture'`, server-minted id — nothing here
1733
+ // mints a file key, a family key or an allocator id in cloud mode) and
1734
+ // edited with `PUT /api/specs/:id`.
1735
+ // 2. The PIN is the Neon control plane, not the YAML pin file. Layer-1
1736
+ // (`spec_id` / `pinned_at` / `expires` — including the [[HS-CAP-SID6]]
1737
+ // self-clear fuse) is `PUT /api/control/capture-focus-pin`; layer-2
1738
+ // (`spec_path` / `kind` / `reason` / `claimed_by` / `claim_session`) is
1739
+ // `PUT /api/control/capture-focus-selection`. That is the split
1740
+ // [[HS-M0MK-CPFP-TASK-migrate-capture-focus-yaml-to-neon-capture-focus-pin]]
1741
+ // and [[HS-IIP4-CFL2-TASK-migrate-capture-focus-layer-2-to-neon-capture-focus-selection]]
1742
+ // already shipped behind those routes; IIP4 §Scope item 6 is exactly this
1743
+ // vendor-side repoint, which CPFP deferred as a sync cascade.
1744
+ //
1745
+ // That sync cascade is why the functions below are SIBLINGS rather than an
1746
+ // async-ification of the originals: `writeCaptureFocus` / `executeUpdateCapture`
1747
+ // / `ensureActiveCapturePin` are called from sync paths in cli.mjs and
1748
+ // capture-tools.mjs, and every one of those callers must keep working
1749
+ // unchanged. Each `*ViaMode` entry point resolves the write mode ONCE and
1750
+ // either delegates to its sync twin (`local`) or runs the console path
1751
+ // (`cloud`); the mode can also be passed in, so a caller that already resolved
1752
+ // it does not resolve it twice.
1753
+ //
1754
+ // Every console call takes an injected `consoleFetch`, so tests drive each
1755
+ // branch with a recorder and never open a socket.
1756
+ // ---------------------------------------------------------------------------
1757
+
1758
+ export const CAPTURE_FOCUS_PIN_ROUTE = '/api/control/capture-focus-pin';
1759
+ export const CAPTURE_FOCUS_SELECTION_ROUTE = '/api/control/capture-focus-selection';
1760
+
1761
+ /**
1762
+ * The capture spec id a `spec_path` names.
1763
+ *
1764
+ * A local twin of capture-tools.mjs's `captureSpecIdFromPath` on purpose: that
1765
+ * module imports THIS one, so importing it back would be a cycle — and its
1766
+ * regex only reads era-1/2 `HS-CAP-XXXX` names, while a capture filed today is
1767
+ * era-3 (`HS-<fileKey>-<familyKey>-CAP-<slug>`, keyed on the file key alone).
1768
+ * Both eras are read here through the shared `parseSpecId`.
1769
+ */
1770
+ export function captureSpecIdFromSpecPath(specPath) {
1771
+ const base = String(specPath || '')
1772
+ .replace(/^.*[/\\]/, '')
1773
+ .replace(/\.md$/i, '');
1774
+ if (!base) return null;
1775
+ const parsed = parseSpecId(base);
1776
+ if (parsed?.fileKey) return `HS-${parsed.fileKey}`;
1777
+ if (parsed?.typeLabel && parsed?.familyKey) return `HS-${parsed.typeLabel}-${parsed.familyKey}`;
1778
+ return base;
1779
+ }
1780
+
1781
+ /**
1782
+ * Default cloud-tier spec read. Dynamically imported so this module keeps no
1783
+ * static edge to vault-io.mjs, and injectable because `loadSpecByIdViaTier`'s
1784
+ * neon branch closes over the real `consoleFetch` (a test must be able to
1785
+ * replace the whole read, not just the transport).
1786
+ */
1787
+ async function defaultLoadSpecViaTier(vaultRoot, ref, consoleUrl) {
1788
+ const { loadSpecByIdViaTier } = await import('./vault-io.mjs');
1789
+ return loadSpecByIdViaTier(vaultRoot, ref, { tier: 'neon', consoleUrl });
1790
+ }
1791
+
1792
+ /** Normalize the cloud dependency bundle every `*ViaConsole` function takes. */
1793
+ export function captureCloudDeps(deps = {}) {
1794
+ return {
1795
+ consoleFetch: deps.consoleFetch ?? realConsoleFetch,
1796
+ consoleUrl: deps.consoleUrl ?? getConsoleUrl(),
1797
+ loadSpecViaTier: deps.loadSpecViaTier ?? defaultLoadSpecViaTier,
1798
+ };
1799
+ }
1800
+
1801
+ /** Resolve the write mode ONCE for a handler entry; `?? 'local'` keeps callers unchanged. */
1802
+ async function resolveWriteModeOnce(explicit, deps = {}) {
1803
+ return explicit ?? (await (deps.resolveWriteMode ?? resolveVaultWriteMode)()) ?? 'local';
1804
+ }
1805
+
1806
+ /**
1807
+ * Read the live capture pin from the Neon control plane: layer-1 ⊕ layer-2,
1808
+ * composed the same way the console's `readCaptureFocusState` does — layer-2 is
1809
+ * attached only when its `spec_path` names the pinned spec, so a stale
1810
+ * selection never paints onto a freshly-pinned one.
1811
+ *
1812
+ * Deliberately NOT `readCaptureFocusVendor` (capture-tools.mjs): that reader
1813
+ * falls back to the offline YAML pin file, and its `loadCaptureFocus` call runs
1814
+ * `migrateLegacyIdeaFocus`, which WRITES that file. Both are exactly what cloud
1815
+ * mode forbids, so this path is strict — it throws
1816
+ * `CONSOLE_UNREACHABLE_CLOUD_MODE` rather than reading (or writing) the disk.
1817
+ *
1818
+ * @returns the same `focus` shape `loadCaptureFocus` returns, or `null` when
1819
+ * Neon says nothing is pinned (an authoritative answer, not a failure).
1820
+ */
1821
+ export async function readCaptureFocusViaConsole(deps = {}) {
1822
+ const { consoleFetch, consoleUrl } = captureCloudDeps(deps);
1823
+ const [pinRes, selRes] = await Promise.all([
1824
+ consoleFetch(CAPTURE_FOCUS_PIN_ROUTE),
1825
+ consoleFetch(CAPTURE_FOCUS_SELECTION_ROUTE),
1826
+ ]);
1827
+ if (!pinRes?.ok) {
1828
+ throw cloudWriteFailure(
1829
+ `GET ${CAPTURE_FOCUS_PIN_ROUTE}`,
1830
+ consoleUrl,
1831
+ `answered ${pinRes?.status ?? 'no response'}`,
1832
+ { status: pinRes?.status ?? null, console_response: pinRes?.body ?? null },
1833
+ );
1834
+ }
1835
+ const pin = pinRes.body?.pin ?? null;
1836
+ if (!pin) return null;
1837
+ const sel = selRes?.ok ? (selRes.body?.selection ?? null) : null;
1838
+ const basenameOf = (p) => String(p || '').replace(/^.*[/\\]/, '');
1839
+ const selMatches = sel?.specPath ? basenameOf(sel.specPath).startsWith(pin.specId) : false;
1840
+ const focus = {
1841
+ spec_id: pin.specId,
1842
+ spec_path: selMatches ? sel.specPath : `specs/captures/${pin.specId}.md`,
1843
+ pinned_at: pin.pinnedAt ?? null,
1844
+ };
1845
+ if (pin.expiresAt) focus.expires = pin.expiresAt;
1846
+ if (selMatches && sel) {
1847
+ if (sel.reason) focus.reason = sel.reason;
1848
+ if (sel.claimedBy) focus.claimed_by = sel.claimedBy;
1849
+ if (sel.claimSession) focus.claim_session = sel.claimSession;
1850
+ if (sel.kind) focus.kind = sel.kind;
1851
+ }
1852
+ if (!focus.kind) focus.kind = 'idea';
1853
+ return focus;
1854
+ }
1855
+
1856
+ /**
1857
+ * Cloud twin of {@link writeCaptureFocus}: pin layer-1 + layer-2 in one call.
1858
+ * The YAML pin file is never touched.
1859
+ *
1860
+ * `expires` (the empty-scaffold silent fuse) is carried as the pin route's
1861
+ * `ttl_ms`, which is how `capture_focus_pin` stores it — `getCaptureFocusPin`'s
1862
+ * SQL already excludes rows past `expires_at`, so the [[HS-CAP-SID6]] self-retire
1863
+ * behaves identically on this tier (CPFP AC#2).
1864
+ */
1865
+ export async function writeCaptureFocusViaConsole(
1866
+ { specId = null, specPath, reason = null, claim = null, kind = 'idea', expires = null } = {},
1867
+ deps = {},
1868
+ ) {
1869
+ const { consoleFetch, consoleUrl } = captureCloudDeps(deps);
1870
+ const id = specId || captureSpecIdFromSpecPath(specPath);
1871
+ if (!id) {
1872
+ throw cloudWriteFailure(
1873
+ `PUT ${CAPTURE_FOCUS_PIN_ROUTE}`,
1874
+ consoleUrl,
1875
+ `no capture spec id could be resolved from spec_path ${JSON.stringify(specPath ?? null)} — the pin route requires one`,
1876
+ );
1877
+ }
1878
+ const ttlMs = expires ? Date.parse(expires) - Date.now() : null;
1879
+ const [pinRes, selRes] = await Promise.all([
1880
+ consoleFetch(CAPTURE_FOCUS_PIN_ROUTE, {
1881
+ method: 'PUT',
1882
+ body: {
1883
+ spec_id: id,
1884
+ pinned_by: claim?.claimed_by || 'agent',
1885
+ ...(Number.isFinite(ttlMs) ? { ttl_ms: ttlMs } : {}),
1886
+ },
1887
+ }),
1888
+ consoleFetch(CAPTURE_FOCUS_SELECTION_ROUTE, {
1889
+ method: 'PUT',
1890
+ body: {
1891
+ spec_path: specPath ?? null,
1892
+ kind: typeof kind === 'string' && kind.trim() ? kind.trim() : 'idea',
1893
+ reason: reason ?? null,
1894
+ claimed_by: claim?.claimed_by ?? null,
1895
+ claim_session: claim?.claim_session ?? null,
1896
+ },
1897
+ }),
1898
+ ]);
1899
+ if (!pinRes?.ok || !selRes?.ok) {
1900
+ throw cloudWriteFailure(
1901
+ `PUT ${CAPTURE_FOCUS_PIN_ROUTE} + ${CAPTURE_FOCUS_SELECTION_ROUTE}`,
1902
+ consoleUrl,
1903
+ `pin answered ${pinRes?.status ?? 'no response'}, selection answered ${selRes?.status ?? 'no response'} — the capture is NOT on deck`,
1904
+ {
1905
+ pin_status: pinRes?.status ?? null,
1906
+ selection_status: selRes?.status ?? null,
1907
+ spec_id: id,
1908
+ spec_path: specPath ?? null,
1909
+ },
1910
+ );
1911
+ }
1912
+ return {
1913
+ pinned: true,
1914
+ tier: 'neon',
1915
+ on_disk: false,
1916
+ spec_id: id,
1917
+ spec_path: specPath ?? null,
1918
+ pin: pinRes.body?.pin ?? null,
1919
+ selection: selRes.body?.selection ?? null,
1920
+ };
1921
+ }
1922
+
1923
+ /**
1924
+ * Cloud twin of {@link updateCaptureFocusClaim} — refresh the claim metadata on
1925
+ * the LIVE pin. Layer-2 only: the claim lives in `capture_focus_selection`, so
1926
+ * the layer-1 row (and with it `pinned_at` and the fuse) is left alone.
1927
+ *
1928
+ * `setCaptureFocusSelection` replaces the whole row, so the fields this refresh
1929
+ * is not changing (`spec_path` / `kind` / `reason`) are re-sent from the row as
1930
+ * read. Best-effort like its sync twin: callers fire it after their real work
1931
+ * succeeded and none check the return value, so a failure reports `false` and a
1932
+ * stderr line rather than throwing.
1933
+ */
1934
+ export async function updateCaptureFocusClaimViaConsole(claim, deps = {}) {
1935
+ const { consoleFetch, consoleUrl } = captureCloudDeps(deps);
1936
+ try {
1937
+ const focus = await readCaptureFocusViaConsole({ consoleFetch, consoleUrl });
1938
+ if (!focus?.spec_path) return false;
1939
+ const res = await consoleFetch(CAPTURE_FOCUS_SELECTION_ROUTE, {
1940
+ method: 'PUT',
1941
+ body: {
1942
+ spec_path: focus.spec_path,
1943
+ kind: focus.kind || 'idea',
1944
+ reason: focus.reason ?? null,
1945
+ claimed_by: claim?.claimed_by ?? null,
1946
+ claim_session: claim?.claim_session ?? null,
1947
+ },
1948
+ });
1949
+ if (!res?.ok) {
1950
+ process.stderr.write?.(
1951
+ `[heyspex-mcp] updateCaptureFocusClaimViaConsole refused: ${CAPTURE_FOCUS_SELECTION_ROUTE} answered ${res?.status ?? 'no response'}\n`,
1952
+ );
1953
+ return false;
1954
+ }
1955
+ return true;
1956
+ } catch (err) {
1957
+ process.stderr.write?.(
1958
+ `[heyspex-mcp] updateCaptureFocusClaimViaConsole failed: ${err?.message ?? err}\n`,
1959
+ );
1960
+ return false;
1961
+ }
1962
+ }
1963
+
1964
+ /**
1965
+ * Cloud twin of {@link clearCaptureFocusExpiry}: drop the empty-scaffold fuse
1966
+ * once a capture has real content, by re-pinning layer-1 with `ttl_ms: null`.
1967
+ * Same no-op contract as the sync twin — `false` when nothing is pinned or the
1968
+ * pin carries no fuse.
1969
+ */
1970
+ export async function clearCaptureFocusExpiryViaConsole(deps = {}) {
1971
+ const { consoleFetch, consoleUrl } = captureCloudDeps(deps);
1972
+ const pinRes = await consoleFetch(CAPTURE_FOCUS_PIN_ROUTE);
1973
+ if (!pinRes?.ok) {
1974
+ throw cloudWriteFailure(
1975
+ `GET ${CAPTURE_FOCUS_PIN_ROUTE}`,
1976
+ consoleUrl,
1977
+ `answered ${pinRes?.status ?? 'no response'}`,
1978
+ { status: pinRes?.status ?? null },
1979
+ );
1980
+ }
1981
+ const pin = pinRes.body?.pin ?? null;
1982
+ if (!pin?.specId || !pin.expiresAt) return false;
1983
+ const res = await consoleFetch(CAPTURE_FOCUS_PIN_ROUTE, {
1984
+ method: 'PUT',
1985
+ body: { spec_id: pin.specId, pinned_by: pin.pinnedBy || 'agent', ttl_ms: null },
1986
+ });
1987
+ if (!res?.ok) {
1988
+ throw cloudWriteFailure(
1989
+ `PUT ${CAPTURE_FOCUS_PIN_ROUTE}`,
1990
+ consoleUrl,
1991
+ `answered ${res?.status ?? 'no response'} clearing the capture-pin fuse`,
1992
+ { status: res?.status ?? null, spec_id: pin.specId },
1993
+ );
1994
+ }
1995
+ return true;
1996
+ }
1997
+
1998
+ /**
1999
+ * Cloud twin of {@link createFreshCaptureSpec}.
2000
+ *
2001
+ * NOTHING is minted client-side here — no `allocateRandomVaultId`, no
2002
+ * `mintFamilyKey`, no `mintFileKey`. The console mints the id, the family and
2003
+ * the filename (`createSpec`: `capture` → `specs/captures/`, `CAP`), which is
2004
+ * the whole point: a key minted on a machine whose vault the console has never
2005
+ * indexed collides with a cloud-side one ([[HS-2FQY]]).
2006
+ */
2007
+ export async function createFreshCaptureSpecViaConsole(vaultRoot, lineage = null, deps = {}) {
2008
+ const { consoleFetch, consoleUrl } = captureCloudDeps(deps);
2009
+ const today = new Date().toISOString().slice(0, 10);
2010
+ // No `status:` — the console seeds `open` and refuses an out-of-band status
2011
+ // on create ([[HS-ISZP]]); the disk twin above keeps it because it writes
2012
+ // the frontmatter itself.
2013
+ const frontmatter = {
2014
+ kind: 'idea',
2015
+ // Same owner rule as the disk twin: the paired account or no key at all.
2016
+ ...ownerFrontmatterEntry({ resolve: deps.resolveSessionOwner ?? resolveSessionOwner }),
2017
+ tags: ['idea', 'capture'],
2018
+ promoted_to: null,
2019
+ capture_status: 'raw',
2020
+ // HS-KFB2 lineage carry-over — identical to the local path.
2021
+ ...(lineage?.promotedTo ? { continues: `[[${unwrapWikilink(lineage.promotedTo)}]]` } : {}),
2022
+ };
2023
+ const body = lineage?.promotedTo
2024
+ ? CAPTURE_BODY_TEMPLATE.replace(
2025
+ /^# Core Idea\n/,
2026
+ `# Core Idea\n\n> Continuation of a recently-promoted idea — see [[${unwrapWikilink(lineage.promotedTo)}]]` +
2027
+ (lineage.retiredCaptureBasename
2028
+ ? ` (originally [[${unwrapWikilink(lineage.retiredCaptureBasename)}]]).`
2029
+ : '.') +
2030
+ '\n',
2031
+ )
2032
+ : CAPTURE_BODY_TEMPLATE;
2033
+ const created = await createSpecViaConsole({
2034
+ consoleFetch,
2035
+ consoleUrl,
2036
+ type: 'capture',
2037
+ name: `Capture ${today}`,
2038
+ frontmatter,
2039
+ body,
2040
+ });
2041
+ return {
2042
+ spec_id: created.specId,
2043
+ spec_path: created.relPath ?? `specs/captures/${created.basename}.md`,
2044
+ on_disk: false,
2045
+ ...(lineage?.promotedTo ? { continuation_of: unwrapWikilink(lineage.promotedTo) } : {}),
2046
+ };
2047
+ }
2048
+
2049
+ /**
2050
+ * Mode-aware {@link createFreshCaptureSpec}. `local` delegates verbatim.
2051
+ * In `cloud` the allocator arguments are IGNORED (the server mints) — they stay
2052
+ * in the signature only so this is a drop-in for the sync call sites.
2053
+ */
2054
+ export async function createFreshCaptureSpecViaMode(
2055
+ vaultRoot,
2056
+ allocateRandomVaultId,
2057
+ occupiedCaptureIds,
2058
+ lineage = null,
2059
+ deps = {},
2060
+ ) {
2061
+ const writeMode = await resolveWriteModeOnce(deps.writeMode, deps);
2062
+ if (writeMode !== 'cloud') {
2063
+ return createFreshCaptureSpec(vaultRoot, allocateRandomVaultId, occupiedCaptureIds, lineage, deps);
2064
+ }
2065
+ return createFreshCaptureSpecViaConsole(vaultRoot, lineage, deps);
2066
+ }
2067
+
2068
+ /** Mode-aware {@link writeCaptureFocus}. */
2069
+ export async function writeCaptureFocusViaMode(vaultRoot, specPath, reason, claim, opts = {}, deps = {}) {
2070
+ const writeMode = await resolveWriteModeOnce(deps.writeMode, deps);
2071
+ if (writeMode !== 'cloud') {
2072
+ writeCaptureFocus(vaultRoot, specPath, reason, claim, opts);
2073
+ return { pinned: true, tier: 'file', on_disk: true, spec_path: specPath };
2074
+ }
2075
+ return writeCaptureFocusViaConsole(
2076
+ {
2077
+ specId: opts.specId ?? null,
2078
+ specPath,
2079
+ reason,
2080
+ claim,
2081
+ kind: opts.kind,
2082
+ expires: opts.expires ?? null,
2083
+ },
2084
+ deps,
2085
+ );
2086
+ }
2087
+
2088
+ /** Mode-aware {@link updateCaptureFocusClaim}. */
2089
+ export async function updateCaptureFocusClaimViaMode(vaultRoot, claim, deps = {}) {
2090
+ const writeMode = await resolveWriteModeOnce(deps.writeMode, deps);
2091
+ if (writeMode !== 'cloud') return updateCaptureFocusClaim(vaultRoot, claim);
2092
+ return updateCaptureFocusClaimViaConsole(claim, deps);
2093
+ }
2094
+
2095
+ /** Mode-aware {@link clearCaptureFocusExpiry}. */
2096
+ export async function clearCaptureFocusExpiryViaMode(vaultRoot, deps = {}) {
2097
+ const writeMode = await resolveWriteModeOnce(deps.writeMode, deps);
2098
+ if (writeMode !== 'cloud') return clearCaptureFocusExpiry(vaultRoot);
2099
+ return clearCaptureFocusExpiryViaConsole(deps);
2100
+ }
2101
+
2102
+ /**
2103
+ * Mode-aware {@link ensureActiveCapturePin}.
2104
+ *
2105
+ * The cloud branch reuses {@link resolveActiveCapture} rather than restating its
2106
+ * retirement / recently-promoted-lineage rules: that function already takes both
2107
+ * a `focusOverride` and an injected `loadSpec`, so the console-sourced pin and a
2108
+ * pre-fetched spec are handed straight to it and the SID6 / HS-KFB2 behavior is
2109
+ * the same code on both tiers.
2110
+ *
2111
+ * The `AUTO_CREATE_UNAVAILABLE` guard is local-only by construction: cloud mode
2112
+ * needs no id allocator because the console mints the id.
2113
+ */
2114
+ export async function ensureActiveCapturePinViaMode(
2115
+ vaultRoot,
2116
+ parseFrontmatter,
2117
+ loadSpec,
2118
+ deps = {},
2119
+ cloudDeps = {},
2120
+ ) {
2121
+ const writeMode = await resolveWriteModeOnce(cloudDeps.writeMode, cloudDeps);
2122
+ if (writeMode !== 'cloud') {
2123
+ return ensureActiveCapturePin(vaultRoot, parseFrontmatter, loadSpec, deps);
2124
+ }
2125
+ const ctx = captureCloudDeps(cloudDeps);
2126
+ const { pinReason = 'Auto-created via MCP', claim = null } = deps;
2127
+
2128
+ const focus = await readCaptureFocusViaConsole(ctx);
2129
+ let pinnedSpec = null;
2130
+ if (focus?.spec_path) {
2131
+ pinnedSpec = await ctx.loadSpecViaTier(
2132
+ vaultRoot,
2133
+ focus.spec_id ?? captureSpecIdFromSpecPath(focus.spec_path),
2134
+ ctx.consoleUrl,
2135
+ );
2136
+ }
2137
+ let resolved = resolveActiveCapture(vaultRoot, parseFrontmatter, () => pinnedSpec, focus ?? null);
2138
+ if (!resolved.error) {
2139
+ if (claim) await updateCaptureFocusClaimViaConsole(claim, ctx);
2140
+ return { resolved, fresh: false, auto_created: false };
2141
+ }
2142
+
2143
+ const previousError = resolved.error;
2144
+ const recoverable =
2145
+ resolved.error === 'NO_CAPTURE_PINNED' ||
2146
+ resolved.error === 'CAPTURE_NOT_FOUND' ||
2147
+ resolved.error === 'CAPTURE_RETIRED';
2148
+ if (!recoverable) {
2149
+ return { error: resolved.error, message: resolved.message };
2150
+ }
2151
+
2152
+ const lineage =
2153
+ resolved.recentlyPromoted && resolved.promotedTo
2154
+ ? { promotedTo: resolved.promotedTo, retiredCaptureBasename: resolved.retiredCaptureBasename }
2155
+ : null;
2156
+
2157
+ const created = await createFreshCaptureSpecViaConsole(vaultRoot, lineage, ctx);
2158
+ const expires = new Date(Date.now() + CAPTURE_PIN_FUSE_MS).toISOString();
2159
+ await writeCaptureFocusViaConsole(
2160
+ { specId: created.spec_id, specPath: created.spec_path, reason: pinReason, claim, expires },
2161
+ ctx,
2162
+ );
2163
+
2164
+ const freshSpec = await ctx.loadSpecViaTier(vaultRoot, created.spec_id, ctx.consoleUrl);
2165
+ resolved = resolveActiveCapture(vaultRoot, parseFrontmatter, () => freshSpec, {
2166
+ spec_id: created.spec_id,
2167
+ spec_path: created.spec_path,
2168
+ pinned_at: new Date().toISOString(),
2169
+ expires,
2170
+ kind: 'idea',
2171
+ reason: pinReason,
2172
+ });
2173
+ if (resolved.error) {
2174
+ return {
2175
+ error: resolved.error,
2176
+ message: resolved.message || 'Auto-create failed after pin write.',
2177
+ };
2178
+ }
2179
+ return {
2180
+ resolved,
2181
+ fresh: true,
2182
+ auto_created: true,
2183
+ replaced_stale_pin: previousError === 'CAPTURE_NOT_FOUND',
2184
+ ...(created.continuation_of ? { continuation_of: created.continuation_of } : {}),
2185
+ };
2186
+ }
2187
+
2188
+ /**
2189
+ * Mode-aware {@link executeUpdateCapture}.
2190
+ *
2191
+ * Cloud branch: the capture is sourced through the cloud read tier
2192
+ * (`loadSpecByIdViaTier`, the read half this repo already shipped — nothing new
2193
+ * is written here to fetch a spec), mutated by the SAME pure
2194
+ * `computeCaptureUpdate` the local path runs, and persisted with
2195
+ * `PUT /api/specs/:id`. The fuse-clear and claim-refresh follow-ups go to the
2196
+ * console pin routes instead of the YAML pin file.
2197
+ */
2198
+ export async function executeUpdateCaptureViaMode(
2199
+ vaultRoot,
2200
+ parseFrontmatter,
2201
+ args = {},
2202
+ deps = {},
2203
+ cloudDeps = {},
2204
+ ) {
2205
+ const writeMode = await resolveWriteModeOnce(cloudDeps.writeMode, cloudDeps);
2206
+ if (writeMode !== 'cloud') {
2207
+ return executeUpdateCapture(vaultRoot, parseFrontmatter, args, deps);
2208
+ }
2209
+ const ctx = captureCloudDeps(cloudDeps);
2210
+
2211
+ let specPath = null;
2212
+ let specId = null;
2213
+ let loaded = null;
2214
+ let autoCreated = false;
2215
+ let continuationOf = null;
2216
+
2217
+ if (args.spec_id) {
2218
+ loaded = await ctx.loadSpecViaTier(vaultRoot, args.spec_id, ctx.consoleUrl);
2219
+ if (!loaded) {
2220
+ return { error: 'SPEC_NOT_FOUND', message: `Capture ${args.spec_id} not found` };
2221
+ }
2222
+ specPath = loaded.specPath ?? null;
2223
+ specId = loaded.frontmatter?.id ?? args.spec_id;
2224
+ } else {
2225
+ const ensured = await ensureActiveCapturePinViaMode(
2226
+ vaultRoot,
2227
+ parseFrontmatter,
2228
+ deps.loadSpec,
2229
+ {
2230
+ pinReason: 'Auto-created via heyspex_update_capture',
2231
+ claim: deps.claim ?? null,
2232
+ },
2233
+ { ...cloudDeps, writeMode: 'cloud' },
2234
+ );
2235
+ if (ensured.error) {
2236
+ return { error: ensured.error, message: ensured.message };
2237
+ }
2238
+ autoCreated = ensured.auto_created === true;
2239
+ continuationOf = ensured.continuation_of ?? null;
2240
+ specPath = ensured.resolved.spec_path;
2241
+ specId = ensured.resolved.spec_id;
2242
+ loaded = { frontmatter: ensured.resolved.frontmatter, body: ensured.resolved.body, specPath };
2243
+ }
2244
+
2245
+ const computed = computeCaptureUpdate(loaded.frontmatter ?? {}, loaded.body ?? '', args);
2246
+ if (computed.error) return computed;
2247
+ const { fm, newBody, core, notes, summaryWarning, hasContent } = computed;
2248
+
2249
+ const targetId = fm.id ?? specId;
2250
+ if (!targetId) {
2251
+ return {
2252
+ error: 'CLOUD_MODE_UNSUPPORTED_WRITE',
2253
+ message:
2254
+ `Capture ${specPath ?? '(unknown path)'} carries no \`id:\` frontmatter, and PUT /api/specs/:id needs one. `
2255
+ + 'NOTHING WAS WRITTEN — the local disk write is deliberately NOT used as a fallback in cloud vault-write mode.',
2256
+ spec_path: specPath,
2257
+ write_mode: 'cloud',
2258
+ on_disk: false,
2259
+ };
2260
+ }
2261
+
2262
+ let written;
2263
+ try {
2264
+ written = await updateSpecViaConsole({
2265
+ consoleFetch: ctx.consoleFetch,
2266
+ consoleUrl: ctx.consoleUrl,
2267
+ specId: targetId,
2268
+ frontmatter: fm,
2269
+ body: newBody,
2270
+ });
2271
+ } catch (err) {
2272
+ return {
2273
+ error: 'CONSOLE_UNREACHABLE_CLOUD_MODE',
2274
+ message: String(err?.message ?? err),
2275
+ spec_id: targetId,
2276
+ spec_path: specPath,
2277
+ write_mode: 'cloud',
2278
+ on_disk: false,
2279
+ ...(err?.notFound ? { not_found: true } : {}),
2280
+ };
2281
+ }
2282
+
2283
+ if (hasContent) {
2284
+ // Best-effort, exactly like the local fuse-clear: a fuse that cannot be
2285
+ // dropped must not fail an update whose content already landed.
2286
+ try {
2287
+ await clearCaptureFocusExpiryViaConsole(ctx);
2288
+ } catch (err) {
2289
+ process.stderr.write?.(
2290
+ `[heyspex-mcp] capture fuse not cleared through the console: ${err?.message ?? err}\n`,
2291
+ );
2292
+ }
2293
+ }
2294
+ if (deps.claim) {
2295
+ await updateCaptureFocusClaimViaConsole(deps.claim, ctx);
2296
+ }
2297
+
2298
+ return buildUpdateCaptureResult({
2299
+ fm,
2300
+ newBody,
2301
+ specPath: written.relPath ?? specPath,
2302
+ core,
2303
+ notes,
2304
+ args,
2305
+ autoCreated,
2306
+ continuationOf,
2307
+ summaryWarning,
2308
+ extra: { write_mode: 'cloud', on_disk: false, console_url: written.consoleUrl ?? ctx.consoleUrl },
2309
+ });
2310
+ }
2311
+
2312
+ /**
2313
+ * Mode-aware {@link executePinCapture}. Cloud resolves the capture through the
2314
+ * cloud read tier and pins it on the Neon control plane; the YAML pin file is
2315
+ * never written.
2316
+ */
2317
+ export async function executePinCaptureViaMode(
2318
+ vaultRoot,
2319
+ parseFrontmatter,
2320
+ loadSpec,
2321
+ args = {},
2322
+ cloudDeps = {},
2323
+ ) {
2324
+ const writeMode = await resolveWriteModeOnce(cloudDeps.writeMode, cloudDeps);
2325
+ if (writeMode !== 'cloud') {
2326
+ return executePinCapture(vaultRoot, parseFrontmatter, loadSpec, args);
2327
+ }
2328
+ const ctx = captureCloudDeps(cloudDeps);
2329
+ const specId = args.spec_id;
2330
+ if (!specId) {
2331
+ return { error: 'MISSING_SPEC_ID', message: 'spec_id is required' };
2332
+ }
2333
+ const spec = await ctx.loadSpecViaTier(vaultRoot, specId, ctx.consoleUrl);
2334
+ if (!spec) {
2335
+ return { error: 'SPEC_NOT_FOUND', message: `Capture ${specId} not found` };
2336
+ }
2337
+ const fmType = spec.frontmatter?.type;
2338
+ if (fmType !== 'capture' && fmType !== 'idea') {
2339
+ return { error: 'NOT_A_CAPTURE', message: `${specId} is not type: capture` };
2340
+ }
2341
+ const specPath = spec.specPath ?? null;
2342
+ const pinnedKind = spec.frontmatter?.kind;
2343
+ try {
2344
+ await writeCaptureFocusViaConsole(
2345
+ {
2346
+ specId: spec.frontmatter?.id ?? specId,
2347
+ specPath,
2348
+ reason: args.reason || 'Pinned via MCP',
2349
+ claim: null,
2350
+ kind: typeof pinnedKind === 'string' ? pinnedKind : 'idea',
2351
+ },
2352
+ ctx,
2353
+ );
2354
+ } catch (err) {
2355
+ return {
2356
+ error: 'CONSOLE_UNREACHABLE_CLOUD_MODE',
2357
+ message: String(err?.message ?? err),
2358
+ spec_id: specId,
2359
+ spec_path: specPath,
2360
+ write_mode: 'cloud',
2361
+ on_disk: false,
2362
+ };
2363
+ }
2364
+ return {
2365
+ success: true,
2366
+ spec_id: specId,
2367
+ spec_path: specPath,
2368
+ write_mode: 'cloud',
2369
+ on_disk: false,
2370
+ message: `Capture ${specId} is now on deck for /hs-capture`,
2371
+ };
2372
+ }