heyspex 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (352) hide show
  1. package/.claude-plugin/marketplace.json +14 -0
  2. package/README.md +21 -0
  3. package/bin/hs.mjs +1089 -104
  4. package/bin/spexcode.mjs +262 -0
  5. package/dev-factory/engine/worktree.mjs +740 -0
  6. package/dev-factory/lib/code-repo-root.mjs +67 -0
  7. package/dev-factory/lib/context-budget.mjs +84 -0
  8. package/dev-factory/lib/epic-detect.mjs +190 -0
  9. package/dev-factory/lib/fleet-queue-file.mjs +846 -0
  10. package/dev-factory/lib/fleet-queue.mjs +426 -0
  11. package/dev-factory/lib/fleet-state.mjs +14 -18
  12. package/dev-factory/lib/model-catalog.mjs +338 -0
  13. package/dev-factory/lib/seat-context.mjs +199 -0
  14. package/dev-factory/lib/spec-io.mjs +93 -0
  15. package/dev-factory/lib/sufficiency-score.mjs +8 -2
  16. package/dev-factory/lib/worktree-allocator.mjs +744 -0
  17. package/dev-factory/model-catalog.json +222 -0
  18. package/dist/cli/hs-check.mjs +1127 -103
  19. package/dist/cli/hs-claim.mjs +981 -63
  20. package/dist/cli/hs-connect.mjs +10059 -1729
  21. package/dist/cli/hs-defer.mjs +5507 -3072
  22. package/dist/cli/hs-dev.mjs +576 -0
  23. package/dist/cli/hs-env.mjs +416 -302
  24. package/dist/cli/hs-index-rebuild.mjs +7334 -0
  25. package/dist/cli/hs-index-reconcile.mjs +8138 -0
  26. package/dist/cli/hs-init.mjs +2840 -149
  27. package/dist/cli/hs-install.mjs +7214 -594
  28. package/dist/cli/hs-join.mjs +10640 -2421
  29. package/dist/cli/hs-vault-pull.mjs +8157 -6370
  30. package/dist/cli/hs-vault-push.mjs +6433 -0
  31. package/dist/cli/hs-vault.mjs +4029 -1555
  32. package/dist/cli/hs-workspace.mjs +2702 -538
  33. package/dist/cli-templates/customer-vault/specs/entity-model.md +594 -601
  34. package/dist/cli-templates/customer-vault/specs/protocols/canonical-frontmatter-templates.md +110 -177
  35. package/dist/cli-templates/customer-vault/specs/protocols/destructive-action-checklist.md +122 -0
  36. package/dist/cli-templates/customer-vault/specs/protocols/memory-protocol.md +13 -15
  37. package/dist/cli-templates/customer-vault/specs/protocols/session-close-protocol.md +91 -0
  38. package/dist/cli-templates/customer-vault/specs/protocols/spec-hierarchy.md +150 -168
  39. package/dist/cli-templates/customer-vault/specs/protocols/spec-summary-authoring.md +83 -0
  40. package/dist/cli-templates/harness-commands/claude/hs-auto.md +36 -0
  41. package/dist/cli-templates/harness-commands/claude/hs-backfill.md +28 -0
  42. package/dist/cli-templates/harness-commands/claude/hs-capture.md +62 -0
  43. package/dist/cli-templates/harness-commands/claude/hs-defer.md +29 -0
  44. package/dist/cli-templates/harness-commands/claude/hs-dev.md +20 -0
  45. package/dist/cli-templates/harness-commands/claude/hs-doctor.md +32 -0
  46. package/dist/cli-templates/harness-commands/claude/hs-ground.md +30 -0
  47. package/dist/cli-templates/harness-commands/claude/hs-handoff.md +33 -0
  48. package/dist/cli-templates/harness-commands/claude/hs-log.md +27 -0
  49. package/dist/cli-templates/harness-commands/claude/hs-next.md +29 -0
  50. package/dist/cli-templates/harness-commands/claude/hs-promote.md +70 -0
  51. package/dist/cli-templates/harness-commands/claude/hs-push.md +35 -0
  52. package/dist/cli-templates/harness-commands/claude/hs-qc.md +27 -0
  53. package/dist/cli-templates/harness-commands/claude/hs-res.md +63 -0
  54. package/dist/cli-templates/harness-commands/claude/hs-resolve.md +23 -0
  55. package/dist/cli-templates/harness-commands/claude/hs-review.md +23 -0
  56. package/dist/cli-templates/harness-commands/claude/hs-view.md +28 -0
  57. package/dist/cli-templates/harness-commands/claude/kill-ports.md +32 -0
  58. package/dist/cli-templates/harness-commands/claude/prototype.md +91 -0
  59. package/dist/cli-templates/harness-commands/cursor/hs-auto.md +40 -0
  60. package/dist/cli-templates/harness-commands/cursor/hs-backfill.md +31 -0
  61. package/dist/cli-templates/harness-commands/cursor/hs-capture.md +65 -0
  62. package/dist/cli-templates/harness-commands/cursor/hs-defer.md +32 -0
  63. package/dist/cli-templates/harness-commands/cursor/hs-dev.md +24 -0
  64. package/dist/cli-templates/harness-commands/cursor/hs-doctor.md +36 -0
  65. package/dist/cli-templates/harness-commands/cursor/hs-ground.md +33 -0
  66. package/dist/cli-templates/harness-commands/cursor/hs-handoff.md +37 -0
  67. package/dist/cli-templates/harness-commands/cursor/hs-log.md +31 -0
  68. package/dist/cli-templates/harness-commands/cursor/hs-next.md +32 -0
  69. package/dist/cli-templates/harness-commands/cursor/hs-promote.md +73 -0
  70. package/dist/cli-templates/harness-commands/cursor/hs-push.md +38 -0
  71. package/dist/cli-templates/harness-commands/cursor/hs-qc.md +30 -0
  72. package/dist/cli-templates/harness-commands/cursor/hs-res.md +67 -0
  73. package/dist/cli-templates/harness-commands/cursor/hs-resolve.md +26 -0
  74. package/dist/cli-templates/harness-commands/cursor/hs-review.md +26 -0
  75. package/dist/cli-templates/harness-commands/cursor/hs-view.md +32 -0
  76. package/dist/cli-templates/harness-commands/cursor/kill-ports.md +35 -0
  77. package/dist/cli-templates/harness-commands/cursor/prototype.md +95 -0
  78. package/docs/copilot-plugin-install.md +162 -0
  79. package/docs/driving-heyspex-from-copilot.md +165 -0
  80. package/docs/mcp-client-config.md +259 -6
  81. package/package.json +249 -124
  82. package/plugins/heyspex/.claude-plugin/plugin.json +12 -0
  83. package/plugins/heyspex/.cursor-plugin/plugin.json +15 -0
  84. package/plugins/heyspex/.mcp.json +12 -0
  85. package/plugins/heyspex/README.md +309 -0
  86. package/plugins/heyspex/bin/launch-mcp.mjs +95 -0
  87. package/plugins/heyspex/bin/run-hook.mjs +80 -0
  88. package/plugins/heyspex/hooks/cursor-hooks.json +308 -0
  89. package/plugins/heyspex/hooks/hooks.json +418 -0
  90. package/plugins/heyspex/mcp.json +13 -0
  91. package/scripts/check-mcp-freshness.mjs +17 -9
  92. package/scripts/claude-hooks/_env-vault-events.mjs +68 -0
  93. package/scripts/claude-hooks/_guardrail-events.mjs +96 -0
  94. package/scripts/claude-hooks/_guardrail-loader.mjs +494 -0
  95. package/scripts/claude-hooks/_intent-classifier-router.mjs +456 -0
  96. package/scripts/claude-hooks/_intent-classifier-semantic.mjs +356 -0
  97. package/scripts/claude-hooks/_run.mjs +413 -0
  98. package/scripts/claude-hooks/_transcript-text.mjs +60 -0
  99. package/scripts/claude-hooks/_vault-retrieval.mjs +322 -0
  100. package/scripts/claude-hooks/agent-spawn-delegation-gate.mjs +133 -0
  101. package/scripts/claude-hooks/capture-execution-gate.mjs +51 -0
  102. package/scripts/claude-hooks/capture-workflow-gate.mjs +11 -0
  103. package/scripts/claude-hooks/check-auto-memory-budget.mjs +396 -0
  104. package/scripts/claude-hooks/check-claude-md-budget.mjs +327 -0
  105. package/scripts/claude-hooks/check-mcp-freshness-hook.mjs +14 -0
  106. package/scripts/claude-hooks/claim-before-code-gate.mjs +11 -0
  107. package/scripts/claude-hooks/claim-liveness-shared.mjs +577 -0
  108. package/scripts/claude-hooks/close-terminal-on-ship.mjs +64 -0
  109. package/scripts/claude-hooks/coo-denylist-gate.mjs +11 -0
  110. package/scripts/claude-hooks/coo-guard-check.mjs +11 -0
  111. package/scripts/claude-hooks/deep-research-cost-gate.mjs +11 -0
  112. package/scripts/claude-hooks/dev-server-task-reminder.mjs +15 -0
  113. package/scripts/claude-hooks/diagnose-capture-reminder.mjs +11 -0
  114. package/scripts/claude-hooks/directive-obligation-gate.mjs +11 -0
  115. package/scripts/claude-hooks/emit-claim-status.mjs +41 -0
  116. package/scripts/claude-hooks/enforce-session-sequence.mjs +11 -0
  117. package/scripts/claude-hooks/enforce-vault-naming.mjs +11 -0
  118. package/scripts/claude-hooks/env-vault-guard.mjs +11 -0
  119. package/scripts/claude-hooks/epic-milestone-phrasing-gate.mjs +11 -0
  120. package/scripts/claude-hooks/git-identity-guard.mjs +11 -0
  121. package/scripts/claude-hooks/git-reflog-audit.mjs +15 -0
  122. package/scripts/claude-hooks/git-stash-gate.mjs +11 -0
  123. package/scripts/claude-hooks/guard-auto-memory-writes.mjs +137 -0
  124. package/scripts/claude-hooks/id-display-format-gate.mjs +11 -0
  125. package/scripts/claude-hooks/inject-communication-style.mjs +86 -0
  126. package/scripts/claude-hooks/inject-coo-seat.mjs +78 -0
  127. package/scripts/claude-hooks/inject-dev-server-awareness.mjs +11 -0
  128. package/scripts/claude-hooks/inject-doctrine.mjs +13 -0
  129. package/scripts/claude-hooks/inject-vault-context.mjs +14 -0
  130. package/scripts/claude-hooks/intent-classifier-trigger.mjs +279 -0
  131. package/scripts/claude-hooks/log-session-activity.mjs +341 -0
  132. package/scripts/claude-hooks/naughty-list-gate.mjs +11 -0
  133. package/scripts/claude-hooks/notify-status-flip.mjs +72 -0
  134. package/scripts/claude-hooks/plan-exit-ingest-gate.mjs +170 -0
  135. package/scripts/claude-hooks/plan-ingest-close-reminder.mjs +66 -0
  136. package/scripts/claude-hooks/plan-review-gate.mjs +11 -0
  137. package/scripts/claude-hooks/preflight-reminder.mjs +262 -0
  138. package/scripts/claude-hooks/protected-action-intent.mjs +221 -0
  139. package/scripts/claude-hooks/research-method-guard.mjs +398 -0
  140. package/scripts/claude-hooks/research-protocol-gate.mjs +11 -0
  141. package/scripts/claude-hooks/session-end.mjs +451 -0
  142. package/scripts/claude-hooks/settings-write-gate.mjs +11 -0
  143. package/scripts/claude-hooks/settlement-check-gate.mjs +39 -0
  144. package/scripts/claude-hooks/status-flip-detect.mjs +17 -0
  145. package/scripts/claude-hooks/terminal-close-shared.mjs +426 -0
  146. package/scripts/claude-hooks/terminal-close-watcher.mjs +122 -0
  147. package/scripts/claude-hooks/vault-mirror-catchup.mjs +11 -0
  148. package/scripts/claude-hooks/vault-write-tier-guard.mjs +11 -0
  149. package/scripts/claude-hooks/wip-checkpoint.mjs +302 -0
  150. package/scripts/cursor-hooks/manifest.mjs +281 -0
  151. package/scripts/dev-server-status.mjs +34 -13
  152. package/scripts/dev-server-stop.mjs +313 -0
  153. package/scripts/heyspex-mode.mjs +288 -0
  154. package/scripts/hs-auto-resolve.mjs +240 -0
  155. package/scripts/hs-auto-source.mjs +398 -0
  156. package/scripts/hs-auto.mjs +134 -0
  157. package/scripts/hs-capture-cli.mjs +326 -0
  158. package/scripts/hs-classifier.mjs +136 -0
  159. package/scripts/hs-companion.mjs +88 -0
  160. package/scripts/hs-gateway.mjs +115 -0
  161. package/scripts/hs-mode.mjs +125 -0
  162. package/scripts/hs-next.mjs +441 -0
  163. package/scripts/hs-settings.mjs +338 -0
  164. package/scripts/hs-view.mjs +496 -0
  165. package/scripts/hs-worktree.mjs +201 -0
  166. package/scripts/install-claude-code-hooks.mjs +272 -0
  167. package/scripts/install-cursor-hooks.mjs +471 -0
  168. package/scripts/lib/agent-env-markers.mjs +38 -0
  169. package/scripts/lib/console-url-resolver.mjs +199 -61
  170. package/scripts/lib/customer-kit.mjs +1203 -0
  171. package/scripts/lib/dev-mode.mjs +249 -32
  172. package/scripts/lib/directive-obligations.mjs +349 -0
  173. package/scripts/lib/heyspex-home.mjs +72 -0
  174. package/scripts/lib/hook-dispositions.mjs +210 -0
  175. package/scripts/lib/hook-vault-header.mjs +30 -0
  176. package/scripts/lib/hs-command.mjs +277 -0
  177. package/scripts/lib/hs-companion-settings.mjs +472 -0
  178. package/scripts/lib/hs-source-branch.mjs +196 -0
  179. package/scripts/lib/hs-source-commit.mjs +59 -5
  180. package/scripts/lib/hs-source-identity.mjs +526 -0
  181. package/scripts/lib/hs-source-log.mjs +13 -22
  182. package/scripts/lib/hs-source-preflight.mjs +18 -4
  183. package/scripts/lib/hs-source-push.mjs +386 -307
  184. package/scripts/lib/hs-worktree-args.mjs +115 -0
  185. package/scripts/lib/shared-path-helpers.mjs +134 -0
  186. package/scripts/lib/standalone-doctor.mjs +540 -3
  187. package/scripts/lib/subagent-git-lock.mjs +109 -0
  188. package/scripts/lib/template-pin.mjs +34 -0
  189. package/scripts/lib/window-placement.mjs +109 -0
  190. package/scripts/provenance-lib.mjs +91 -0
  191. package/scripts/stamp-provenance-on-edit.mjs +222 -0
  192. package/scripts/wip-ledger-health.mjs +653 -0
  193. package/spexharness/doctrine/baseline/SPEXCODE-DOCTRINE.baseline.json +29 -0
  194. package/spexharness/src/adapters/audit.mjs +197 -0
  195. package/spexharness/src/adapters/claude-code.mjs +449 -0
  196. package/spexharness/src/adapters/copilot.mjs +236 -0
  197. package/spexharness/src/adapters/detect-harness.mjs +34 -0
  198. package/spexharness/src/chat/tui/ansi.mjs +448 -0
  199. package/spexharness/src/chat/tui/palette.mjs +22 -0
  200. package/spexharness/src/chat/tui/select.mjs +208 -0
  201. package/spexharness/src/config-root.mjs +54 -0
  202. package/spexharness/src/context-budget.mjs +141 -0
  203. package/spexharness/src/dev-server-control.mjs +389 -0
  204. package/spexharness/src/doctrine/behaviour.mjs +371 -0
  205. package/spexharness/src/doctrine/corpus.mjs +733 -0
  206. package/spexharness/src/doctrine/inject.mjs +338 -0
  207. package/spexharness/src/doctrine/merge.mjs +184 -0
  208. package/spexharness/src/doctrine/sources.mjs +252 -0
  209. package/spexharness/src/doctrine/synthesize.mjs +401 -0
  210. package/spexharness/src/doctrine/vault-source.mjs +574 -0
  211. package/spexharness/src/doctrine/writer.mjs +176 -0
  212. package/spexharness/src/events.mjs +84 -0
  213. package/spexharness/src/gates/_shell-write-targets.mjs +77 -0
  214. package/spexharness/src/gates/capture-workflow-gate.mjs +202 -0
  215. package/spexharness/src/gates/claim-before-code-gate.decide.mjs +33 -0
  216. package/spexharness/src/gates/claim-before-code-gate.mjs +129 -0
  217. package/spexharness/src/gates/deep-research-cost-gate.mjs +275 -0
  218. package/spexharness/src/gates/diagnose-capture-reminder.mjs +112 -0
  219. package/spexharness/src/gates/directive-obligation-gate.decide.mjs +132 -0
  220. package/spexharness/src/gates/directive-obligation-gate.mjs +183 -0
  221. package/spexharness/src/gates/enforce-session-sequence.mjs +895 -0
  222. package/spexharness/src/gates/enforce-vault-naming.mjs +215 -0
  223. package/spexharness/src/gates/epic-milestone-phrasing-gate.mjs +233 -0
  224. package/spexharness/src/gates/git-identity-guard.decide.mjs +436 -0
  225. package/spexharness/src/gates/git-identity-guard.mjs +61 -0
  226. package/spexharness/src/gates/git-reflog-audit.mjs +151 -0
  227. package/spexharness/src/gates/git-stash-gate.decide.mjs +361 -0
  228. package/spexharness/src/gates/git-stash-gate.mjs +159 -0
  229. package/spexharness/src/gates/id-display-format-gate.mjs +190 -0
  230. package/spexharness/src/gates/inject-doctrine-gate.mjs +89 -0
  231. package/spexharness/src/gates/inject-vault-context.mjs +239 -0
  232. package/spexharness/src/gates/naughty-list-gate.mjs +213 -0
  233. package/spexharness/src/gates/plan-review-gate.mjs +233 -0
  234. package/spexharness/src/gates/preflight-reminder-gate.mjs +265 -0
  235. package/spexharness/src/gates/research-protocol-gate.mjs +175 -0
  236. package/spexharness/src/gates/settings-write-gate.decide.mjs +96 -0
  237. package/spexharness/src/gates/settings-write-gate.mjs +104 -0
  238. package/spexharness/src/gates/vault-mirror-catchup.mjs +124 -0
  239. package/spexharness/src/gates/vault-write-tier-guard.decide.mjs +125 -0
  240. package/spexharness/src/gates/vault-write-tier-guard.mjs +42 -0
  241. package/spexharness/src/git-reflog-audit.mjs +294 -0
  242. package/spexharness/src/intent-classifier.mjs +196 -0
  243. package/spexharness/src/intent-directive.mjs +626 -0
  244. package/spexharness/src/profile/learn.mjs +551 -0
  245. package/spexharness/src/profile/proposals.mjs +357 -0
  246. package/spexharness/src/profile/vault-inclusion.mjs +386 -0
  247. package/spexharness/src/redact.mjs +151 -0
  248. package/spexharness/src/rule-table.mjs +849 -0
  249. package/spexharness/src/seat-context.mjs +122 -0
  250. package/spexharness/src/session-bootstrap.mjs +512 -0
  251. package/spexharness/src/session-record.mjs +815 -0
  252. package/spexharness/src/token-inventory.mjs +207 -0
  253. package/vendor/heyspex-mcp/account-switch.mjs +96 -0
  254. package/vendor/heyspex-mcp/alignment-gate.mjs +315 -1
  255. package/vendor/heyspex-mcp/blocker-resolver.mjs +37 -2
  256. package/vendor/heyspex-mcp/breakdown-ladder.mjs +119 -4
  257. package/vendor/heyspex-mcp/breakdown-source.mjs +271 -0
  258. package/vendor/heyspex-mcp/breakdown-writer.mjs +23 -3
  259. package/vendor/heyspex-mcp/capture-buffer-patches.mjs +77 -8
  260. package/vendor/heyspex-mcp/capture-channel.mjs +55 -5
  261. package/vendor/heyspex-mcp/capture-console-client.mjs +117 -3
  262. package/vendor/heyspex-mcp/capture-focus.mjs +1061 -31
  263. package/vendor/heyspex-mcp/capture-outbox.mjs +15 -5
  264. package/vendor/heyspex-mcp/capture-sources.mjs +195 -0
  265. package/vendor/heyspex-mcp/capture-tools.mjs +1264 -0
  266. package/vendor/heyspex-mcp/claim-tools.mjs +1147 -0
  267. package/vendor/heyspex-mcp/cli-verbs.mjs +2074 -0
  268. package/vendor/heyspex-mcp/cli.mjs +207 -15524
  269. package/vendor/heyspex-mcp/close-branch-report.mjs +102 -0
  270. package/vendor/heyspex-mcp/cloud-tool-surface.mjs +128 -0
  271. package/vendor/heyspex-mcp/cloud-vault-writes.mjs +602 -0
  272. package/vendor/heyspex-mcp/config-seed.yaml +15 -0
  273. package/vendor/heyspex-mcp/config-settings.mjs +290 -7
  274. package/vendor/heyspex-mcp/console-fetch.mjs +822 -17
  275. package/vendor/heyspex-mcp/console-transport.mjs +172 -0
  276. package/vendor/heyspex-mcp/credentials.mjs +585 -0
  277. package/vendor/heyspex-mcp/defer-spec.mjs +44 -4
  278. package/vendor/heyspex-mcp/deferred-reclaim.mjs +29 -3
  279. package/vendor/heyspex-mcp/design-handlers.mjs +31 -0
  280. package/vendor/heyspex-mcp/design-tools.mjs +4 -1
  281. package/vendor/heyspex-mcp/dev-factory-bridge.mjs +4 -4
  282. package/vendor/heyspex-mcp/dev-mode-core.mjs +45 -0
  283. package/vendor/heyspex-mcp/dev-tools.mjs +131 -0
  284. package/vendor/heyspex-mcp/diagnose-handlers.mjs +54 -0
  285. package/vendor/heyspex-mcp/events-tools.mjs +207 -0
  286. package/vendor/heyspex-mcp/filing-tools.mjs +723 -0
  287. package/vendor/heyspex-mcp/fleet-handlers.mjs +37 -0
  288. package/vendor/heyspex-mcp/fleet-queue-tools.mjs +71 -15
  289. package/vendor/heyspex-mcp/focus-tools.mjs +190 -0
  290. package/vendor/heyspex-mcp/folder-trust-store.mjs +60 -0
  291. package/vendor/heyspex-mcp/http-transport.mjs +302 -0
  292. package/vendor/heyspex-mcp/list-tools.mjs +257 -0
  293. package/vendor/heyspex-mcp/login-handoff.mjs +15 -1
  294. package/vendor/heyspex-mcp/mcp-config.mjs +107 -0
  295. package/vendor/heyspex-mcp/memory-tools.mjs +523 -0
  296. package/vendor/heyspex-mcp/mvp-mode.mjs +53 -6
  297. package/vendor/heyspex-mcp/obligation-backstop.mjs +103 -0
  298. package/vendor/heyspex-mcp/occupied-keys.mjs +142 -0
  299. package/vendor/heyspex-mcp/on-deck-brief.mjs +3377 -0
  300. package/vendor/heyspex-mcp/on-deck-scheduler.mjs +1708 -0
  301. package/vendor/heyspex-mcp/package-root.mjs +32 -0
  302. package/vendor/heyspex-mcp/peer-presence.mjs +8 -1
  303. package/vendor/heyspex-mcp/plan-ground.mjs +621 -652
  304. package/vendor/heyspex-mcp/plan-push.mjs +908 -22
  305. package/vendor/heyspex-mcp/plan-review.mjs +124 -30
  306. package/vendor/heyspex-mcp/preflight-gate.mjs +0 -0
  307. package/vendor/heyspex-mcp/procedure-handlers.mjs +327 -0
  308. package/vendor/heyspex-mcp/procedure-tools.mjs +1181 -970
  309. package/vendor/heyspex-mcp/promote-capture.mjs +388 -69
  310. package/vendor/heyspex-mcp/promote-diagnose.mjs +74 -0
  311. package/vendor/heyspex-mcp/promote-research-to-diagnose.mjs +9 -2
  312. package/vendor/heyspex-mcp/promote-research.mjs +666 -53
  313. package/vendor/heyspex-mcp/promotion-helpers.mjs +251 -0
  314. package/vendor/heyspex-mcp/prompts.mjs +1460 -0
  315. package/vendor/heyspex-mcp/read-path.mjs +191 -15
  316. package/vendor/heyspex-mcp/receipt-shape.mjs +146 -0
  317. package/vendor/heyspex-mcp/recipe-handlers.mjs +117 -0
  318. package/vendor/heyspex-mcp/regen-tasks.mjs +210 -3
  319. package/vendor/heyspex-mcp/register-spec.mjs +266 -54
  320. package/vendor/heyspex-mcp/request-context.mjs +83 -0
  321. package/vendor/heyspex-mcp/research-focus.mjs +207 -4
  322. package/vendor/heyspex-mcp/research-handlers.mjs +625 -0
  323. package/vendor/heyspex-mcp/research-tools.mjs +4 -4
  324. package/vendor/heyspex-mcp/response-budget.mjs +348 -0
  325. package/vendor/heyspex-mcp/review-focus.mjs +25 -1
  326. package/vendor/heyspex-mcp/roadmap-default.mjs +70 -0
  327. package/vendor/heyspex-mcp/search-specs.mjs +280 -0
  328. package/vendor/heyspex-mcp/seat-block-ledger.mjs +113 -0
  329. package/vendor/heyspex-mcp/seat-identity.mjs +2 -6
  330. package/vendor/heyspex-mcp/session-owner.mjs +109 -0
  331. package/vendor/heyspex-mcp/session-tools.mjs +162 -0
  332. package/vendor/heyspex-mcp/settings-registry.mjs +232 -0
  333. package/vendor/heyspex-mcp/settings-tools.mjs +85 -12
  334. package/vendor/heyspex-mcp/spec-edit-tools.mjs +453 -0
  335. package/vendor/heyspex-mcp/spec-id-lib.mjs +93 -17
  336. package/vendor/heyspex-mcp/split-research.mjs +304 -56
  337. package/vendor/heyspex-mcp/stdio-rpc.mjs +22 -0
  338. package/vendor/heyspex-mcp/submit-for-review.mjs +150 -7
  339. package/vendor/heyspex-mcp/summary-contract.mjs +105 -0
  340. package/vendor/heyspex-mcp/summary-write-tools.mjs +228 -0
  341. package/vendor/heyspex-mcp/symptom-query.mjs +146 -0
  342. package/vendor/heyspex-mcp/test-helpers.mjs +123 -1
  343. package/vendor/heyspex-mcp/tool-catalog.mjs +2819 -0
  344. package/vendor/heyspex-mcp/tool-dispatch.mjs +657 -0
  345. package/vendor/heyspex-mcp/tool-helpers.mjs +325 -0
  346. package/vendor/heyspex-mcp/validate-linkage.mjs +414 -27
  347. package/vendor/heyspex-mcp/vault-io.mjs +720 -0
  348. package/vendor/heyspex-mcp/vault-less-endpoints.mjs +52 -0
  349. package/vendor/heyspex-mcp/vault-resolve.mjs +554 -68
  350. package/vendor/heyspex-mcp/verify-reproduction.mjs +270 -30
  351. package/vendor/heyspex-mcp/whoami.mjs +92 -0
  352. package/vendor/heyspex-mcp/write-outbox.mjs +512 -0
