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
@@ -0,0 +1,1264 @@
1
+ // The capture tools (heyspex_active_capture, update_capture, pin_capture,
2
+ // promote_capture, promote_capture_to_diagnose) and the capture-session helpers
3
+ // the prompts, recipes and filing tools share. Extracted from cli.mjs under
4
+ // [[HS-BLNF-6W61-DIAG-cli-mjs-has-grown-into-a-13-665-line]] (no behavior change).
5
+ //
6
+ // ─────────────────────────────────────────────────────────────────────────────
7
+ // Console capture sessions API helpers — [[HS-TASK-MJJK]]
8
+ // ─────────────────────────────────────────────────────────────────────────────
9
+
10
+ import { parseSpecId, shortLookupIdOf } from './spec-id-lib.mjs';
11
+ import { consoleReadErrorFrom, readViaSelector, resolveReadTier, resolveVaultWriteMode } from './read-path.mjs';
12
+ import { consoleFetch } from './console-transport.mjs';
13
+ import { join } from 'node:path';
14
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
15
+ import { findSpecFile, loadSpec, parseFrontmatter } from './vault-io.mjs';
16
+ import { collectOccupiedIdsFromSpecs } from '../../scripts/vault-id-lib.mjs';
17
+ import { RECENT_PROMOTION_LINEAGE_MS, buildCaptureBody, computeCaptureSufficiency, extractCaptureCoreFromBody, extractCaptureNotesFromBody, findCaptureSpecFile, loadCaptureFocusOffline, resolveActiveCapture, updateCaptureArgsVerdict, writeCaptureFocus } from './capture-focus.mjs';
18
+ import { consoleErrorCode } from './console-fetch.mjs';
19
+ import { executePromoteCapture, executePromoteCaptureToDiagnose } from './promote-capture.mjs';
20
+ import { detectAgentIdentity, effectiveConsoleUrl } from './mcp-config.mjs';
21
+ import { makeCaptureConsoleClient, respondToCaptureWriteFailure } from './capture-console-client.mjs';
22
+ import { buildCaptureBufferPatches } from './capture-buffer-patches.mjs';
23
+ import { normalizeCaptureSources } from './capture-sources.mjs';
24
+ import { resolveActiveCaptureContent } from './capture-active-rehydrate.mjs';
25
+
26
+ /**
27
+ * Query for pending capture sessions owned by the current user.
28
+ * GET /api/capture/sessions?status=pending
29
+ */
30
+ export async function listPendingCaptureSessions() {
31
+ const result = await consoleFetch('/api/capture/sessions?status=pending');
32
+ if (!result.ok) {
33
+ return { ok: false, error: consoleErrorCode(result, 'LIST_FAILED'), result };
34
+ }
35
+ const sessions = result.body?.sessions || [];
36
+ return { ok: true, sessions, result };
37
+ }
38
+
39
+ /**
40
+ * Query for MATERIALIZED capture sessions owned by the current user, most
41
+ * recently active first (server-sorted — listCaptureSessions orders by
42
+ * lastActivityAt desc). Used only for the HS-KFB2 lineage check below — a
43
+ * `capture_sessions` row carries no promotion state of its own (that lives in
44
+ * the materialized FILE's `promoted_to:` frontmatter), so this is the list to
45
+ * scan for "was the idea we're about to fork FROM recently promoted".
46
+ * GET /api/capture/sessions?status=materialized
47
+ */
48
+ export async function listMaterializedCaptureSessions() {
49
+ const result = await consoleFetch('/api/capture/sessions?status=materialized');
50
+ if (!result.ok) {
51
+ return { ok: false, error: consoleErrorCode(result, 'LIST_FAILED'), result };
52
+ }
53
+ const sessions = result.body?.sessions || [];
54
+ return { ok: true, sessions, result };
55
+ }
56
+
57
+ /**
58
+ * [[HS-KFB2-6RGF-DIAG-capture-continuation-mints-a-new-orphan]]: a same-session
59
+ * follow-on to an idea that was JUST promoted should surface as a continuation
60
+ * instead of silently forking a disconnected orphan capture. Given the
61
+ * caller's already-fetched materialized sessions (most-recent-first — the
62
+ * server sorts by lastActivityAt), scans them (bounded, first `limit`) for the
63
+ * first whose on-disk file shows a set `promoted_to:` updated within
64
+ * RECENT_PROMOTION_LINEAGE_MS, and returns that lineage or null.
65
+ *
66
+ * Deliberately advisory-only: never blocks, never attaches automatically —
67
+ * the caller surfaces `continuation_of` to the agent, which SUGGESTS the
68
+ * follow-on belongs on that lineage and gets the user's yes before acting
69
+ * ([[HS-5HYS]] elicitation), exactly like the capture-focus.mjs file-path
70
+ * fix this mirrors. Pure aside from the injected `loadSpecFn` — unit-testable
71
+ * without a live console.
72
+ *
73
+ * @param {Array<{specPath?: string|null}>} materializedSessions
74
+ * @param {(specPath: string) => {frontmatter?: Record<string, unknown>}|null} loadSpecFn
75
+ * @param {number} [limit] how many of the most-recent sessions to check (cheap local file reads; the list is already sorted, so 5 covers any plausible "which of my recent captures did I just promote" window without unbounded I/O).
76
+ * @returns {{ promotedTo: string, retiredCaptureId: string|null, retiredCaptureBasename: string } | null}
77
+ */
78
+ export function findRecentlyPromotedLineage(materializedSessions, loadSpecFn, limit = 5) {
79
+ if (!Array.isArray(materializedSessions)) return null;
80
+ for (const session of materializedSessions.slice(0, limit)) {
81
+ const specPath = session?.specPath;
82
+ if (typeof specPath !== 'string' || !specPath) continue;
83
+ let spec;
84
+ try {
85
+ spec = loadSpecFn(specPath);
86
+ } catch {
87
+ continue;
88
+ }
89
+ if (!spec) continue;
90
+ const promotedTo = String(spec.frontmatter?.promoted_to ?? '').trim();
91
+ if (!promotedTo || promotedTo === 'null') continue;
92
+ const updatedAt = Date.parse(String(spec.frontmatter?.updated ?? ''));
93
+ if (!Number.isFinite(updatedAt) || Date.now() - updatedAt >= RECENT_PROMOTION_LINEAGE_MS) continue;
94
+ return {
95
+ promotedTo,
96
+ retiredCaptureId: spec.frontmatter?.id ?? null,
97
+ retiredCaptureBasename: specPath.replace(/^.*[\\/]/, '').replace(/\.md$/, ''),
98
+ };
99
+ }
100
+ return null;
101
+ }
102
+
103
+ /**
104
+ * Create a new capture session via POST /api/capture/sessions.
105
+ */
106
+ export async function createCaptureSessionOnConsole(input) {
107
+ const result = await consoleFetch('/api/capture/sessions', {
108
+ method: 'POST',
109
+ body: input,
110
+ });
111
+ if (!result.ok) {
112
+ return { ok: false, error: consoleErrorCode(result, 'CREATE_FAILED'), result };
113
+ }
114
+ return { ok: true, session: result.body?.session, result };
115
+ }
116
+
117
+ /**
118
+ * Capture-session console client — error classification, PATCH wrapper, and
119
+ * outbox drain live in `capture-console-client.mjs` so they're testable
120
+ * without spawning the MCP stdin reader / capture-channel watcher. See
121
+ * [[HS-EPIC-CPMR]] for the error taxonomy and the multi-beat protocol
122
+ * substrate fix.
123
+ */
124
+ export const captureConsoleClient = makeCaptureConsoleClient(consoleFetch);
125
+
126
+ export const patchCaptureSessionOnConsole = (sessionId, patchBody) =>
127
+ captureConsoleClient.patchCaptureSession(sessionId, patchBody);
128
+
129
+ /**
130
+ * GET a single capture session by id.
131
+ * GET /api/capture/sessions/:id
132
+ *
133
+ * Id tolerance (owner ruling 2026-08-21, the HS-XV4W promote failure): session
134
+ * rows store the SHORT era-3 id (`HS-<fileKey>`), but humans paste whatever
135
+ * form they last saw — the display id (`HS-XV4W-HV6H`) or the full basename.
136
+ * Try the id as given first (legacy `HS-CAP-*` rows resolve that way), then
137
+ * retry with the collapsed short id, so ANY recognizable form of the
138
+ * filename/id resolves — exactly like the local disk lookup does.
139
+ */
140
+ export async function getCaptureSessionOnConsole(sessionId) {
141
+ const result = await consoleFetch(`/api/capture/sessions/${encodeURIComponent(sessionId)}`);
142
+ if (result.ok) {
143
+ return { ok: true, session: result.body?.session, result };
144
+ }
145
+ const short = shortLookupIdOf(sessionId);
146
+ if (short && short !== String(sessionId).trim()) {
147
+ const retry = await consoleFetch(`/api/capture/sessions/${encodeURIComponent(short)}`);
148
+ if (retry.ok) {
149
+ return { ok: true, session: retry.body?.session, result: retry };
150
+ }
151
+ }
152
+ return { ok: false, error: consoleErrorCode(result, 'GET_FAILED'), result };
153
+ }
154
+
155
+ /**
156
+ * Respond to a failed capture write ([[HS-K27H-DIAG-0G86]]).
157
+ *
158
+ * Capture is online-only ([[heyspex-is-online-only-no-offline]]); the offline
159
+ * buffer exists so nothing is lost across a GENUINE transport failure (status
160
+ * 0: ECONNREFUSED / DNS / timeout / abort) — and ONLY then. An HTTP 4xx/5xx
161
+ * means the server answered: the write failed and is surfaced verbatim, never
162
+ * stashed, and never described as a connectivity problem. The classification
163
+ * + payload building live in capture-console-client.mjs
164
+ * (`respondToCaptureWriteFailure`) so the stash-vs-surface rule is
165
+ * unit-testable; this alias keeps the call sites readable.
166
+ */
167
+ export const captureWriteFailure = (vaultRoot, args, failure) =>
168
+ respondToCaptureWriteFailure(vaultRoot, args, failure);
169
+
170
+ /**
171
+ * Drain offline-buffered captures into a now-reachable session.
172
+ * Thin wrapper around `captureConsoleClient.drainOutbox` — see that module
173
+ * for the transient-vs-structural classification contract. `currentBuffer`
174
+ * (the session's content_buffer) lets replayed append-shaped entries
175
+ * (userQuotes/chatTurns) merge instead of clobbering.
176
+ */
177
+ export const drainCaptureOutbox = (vaultRoot, sessionId, identity, currentBuffer = {}) =>
178
+ captureConsoleClient.drainOutbox(vaultRoot, sessionId, identity, currentBuffer);
179
+
180
+ /**
181
+ * /hs-promote-capture — promote 1+ captures into a research doc with AI-
182
+ * assisted outline. Implementation lives in `promote-capture.mjs`; two-phase
183
+ * flow when target_research === "NEW" (review then execute), single-phase for
184
+ * existing target_research. Mirrors handlePromoteResearch's injection shape.
185
+ *
186
+ * Cloud-tier reads ([[HS-KR16-CWPX-TASK-promotion-reads-through-the-cloud-tier]]):
187
+ * `tier` is resolved ONCE per call here (`resolveReadTier()`) and threaded
188
+ * into `executePromoteCapture`'s deps — never re-resolved downstream. `overrides`
189
+ * lets a test inject `{ tier, consoleUrl, consoleFetch, resolveReadTier }`
190
+ * directly so it never opens a socket or depends on real dev-mode config;
191
+ * every production call site (tool-dispatch.mjs) passes none, so the real
192
+ * `resolveReadTier()` / `effectiveConsoleUrl()` / `consoleFetch` run unchanged.
193
+ *
194
+ * Cloud-tier WRITES ([[HS-BYU4-CWPX-TASK-promotion-writes-go-through-the-console]]):
195
+ * `writeMode` is resolved here the same way and on the same once-per-call rule
196
+ * — `resolveVaultWriteMode()` is the write-side twin of `resolveReadTier()`.
197
+ * This is the seam that makes the cloud write path reachable at all: the
198
+ * execute phase defaults to `deps.writeMode ?? 'local'`, so without this line
199
+ * a cloud-reliant session would still write markdown to a disk it may not have.
200
+ */
201
+ export async function handlePromoteCapture(vaultRoot, args = {}, overrides = {}) {
202
+ try {
203
+ const doResolveReadTier = overrides.resolveReadTier ?? resolveReadTier;
204
+ const tier = overrides.tier ?? (await doResolveReadTier());
205
+ const doResolveWriteMode = overrides.resolveVaultWriteMode ?? resolveVaultWriteMode;
206
+ const writeMode = overrides.writeMode ?? (await doResolveWriteMode());
207
+ const consoleUrl = overrides.consoleUrl ?? effectiveConsoleUrl();
208
+ const doConsoleFetch = overrides.consoleFetch ?? consoleFetch;
209
+ const result = await executePromoteCapture(vaultRoot, args, {
210
+ consoleFetch: (path, options) => doConsoleFetch(path, options),
211
+ tier,
212
+ writeMode,
213
+ consoleUrl,
214
+ });
215
+ if (result?.error) {
216
+ return {
217
+ isError: true,
218
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
219
+ };
220
+ }
221
+ return {
222
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
223
+ };
224
+ } catch (err) {
225
+ return {
226
+ isError: true,
227
+ content: [
228
+ {
229
+ type: 'text',
230
+ text: JSON.stringify(
231
+ {
232
+ error: 'PROMOTE_CAPTURE_EXCEPTION',
233
+ message: err?.message ?? String(err),
234
+ },
235
+ null,
236
+ 2,
237
+ ),
238
+ },
239
+ ],
240
+ };
241
+ }
242
+ }
243
+
244
+ /**
245
+ * heyspex_promote_capture_to_diagnose — one bug-report capture → one HS-DIAG-*
246
+ * diagnose spec ([[HS-MIL-BFIX]]). Single-phase; writes the diagnose + the
247
+ * capture's promoted_to/informs_diagnose linkage transactionally on disk, or
248
+ * POST-diagnose-then-PUT-capture through the console in cloud write mode.
249
+ *
250
+ * `tier` / `writeMode` follow the same once-per-call rule as
251
+ * {@link handlePromoteCapture} — resolved HERE, threaded down, never
252
+ * re-resolved in a helper. `overrides` keeps tests off the network.
253
+ */
254
+ export async function handlePromoteCaptureToDiagnose(vaultRoot, args = {}, overrides = {}) {
255
+ try {
256
+ const doResolveReadTier = overrides.resolveReadTier ?? resolveReadTier;
257
+ const tier = overrides.tier ?? (await doResolveReadTier());
258
+ const doResolveWriteMode = overrides.resolveVaultWriteMode ?? resolveVaultWriteMode;
259
+ const writeMode = overrides.writeMode ?? (await doResolveWriteMode());
260
+ const consoleUrl = overrides.consoleUrl ?? effectiveConsoleUrl();
261
+ const doConsoleFetch = overrides.consoleFetch ?? consoleFetch;
262
+ const result = await executePromoteCaptureToDiagnose(vaultRoot, args, {
263
+ consoleFetch: (path, options) => doConsoleFetch(path, options),
264
+ tier,
265
+ writeMode,
266
+ consoleUrl,
267
+ });
268
+ if (result?.error) {
269
+ return { isError: true, content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
270
+ }
271
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
272
+ } catch (err) {
273
+ return {
274
+ isError: true,
275
+ content: [
276
+ {
277
+ type: 'text',
278
+ text: JSON.stringify(
279
+ { error: 'PROMOTE_CAPTURE_TO_DIAGNOSE_EXCEPTION', message: err?.message ?? String(err) },
280
+ null,
281
+ 2,
282
+ ),
283
+ },
284
+ ],
285
+ };
286
+ }
287
+ }
288
+
289
+ /**
290
+ * Read capture-on-deck state from the Neon control plane, composed the same way
291
+ * the console's `readCaptureFocusState` does: layer-1 pin (`capture_focus_pin`)
292
+ * ⊕ layer-2 metadata (`capture_focus_selection`) ([[HS-TASK-CFL2]]). Layer-2 is
293
+ * attached only when its `spec_path` names the pinned spec, so a stale selection
294
+ * never paints onto a freshly-pinned spec. Falls back to the offline file read
295
+ * (`loadCaptureFocusOffline`) when the console is unreachable — the offline
296
+ * `/hs-capture` seam — which REFUSES a pin whose own `pinned_at` is older than
297
+ * `CAPTURE_FOCUS_FILE_STALE_MS` rather than serving a rotted answer quietly
298
+ * ([[HS-O4U6-2ZRO-DIAG-the-offline-capture-pin-file-is-months]]). Returns the same
299
+ * shape `loadCaptureFocus` does (or null when no pin, and null when one was refused
300
+ * — the loud stderr warning is what distinguishes those here).
301
+ */
302
+ export async function readCaptureFocusVendor(vaultRoot) {
303
+ try {
304
+ const [pinRes, selRes] = await Promise.all([
305
+ consoleFetch('/api/control/capture-focus-pin'),
306
+ consoleFetch('/api/control/capture-focus-selection'),
307
+ ]);
308
+ if (pinRes?.ok) {
309
+ const pin = pinRes.body?.pin ?? null;
310
+ if (!pin) return null; // Neon authoritative — no pin means nothing on deck.
311
+ const sel = selRes?.ok ? (selRes.body?.selection ?? null) : null;
312
+ const base = (p) => String(p || '').replace(/^.*[/\\]/, '');
313
+ const selMatches = sel?.specPath ? base(sel.specPath).startsWith(pin.specId) : false;
314
+ const focus = {
315
+ spec_path: selMatches ? sel.specPath : `specs/captures/${pin.specId}.md`,
316
+ pinned_at: pin.pinnedAt,
317
+ };
318
+ if (pin.expiresAt) focus.expires = pin.expiresAt;
319
+ if (selMatches && sel) {
320
+ if (sel.kind) focus.kind = sel.kind;
321
+ if (sel.reason) focus.reason = sel.reason;
322
+ if (sel.claimedBy) focus.claimed_by = sel.claimedBy;
323
+ if (sel.claimSession) focus.claim_session = sel.claimSession;
324
+ }
325
+ if (!focus.kind) focus.kind = 'idea';
326
+ return focus;
327
+ }
328
+ } catch {
329
+ // Fall through to the offline file seam.
330
+ }
331
+ // Offline seam. The file is NOT the system of record on a console-backed vault,
332
+ // so a pin that has rotted there must be refused rather than served as if it were
333
+ // current — a capture silently attaching to a two-month-old pin is the failure
334
+ // ([[HS-O4U6-2ZRO-DIAG-the-offline-capture-pin-file-is-months]]). `loadCaptureFocusOffline`
335
+ // warns loudly and hands back null; null here already means "nothing on deck",
336
+ // and callers that need to tell the two apart call it directly for the reason.
337
+ return loadCaptureFocusOffline(vaultRoot).focus;
338
+ }
339
+
340
+ /**
341
+ * Resolve the capture genuinely on deck, from the LIVE pin.
342
+ *
343
+ * This is the read the capture-stage gate needs and the one the old hook could
344
+ * not do. `readCaptureFocusVendor` asks the console first and only falls back to
345
+ * `.heyspex/capture-focus.yaml` when it cannot be reached, so the answer tracks
346
+ * what `/hs-capture` actually pinned rather than whatever the last offline
347
+ * session left on disk.
348
+ *
349
+ * Returns the resolved capture, or null for every "nothing is open" case — no
350
+ * pin, a pin whose capture was promoted or closed, a pin with no file yet that
351
+ * the console cannot confirm, or any failure at all. Null means allow: this
352
+ * resolver never refuses on its own uncertainty.
353
+ */
354
+ export async function resolveLiveActiveCapture(vaultRoot) {
355
+ let focus = null;
356
+ try {
357
+ focus = await readCaptureFocusVendor(vaultRoot);
358
+ } catch {
359
+ return null;
360
+ }
361
+ if (!focus?.spec_path) return null;
362
+
363
+ const resolved = resolveActiveCapture(vaultRoot, parseFrontmatter, loadSpec, focus);
364
+ if (resolved && !resolved.error && resolved.spec_id) return resolved;
365
+
366
+ // A capture session that has not materialized to a file yet has no markdown to
367
+ // read, so the disk resolve above returns CAPTURE_NOT_FOUND. Ask the console
368
+ // for the live session row before concluding nothing is open — that pending
369
+ // state is the common case during an active /hs-capture.
370
+ if (resolved?.error !== 'CAPTURE_NOT_FOUND') return null;
371
+ const pinnedId = captureSpecIdFromPath(focus.spec_path);
372
+ if (!pinnedId) return null;
373
+ try {
374
+ const got = await getCaptureSessionOnConsole(pinnedId);
375
+ const session = got.ok ? got.session : null;
376
+ if (!session) return null;
377
+ const status = String(session.status ?? '').trim().toLowerCase();
378
+ if (status && status !== 'pending' && status !== 'open') return null;
379
+ return { spec_id: session.id ?? pinnedId, spec_path: session.specPath ?? focus.spec_path, session };
380
+ } catch {
381
+ return null;
382
+ }
383
+ }
384
+
385
+ /**
386
+ * Build the caller-facing refusal for a capture pin that did not land, so all six
387
+ * `writeCaptureFocusVendor` call sites report the same measured failure instead of
388
+ * each inventing a "pinned" message from its own request.
389
+ */
390
+ export function captureFocusPinRefusal(pin, { specId, specPath } = {}) {
391
+ return {
392
+ error: 'CAPTURE_PIN_NOT_WRITTEN',
393
+ pinned: false,
394
+ spec_id: specId ?? pin?.spec_id ?? null,
395
+ spec_path: specPath ?? pin?.spec_path ?? null,
396
+ pin,
397
+ message:
398
+ `Capture ${specId ?? pin?.spec_id ?? '(unknown)'} was NOT pinned on deck (${pin?.reason ?? 'unknown'}). ` +
399
+ 'Nothing readable persisted, so /hs-capture will resolve to whatever was pinned before — treat this ' +
400
+ 'capture as NOT on deck. Check heyspex_dev_server_status, then re-pin with heyspex_pin_capture.',
401
+ };
402
+ }
403
+
404
+ /**
405
+ * Markdown form of the same measured verdict, for the `/hs-capture` PROMPT
406
+ * surfaces (prompts.mjs), which return markdown and have no `isError` channel.
407
+ * Empty string when the pin landed, so a healthy prompt is byte-identical to
408
+ * before; a loud banner ABOVE the capture body when it did not.
409
+ */
410
+ export function capturePinWarningBanner(pin, specId) {
411
+ if (pin?.pinned) return '';
412
+ return (
413
+ `> ⚠️ **Capture-on-deck pin NOT written** (${pin?.reason ?? 'unknown'}). This capture ` +
414
+ `(\`${specId}\`) is open, but it is **not on deck**: a later \`/hs-capture\` with no id will ` +
415
+ `resolve to a DIFFERENT capture, and \`heyspex_active_capture\` without \`spec_id\` may attach ` +
416
+ `to the wrong one. Always pass \`spec_id: "${specId}"\` explicitly for the rest of this capture, ` +
417
+ `and re-pin with \`heyspex_pin_capture\` once the console is healthy.\n\n`
418
+ );
419
+ }
420
+
421
+ /**
422
+ * Pin capture-on-deck + layer-2 metadata to the Neon control plane the same way
423
+ * the console's `setCaptureFocus` does: PUT `capture_focus_pin` (layer-1) +
424
+ * `capture_focus_selection` (layer-2) ([[HS-TASK-CFL2]]).
425
+ *
426
+ * REPORTS THE RESULT, NEVER THE REQUEST ([[HS-IOJW-5YDH-DIAG]] sweep). This used
427
+ * to return a bare boolean that all six callers discarded while printing
428
+ * "pinned"; worse, the `false` branch was not even a uniform failure. There are
429
+ * three genuinely different outcomes and the old boolean collapsed them:
430
+ *
431
+ * neon — both PUTs ok. Pinned, and the read path reads it.
432
+ * file — the console could not be REACHED (status 0). The legacy file
433
+ * write is a real pin here, because `readCaptureFocusVendor`
434
+ * falls through to `loadCaptureFocusOffline` in exactly this
435
+ * case. Genuinely pinned.
436
+ * none — the console ANSWERED and refused the write (non-2xx), or the
437
+ * file write itself failed. The YAML fallback is DEAD in the
438
+ * answered case: `readCaptureFocusVendor` sees `pinRes.ok` and a
439
+ * null pin, and returns null without ever consulting the file.
440
+ * Nothing readable persisted → `pinned: false`.
441
+ *
442
+ * The file write is measured, not assumed (`measureDiskLanding` idiom): the pin
443
+ * is only claimed after the bytes are read back carrying this spec_path.
444
+ *
445
+ * @returns {Promise<{ pinned: boolean, tier: 'neon'|'file'|'none', reason: string,
446
+ * reported_by: 'measured', spec_id: string|null, spec_path: string|null,
447
+ * console_status?: object, landed?: object }>}
448
+ */
449
+ export async function writeCaptureFocusVendor(
450
+ vaultRoot,
451
+ { specId, specPath, reason, claim } = {},
452
+ // Injection seam (idiom: fleet-queue-tools.mjs) so a test can drive each of the
453
+ // three outcomes without a console. Production callers pass two args.
454
+ { fetchImpl = consoleFetch } = {},
455
+ ) {
456
+ const id =
457
+ specId ||
458
+ captureSpecIdFromPath(specPath);
459
+ const resolvedPath = specPath || (id ? `specs/captures/${id}.md` : null);
460
+ const base = { reported_by: 'measured', spec_id: id ?? null, spec_path: resolvedPath };
461
+
462
+ // `consoleFetch` never throws — an unreachable console comes back as
463
+ // `{ ok: false, status: 0 }`. So "answered" is `status !== 0`, and that is the
464
+ // distinction that decides whether the file fallback can ever be read back.
465
+ let consoleAnswered = false;
466
+ let consoleStatus = null;
467
+ try {
468
+ if (id) {
469
+ const [pinRes, selRes] = await Promise.all([
470
+ fetchImpl('/api/control/capture-focus-pin', {
471
+ method: 'PUT',
472
+ body: { spec_id: id, pinned_by: claim?.claimed_by || 'agent' },
473
+ }),
474
+ fetchImpl('/api/control/capture-focus-selection', {
475
+ method: 'PUT',
476
+ body: {
477
+ spec_path: resolvedPath,
478
+ kind: 'idea',
479
+ reason: reason ?? null,
480
+ claimed_by: claim?.claimed_by ?? null,
481
+ claim_session: claim?.claim_session ?? null,
482
+ },
483
+ }),
484
+ ]);
485
+ if (pinRes?.ok && selRes?.ok) return { ...base, pinned: true, tier: 'neon', reason: 'neon_write_ok' };
486
+ consoleStatus = { pin: pinRes?.status ?? null, selection: selRes?.status ?? null };
487
+ // A non-zero status means the server ANSWERED and rejected the write.
488
+ consoleAnswered = Boolean(pinRes?.status) || Boolean(selRes?.status);
489
+ console.error(
490
+ `[hs-capture] capture focus not pinned to Neon — console returned pin=${pinRes?.status ?? 'no response'} ` +
491
+ `sel=${selRes?.status ?? 'no response'}. Falling back to the offline file.`,
492
+ );
493
+ }
494
+ } catch (err) {
495
+ console.error(
496
+ `[hs-capture] capture focus Neon write failed (${err?.message ?? err}); falling back to the offline file.`,
497
+ );
498
+ }
499
+
500
+ // Offline / failure fallback: keep the legacy file write (offline capture seam).
501
+ const landed = writeCaptureFocusMeasured(vaultRoot, specPath, reason, claim);
502
+ if (!landed.on_disk || landed.spec_path_readback !== true) {
503
+ return {
504
+ ...base,
505
+ pinned: false,
506
+ tier: 'none',
507
+ reason: 'file_write_failed',
508
+ ...(consoleStatus ? { console_status: consoleStatus } : {}),
509
+ landed,
510
+ };
511
+ }
512
+ if (consoleAnswered) {
513
+ // The console is UP and refused the write. Neon is what the read path
514
+ // consults while it is up, so the file just written will never be read back
515
+ // — a shadow, not a pin. Say so rather than reporting a successful fallback.
516
+ return {
517
+ ...base,
518
+ pinned: false,
519
+ tier: 'none',
520
+ reason: 'console_answered_but_rejected_write',
521
+ console_status: consoleStatus,
522
+ landed,
523
+ };
524
+ }
525
+ return { ...base, pinned: true, tier: 'file', reason: 'console_unreachable_offline_file_pin', landed };
526
+ }
527
+
528
+ /**
529
+ * `writeCaptureFocus` + a read-back, so the offline pin is measured rather than
530
+ * assumed. Mirrors `measureDiskLanding` (tool-helpers.mjs) for a file this module
531
+ * writes through a helper that returns nothing. Never throws.
532
+ */
533
+ export function writeCaptureFocusMeasured(vaultRoot, specPath, reason, claim) {
534
+ const abs = join(vaultRoot, '.heyspex', 'capture-focus.yaml');
535
+ const out = { tier: 'disk', reported_by: 'measured', abs_path: abs, on_disk: false, bytes: null, spec_path_readback: false };
536
+ try {
537
+ writeCaptureFocus(vaultRoot, specPath, reason, claim);
538
+ } catch (err) {
539
+ out.error = String(err?.message ?? err);
540
+ return out;
541
+ }
542
+ try {
543
+ const raw = readFileSync(abs, 'utf8');
544
+ out.on_disk = true;
545
+ out.bytes = Buffer.byteLength(raw);
546
+ // An empty spec_path is not a pin — `spec_path:` with nothing after it names
547
+ // no capture, so it must never read back as a landed offline pin.
548
+ out.spec_path_readback =
549
+ typeof specPath === 'string' && specPath.trim().length > 0 && raw.includes(`spec_path: ${specPath}`);
550
+ } catch (err) {
551
+ out.error = String(err?.message ?? err);
552
+ }
553
+ return out;
554
+ }
555
+
556
+ /** Derive the capture spec id (`HS-CAP-XXXX`) from a capture spec_path basename. */
557
+ export function captureSpecIdFromPath(specPath) {
558
+ const baseName = String(specPath || '').replace(/^.*[/\\]/, '').replace(/\.md$/i, '');
559
+ return baseName.match(/^(HS-[A-Z]+-[A-Z0-9]{3,})/)?.[1] || null;
560
+ }
561
+
562
+ /**
563
+ * Grounding-time sufficiency verdict for the database-backed capture path
564
+ * ([[HS-Q8O5-MG6A-DIAG-the-capture-sufficiency-verdict-never]],
565
+ * [[HS-58O9-X98Y-DIAG-cloud-capture-materializer-never]]): `computeCaptureSufficiency`
566
+ * is the same scorer `heyspex_promote_capture` runs, but it was only ever wired
567
+ * into the disk/file-backed `resolveActiveCapture` path — every database-tier
568
+ * `heyspex_active_capture` / `heyspex_update_capture` response (the normal case
569
+ * whenever the console is reachable) carried no `sufficiency` field at all, so
570
+ * an agent following the /hs-capture protocol had nothing to gate "offer
571
+ * promote" on. Scores the materialized file at `specPath` when one exists;
572
+ * reports `null` with a reason (not a fabricated fail) before materialization,
573
+ * since there is no body yet to score.
574
+ * @param {string} vaultRoot
575
+ * @param {string|null|undefined} specPath
576
+ * @returns {{ sufficiency: object|null, sufficiency_reason?: string }}
577
+ */
578
+ export function sufficiencyForSpecPath(vaultRoot, specPath, session = null) {
579
+ // Cloud/database path ([[HS-WF23]]): the session row carries the content the console
580
+ // holds, but the spec file is not on this machine (cloud materialization) or does not exist
581
+ // yet. Score the body rebuilt from the session's content_buffer rather than report
582
+ // `spec_unreadable` for a capture that has real content.
583
+ const fromBuffer = () => {
584
+ const body = bodyFromContentBuffer(session?.contentBuffer);
585
+ if (body === null) return null;
586
+ try {
587
+ return { sufficiency: computeCaptureSufficiency({ kind: session?.contentBuffer?.kind }, body) };
588
+ } catch {
589
+ return { sufficiency: null, sufficiency_reason: 'scorer_error' };
590
+ }
591
+ };
592
+ if (!specPath) {
593
+ return fromBuffer() ?? { sufficiency: null, sufficiency_reason: 'not_yet_materialized' };
594
+ }
595
+ try {
596
+ const spec = loadSpec(vaultRoot, specPath);
597
+ if (!spec) return fromBuffer() ?? { sufficiency: null, sufficiency_reason: 'spec_unreadable' };
598
+ return { sufficiency: computeCaptureSufficiency(spec.frontmatter, spec.body) };
599
+ } catch {
600
+ return fromBuffer() ?? { sufficiency: null, sufficiency_reason: 'scorer_error' };
601
+ }
602
+ }
603
+
604
+ /**
605
+ * Rebuild the capture body from a session content_buffer (structured `{at,text}` or flat
606
+ * shapes), byte-compatible with what materialization writes. `null` when the buffer holds no
607
+ * authored content (nothing to score — keep the honest null verdict).
608
+ * @param {object|null|undefined} cb
609
+ * @returns {string|null}
610
+ */
611
+ export function bodyFromContentBuffer(cb) {
612
+ if (!cb || typeof cb !== 'object') return null;
613
+ const { core, summary, notes } = normalizeCaptureBuffer(cb);
614
+ const example = bufferText(cb.example);
615
+ const userStories = bufferText(cb.userStories ?? cb.user_stories);
616
+ const doneWhen = bufferText(cb.doneWhen ?? cb.done_when);
617
+ const jtbd = bufferText(cb.jtbd);
618
+ const details = bufferText(cb.details ?? cb.agent_expansion);
619
+ if (![core, summary, notes, example, userStories, doneWhen, jtbd, details].some(Boolean)) return null;
620
+ return buildCaptureBody(core, notes, summary, details, '', example, userStories, doneWhen, jtbd);
621
+ }
622
+
623
+ /**
624
+ * [[HS-TASK-MJJK]] — rewired to query sessions API first.
625
+ * 1. If spec_id is provided, resolve that exact capture DB-first
626
+ * (getCaptureSessionOnConsole), so a pending session resolves; fall back to
627
+ * disk for materialized / legacy captures whose live row is gone.
628
+ * 2. Otherwise, query GET /api/capture/sessions?status=pending for active session.
629
+ * 3. If a pending session exists, return its data (database is source of truth).
630
+ * 4. If API unreachable or no pending session, fallback to file-based logic.
631
+ */
632
+ export async function handleActiveCapture(vaultRoot, args) {
633
+ // If spec_id is provided, resolve THAT exact capture. The channel push carries
634
+ // the authoritative capture_id (capture-channel.mjs), so the agent is told to
635
+ // pass it here — resolving by id is how we avoid the "wrong capture" divergence.
636
+ if (args.spec_id) {
637
+ const wantedId = String(args.spec_id).trim();
638
+ // DB FIRST: a pending capture session has no markdown file yet, so the
639
+ // disk-only lookup below would 404 it. Resolve the live session row by id so
640
+ // a pending capture (the common channel-driven case) returns real state.
641
+ const got = await getCaptureSessionOnConsole(wantedId);
642
+ if (got.ok && got.session) {
643
+ const session = got.session;
644
+ // Same [[HS-U29N-DIAG-T2DR]] rehydration as the no-spec_id path below —
645
+ // an explicitly-named session can carry the same stale/empty buffer
646
+ // divergence from its materialized file.
647
+ const rehydrated = resolveActiveCaptureContent(session, {
648
+ normalizeBuffer: normalizeCaptureBuffer,
649
+ loadSpec: (specPath) => loadSpec(vaultRoot, specPath),
650
+ extractCore: extractCaptureCoreFromBody,
651
+ extractNotes: extractCaptureNotesFromBody,
652
+ });
653
+ const contentBuffer = rehydrated.contentBuffer;
654
+ const payload = {
655
+ spec_id: session.id,
656
+ spec_path: session.specPath || null,
657
+ name: contentBuffer.name || `Capture ${session.id}`,
658
+ core_idea: contentBuffer.core_idea || '',
659
+ notes: contentBuffer.notes || '',
660
+ status: session.status,
661
+ source: rehydrated.rehydrated_from_disk ? 'database+disk-rehydrated' : 'database',
662
+ is_new: rehydrated.is_new,
663
+ session,
664
+ ...sufficiencyForSpecPath(vaultRoot, session.specPath, session),
665
+ };
666
+ if (!rehydrated.is_new) {
667
+ payload.guidance =
668
+ 'This capture already has content (is_new: false) — do NOT overwrite existing sections (core_idea/summary/notes/example/user_stories) without explicit user instruction. Append or ask before replacing.';
669
+ }
670
+ return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
671
+ }
672
+ // DB miss (404 / GET_FAILED) or console unreachable → fall back to disk for
673
+ // materialized / legacy captures whose live session row is gone.
674
+ const specPath =
675
+ findCaptureSpecFile(vaultRoot, wantedId) || findSpecFile(vaultRoot, wantedId);
676
+ if (!specPath) {
677
+ return {
678
+ isError: true,
679
+ content: [{ type: 'text', text: JSON.stringify({ error: 'SPEC_NOT_FOUND', message: wantedId }) }],
680
+ };
681
+ }
682
+ const spec = loadSpec(vaultRoot, specPath);
683
+ if (!spec) {
684
+ return { isError: true, content: [{ type: 'text', text: JSON.stringify({ error: 'LOAD_FAILED' }) }] };
685
+ }
686
+ const diskCore = extractCaptureCoreFromBody(spec.body);
687
+ const diskNotes = extractCaptureNotesFromBody(spec.body);
688
+ const resolved = {
689
+ spec_id: spec.frontmatter?.id || wantedId,
690
+ spec_path: specPath,
691
+ name: spec.frontmatter?.name,
692
+ core_idea: diskCore,
693
+ notes: diskNotes,
694
+ // A disk file exists for this exact id — never a fresh/empty capture,
695
+ // regardless of whether its sections happen to be blank.
696
+ is_new: false,
697
+ focus: await readCaptureFocusVendor(vaultRoot),
698
+ sufficiency: computeCaptureSufficiency(spec.frontmatter, spec.body),
699
+ };
700
+ return { content: [{ type: 'text', text: JSON.stringify(resolved, null, 2) }] };
701
+ }
702
+
703
+ // Capture is online-only — query the sessions API. [[HS-TASK-MJJK]]
704
+ const identity = detectAgentIdentity();
705
+ const forceNew = args.force_new === true;
706
+
707
+ // force_new: true ([[HS-1C4L-9VZV-DIAG-multi-agent-capture-focus-pin-collision]])
708
+ // — skip the pending-session list/attach path entirely and unconditionally mint
709
+ // a fresh session. `forceNew: true` also disables the console's empty-session
710
+ // reuse-fold (scoped by ownerUserId+openedBy, NOT openedBySession — so without
711
+ // this, two concurrent agent sessions could still silently fold onto the same
712
+ // empty pending row even when this caller never intended to attach to anything).
713
+ let session = null;
714
+ // HS-KFB2: true only when THIS call minted a brand-new session (never when
715
+ // attaching to an existing pending one) — that's the exact moment a
716
+ // same-session follow-on to a recently-promoted idea would otherwise become
717
+ // a silent orphan. force_new explicitly asks for a genuinely new, unlinked
718
+ // topic (its own doc comment above), so it deliberately skips this check.
719
+ let freshlyCreated = false;
720
+ if (forceNew) {
721
+ const createResult = await createCaptureSessionOnConsole({
722
+ opened_by: 'agent',
723
+ opened_by_session: identity.sessionId,
724
+ mode: 'attended',
725
+ status: 'pending',
726
+ force_new: true,
727
+ });
728
+ if (!createResult.ok) {
729
+ const refusal = captureWriteFailure(vaultRoot, {}, createResult);
730
+ return { content: [{ type: 'text', text: JSON.stringify(refusal, null, 2) }] };
731
+ }
732
+ session = createResult.session;
733
+ } else {
734
+ const sessionsResult = await listPendingCaptureSessions();
735
+
736
+ if (!sessionsResult.ok) {
737
+ // Transport failure → graceful refuse; never mint a local file scaffold
738
+ // ([[heyspex-is-online-only-no-offline]]). active_capture carries no user
739
+ // words, so nothing is stashed. An answered HTTP error is surfaced
740
+ // honestly instead of masquerading as unreachable (HS-K27H).
741
+ const refusal = captureWriteFailure(vaultRoot, {}, sessionsResult);
742
+ return { content: [{ type: 'text', text: JSON.stringify(refusal, null, 2) }] };
743
+ }
744
+
745
+ // Reachable: attach to THE pending session, or agent-first auto-create one — in
746
+ // the DB, not a file. This is where the three surfaces converge.
747
+ session = sessionsResult.sessions[0] || null;
748
+ if (!session) {
749
+ const createResult = await createCaptureSessionOnConsole({
750
+ opened_by: 'agent',
751
+ opened_by_session: identity.sessionId,
752
+ mode: 'attended',
753
+ status: 'pending',
754
+ });
755
+ if (!createResult.ok) {
756
+ const refusal = captureWriteFailure(vaultRoot, {}, createResult);
757
+ return { content: [{ type: 'text', text: JSON.stringify(refusal, null, 2) }] };
758
+ }
759
+ session = createResult.session;
760
+ freshlyCreated = true;
761
+ }
762
+ }
763
+
764
+ // Now that we're online, flush any offline-buffered captures into this session
765
+ // (append against the session's live buffer, not a blank one — HS-K27H).
766
+ const drain = await drainCaptureOutbox(
767
+ vaultRoot,
768
+ session.id,
769
+ identity,
770
+ session.contentBuffer && typeof session.contentBuffer === 'object'
771
+ ? session.contentBuffer
772
+ : {},
773
+ );
774
+ if (drain.drained > 0) {
775
+ const refetched = await getCaptureSessionOnConsole(session.id);
776
+ if (refetched.ok && refetched.session) session = refetched.session;
777
+ }
778
+
779
+ // [[HS-U29N-DIAG-T2DR]] fix: never trust an empty content_buffer at face
780
+ // value when the attached session's capture is already materialized — the
781
+ // buffer and the on-disk file can diverge, and reporting "empty" here is
782
+ // exactly what let an agent conclude a live capture was safe to overwrite.
783
+ // Rehydrate from disk when needed, and always tell the caller whether this
784
+ // is a genuinely new/empty capture via `is_new`.
785
+ const rehydrated = resolveActiveCaptureContent(session, {
786
+ normalizeBuffer: normalizeCaptureBuffer,
787
+ loadSpec: (specPath) => loadSpec(vaultRoot, specPath),
788
+ extractCore: extractCaptureCoreFromBody,
789
+ extractNotes: extractCaptureNotesFromBody,
790
+ });
791
+ const contentBuffer = rehydrated.contentBuffer;
792
+ const payload = {
793
+ spec_id: session.id,
794
+ spec_path: session.specPath || null,
795
+ name: contentBuffer.name || `Capture ${session.id}`,
796
+ core_idea: contentBuffer.core_idea || '',
797
+ notes: contentBuffer.notes || '',
798
+ status: session.status,
799
+ source: rehydrated.rehydrated_from_disk ? 'database+disk-rehydrated' : 'database',
800
+ is_new: rehydrated.is_new,
801
+ session,
802
+ synced_from_offline_buffer: drain.drained || undefined,
803
+ ...sufficiencyForSpecPath(vaultRoot, session.specPath, session),
804
+ };
805
+ if (!rehydrated.is_new) {
806
+ payload.guidance =
807
+ 'This capture already has content (is_new: false) — do NOT overwrite existing sections (core_idea/summary/notes/example/user_stories) without explicit user instruction. Append or ask before replacing.';
808
+ }
809
+ // HS-KFB2: this call minted a brand-new session — check whether it's really
810
+ // a same-session follow-on to an idea that was JUST promoted, so the agent
811
+ // can suggest attaching to that lineage instead of treating this as
812
+ // unrelated. Advisory only (never blocks, never auto-attaches); a lookup
813
+ // failure degrades to silence, same as every other best-effort check here.
814
+ if (freshlyCreated) {
815
+ try {
816
+ const materialized = await listMaterializedCaptureSessions();
817
+ if (materialized.ok) {
818
+ const lineage = findRecentlyPromotedLineage(materialized.sessions, (specPath) =>
819
+ loadSpec(vaultRoot, specPath),
820
+ );
821
+ if (lineage) {
822
+ payload.continuation_of = lineage.promotedTo;
823
+ payload.continuation_note =
824
+ `This capture was just auto-created, and a recently-promoted idea (${lineage.promotedTo}` +
825
+ (lineage.retiredCaptureId ? `, from ${lineage.retiredCaptureId}` : '') +
826
+ `) is still fresh in this session. Before treating this as unrelated, consider telling the ` +
827
+ `user this looks like a follow-on to that idea and offering to add it to ${lineage.promotedTo} ` +
828
+ `instead of (or in addition to) this new capture ([[HS-KFB2-6RGF-DIAG-capture-continuation-mints-a-new-orphan]]).`;
829
+ }
830
+ }
831
+ } catch {
832
+ // best-effort — never block active_capture on this check
833
+ }
834
+ }
835
+ return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
836
+ }
837
+
838
+ /**
839
+ * [[HS-TASK-MJJK]] + Defect 3 — capture is online-only ([[heyspex-is-online-only-no-offline]]).
840
+ * 1. If spec_id is provided, use that session.
841
+ * 2. Otherwise, attach to the pending session, or agent-first auto-create one in the DB.
842
+ * 3. Drain any offline-buffered captures, then PATCH the session's content_buffer.
843
+ * 4. If console is unreachable, stash the verbatim input to the offline buffer
844
+ * ([[capture-outbox]]) and refuse gracefully — NEVER a divergent file scaffold.
845
+ *
846
+ * Materialization is triggered server-side per [[HS-TASK-U9E7]].
847
+ */
848
+ export async function handleUpdateCapture(vaultRoot, args) {
849
+ // First hop: refuse an unrecognized/misspelled arg BY NAME instead of
850
+ // letting it silently vanish into buildCaptureBufferPatches's explicit
851
+ // allowlist — the declared `additionalProperties: false` inputSchema above
852
+ // is never validated at runtime by any layer
853
+ // [[additionalproperties-false-in-an-mcp]].
854
+ const argsVerdict = updateCaptureArgsVerdict(args);
855
+ if (!argsVerdict.ok) {
856
+ return { content: [{ type: 'text', text: JSON.stringify(argsVerdict.error, null, 2) }] };
857
+ }
858
+
859
+ // sources (links / original files): validate BEFORE anything is written so an
860
+ // oversize or malformed entry is refused by name with nothing half-saved.
861
+ // The normalized result is not reused: buildCaptureBufferPatches re-derives
862
+ // it with the same timestamp it stamps on every other field.
863
+ if (args.sources !== undefined) {
864
+ const srcVerdict = normalizeCaptureSources(args.sources, new Date().toISOString());
865
+ if (!srcVerdict.ok) {
866
+ return { content: [{ type: 'text', text: JSON.stringify(srcVerdict.error, null, 2) }] };
867
+ }
868
+ }
869
+
870
+ const identity = detectAgentIdentity();
871
+
872
+ // Resolve the session to update — online-only, no file-mode fallback.
873
+ let sessionId = args.spec_id?.trim();
874
+
875
+ if (!sessionId) {
876
+ const sessionsResult = await listPendingCaptureSessions();
877
+ if (!sessionsResult.ok) {
878
+ // Transport failure → stash the verbatim words + graceful refuse (no
879
+ // file write). An answered HTTP error is surfaced honestly instead —
880
+ // never stashed, never called "unreachable" (HS-K27H).
881
+ const refusal = captureWriteFailure(vaultRoot, args, sessionsResult);
882
+ return { content: [{ type: 'text', text: JSON.stringify(refusal, null, 2) }] };
883
+ }
884
+ if (sessionsResult.sessions.length > 0) {
885
+ sessionId = sessionsResult.sessions[0].id;
886
+ } else {
887
+ // Reachable but no pending session → agent-first auto-create in the DB.
888
+ const createResult = await createCaptureSessionOnConsole({
889
+ opened_by: 'agent',
890
+ opened_by_session: identity.sessionId,
891
+ mode: 'attended',
892
+ status: 'pending',
893
+ });
894
+ if (!createResult.ok) {
895
+ const refusal = captureWriteFailure(vaultRoot, args, createResult);
896
+ return { content: [{ type: 'text', text: JSON.stringify(refusal, null, 2) }] };
897
+ }
898
+ sessionId = createResult.session.id;
899
+ }
900
+ }
901
+
902
+ // Read the current buffer BEFORE draining so replayed append-shaped entries
903
+ // (userQuotes/chatTurns) merge with what is already on the session instead
904
+ // of clobbering it (HS-K27H) — and so this call's own multi-beat appends
905
+ // merge too. The console PATCH route stores top-level keys verbatim
906
+ // (last-write-wins); the buffer must carry the canonical STRUCTURED shapes
907
+ // (userQuotes: Array<{at,text}>, coreIdea/concept/details:
908
+ // {at,text,confirmed}); flat strings render an empty body ([[HS-EPIC-CPMR]]).
909
+ let currentBuffer = {};
910
+ const existing = await getCaptureSessionOnConsole(sessionId);
911
+ if (
912
+ existing.ok &&
913
+ existing.session &&
914
+ existing.session.contentBuffer &&
915
+ typeof existing.session.contentBuffer === 'object'
916
+ ) {
917
+ currentBuffer = existing.session.contentBuffer;
918
+ }
919
+
920
+ // Online with a session → flush any offline-buffered captures first (oldest
921
+ // first, chronological), then file this update. Re-read the buffer if the
922
+ // drain wrote anything, so this call's patches build on the drained state.
923
+ const drainResult = await drainCaptureOutbox(vaultRoot, sessionId, identity, currentBuffer);
924
+ if (drainResult.drained > 0) {
925
+ const refetched = await getCaptureSessionOnConsole(sessionId);
926
+ if (
927
+ refetched.ok &&
928
+ refetched.session &&
929
+ refetched.session.contentBuffer &&
930
+ typeof refetched.session.contentBuffer === 'object'
931
+ ) {
932
+ currentBuffer = refetched.session.contentBuffer;
933
+ }
934
+ }
935
+
936
+ const iso = new Date().toISOString();
937
+ const patches = buildCaptureBufferPatches(args, currentBuffer, iso);
938
+
939
+ const attach = { agent: identity.agent, sessionId: identity.sessionId };
940
+ const patchResult = await patchCaptureSessionOnConsole(sessionId, { patches, attach });
941
+
942
+ if (!patchResult.ok) {
943
+ // HS-K27H classification — one decision point:
944
+ // transport (status 0) → stash to the offline buffer + honest sync
945
+ // message (the next drain replays every stashed field).
946
+ // 4xx → status "refused": the server answered and rejected the write;
947
+ // server error + message verbatim; NOT stashed (the pre-fix 409
948
+ // dead-letters permanently head-of-line-blocked the queue).
949
+ // 5xx → status "failed": the server answered and errored; message
950
+ // verbatim; NOT stashed (pre-fix this was disguised as
951
+ // "can't reach the server" with a false sync promise).
952
+ const payload = captureWriteFailure(vaultRoot, args, patchResult);
953
+ if (payload.status !== 'deferred') payload.spec_id = sessionId;
954
+ return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
955
+ }
956
+
957
+ const payload = {
958
+ success: true,
959
+ spec_id: sessionId,
960
+ spec_path: patchResult.session?.specPath || null,
961
+ source: 'database',
962
+ session: patchResult.session,
963
+ materialization: patchResult.materialization || null,
964
+ message: 'Capture session updated via API. Materialization handled server-side.',
965
+ ...sufficiencyForSpecPath(vaultRoot, patchResult.session?.specPath, patchResult.session),
966
+ };
967
+
968
+ // Agent-originated captures never auto-open a browser window (companion /
969
+ // chrome-extension only). On FIRST materialization, surface a one-shot
970
+ // "want to see it?" offer so the agent can ask and open on a plain yes.
971
+ const mat = patchResult.materialization;
972
+ const openedBy = patchResult.session?.openedBy;
973
+ if (mat && mat.materialized === true && mat.first === true && openedBy === 'agent') {
974
+ payload.offer_open_viewer = {
975
+ prompt:
976
+ `Capture \`${sessionId}\` is on disk. Want to see it? Say **yes** / **open** / **show** and I'll open the doc.`,
977
+ confirm_tokens: ['yes', 'open', 'show'],
978
+ on_confirm: {
979
+ shell: `node bin/hs.mjs view docs ${sessionId}`,
980
+ note:
981
+ 'Opens the capture in a chromeless solo docs window. NEVER auto-open without an explicit confirm token from the user.',
982
+ },
983
+ };
984
+ }
985
+
986
+ return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
987
+ }
988
+
989
+ /**
990
+ * [[HS-TASK-MJJK]], fixed per [[HS-1C4L-9VZV-DIAG-multi-agent-capture-focus-pin-collision]]
991
+ * AC3 — `spec_id` is REQUIRED by the tool's inputSchema, but the original
992
+ * implementation silently ignored it and always created/reused an unrelated
993
+ * session — a caller asking to pin an EXISTING capture got a different one
994
+ * back with no error. That made the tool non-functional for its documented
995
+ * purpose (the "escape hatch" heyspex_active_capture's own description
996
+ * points to), which is what led the diagnose to conclude it had no real
997
+ * callable surface.
998
+ *
999
+ * Now: `spec_id` given → resolve THAT capture (DB session, else on-disk
1000
+ * materialized spec) and pin it — touching the DB row's activity/attachment
1001
+ * so it becomes the most-recently-active pending session (the "on deck" one
1002
+ * for the no-spec_id `heyspex_active_capture` resolution), never creating or
1003
+ * attaching to a different capture. `spec_id` omitted → back-compat: mint a
1004
+ * fresh session, same as before.
1005
+ */
1006
+ export async function handlePinCapture(vaultRoot, args) {
1007
+ const identity = detectAgentIdentity();
1008
+ const wantedId = args.spec_id ? String(args.spec_id).trim() : '';
1009
+
1010
+ if (wantedId) {
1011
+ const got = await getCaptureSessionOnConsole(wantedId);
1012
+ if (got.ok && got.session) {
1013
+ // Touch the row (empty patch + attach) so it becomes sessions[0] for
1014
+ // subsequent no-spec_id lookups — the actual "pin" effect, without
1015
+ // mutating any content. Refuses (409) only if the row is abandoned.
1016
+ const patchResult = await patchCaptureSessionOnConsole(wantedId, {
1017
+ patches: [],
1018
+ attach: { agent: identity.agent, sessionId: identity.sessionId },
1019
+ });
1020
+ const session = patchResult.ok ? patchResult.session : got.session;
1021
+ const pin = await writeCaptureFocusVendor(vaultRoot, {
1022
+ specId: session.id,
1023
+ specPath: session.specPath || '',
1024
+ reason: args.reason || 'Pinned via MCP',
1025
+ claim: { claimed_by: identity.agent, claim_session: identity.sessionId },
1026
+ });
1027
+ // Measured, not assumed: `pinned: false` means nothing readable persisted,
1028
+ // so this cannot return `success: true, message: "... pinned ..."`.
1029
+ if (!pin.pinned) {
1030
+ return {
1031
+ isError: true,
1032
+ content: [{
1033
+ type: 'text',
1034
+ text: JSON.stringify({
1035
+ ...captureFocusPinRefusal(pin, { specId: session.id, specPath: session.specPath || null }),
1036
+ source: 'database',
1037
+ session,
1038
+ }, null, 2),
1039
+ }],
1040
+ };
1041
+ }
1042
+ return {
1043
+ content: [{
1044
+ type: 'text',
1045
+ text: JSON.stringify({
1046
+ success: true,
1047
+ spec_id: session.id,
1048
+ spec_path: session.specPath || null,
1049
+ source: 'database',
1050
+ session,
1051
+ pin,
1052
+ pinned: true,
1053
+ message: `Capture ${session.id} pinned for /hs-capture (${pin.tier})`,
1054
+ }, null, 2),
1055
+ }],
1056
+ };
1057
+ }
1058
+ // DB miss → fall back to a materialized on-disk capture (legacy / no live
1059
+ // session row). Pin the file directly; no session to touch.
1060
+ const specPath = findCaptureSpecFile(vaultRoot, wantedId) || findSpecFile(vaultRoot, wantedId);
1061
+ if (!specPath) {
1062
+ return {
1063
+ isError: true,
1064
+ content: [{ type: 'text', text: JSON.stringify({ error: 'SPEC_NOT_FOUND', message: wantedId }) }],
1065
+ };
1066
+ }
1067
+ const pin = await writeCaptureFocusVendor(vaultRoot, {
1068
+ specId: wantedId,
1069
+ specPath,
1070
+ reason: args.reason || 'Pinned via MCP',
1071
+ claim: { claimed_by: identity.agent, claim_session: identity.sessionId },
1072
+ });
1073
+ if (!pin.pinned) {
1074
+ return {
1075
+ isError: true,
1076
+ content: [{
1077
+ type: 'text',
1078
+ text: JSON.stringify(
1079
+ { ...captureFocusPinRefusal(pin, { specId: wantedId, specPath }), source: 'disk' },
1080
+ null,
1081
+ 2,
1082
+ ),
1083
+ }],
1084
+ };
1085
+ }
1086
+ return {
1087
+ content: [{
1088
+ type: 'text',
1089
+ text: JSON.stringify({
1090
+ success: true,
1091
+ spec_id: wantedId,
1092
+ spec_path: specPath,
1093
+ source: 'disk',
1094
+ pin,
1095
+ pinned: true,
1096
+ message: `Capture ${wantedId} pinned for /hs-capture (disk-only, no live session; ${pin.tier})`,
1097
+ }, null, 2),
1098
+ }],
1099
+ };
1100
+ }
1101
+
1102
+ // No spec_id → back-compat: create a session row first (pre-existing
1103
+ // behavior, unchanged).
1104
+ const createResult = await createCaptureSessionOnConsole({
1105
+ opened_by: 'agent',
1106
+ opened_by_session: identity.sessionId,
1107
+ mode: 'attended',
1108
+ status: 'pending',
1109
+ });
1110
+
1111
+ if (createResult.ok && createResult.session) {
1112
+ // Session created — pin capture-on-deck + layer-2 metadata to Neon
1113
+ // ([[HS-TASK-CFL2]]; file is the offline fallback).
1114
+ const session = createResult.session;
1115
+ const pin = await writeCaptureFocusVendor(vaultRoot, {
1116
+ specId: session.id,
1117
+ specPath: session.specPath || '',
1118
+ reason: args.reason || 'Pinned via MCP',
1119
+ claim: { claimed_by: identity.agent, claim_session: identity.sessionId },
1120
+ });
1121
+
1122
+ if (!pin.pinned) {
1123
+ // The session row WAS created — say so, so the caller does not mint a second
1124
+ // one — but never report it as pinned when nothing readable persisted.
1125
+ return {
1126
+ isError: true,
1127
+ content: [{
1128
+ type: 'text',
1129
+ text: JSON.stringify({
1130
+ ...captureFocusPinRefusal(pin, { specId: session.id, specPath: session.specPath || null }),
1131
+ source: 'database',
1132
+ session_created: true,
1133
+ session,
1134
+ }, null, 2),
1135
+ }],
1136
+ };
1137
+ }
1138
+
1139
+ return {
1140
+ content: [{
1141
+ type: 'text',
1142
+ text: JSON.stringify({
1143
+ success: true,
1144
+ spec_id: session.id,
1145
+ spec_path: session.specPath || null,
1146
+ source: 'database',
1147
+ session,
1148
+ pin,
1149
+ pinned: true,
1150
+ message: `Capture session ${session.id} created and pinned for /hs-capture (${pin.tier})`,
1151
+ }, null, 2),
1152
+ }],
1153
+ };
1154
+ }
1155
+
1156
+ // Unreachable console → graceful refuse; no local file pin (online-only).
1157
+ const refusal = captureWriteFailure(vaultRoot, args, createResult);
1158
+ return { content: [{ type: 'text', text: JSON.stringify(refusal, null, 2) }] };
1159
+ }
1160
+
1161
+ // Accept HS-CAP-* primarily; HS-IDEA-* still recognized for legacy pins until E2.
1162
+ // CAP is family-keyed (dual-read via parseSpecId — accepts HS-CAP-XXXX and
1163
+ // HS-XXXX-CAP); IDEA is not reordered, so it stays a type-first prefix check
1164
+ // ([[HS-EPIC-DLE3]]).
1165
+ export function isCaptureSpecId(id) {
1166
+ const v = String(id ?? '').trim();
1167
+ return parseSpecId(v)?.typeLabel === 'CAP' || /^HS-IDEA-[A-Z0-9]{3,}$/i.test(v);
1168
+ }
1169
+
1170
+ export function collectOccupiedCaptureIds(vaultRoot) {
1171
+ const scanSpecs = [];
1172
+ for (const [dir, type] of [
1173
+ ['captures', 'capture'],
1174
+ ['ideas', 'capture'], // legacy bucket; still contributes to occupied set
1175
+ ]) {
1176
+ const root = join(vaultRoot, 'specs', dir);
1177
+ if (!existsSync(root)) continue;
1178
+ for (const file of readdirSync(root)) {
1179
+ if (!file.endsWith('.md') || file.endsWith('.review.md')) continue;
1180
+ try {
1181
+ const content = readFileSync(join(root, file), 'utf-8');
1182
+ const { frontmatter } = parseFrontmatter(content);
1183
+ scanSpecs.push({
1184
+ id: String(frontmatter.id ?? ''),
1185
+ basename: file.replace(/\.md$/i, ''),
1186
+ type,
1187
+ });
1188
+ } catch {
1189
+ /* skip */
1190
+ }
1191
+ }
1192
+ }
1193
+ return collectOccupiedIdsFromSpecs(scanSpecs).captures;
1194
+ }
1195
+
1196
+ /**
1197
+ * Cloud-reliant sibling of `collectOccupiedCaptureIds` ([[HS-DJWP-MCPC-TASK]]
1198
+ * "Occupied-id scans" family). `collectOccupiedCaptureIds` stays untouched
1199
+ * (the `diskRead` branch); in `neon` tier this unions `GET
1200
+ * /api/vault/occupied-ids?bucket=captures` and `?bucket=ideas` instead of
1201
+ * `readdirSync`ing `specs/{captures,ideas}`.
1202
+ *
1203
+ * NOTE: as of this task, `collectOccupiedCaptureIds` itself has no caller in
1204
+ * cli.mjs (confirmed by grep — pre-existing dead code, unrelated to this
1205
+ * task). This wrapper is wired and tested so the capability exists the moment
1206
+ * a caller needs it, without leaving a disk-only gap behind.
1207
+ */
1208
+ export async function collectOccupiedCaptureIdsViaTier(vaultRoot, { tier, consoleUrl }) {
1209
+ return readViaSelector({
1210
+ allowDiskFallback: false,
1211
+ tier,
1212
+ diskRead: () => collectOccupiedCaptureIds(vaultRoot),
1213
+ neonRead: async () => {
1214
+ const [capturesRes, ideasRes] = await Promise.all([
1215
+ consoleFetch('/api/vault/occupied-ids?bucket=captures'),
1216
+ consoleFetch('/api/vault/occupied-ids?bucket=ideas'),
1217
+ ]);
1218
+ if (!capturesRes.ok) {
1219
+ throw consoleReadErrorFrom(capturesRes, `GET /api/vault/occupied-ids?bucket=captures returned ${capturesRes.status}: ${capturesRes.error || ''}`, null);
1220
+ }
1221
+ if (!ideasRes.ok) {
1222
+ throw consoleReadErrorFrom(ideasRes, `GET /api/vault/occupied-ids?bucket=ideas returned ${ideasRes.status}: ${ideasRes.error || ''}`, null);
1223
+ }
1224
+ return new Set([
1225
+ ...(Array.isArray(capturesRes.body?.ids) ? capturesRes.body.ids : []),
1226
+ ...(Array.isArray(ideasRes.body?.ids) ? ideasRes.body.ids : []),
1227
+ ]);
1228
+ },
1229
+ routeLabel: 'GET /api/vault/occupied-ids',
1230
+ consoleUrl,
1231
+ });
1232
+ }
1233
+
1234
+ /**
1235
+ * [[HS-PG9I-TASK]] — pull a text value out of a content_buffer field that may be
1236
+ * either a flattened string OR the structured `{ at, text, confirmed }` object the
1237
+ * PATCH route stores ([[capture-buffer-patches]]). The live console GET returns the
1238
+ * STRUCTURED shape (`coreIdea`/`summary`/`userQuotes`), so resume detection must not
1239
+ * assume the flat keys.
1240
+ */
1241
+ export function bufferText(value) {
1242
+ if (!value) return '';
1243
+ if (typeof value === 'string') return value.trim();
1244
+ if (typeof value === 'object' && value.text) return String(value.text).trim();
1245
+ return '';
1246
+ }
1247
+
1248
+ /**
1249
+ * [[HS-PG9I-TASK]] — normalize a session content_buffer into { core, summary, notes }
1250
+ * regardless of whether the console returned flat keys (core_idea/notes) or the
1251
+ * structured shape (coreIdea/summary/userQuotes[]). This is what makes the resume
1252
+ * branch fire for a DB-resident capture that already carries a Core Idea.
1253
+ */
1254
+ export function normalizeCaptureBuffer(contentBuffer = {}) {
1255
+ const core = bufferText(contentBuffer.core_idea ?? contentBuffer.coreIdea);
1256
+ const summary = bufferText(contentBuffer.summary ?? contentBuffer.concept);
1257
+ let notes = typeof contentBuffer.notes === 'string' ? contentBuffer.notes : '';
1258
+ if (!notes && Array.isArray(contentBuffer.userQuotes)) {
1259
+ notes = contentBuffer.userQuotes
1260
+ .map((q) => `- **${q.at || ''} · you:** "${bufferText(q) || q.text || ''}"`)
1261
+ .join('\n');
1262
+ }
1263
+ return { core, summary, notes };
1264
+ }