@@ -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;
@@ -581,10 +851,23 @@ export function validateSummaryLength(summary) {
581
851
  /** @deprecated renamed to validateSummaryLength (HS-EPIC-OED2). */
582
852
  export const validateConceptLength = validateSummaryLength;
583
853
 
854
+ /**
855
+ * Strictly verbatim note entry (HS-TGHU). MUST match the console's
856
+ * formatUserNoteEntry (capture-vault.ts): a plain one-line quote keeps the
857
+ * legacy one-line form; anything with newlines or edge whitespace renders as a
858
+ * per-line " > " blockquote. Only CRLF/CR become LF.
859
+ */
584
860
  export function formatUserNoteEntry(iso, role, text) {
585
- const clean = text.trim().replace(/\n+/g, ' ');
861
+ const raw = String(text).replace(/\r\n?/g, '\n');
586
862
  const label = role === 'user' ? 'you' : role;
587
- return `- **${iso} · ${label}:** "${clean}"`;
863
+ if (!raw.includes('\n') && raw === raw.trim()) {
864
+ return `- **${iso} · ${label}:** "${raw}"`;
865
+ }
866
+ const quoted = raw
867
+ .split('\n')
868
+ .map((line) => (line === '' ? ' >' : ` > ${line}`))
869
+ .join('\n');
870
+ return `- **${iso} · ${label}:**\n${quoted}`;
588
871
  }
589
872
 
590
873
  export function appendCaptureNote(existingNotes, entry) {
@@ -1025,6 +1308,7 @@ export const UPDATE_CAPTURE_ACCEPTED_ARGS = [
1025
1308
  'kind',
1026
1309
  'done_when',
1027
1310
  'jtbd',
1311
+ 'sources',
1028
1312
  ];
1029
1313
 
1030
1314
  /**
@@ -1083,6 +1367,60 @@ export function executeUpdateCapture(vaultRoot, parseFrontmatter, args = {}, dep
1083
1367
  return { error: 'CAPTURE_FILE_MISSING', message: specPath };
1084
1368
  }
1085
1369
 
1370
+ const content = readFileSync(fullPath, 'utf-8');
1371
+ const { frontmatter, body } = parseFrontmatter(content);
1372
+ const computed = computeCaptureUpdate(frontmatter, body, args);
1373
+ if (computed.error) return computed;
1374
+ const { fm, newBody, core, notes, summaryWarning, hasContent } = computed;
1375
+
1376
+ writeFileSync(fullPath, stringifyCaptureSpec(fm, newBody), 'utf8');
1377
+
1378
+ // Real content just landed → drop the empty-scaffold fuse so this capture is
1379
+ // no longer treated as an abandonable zombie. Runs regardless of whether a
1380
+ // claim refresh follows (a content write without a claim must still defuse).
1381
+ if (hasContent) {
1382
+ clearCaptureFocusExpiry(vaultRoot);
1383
+ }
1384
+
1385
+ if (deps.claim) {
1386
+ updateCaptureFocusClaim(vaultRoot, deps.claim);
1387
+ }
1388
+
1389
+ return buildUpdateCaptureResult({
1390
+ fm,
1391
+ newBody,
1392
+ specPath,
1393
+ core,
1394
+ notes,
1395
+ args,
1396
+ autoCreated,
1397
+ continuationOf,
1398
+ summaryWarning,
1399
+ });
1400
+ }
1401
+
1402
+ /**
1403
+ * The PURE middle of `heyspex_update_capture`: given the capture's current
1404
+ * `{ frontmatter, body }` and the tool args, produce the post-edit
1405
+ * `{ fm, newBody }` pair plus everything the result needs. No I/O at all —
1406
+ * which is what lets the local path `writeFileSync` it and the cloud path hand
1407
+ * the very same pair to `PUT /api/specs/:id` ({@link updateSpecViaConsole})
1408
+ * without two copies of this logic drifting apart.
1409
+ *
1410
+ * Returns a `{ error, message }` result (the shape the tool already returns)
1411
+ * for every rejection.
1412
+ */
1413
+ function computeCaptureUpdate(frontmatter, body, args = {}) {
1414
+ // sources (links / original files) live in the capture session's content_buffer
1415
+ // (console API path). This spec-file path has nowhere to put them, so refuse BY
1416
+ // NAME rather than silently dropping them (additionalproperties-false-in-an-mcp).
1417
+ if (Array.isArray(args.sources) && args.sources.length > 0) {
1418
+ return {
1419
+ error: 'SOURCES_UNSUPPORTED_ON_SPEC_FILE_PATH',
1420
+ message:
1421
+ 'heyspex_update_capture `sources` is only supported through the console capture-session path. NOTHING WAS WRITTEN. Retry against a reachable console.',
1422
+ };
1423
+ }
1086
1424
  // `summary` is the canonical input; `concept` is the deprecated alias (HS-EPIC-OED2).
1087
1425
  const summaryArg = args.summary ?? args.concept;
1088
1426
 
@@ -1099,8 +1437,6 @@ export function executeUpdateCapture(vaultRoot, parseFrontmatter, args = {}, dep
1099
1437
  if (blocked) return blocked;
1100
1438
  }
1101
1439
 
1102
- const content = readFileSync(fullPath, 'utf-8');
1103
- const { frontmatter, body } = parseFrontmatter(content);
1104
1440
  let core = extractCaptureCoreFromBody(body);
1105
1441
  let notes = extractCaptureNotesFromBody(body);
1106
1442
  let summary = extractCaptureSummaryFromBody(body);
@@ -1190,8 +1526,22 @@ export function executeUpdateCapture(vaultRoot, parseFrontmatter, args = {}, dep
1190
1526
 
1191
1527
  // Preserve any existing `## Source Material` across the rebuild (MCP args don't
1192
1528
  // 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);
1529
+ // Structured read so a LIST-form section (many sources per capture,
1530
+ // [[HS-9GGC-U1DO-EPIC-source-list-schema-and-one-capture]]) survives too —
1531
+ // the plain-string reader would flatten it into a re-fenced blob.
1532
+ const existingSourceMaterial = extractSourceMaterialStructured(body);
1533
+ const newBody = buildCaptureBody(
1534
+ core,
1535
+ notes,
1536
+ summary,
1537
+ agentExpansion,
1538
+ existingSourceMaterial.blob,
1539
+ example,
1540
+ userStories,
1541
+ doneWhen,
1542
+ jtbd,
1543
+ existingSourceMaterial.sources,
1544
+ );
1195
1545
  const updated = new Date().toISOString().slice(0, 10);
1196
1546
  const fm = { ...frontmatter, updated };
1197
1547
  // SV2K: assert type: capture going forward; legacy type: idea is tolerated until E2.
@@ -1211,11 +1561,9 @@ export function executeUpdateCapture(vaultRoot, parseFrontmatter, args = {}, dep
1211
1561
  if (firstLine) fm.name = firstLine.replace(/^[-*>\s"]+|["]+$/g, '').slice(0, 80);
1212
1562
  }
1213
1563
 
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).
1564
+ // Real content just landed → the caller drops the empty-scaffold fuse so this
1565
+ // capture is no longer treated as an abandonable zombie. Computed here (pure)
1566
+ // and acted on by whichever persistence path ran.
1219
1567
  const hasContent =
1220
1568
  core.trim() !== '' ||
1221
1569
  notes.trim() !== '' ||
@@ -1223,14 +1571,27 @@ export function executeUpdateCapture(vaultRoot, parseFrontmatter, args = {}, dep
1223
1571
  example.trim() !== '' ||
1224
1572
  doneWhen.trim() !== '' ||
1225
1573
  String(agentExpansion ?? '').trim() !== '';
1226
- if (hasContent) {
1227
- clearCaptureFocusExpiry(vaultRoot);
1228
- }
1229
1574
 
1230
- if (deps.claim) {
1231
- updateCaptureFocusClaim(vaultRoot, deps.claim);
1232
- }
1575
+ return { fm, newBody, core, notes, summaryWarning, hasContent };
1576
+ }
1233
1577
 
1578
+ /**
1579
+ * Shape the `heyspex_update_capture` success payload. Shared verbatim by the
1580
+ * local (disk) and cloud (console) persistence paths so the two can never
1581
+ * report different shapes for the same write.
1582
+ */
1583
+ function buildUpdateCaptureResult({
1584
+ fm,
1585
+ newBody,
1586
+ specPath,
1587
+ core,
1588
+ notes,
1589
+ args = {},
1590
+ autoCreated,
1591
+ continuationOf,
1592
+ summaryWarning,
1593
+ extra = null,
1594
+ }) {
1234
1595
  const result = {
1235
1596
  success: true,
1236
1597
  spec_id: fm.id,
@@ -1264,6 +1625,9 @@ export function executeUpdateCapture(vaultRoot, parseFrontmatter, args = {}, dep
1264
1625
  // the agent elicits missing elements in this pass instead of bouncing at
1265
1626
  // promote time. Read-only — never blocks the update or triggers a promote.
1266
1627
  result.sufficiency = computeCaptureSufficiency(fm, newBody);
1628
+ // Cloud-only additions (`write_mode` / `on_disk` / `console_url`). Null on the
1629
+ // local path, so that payload stays byte-identical to what it always was.
1630
+ if (extra) Object.assign(result, extra);
1267
1631
  return result;
1268
1632
  }
1269
1633
 
@@ -1280,7 +1644,13 @@ function unwrapWikilink(s) {
1280
1644
  return m ? m[1] : trimmed;
1281
1645
  }
1282
1646
 
1283
- export function createFreshCaptureSpec(vaultRoot, allocateRandomVaultId, occupiedCaptureIds, lineage = null) {
1647
+ export function createFreshCaptureSpec(
1648
+ vaultRoot,
1649
+ allocateRandomVaultId,
1650
+ occupiedCaptureIds,
1651
+ lineage = null,
1652
+ deps = {},
1653
+ ) {
1284
1654
  // Era-3 file-key-first ([[naming-v3-7-rulings-hs-namespace-kept]]): the
1285
1655
  // FAMILY token still comes from the legacy allocator's `HS-<family>-CAP`
1286
1656
  // draw (parentless capture — no family to inherit); `id:` keys onto the
@@ -1289,12 +1659,17 @@ export function createFreshCaptureSpec(vaultRoot, allocateRandomVaultId, occupie
1289
1659
  const familyKey = parseSpecId(allocatedFamilyId)?.familyKey ?? mintFamilyKey(vaultRoot);
1290
1660
  // Per-file FILE KEY (owner ruling 2026-07-10) — filename + `file_id:` SSOT,
1291
1661
  // and (V3) the anchor for `id:` itself.
1292
- const fileKey = mintFileKey(vaultRoot);
1662
+ // Disk occupancy ([[HS-FJWL]]): this is the LOCAL branch of
1663
+ // `createFreshCaptureSpecViaMode` — cloud reliance routes to
1664
+ // `createFreshCaptureSpecViaConsole`, where the console mints the id — so
1665
+ // the vault walk is complete here by construction.
1666
+ const fileKey = mintFileKey(collectOccupiedKeysEitherKind(vaultRoot));
1293
1667
  const newId = `HS-${fileKey}`;
1294
1668
  const today = new Date().toISOString().slice(0, 10);
1295
1669
  const slug = `idea-${today}`;
1296
1670
  const filename = `${newId}-${familyKey}-CAP-${slug}.md`;
1297
1671
  const dir = join(vaultRoot, 'specs', 'captures');
1672
+ assertPathUnderRoot(vaultRoot, dir, 'capture-focus write');
1298
1673
  mkdirSync(dir, { recursive: true });
1299
1674
  const iso = new Date().toISOString();
1300
1675
  const frontmatter = {
@@ -1305,7 +1680,9 @@ export function createFreshCaptureSpec(vaultRoot, allocateRandomVaultId, occupie
1305
1680
  type: 'capture',
1306
1681
  kind: 'idea',
1307
1682
  status: 'open',
1308
- owner: 'pwher',
1683
+ // `owner:` is the paired session account, OMITTED when none resolves —
1684
+ // never a literal handle ([[HS-WE87-WW9R-TASK-thread-the-gated-caller-through-every]]).
1685
+ ...ownerFrontmatterEntry({ resolve: deps.resolveSessionOwner ?? resolveSessionOwner }),
1309
1686
  created: iso,
1310
1687
  updated: iso,
1311
1688
  tags: ['idea', 'capture'],
@@ -1364,3 +1741,656 @@ export function executePinCapture(vaultRoot, parseFrontmatter, loadSpec, args =
1364
1741
  message: `Capture ${specId} is now on deck for /hs-capture`,
1365
1742
  };
1366
1743
  }
1744
+
1745
+ // ---------------------------------------------------------------------------
1746
+ // Cloud vault-write mode
1747
+ // ([[HS-BYU4-CWPX-TASK-promotion-writes-go-through-the-console]])
1748
+ //
1749
+ // Everything above this line is the LOCAL write path and is untouched: the
1750
+ // markdown goes to `specs/captures/` and the pin goes to the YAML pin file,
1751
+ // exactly as before.
1752
+ //
1753
+ // When `resolveVaultWriteMode()` says `cloud`, neither may touch this machine:
1754
+ //
1755
+ // 1. The capture MARKDOWN (the document itself) is created with
1756
+ // `POST /api/specs` (`type: 'capture'`, server-minted id — nothing here
1757
+ // mints a file key, a family key or an allocator id in cloud mode) and
1758
+ // edited with `PUT /api/specs/:id`.
1759
+ // 2. The PIN is the Neon control plane, not the YAML pin file. Layer-1
1760
+ // (`spec_id` / `pinned_at` / `expires` — including the [[HS-CAP-SID6]]
1761
+ // self-clear fuse) is `PUT /api/control/capture-focus-pin`; layer-2
1762
+ // (`spec_path` / `kind` / `reason` / `claimed_by` / `claim_session`) is
1763
+ // `PUT /api/control/capture-focus-selection`. That is the split
1764
+ // [[HS-M0MK-CPFP-TASK-migrate-capture-focus-yaml-to-neon-capture-focus-pin]]
1765
+ // and [[HS-IIP4-CFL2-TASK-migrate-capture-focus-layer-2-to-neon-capture-focus-selection]]
1766
+ // already shipped behind those routes; IIP4 §Scope item 6 is exactly this
1767
+ // vendor-side repoint, which CPFP deferred as a sync cascade.
1768
+ //
1769
+ // That sync cascade is why the functions below are SIBLINGS rather than an
1770
+ // async-ification of the originals: `writeCaptureFocus` / `executeUpdateCapture`
1771
+ // / `ensureActiveCapturePin` are called from sync paths in cli.mjs and
1772
+ // capture-tools.mjs, and every one of those callers must keep working
1773
+ // unchanged. Each `*ViaMode` entry point resolves the write mode ONCE and
1774
+ // either delegates to its sync twin (`local`) or runs the console path
1775
+ // (`cloud`); the mode can also be passed in, so a caller that already resolved
1776
+ // it does not resolve it twice.
1777
+ //
1778
+ // Every console call takes an injected `consoleFetch`, so tests drive each
1779
+ // branch with a recorder and never open a socket.
1780
+ // ---------------------------------------------------------------------------
1781
+
1782
+ export const CAPTURE_FOCUS_PIN_ROUTE = '/api/control/capture-focus-pin';
1783
+ export const CAPTURE_FOCUS_SELECTION_ROUTE = '/api/control/capture-focus-selection';
1784
+
1785
+ /**
1786
+ * The capture spec id a `spec_path` names.
1787
+ *
1788
+ * A local twin of capture-tools.mjs's `captureSpecIdFromPath` on purpose: that
1789
+ * module imports THIS one, so importing it back would be a cycle — and its
1790
+ * regex only reads era-1/2 `HS-CAP-XXXX` names, while a capture filed today is
1791
+ * era-3 (`HS-<fileKey>-<familyKey>-CAP-<slug>`, keyed on the file key alone).
1792
+ * Both eras are read here through the shared `parseSpecId`.
1793
+ */
1794
+ export function captureSpecIdFromSpecPath(specPath) {
1795
+ const base = String(specPath || '')
1796
+ .replace(/^.*[/\\]/, '')
1797
+ .replace(/\.md$/i, '');
1798
+ if (!base) return null;
1799
+ const parsed = parseSpecId(base);
1800
+ if (parsed?.fileKey) return `HS-${parsed.fileKey}`;
1801
+ if (parsed?.typeLabel && parsed?.familyKey) return `HS-${parsed.typeLabel}-${parsed.familyKey}`;
1802
+ return base;
1803
+ }
1804
+
1805
+ /**
1806
+ * Default cloud-tier spec read. Dynamically imported so this module keeps no
1807
+ * static edge to vault-io.mjs, and injectable because `loadSpecByIdViaTier`'s
1808
+ * neon branch closes over the real `consoleFetch` (a test must be able to
1809
+ * replace the whole read, not just the transport).
1810
+ */
1811
+ async function defaultLoadSpecViaTier(vaultRoot, ref, consoleUrl) {
1812
+ const { loadSpecByIdViaTier } = await import('./vault-io.mjs');
1813
+ return loadSpecByIdViaTier(vaultRoot, ref, { tier: 'neon', consoleUrl });
1814
+ }
1815
+
1816
+ /** Normalize the cloud dependency bundle every `*ViaConsole` function takes. */
1817
+ export function captureCloudDeps(deps = {}) {
1818
+ return {
1819
+ consoleFetch: deps.consoleFetch ?? realConsoleFetch,
1820
+ consoleUrl: deps.consoleUrl ?? getConsoleUrl(),
1821
+ loadSpecViaTier: deps.loadSpecViaTier ?? defaultLoadSpecViaTier,
1822
+ };
1823
+ }
1824
+
1825
+ /** Resolve the write mode ONCE for a handler entry; `?? 'local'` keeps callers unchanged. */
1826
+ async function resolveWriteModeOnce(explicit, deps = {}) {
1827
+ return explicit ?? (await (deps.resolveWriteMode ?? resolveVaultWriteMode)()) ?? 'local';
1828
+ }
1829
+
1830
+ /**
1831
+ * Read the live capture pin from the Neon control plane: layer-1 ⊕ layer-2,
1832
+ * composed the same way the console's `readCaptureFocusState` does — layer-2 is
1833
+ * attached only when its `spec_path` names the pinned spec, so a stale
1834
+ * selection never paints onto a freshly-pinned one.
1835
+ *
1836
+ * Deliberately NOT `readCaptureFocusVendor` (capture-tools.mjs): that reader
1837
+ * falls back to the offline YAML pin file, and its `loadCaptureFocus` call runs
1838
+ * `migrateLegacyIdeaFocus`, which WRITES that file. Both are exactly what cloud
1839
+ * mode forbids, so this path is strict — it throws
1840
+ * `CONSOLE_UNREACHABLE_CLOUD_MODE` rather than reading (or writing) the disk.
1841
+ *
1842
+ * @returns the same `focus` shape `loadCaptureFocus` returns, or `null` when
1843
+ * Neon says nothing is pinned (an authoritative answer, not a failure).
1844
+ */
1845
+ export async function readCaptureFocusViaConsole(deps = {}) {
1846
+ const { consoleFetch, consoleUrl } = captureCloudDeps(deps);
1847
+ const [pinRes, selRes] = await Promise.all([
1848
+ consoleFetch(CAPTURE_FOCUS_PIN_ROUTE),
1849
+ consoleFetch(CAPTURE_FOCUS_SELECTION_ROUTE),
1850
+ ]);
1851
+ if (!pinRes?.ok) {
1852
+ throw cloudWriteFailure(
1853
+ `GET ${CAPTURE_FOCUS_PIN_ROUTE}`,
1854
+ consoleUrl,
1855
+ `answered ${pinRes?.status ?? 'no response'}`,
1856
+ { status: pinRes?.status ?? null, console_response: pinRes?.body ?? null },
1857
+ );
1858
+ }
1859
+ const pin = pinRes.body?.pin ?? null;
1860
+ if (!pin) return null;
1861
+ const sel = selRes?.ok ? (selRes.body?.selection ?? null) : null;
1862
+ const basenameOf = (p) => String(p || '').replace(/^.*[/\\]/, '');
1863
+ const selMatches = sel?.specPath ? basenameOf(sel.specPath).startsWith(pin.specId) : false;
1864
+ const focus = {
1865
+ spec_id: pin.specId,
1866
+ spec_path: selMatches ? sel.specPath : `specs/captures/${pin.specId}.md`,
1867
+ pinned_at: pin.pinnedAt ?? null,
1868
+ };
1869
+ if (pin.expiresAt) focus.expires = pin.expiresAt;
1870
+ if (selMatches && sel) {
1871
+ if (sel.reason) focus.reason = sel.reason;
1872
+ if (sel.claimedBy) focus.claimed_by = sel.claimedBy;
1873
+ if (sel.claimSession) focus.claim_session = sel.claimSession;
1874
+ if (sel.kind) focus.kind = sel.kind;
1875
+ }
1876
+ if (!focus.kind) focus.kind = 'idea';
1877
+ return focus;
1878
+ }
1879
+
1880
+ /**
1881
+ * Cloud twin of {@link writeCaptureFocus}: pin layer-1 + layer-2 in one call.
1882
+ * The YAML pin file is never touched.
1883
+ *
1884
+ * `expires` (the empty-scaffold silent fuse) is carried as the pin route's
1885
+ * `ttl_ms`, which is how `capture_focus_pin` stores it — `getCaptureFocusPin`'s
1886
+ * SQL already excludes rows past `expires_at`, so the [[HS-CAP-SID6]] self-retire
1887
+ * behaves identically on this tier (CPFP AC#2).
1888
+ */
1889
+ export async function writeCaptureFocusViaConsole(
1890
+ { specId = null, specPath, reason = null, claim = null, kind = 'idea', expires = null } = {},
1891
+ deps = {},
1892
+ ) {
1893
+ const { consoleFetch, consoleUrl } = captureCloudDeps(deps);
1894
+ const id = specId || captureSpecIdFromSpecPath(specPath);
1895
+ if (!id) {
1896
+ throw cloudWriteFailure(
1897
+ `PUT ${CAPTURE_FOCUS_PIN_ROUTE}`,
1898
+ consoleUrl,
1899
+ `no capture spec id could be resolved from spec_path ${JSON.stringify(specPath ?? null)} — the pin route requires one`,
1900
+ );
1901
+ }
1902
+ const ttlMs = expires ? Date.parse(expires) - Date.now() : null;
1903
+ const [pinRes, selRes] = await Promise.all([
1904
+ consoleFetch(CAPTURE_FOCUS_PIN_ROUTE, {
1905
+ method: 'PUT',
1906
+ body: {
1907
+ spec_id: id,
1908
+ pinned_by: claim?.claimed_by || 'agent',
1909
+ ...(Number.isFinite(ttlMs) ? { ttl_ms: ttlMs } : {}),
1910
+ },
1911
+ }),
1912
+ consoleFetch(CAPTURE_FOCUS_SELECTION_ROUTE, {
1913
+ method: 'PUT',
1914
+ body: {
1915
+ spec_path: specPath ?? null,
1916
+ kind: typeof kind === 'string' && kind.trim() ? kind.trim() : 'idea',
1917
+ reason: reason ?? null,
1918
+ claimed_by: claim?.claimed_by ?? null,
1919
+ claim_session: claim?.claim_session ?? null,
1920
+ },
1921
+ }),
1922
+ ]);
1923
+ if (!pinRes?.ok || !selRes?.ok) {
1924
+ throw cloudWriteFailure(
1925
+ `PUT ${CAPTURE_FOCUS_PIN_ROUTE} + ${CAPTURE_FOCUS_SELECTION_ROUTE}`,
1926
+ consoleUrl,
1927
+ `pin answered ${pinRes?.status ?? 'no response'}, selection answered ${selRes?.status ?? 'no response'} — the capture is NOT on deck`,
1928
+ {
1929
+ pin_status: pinRes?.status ?? null,
1930
+ selection_status: selRes?.status ?? null,
1931
+ spec_id: id,
1932
+ spec_path: specPath ?? null,
1933
+ },
1934
+ );
1935
+ }
1936
+ return {
1937
+ pinned: true,
1938
+ tier: 'neon',
1939
+ on_disk: false,
1940
+ spec_id: id,
1941
+ spec_path: specPath ?? null,
1942
+ pin: pinRes.body?.pin ?? null,
1943
+ selection: selRes.body?.selection ?? null,
1944
+ };
1945
+ }
1946
+
1947
+ /**
1948
+ * Cloud twin of {@link updateCaptureFocusClaim} — refresh the claim metadata on
1949
+ * the LIVE pin. Layer-2 only: the claim lives in `capture_focus_selection`, so
1950
+ * the layer-1 row (and with it `pinned_at` and the fuse) is left alone.
1951
+ *
1952
+ * `setCaptureFocusSelection` replaces the whole row, so the fields this refresh
1953
+ * is not changing (`spec_path` / `kind` / `reason`) are re-sent from the row as
1954
+ * read. Best-effort like its sync twin: callers fire it after their real work
1955
+ * succeeded and none check the return value, so a failure reports `false` and a
1956
+ * stderr line rather than throwing.
1957
+ */
1958
+ export async function updateCaptureFocusClaimViaConsole(claim, deps = {}) {
1959
+ const { consoleFetch, consoleUrl } = captureCloudDeps(deps);
1960
+ try {
1961
+ const focus = await readCaptureFocusViaConsole({ consoleFetch, consoleUrl });
1962
+ if (!focus?.spec_path) return false;
1963
+ const res = await consoleFetch(CAPTURE_FOCUS_SELECTION_ROUTE, {
1964
+ method: 'PUT',
1965
+ body: {
1966
+ spec_path: focus.spec_path,
1967
+ kind: focus.kind || 'idea',
1968
+ reason: focus.reason ?? null,
1969
+ claimed_by: claim?.claimed_by ?? null,
1970
+ claim_session: claim?.claim_session ?? null,
1971
+ },
1972
+ });
1973
+ if (!res?.ok) {
1974
+ process.stderr.write?.(
1975
+ `[heyspex-mcp] updateCaptureFocusClaimViaConsole refused: ${CAPTURE_FOCUS_SELECTION_ROUTE} answered ${res?.status ?? 'no response'}\n`,
1976
+ );
1977
+ return false;
1978
+ }
1979
+ return true;
1980
+ } catch (err) {
1981
+ process.stderr.write?.(
1982
+ `[heyspex-mcp] updateCaptureFocusClaimViaConsole failed: ${err?.message ?? err}\n`,
1983
+ );
1984
+ return false;
1985
+ }
1986
+ }
1987
+
1988
+ /**
1989
+ * Cloud twin of {@link clearCaptureFocusExpiry}: drop the empty-scaffold fuse
1990
+ * once a capture has real content, by re-pinning layer-1 with `ttl_ms: null`.
1991
+ * Same no-op contract as the sync twin — `false` when nothing is pinned or the
1992
+ * pin carries no fuse.
1993
+ */
1994
+ export async function clearCaptureFocusExpiryViaConsole(deps = {}) {
1995
+ const { consoleFetch, consoleUrl } = captureCloudDeps(deps);
1996
+ const pinRes = await consoleFetch(CAPTURE_FOCUS_PIN_ROUTE);
1997
+ if (!pinRes?.ok) {
1998
+ throw cloudWriteFailure(
1999
+ `GET ${CAPTURE_FOCUS_PIN_ROUTE}`,
2000
+ consoleUrl,
2001
+ `answered ${pinRes?.status ?? 'no response'}`,
2002
+ { status: pinRes?.status ?? null },
2003
+ );
2004
+ }
2005
+ const pin = pinRes.body?.pin ?? null;
2006
+ if (!pin?.specId || !pin.expiresAt) return false;
2007
+ const res = await consoleFetch(CAPTURE_FOCUS_PIN_ROUTE, {
2008
+ method: 'PUT',
2009
+ body: { spec_id: pin.specId, pinned_by: pin.pinnedBy || 'agent', ttl_ms: null },
2010
+ });
2011
+ if (!res?.ok) {
2012
+ throw cloudWriteFailure(
2013
+ `PUT ${CAPTURE_FOCUS_PIN_ROUTE}`,
2014
+ consoleUrl,
2015
+ `answered ${res?.status ?? 'no response'} clearing the capture-pin fuse`,
2016
+ { status: res?.status ?? null, spec_id: pin.specId },
2017
+ );
2018
+ }
2019
+ return true;
2020
+ }
2021
+
2022
+ /**
2023
+ * Cloud twin of {@link createFreshCaptureSpec}.
2024
+ *
2025
+ * NOTHING is minted client-side here — no `allocateRandomVaultId`, no
2026
+ * `mintFamilyKey`, no `mintFileKey`. The console mints the id, the family and
2027
+ * the filename (`createSpec`: `capture` → `specs/captures/`, `CAP`), which is
2028
+ * the whole point: a key minted on a machine whose vault the console has never
2029
+ * indexed collides with a cloud-side one ([[HS-2FQY]]).
2030
+ */
2031
+ export async function createFreshCaptureSpecViaConsole(vaultRoot, lineage = null, deps = {}) {
2032
+ const { consoleFetch, consoleUrl } = captureCloudDeps(deps);
2033
+ const today = new Date().toISOString().slice(0, 10);
2034
+ // No `status:` — the console seeds `open` and refuses an out-of-band status
2035
+ // on create ([[HS-ISZP]]); the disk twin above keeps it because it writes
2036
+ // the frontmatter itself.
2037
+ const frontmatter = {
2038
+ kind: 'idea',
2039
+ // Same owner rule as the disk twin: the paired account or no key at all.
2040
+ ...ownerFrontmatterEntry({ resolve: deps.resolveSessionOwner ?? resolveSessionOwner }),
2041
+ tags: ['idea', 'capture'],
2042
+ promoted_to: null,
2043
+ capture_status: 'raw',
2044
+ // HS-KFB2 lineage carry-over — identical to the local path.
2045
+ ...(lineage?.promotedTo ? { continues: `[[${unwrapWikilink(lineage.promotedTo)}]]` } : {}),
2046
+ };
2047
+ const body = lineage?.promotedTo
2048
+ ? CAPTURE_BODY_TEMPLATE.replace(
2049
+ /^# Core Idea\n/,
2050
+ `# Core Idea\n\n> Continuation of a recently-promoted idea — see [[${unwrapWikilink(lineage.promotedTo)}]]` +
2051
+ (lineage.retiredCaptureBasename
2052
+ ? ` (originally [[${unwrapWikilink(lineage.retiredCaptureBasename)}]]).`
2053
+ : '.') +
2054
+ '\n',
2055
+ )
2056
+ : CAPTURE_BODY_TEMPLATE;
2057
+ const created = await createSpecViaConsole({
2058
+ consoleFetch,
2059
+ consoleUrl,
2060
+ type: 'capture',
2061
+ name: `Capture ${today}`,
2062
+ frontmatter,
2063
+ body,
2064
+ });
2065
+ return {
2066
+ spec_id: created.specId,
2067
+ spec_path: created.relPath ?? `specs/captures/${created.basename}.md`,
2068
+ on_disk: false,
2069
+ ...(lineage?.promotedTo ? { continuation_of: unwrapWikilink(lineage.promotedTo) } : {}),
2070
+ };
2071
+ }
2072
+
2073
+ /**
2074
+ * Mode-aware {@link createFreshCaptureSpec}. `local` delegates verbatim.
2075
+ * In `cloud` the allocator arguments are IGNORED (the server mints) — they stay
2076
+ * in the signature only so this is a drop-in for the sync call sites.
2077
+ */
2078
+ export async function createFreshCaptureSpecViaMode(
2079
+ vaultRoot,
2080
+ allocateRandomVaultId,
2081
+ occupiedCaptureIds,
2082
+ lineage = null,
2083
+ deps = {},
2084
+ ) {
2085
+ const writeMode = await resolveWriteModeOnce(deps.writeMode, deps);
2086
+ if (writeMode !== 'cloud') {
2087
+ return createFreshCaptureSpec(vaultRoot, allocateRandomVaultId, occupiedCaptureIds, lineage, deps);
2088
+ }
2089
+ return createFreshCaptureSpecViaConsole(vaultRoot, lineage, deps);
2090
+ }
2091
+
2092
+ /** Mode-aware {@link writeCaptureFocus}. */
2093
+ export async function writeCaptureFocusViaMode(vaultRoot, specPath, reason, claim, opts = {}, deps = {}) {
2094
+ const writeMode = await resolveWriteModeOnce(deps.writeMode, deps);
2095
+ if (writeMode !== 'cloud') {
2096
+ writeCaptureFocus(vaultRoot, specPath, reason, claim, opts);
2097
+ return { pinned: true, tier: 'file', on_disk: true, spec_path: specPath };
2098
+ }
2099
+ return writeCaptureFocusViaConsole(
2100
+ {
2101
+ specId: opts.specId ?? null,
2102
+ specPath,
2103
+ reason,
2104
+ claim,
2105
+ kind: opts.kind,
2106
+ expires: opts.expires ?? null,
2107
+ },
2108
+ deps,
2109
+ );
2110
+ }
2111
+
2112
+ /** Mode-aware {@link updateCaptureFocusClaim}. */
2113
+ export async function updateCaptureFocusClaimViaMode(vaultRoot, claim, deps = {}) {
2114
+ const writeMode = await resolveWriteModeOnce(deps.writeMode, deps);
2115
+ if (writeMode !== 'cloud') return updateCaptureFocusClaim(vaultRoot, claim);
2116
+ return updateCaptureFocusClaimViaConsole(claim, deps);
2117
+ }
2118
+
2119
+ /** Mode-aware {@link clearCaptureFocusExpiry}. */
2120
+ export async function clearCaptureFocusExpiryViaMode(vaultRoot, deps = {}) {
2121
+ const writeMode = await resolveWriteModeOnce(deps.writeMode, deps);
2122
+ if (writeMode !== 'cloud') return clearCaptureFocusExpiry(vaultRoot);
2123
+ return clearCaptureFocusExpiryViaConsole(deps);
2124
+ }
2125
+
2126
+ /**
2127
+ * Mode-aware {@link ensureActiveCapturePin}.
2128
+ *
2129
+ * The cloud branch reuses {@link resolveActiveCapture} rather than restating its
2130
+ * retirement / recently-promoted-lineage rules: that function already takes both
2131
+ * a `focusOverride` and an injected `loadSpec`, so the console-sourced pin and a
2132
+ * pre-fetched spec are handed straight to it and the SID6 / HS-KFB2 behavior is
2133
+ * the same code on both tiers.
2134
+ *
2135
+ * The `AUTO_CREATE_UNAVAILABLE` guard is local-only by construction: cloud mode
2136
+ * needs no id allocator because the console mints the id.
2137
+ */
2138
+ export async function ensureActiveCapturePinViaMode(
2139
+ vaultRoot,
2140
+ parseFrontmatter,
2141
+ loadSpec,
2142
+ deps = {},
2143
+ cloudDeps = {},
2144
+ ) {
2145
+ const writeMode = await resolveWriteModeOnce(cloudDeps.writeMode, cloudDeps);
2146
+ if (writeMode !== 'cloud') {
2147
+ return ensureActiveCapturePin(vaultRoot, parseFrontmatter, loadSpec, deps);
2148
+ }
2149
+ const ctx = captureCloudDeps(cloudDeps);
2150
+ const { pinReason = 'Auto-created via MCP', claim = null } = deps;
2151
+
2152
+ const focus = await readCaptureFocusViaConsole(ctx);
2153
+ let pinnedSpec = null;
2154
+ if (focus?.spec_path) {
2155
+ pinnedSpec = await ctx.loadSpecViaTier(
2156
+ vaultRoot,
2157
+ focus.spec_id ?? captureSpecIdFromSpecPath(focus.spec_path),
2158
+ ctx.consoleUrl,
2159
+ );
2160
+ }
2161
+ let resolved = resolveActiveCapture(vaultRoot, parseFrontmatter, () => pinnedSpec, focus ?? null);
2162
+ if (!resolved.error) {
2163
+ if (claim) await updateCaptureFocusClaimViaConsole(claim, ctx);
2164
+ return { resolved, fresh: false, auto_created: false };
2165
+ }
2166
+
2167
+ const previousError = resolved.error;
2168
+ const recoverable =
2169
+ resolved.error === 'NO_CAPTURE_PINNED' ||
2170
+ resolved.error === 'CAPTURE_NOT_FOUND' ||
2171
+ resolved.error === 'CAPTURE_RETIRED';
2172
+ if (!recoverable) {
2173
+ return { error: resolved.error, message: resolved.message };
2174
+ }
2175
+
2176
+ const lineage =
2177
+ resolved.recentlyPromoted && resolved.promotedTo
2178
+ ? { promotedTo: resolved.promotedTo, retiredCaptureBasename: resolved.retiredCaptureBasename }
2179
+ : null;
2180
+
2181
+ const created = await createFreshCaptureSpecViaConsole(vaultRoot, lineage, ctx);
2182
+ const expires = new Date(Date.now() + CAPTURE_PIN_FUSE_MS).toISOString();
2183
+ await writeCaptureFocusViaConsole(
2184
+ { specId: created.spec_id, specPath: created.spec_path, reason: pinReason, claim, expires },
2185
+ ctx,
2186
+ );
2187
+
2188
+ const freshSpec = await ctx.loadSpecViaTier(vaultRoot, created.spec_id, ctx.consoleUrl);
2189
+ resolved = resolveActiveCapture(vaultRoot, parseFrontmatter, () => freshSpec, {
2190
+ spec_id: created.spec_id,
2191
+ spec_path: created.spec_path,
2192
+ pinned_at: new Date().toISOString(),
2193
+ expires,
2194
+ kind: 'idea',
2195
+ reason: pinReason,
2196
+ });
2197
+ if (resolved.error) {
2198
+ return {
2199
+ error: resolved.error,
2200
+ message: resolved.message || 'Auto-create failed after pin write.',
2201
+ };
2202
+ }
2203
+ return {
2204
+ resolved,
2205
+ fresh: true,
2206
+ auto_created: true,
2207
+ replaced_stale_pin: previousError === 'CAPTURE_NOT_FOUND',
2208
+ ...(created.continuation_of ? { continuation_of: created.continuation_of } : {}),
2209
+ };
2210
+ }
2211
+
2212
+ /**
2213
+ * Mode-aware {@link executeUpdateCapture}.
2214
+ *
2215
+ * Cloud branch: the capture is sourced through the cloud read tier
2216
+ * (`loadSpecByIdViaTier`, the read half this repo already shipped — nothing new
2217
+ * is written here to fetch a spec), mutated by the SAME pure
2218
+ * `computeCaptureUpdate` the local path runs, and persisted with
2219
+ * `PUT /api/specs/:id`. The fuse-clear and claim-refresh follow-ups go to the
2220
+ * console pin routes instead of the YAML pin file.
2221
+ */
2222
+ export async function executeUpdateCaptureViaMode(
2223
+ vaultRoot,
2224
+ parseFrontmatter,
2225
+ args = {},
2226
+ deps = {},
2227
+ cloudDeps = {},
2228
+ ) {
2229
+ const writeMode = await resolveWriteModeOnce(cloudDeps.writeMode, cloudDeps);
2230
+ if (writeMode !== 'cloud') {
2231
+ return executeUpdateCapture(vaultRoot, parseFrontmatter, args, deps);
2232
+ }
2233
+ const ctx = captureCloudDeps(cloudDeps);
2234
+
2235
+ let specPath = null;
2236
+ let specId = null;
2237
+ let loaded = null;
2238
+ let autoCreated = false;
2239
+ let continuationOf = null;
2240
+
2241
+ if (args.spec_id) {
2242
+ loaded = await ctx.loadSpecViaTier(vaultRoot, args.spec_id, ctx.consoleUrl);
2243
+ if (!loaded) {
2244
+ return { error: 'SPEC_NOT_FOUND', message: `Capture ${args.spec_id} not found` };
2245
+ }
2246
+ specPath = loaded.specPath ?? null;
2247
+ specId = loaded.frontmatter?.id ?? args.spec_id;
2248
+ } else {
2249
+ const ensured = await ensureActiveCapturePinViaMode(
2250
+ vaultRoot,
2251
+ parseFrontmatter,
2252
+ deps.loadSpec,
2253
+ {
2254
+ pinReason: 'Auto-created via heyspex_update_capture',
2255
+ claim: deps.claim ?? null,
2256
+ },
2257
+ { ...cloudDeps, writeMode: 'cloud' },
2258
+ );
2259
+ if (ensured.error) {
2260
+ return { error: ensured.error, message: ensured.message };
2261
+ }
2262
+ autoCreated = ensured.auto_created === true;
2263
+ continuationOf = ensured.continuation_of ?? null;
2264
+ specPath = ensured.resolved.spec_path;
2265
+ specId = ensured.resolved.spec_id;
2266
+ loaded = { frontmatter: ensured.resolved.frontmatter, body: ensured.resolved.body, specPath };
2267
+ }
2268
+
2269
+ const computed = computeCaptureUpdate(loaded.frontmatter ?? {}, loaded.body ?? '', args);
2270
+ if (computed.error) return computed;
2271
+ const { fm, newBody, core, notes, summaryWarning, hasContent } = computed;
2272
+
2273
+ const targetId = fm.id ?? specId;
2274
+ if (!targetId) {
2275
+ return {
2276
+ error: 'CLOUD_MODE_UNSUPPORTED_WRITE',
2277
+ message:
2278
+ `Capture ${specPath ?? '(unknown path)'} carries no \`id:\` frontmatter, and PUT /api/specs/:id needs one. `
2279
+ + 'NOTHING WAS WRITTEN — the local disk write is deliberately NOT used as a fallback in cloud vault-write mode.',
2280
+ spec_path: specPath,
2281
+ write_mode: 'cloud',
2282
+ on_disk: false,
2283
+ };
2284
+ }
2285
+
2286
+ let written;
2287
+ try {
2288
+ written = await updateSpecViaConsole({
2289
+ consoleFetch: ctx.consoleFetch,
2290
+ consoleUrl: ctx.consoleUrl,
2291
+ specId: targetId,
2292
+ frontmatter: fm,
2293
+ body: newBody,
2294
+ });
2295
+ } catch (err) {
2296
+ return {
2297
+ error: 'CONSOLE_UNREACHABLE_CLOUD_MODE',
2298
+ message: String(err?.message ?? err),
2299
+ spec_id: targetId,
2300
+ spec_path: specPath,
2301
+ write_mode: 'cloud',
2302
+ on_disk: false,
2303
+ ...(err?.notFound ? { not_found: true } : {}),
2304
+ };
2305
+ }
2306
+
2307
+ if (hasContent) {
2308
+ // Best-effort, exactly like the local fuse-clear: a fuse that cannot be
2309
+ // dropped must not fail an update whose content already landed.
2310
+ try {
2311
+ await clearCaptureFocusExpiryViaConsole(ctx);
2312
+ } catch (err) {
2313
+ process.stderr.write?.(
2314
+ `[heyspex-mcp] capture fuse not cleared through the console: ${err?.message ?? err}\n`,
2315
+ );
2316
+ }
2317
+ }
2318
+ if (deps.claim) {
2319
+ await updateCaptureFocusClaimViaConsole(deps.claim, ctx);
2320
+ }
2321
+
2322
+ return buildUpdateCaptureResult({
2323
+ fm,
2324
+ newBody,
2325
+ specPath: written.relPath ?? specPath,
2326
+ core,
2327
+ notes,
2328
+ args,
2329
+ autoCreated,
2330
+ continuationOf,
2331
+ summaryWarning,
2332
+ extra: { write_mode: 'cloud', on_disk: false, console_url: written.consoleUrl ?? ctx.consoleUrl },
2333
+ });
2334
+ }
2335
+
2336
+ /**
2337
+ * Mode-aware {@link executePinCapture}. Cloud resolves the capture through the
2338
+ * cloud read tier and pins it on the Neon control plane; the YAML pin file is
2339
+ * never written.
2340
+ */
2341
+ export async function executePinCaptureViaMode(
2342
+ vaultRoot,
2343
+ parseFrontmatter,
2344
+ loadSpec,
2345
+ args = {},
2346
+ cloudDeps = {},
2347
+ ) {
2348
+ const writeMode = await resolveWriteModeOnce(cloudDeps.writeMode, cloudDeps);
2349
+ if (writeMode !== 'cloud') {
2350
+ return executePinCapture(vaultRoot, parseFrontmatter, loadSpec, args);
2351
+ }
2352
+ const ctx = captureCloudDeps(cloudDeps);
2353
+ const specId = args.spec_id;
2354
+ if (!specId) {
2355
+ return { error: 'MISSING_SPEC_ID', message: 'spec_id is required' };
2356
+ }
2357
+ const spec = await ctx.loadSpecViaTier(vaultRoot, specId, ctx.consoleUrl);
2358
+ if (!spec) {
2359
+ return { error: 'SPEC_NOT_FOUND', message: `Capture ${specId} not found` };
2360
+ }
2361
+ const fmType = spec.frontmatter?.type;
2362
+ if (fmType !== 'capture' && fmType !== 'idea') {
2363
+ return { error: 'NOT_A_CAPTURE', message: `${specId} is not type: capture` };
2364
+ }
2365
+ const specPath = spec.specPath ?? null;
2366
+ const pinnedKind = spec.frontmatter?.kind;
2367
+ try {
2368
+ await writeCaptureFocusViaConsole(
2369
+ {
2370
+ specId: spec.frontmatter?.id ?? specId,
2371
+ specPath,
2372
+ reason: args.reason || 'Pinned via MCP',
2373
+ claim: null,
2374
+ kind: typeof pinnedKind === 'string' ? pinnedKind : 'idea',
2375
+ },
2376
+ ctx,
2377
+ );
2378
+ } catch (err) {
2379
+ return {
2380
+ error: 'CONSOLE_UNREACHABLE_CLOUD_MODE',
2381
+ message: String(err?.message ?? err),
2382
+ spec_id: specId,
2383
+ spec_path: specPath,
2384
+ write_mode: 'cloud',
2385
+ on_disk: false,
2386
+ };
2387
+ }
2388
+ return {
2389
+ success: true,
2390
+ spec_id: specId,
2391
+ spec_path: specPath,
2392
+ write_mode: 'cloud',
2393
+ on_disk: false,
2394
+ message: `Capture ${specId} is now on deck for /hs-capture`,
2395
+ };
2396
+ }