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
@@ -5,7 +5,8 @@
5
5
  * `/api/specs/:id/status` is SSOT (coercion, rollup, spec events). [[HS-DIAG-DOSH]]
6
6
  */
7
7
 
8
- import { parseAcceptanceSubsections } from './alignment-gate.mjs';
8
+ import { extractSection, hasHeading, parseAcceptanceSubsections } from './alignment-gate.mjs';
9
+ import { gatherCloseBranchReport } from './close-branch-report.mjs';
9
10
  import { readMvpModeState } from './mvp-mode.mjs';
10
11
  import { isBelowReviewLevel, reviewLevelForGrain } from './review-rollup.mjs';
11
12
 
@@ -18,12 +19,87 @@ export function resolveActiveReviewLevel(vaultRoot) {
18
19
  return reviewLevelForGrain(mvp?.review_grain);
19
20
  }
20
21
 
22
+ const REVIEW_LEVELS = new Set(['task', 'epic', 'milestone']);
23
+
24
+ /**
25
+ * Reconcile the grain this machine read with the grain the console reports it
26
+ * gated under ([[HS-A53F]]). The console's `review_level` wins when present —
27
+ * the status route decides the coercion, so its grain is the one that produced
28
+ * `effective_status`; the client's own read is kept beside it as
29
+ * `client_review_level` and the two are compared explicitly. An older console
30
+ * that reports no grain leaves the client's value in place (`source: client`).
31
+ */
32
+ export function reviewLevelReport(clientReviewLevel, consoleBody) {
33
+ const serverLevel = consoleBody?.review_level;
34
+ const hasServer = typeof serverLevel === 'string' && REVIEW_LEVELS.has(serverLevel);
35
+ const reviewLevel = hasServer ? serverLevel : clientReviewLevel;
36
+ return {
37
+ review_level: reviewLevel,
38
+ review_level_source: hasServer ? (consoleBody?.review_level_source ?? 'server') : 'client',
39
+ client_review_level: clientReviewLevel,
40
+ review_level_mismatch: hasServer && serverLevel !== clientReviewLevel,
41
+ };
42
+ }
43
+
21
44
  /** Count acceptance criteria the same way the close-out gate expects (flat bullets). */
22
45
  export function countAcceptanceCriteria(body) {
23
46
  const { sections } = parseAcceptanceSubsections(body ?? '');
24
47
  return sections.reduce((n, s) => n + (s.bullets?.length ?? 0), 0);
25
48
  }
26
49
 
50
+ // Section anchors, mirrored from `parseAcceptanceSubsections` (alignment-gate.mjs)
51
+ // so the checkbox reader below looks at EXACTLY the criteria that function counts.
52
+ // The regexes are duplicated rather than imported because they are module-private
53
+ // there; `submit-for-review.test.mjs` pins the two against each other over the
54
+ // shared fixtures, so a drift is a red test rather than a silent disagreement.
55
+ const ACCEPTANCE_HEADING_TITLE = /^acceptance(?:\s+criteria)?$/i;
56
+ const ACCEPTANCE_HEADING_TITLE_ANY = /^acceptance/i;
57
+ const DONE_WHEN_HEADING_TITLE_ALIAS = /^done\s+when\b/i;
58
+ // Group 1 is the checkbox content when the item renders one; undefined otherwise.
59
+ const CHECKBOX_LIST_ITEM = /^\s*(?:[-*]\s+(?:\[([ xX])\]\s+)?|\d+[.)]\s+)(.+?)\s*$/;
60
+
61
+ /**
62
+ * Read the acceptance criteria's rendered checkboxes as REAL STATE
63
+ * ([[HS-LBNJ-6AT2-DIAG]] AC3).
64
+ *
65
+ * Before this, `[ ]` and `[x]` were matched and stripped identically by every
66
+ * parser on both runtimes — only a COUNT survived — so "progress" was always the
67
+ * agent's self-reported `acProgress` integer and never the document. A spec could
68
+ * render every box unchecked and still pass the AC gate. A rendered `- [ ]` that
69
+ * no code reads is worse than no checkbox at all, because every human and agent
70
+ * reads it as a verification signal.
71
+ *
72
+ * Returns `{ present, total, checked, unchecked }` where `unchecked` lists the
73
+ * 1-based positions still showing `[ ]`. `present: false` (no criterion renders a
74
+ * box — the 241 specs using numbered criteria) means the caller keeps its prior
75
+ * behavior exactly: this is additive, not a new requirement on every spec.
76
+ */
77
+ export function readAcceptanceCheckboxes(body) {
78
+ const text = body ?? '';
79
+ let section = extractSection(text, ACCEPTANCE_HEADING_TITLE);
80
+ if (!section && !hasHeading(text, ACCEPTANCE_HEADING_TITLE_ANY)) {
81
+ section = extractSection(text, DONE_WHEN_HEADING_TITLE_ALIAS);
82
+ }
83
+ if (!section) return { present: false, total: 0, checked: 0, unchecked: [] };
84
+
85
+ let index = 0;
86
+ let total = 0;
87
+ let checked = 0;
88
+ const unchecked = [];
89
+ for (const line of section.split(/\r?\n/)) {
90
+ const m = line.match(CHECKBOX_LIST_ITEM);
91
+ if (!m || !m[2].trim()) continue;
92
+ index += 1;
93
+ // An item with no checkbox makes no claim either way — it can neither
94
+ // satisfy nor block the gate, so it is counted as a criterion but not as a box.
95
+ if (m[1] === undefined) continue;
96
+ total += 1;
97
+ if (m[1].toLowerCase() === 'x') checked += 1;
98
+ else unchecked.push(index);
99
+ }
100
+ return { present: total > 0, total, checked, unchecked };
101
+ }
102
+
27
103
  /**
28
104
  * Post-close vault steps still required after the status API flip (HS-REV vs seed).
29
105
  */
@@ -67,7 +143,13 @@ function findClaimAcProgress(claimsBody, specId) {
67
143
  /**
68
144
  * @param {{ vaultRoot: string, consoleFetch: Function, postClaimEndpoint?: Function, args?: Record<string, unknown> }} ctx
69
145
  */
70
- export async function executeSubmitForReview({ vaultRoot, consoleFetch, postClaimEndpoint, args = {} }) {
146
+ export async function executeSubmitForReview({
147
+ vaultRoot,
148
+ consoleFetch,
149
+ postClaimEndpoint,
150
+ args = {},
151
+ gatherBranchReport = gatherCloseBranchReport,
152
+ }) {
71
153
  const specId = typeof args.spec_id === 'string' ? args.spec_id.trim() : '';
72
154
  if (!specId) {
73
155
  return { error: 'INVALID_ARGS', message: 'spec_id is required' };
@@ -75,6 +157,12 @@ export async function executeSubmitForReview({ vaultRoot, consoleFetch, postClai
75
157
 
76
158
  const skipAcCheck = args.skip_ac_check === true;
77
159
  const releaseClaim = args.release_claim !== false;
160
+ // Historical token calibration (HS-HBD2): forward the caller's real
161
+ // context-window spend, when known, so the console pairs it with the
162
+ // pre-build estimate on the build-complete spec event. Only a genuine
163
+ // finite number is forwarded — the console PATCH route is the source of
164
+ // truth for range validation (>= 0), this just avoids sending junk types.
165
+ const hasActualTokens = typeof args.actual_tokens === 'number' && Number.isFinite(args.actual_tokens);
78
166
 
79
167
  const specRes = await consoleFetch(`/api/specs/${encodeURIComponent(specId)}`);
80
168
  if (!specRes.ok) {
@@ -115,8 +203,28 @@ export async function executeSubmitForReview({ vaultRoot, consoleFetch, postClai
115
203
  claimInfo = findClaimAcProgress(claimsRes.body, specId);
116
204
  }
117
205
 
206
+ const checkboxes = readAcceptanceCheckboxes(spec.body);
207
+
118
208
  if (!skipAcCheck && criteriaCount > 0) {
119
209
  const progress = claimInfo.found ? claimInfo.acProgress : undefined;
210
+ // Checkbox-authoritative ([[HS-LBNJ-6AT2-DIAG]] AC3): where the spec renders
211
+ // criteria checkboxes, THEY are the state — an unchecked box refuses the
212
+ // hand-off no matter what the self-reported integer says. Where it renders
213
+ // none, the acProgress rule below is unchanged.
214
+ if (checkboxes.present && checkboxes.unchecked.length > 0) {
215
+ return {
216
+ error: 'AC_CHECKBOXES_UNCHECKED',
217
+ message:
218
+ `Acceptance criteria ${checkboxes.unchecked.join(', ')} are still rendered unchecked ` +
219
+ `(${checkboxes.checked} of ${checkboxes.total} boxes ticked in ${specId}). ` +
220
+ `Tick the boxes you actually verified — the document is the record, not the progress count — ` +
221
+ `or pass skip_ac_check: true with a reason in notes.`,
222
+ criteria_count: criteriaCount,
223
+ ac_progress: progress ?? null,
224
+ checkboxes,
225
+ claim_found: claimInfo.found,
226
+ };
227
+ }
120
228
  if (progress === undefined || progress < criteriaCount) {
121
229
  return {
122
230
  error: 'AC_PROGRESS_INCOMPLETE',
@@ -124,6 +232,7 @@ export async function executeSubmitForReview({ vaultRoot, consoleFetch, postClai
124
232
  `Acceptance criteria progress is ${progress ?? 'unknown'} of ${criteriaCount}. Call heyspex_report_progress after each criterion, or pass skip_ac_check: true with a reason in notes.`,
125
233
  criteria_count: criteriaCount,
126
234
  ac_progress: progress ?? null,
235
+ checkboxes,
127
236
  claim_found: claimInfo.found,
128
237
  };
129
238
  }
@@ -140,16 +249,32 @@ export async function executeSubmitForReview({ vaultRoot, consoleFetch, postClai
140
249
 
141
250
  const statusRes = await consoleFetch(`/api/specs/${encodeURIComponent(specId)}/status`, {
142
251
  method: 'PATCH',
143
- body: { status: 'in_review' },
252
+ body: {
253
+ status: 'in_review',
254
+ ...(hasActualTokens ? { actual_tokens: args.actual_tokens } : {}),
255
+ },
144
256
  });
145
257
 
146
258
  if (!statusRes.ok) {
259
+ // The server names the grain it gated under ([[HS-A53F]]); put it beside the
260
+ // grain THIS machine read so a 409 under a disagreeing grain is legible as a
261
+ // resolver mismatch, not as "the agent tried to close".
262
+ const grainReport = reviewLevelReport(reviewLevel, statusRes.body);
147
263
  return {
148
264
  error: 'STATUS_PATCH_FAILED',
149
265
  message: `PATCH /api/specs/${specId}/status returned ${statusRes.status}`,
150
266
  status: statusRes.status,
151
267
  console_response: statusRes.body,
152
268
  attempted: { requested_status: 'in_review' },
269
+ ...grainReport,
270
+ ...(grainReport.review_level_mismatch
271
+ ? {
272
+ hint:
273
+ `This machine's config.yaml says review_grain "${reviewLevel}" but the console ` +
274
+ `decided "${grainReport.review_level}" (source: ${grainReport.review_level_source}). ` +
275
+ `The server is authoritative; align the cloud setting via PUT /api/mvp-mode { review_grain }.`,
276
+ }
277
+ : {}),
153
278
  };
154
279
  }
155
280
 
@@ -158,6 +283,11 @@ export async function executeSubmitForReview({ vaultRoot, consoleFetch, postClai
158
283
  transition.spec?.frontmatter?.status ?? transition.spec?.status ?? 'in_review',
159
284
  ).toLowerCase();
160
285
  const coercedTo = transition.coerced_to ?? transition.coercedTo ?? null;
286
+ const grainReport = reviewLevelReport(reviewLevel, transition);
287
+ // The SERVER's grain drives the close-out guidance when it was reported — the
288
+ // server is what actually coerced (or did not coerce) the status.
289
+ const effectiveReviewLevel = grainReport.review_level;
290
+ const effectiveIsReviewUnit = !isBelowReviewLevel(specKind, effectiveReviewLevel);
161
291
 
162
292
  let claimReleased;
163
293
  if (releaseClaim && typeof postClaimEndpoint === 'function') {
@@ -167,20 +297,33 @@ export async function executeSubmitForReview({ vaultRoot, consoleFetch, postClai
167
297
 
168
298
  const guidance = buildCloseOutGuidance({
169
299
  specKind,
170
- reviewLevel,
300
+ reviewLevel: effectiveReviewLevel,
171
301
  effectiveStatus,
172
- isReviewUnit,
302
+ isReviewUnit: effectiveIsReviewUnit,
173
303
  });
174
304
 
305
+ // Name the branch each half of the session landed on, and whether they
306
+ // match ([[HS-PPX7-CWPX-TASK-session-close-names-the-branch-each]]).
307
+ // Best-effort: a failure here never blocks the close-out.
308
+ try {
309
+ const report = await gatherBranchReport({ consoleFetch });
310
+ guidance.branch_report = report;
311
+ } catch {
312
+ guidance.branch_report = { status: 'unknown', matches: false, lines: ['Branch report unavailable.'] };
313
+ }
314
+
175
315
  return {
176
316
  spec_id: specId,
177
317
  requested_status: 'in_review',
178
318
  effective_status: effectiveStatus,
179
319
  coerced_to: coercedTo,
180
- review_level: reviewLevel,
181
- is_review_unit: isReviewUnit,
320
+ ...grainReport,
321
+ is_review_unit: effectiveIsReviewUnit,
182
322
  criteria_count: criteriaCount,
183
323
  ac_progress: claimInfo.acProgress ?? null,
324
+ // Reported on success too, so a caller can see WHICH signal cleared the gate
325
+ // — the document's boxes or the unverified self-report count.
326
+ checkboxes,
184
327
  rolled_up: transition.rolledUp ?? transition.rolled_up ?? undefined,
185
328
  propagated: transition.propagated ?? undefined,
186
329
  claim_released: claimReleased,
@@ -160,3 +160,108 @@ ${readStep}
160
160
 
161
161
  Make only this one change; ask only if the spec body is too thin to summarize honestly.`;
162
162
  }
163
+
164
+ /** Target lengths from the contract above — soft, surfaced as warnings by the writer. */
165
+ export const SUMMARY_OPENING_SENTENCE_TARGET_CHARS = 160;
166
+ export const SUMMARY_TOTAL_TARGET_CHARS = 400;
167
+
168
+ /**
169
+ * Validate an authored `summary` value BEFORE it is written anywhere (the
170
+ * console row via `heyspex_author_summary`, or a vault file). One validator
171
+ * for both destinations so the cloud write is held to exactly the rules the
172
+ * on-disk `summary: |` block is. Pure.
173
+ *
174
+ * Hard failures (nothing is written): not a string, empty after trim, a
175
+ * pasted YAML field (`summary: |` / `summary:` prefix — the writer adds the
176
+ * field itself), or a frontmatter fence. Soft findings come back as
177
+ * `warnings` — the contract's ≤160 / ≤400 targets are targets, not gates.
178
+ *
179
+ * @param {unknown} raw
180
+ * @returns {{ ok: true, summary: string, warnings: string[] } | { ok: false, error: string, message: string }}
181
+ */
182
+ export function validateAuthoredSummary(raw) {
183
+ if (typeof raw !== 'string') {
184
+ return { ok: false, error: 'INVALID_SUMMARY', message: '`summary` must be a string of plain-language prose.' };
185
+ }
186
+ const normalized = raw.replace(/\r\n?/g, '\n').trim();
187
+ if (!normalized) {
188
+ return { ok: false, error: 'INVALID_SUMMARY', message: '`summary` is empty — author the plain-language summary per spec-summary-authoring, then pass it.' };
189
+ }
190
+ if (/^summary\s*:/i.test(normalized)) {
191
+ return {
192
+ ok: false,
193
+ error: 'INVALID_SUMMARY',
194
+ message: 'Pass the summary PROSE only — no `summary:` / `summary: |` YAML prefix. The writer stores it as the frontmatter field itself.',
195
+ };
196
+ }
197
+ if (/^---\s*$/m.test(normalized)) {
198
+ return { ok: false, error: 'INVALID_SUMMARY', message: '`summary` must not contain a frontmatter fence (`---`).' };
199
+ }
200
+ const warnings = [];
201
+ const opening = (normalized.match(/^[\s\S]*?[.!?](?=\s|$)/) || [normalized])[0].trim();
202
+ if (opening.length > SUMMARY_OPENING_SENTENCE_TARGET_CHARS) {
203
+ warnings.push(
204
+ `opening sentence is ${opening.length} chars (target ≤${SUMMARY_OPENING_SENTENCE_TARGET_CHARS}) — list rows and cards render only this sentence.`,
205
+ );
206
+ }
207
+ if (normalized.length > SUMMARY_TOTAL_TARGET_CHARS) {
208
+ warnings.push(`summary is ${normalized.length} chars (target ≤${SUMMARY_TOTAL_TARGET_CHARS} total).`);
209
+ }
210
+ return { ok: true, summary: normalized, warnings };
211
+ }
212
+
213
+ /**
214
+ * Cloud-reliance twin of {@link buildSummaryFillInPrompt}
215
+ * ([[HS-T41O-NZ8S-DIAG]]): when the console's Neon row is the source of record
216
+ * (prod / cloud dev-mode reliance, or a spec that exists only in Neon and not
217
+ * in this machine's vault clone), the agent must NOT edit a vault file — the
218
+ * console never reads it back. This prompt names the console write instead:
219
+ * `heyspex_author_summary { spec_id, summary }` → `PUT /api/specs/:id`.
220
+ * It deliberately carries NO on-disk path and no "open the file" step.
221
+ *
222
+ * @param {{ specId: string, name: string, type: string,
223
+ * parent?: { id: string, kind?: string, title?: string, goal?: string|null } | null,
224
+ * consoleUrl?: string|null }} input
225
+ * @returns {string}
226
+ */
227
+ export function buildSummaryConsoleWritePrompt({ specId, name, type, parent = null, consoleUrl = null }) {
228
+ const parentKind = (parent && parent.kind) || 'parent';
229
+ const parentBlock =
230
+ parent && parent.id
231
+ ? `
232
+
233
+ ## Parent context
234
+ This ${type} rolls up to ${parentKind} **${parent.id}**${parent.title ? ` — ${parent.title}` : ''}.
235
+ ${
236
+ parent.goal && String(parent.goal).trim()
237
+ ? `Its goal, in the parent's own words:\n> ${String(parent.goal).replace(/\s+/g, ' ').trim()}`
238
+ : `The parent has no \`summary:\` yet — read its body from the console (\`GET /api/specs/${parent.id}\`, or \`heyspex_next_on_deck { spec_id: "${parent.id}", summary: true }\`) to state the parent's goal in plain words.`
239
+ }
240
+ Your opening sentence MUST fuse that goal with what THIS ${type} delivers toward it, in whichever order reads naturally when said out loud (no rigid "So that …, this …" template, no long purpose clause stacked in front of the subject). Keep it plain enough for a twelve-year-old who's never heard of this project.`
241
+ : '';
242
+ const where = consoleUrl ? ` (${consoleUrl})` : '';
243
+ const toolArgs = `{ spec_id: "${specId}", summary: "<your summary>" }`;
244
+ return `You are a coding agent working in the HeySpex workflow. This session is CLOUD-reliant: the console's record for the spec${where} is the source of record. Do NOT edit a vault file on disk for this — your clone may not hold the spec, and the console never reads that file back.
245
+
246
+ ## Goal
247
+ The spec **${specId}** (${name}) is missing a \`summary:\` field. Author it, then write it through the console.
248
+
249
+ ## Target spec
250
+ - **Spec ID:** ${specId}
251
+ - **Title:** ${name}
252
+ - **Entity type:** ${type}
253
+ - **Source of record:** the console row (\`GET /api/specs/${specId}\`) — not a file
254
+ - **Missing field:** \`summary\`${parentBlock}
255
+
256
+ ## What good looks like
257
+ ${SUMMARY_CONTRACT}
258
+ - For the console write, pass the PROSE only as the \`summary\` argument — no \`summary: |\` YAML wrapper; the console stores it as the frontmatter field.
259
+
260
+ ## What to do
261
+ 1. Read the spec's scope from the brief above (or \`GET /api/specs/${specId}\` on the console)${parent && parent.id ? ', and review the parent context above so your opening sentence carries the parent\'s goal' : ''}.
262
+ 2. Author a \`summary\` that satisfies the contract above.
263
+ 3. Write it through the console: \`heyspex_load_tools { names: ["heyspex_author_summary"] }\` (it is a deferred tool), then \`heyspex_author_summary ${toolArgs}\`. CLI fallback: \`hs mcp call heyspex_author_summary --args '{"spec_id":"${specId}","summary":"<your summary>"}' --json\`. The tool changes ONLY \`summary\` (\`PUT /api/specs/:id\` with the row's other frontmatter and body preserved).
264
+ 4. Re-run \`heyspex_next_on_deck { summary: true }\` — the brief now shows the summary and asks to claim.
265
+
266
+ Make only this one change; ask only if the spec body is too thin to summarize honestly.`;
267
+ }
@@ -0,0 +1,228 @@
1
+ /**
2
+ * `heyspex_author_summary` — write a spec's plain-language `summary:` field
3
+ * where the session's source of record actually lives ([[HS-T41O-NZ8S-DIAG]]).
4
+ *
5
+ * The `/hs-next` brief's `author_summary_then_claim` phase used to tell every
6
+ * agent to `Edit` the vault file on disk. In cloud reliance (prod, or dev-mode
7
+ * `reliance: cloud`) the console's Neon row is the source of record: a spec
8
+ * filed there may not exist in this machine's clone at all, and a file edit is
9
+ * never read back. This tool is the write the brief now names there:
10
+ *
11
+ * - `cloud` write mode — `GET /api/specs/:id` for the row's current
12
+ * frontmatter + body, set `summary`, `PUT /api/specs/:id` through
13
+ * {@link updateSpecViaConsole}. Nothing touches this machine's disk.
14
+ * - `local` write mode — the vault file on disk is the source of record, so
15
+ * the `summary: |` block scalar is upserted into that file's frontmatter
16
+ * (the same shape a hand `Edit` produces); every other field and the body
17
+ * are byte-preserved.
18
+ *
19
+ * Both destinations run the SAME validator ({@link validateAuthoredSummary})
20
+ * and change ONLY `summary`. The write mode is resolved ONCE at handler entry
21
+ * from `resolveVaultWriteMode()` (the write twin of the read tier), exactly as
22
+ * `handleEditFile` does.
23
+ *
24
+ * Transport-free by design: `deps.consoleFetch` / `deps.resolveWriteMode` /
25
+ * `deps.consoleUrl` are injection seams so tests never open a socket.
26
+ */
27
+
28
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
29
+ import { join } from 'node:path';
30
+ import { deferredPayloadFromError, updateSpecViaConsole } from './cloud-vault-writes.mjs';
31
+ import { consoleFetch, controlOk } from './console-transport.mjs';
32
+ import { consoleErrorCode, consoleErrorMessage } from './console-fetch.mjs';
33
+ import { getConsoleUrl } from './mcp-config.mjs';
34
+ import { resolveVaultWriteMode } from './read-path.mjs';
35
+ import { formatSummaryFrontmatterField, validateAuthoredSummary } from './summary-contract.mjs';
36
+ import { findSpecFile } from './vault-io.mjs';
37
+
38
+ export const AUTHOR_SUMMARY_TOOL_NAME = 'heyspex_author_summary';
39
+
40
+ /** The follow-up every success names, so the agent completes the phase without guessing. */
41
+ const NEXT_STEP = 'Re-run heyspex_next_on_deck { summary: true } — the brief now shows the summary and asks to claim.';
42
+
43
+ function errorResult(payload) {
44
+ return { isError: true, content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
45
+ }
46
+
47
+ /**
48
+ * Upsert `summary` into a raw spec document's YAML frontmatter as a block
49
+ * scalar (`summary: |`), replacing an existing `summary:` field (single-line
50
+ * or block/folded scalar) in place, else appending it as the last frontmatter
51
+ * field. Every other byte — other fields, their order, the body, the file's
52
+ * line-ending style — is preserved. Pure.
53
+ *
54
+ * @param {string} raw
55
+ * @param {string} summary already validated, non-empty prose
56
+ * @returns {{ ok: true, raw: string, replaced: boolean } | { ok: false, error: string, message: string }}
57
+ */
58
+ export function upsertSummaryInRawSpec(raw, summary) {
59
+ const text = String(raw ?? '');
60
+ const eol = text.includes('\r\n') ? '\r\n' : '\n';
61
+ const lines = text.split(/\r?\n/);
62
+ if (lines[0]?.trim() !== '---') {
63
+ return { ok: false, error: 'NO_FRONTMATTER', message: 'The spec file has no YAML frontmatter block to add `summary:` to.' };
64
+ }
65
+ let end = -1;
66
+ for (let i = 1; i < lines.length; i += 1) {
67
+ if (lines[i].trim() === '---') {
68
+ end = i;
69
+ break;
70
+ }
71
+ }
72
+ if (end === -1) {
73
+ return { ok: false, error: 'NO_FRONTMATTER', message: 'The spec file\'s frontmatter block is not closed with `---`.' };
74
+ }
75
+ const field = formatSummaryFrontmatterField(summary).split('\n');
76
+
77
+ let start = -1;
78
+ for (let i = 1; i < end; i += 1) {
79
+ if (/^summary\s*:/.test(lines[i])) {
80
+ start = i;
81
+ break;
82
+ }
83
+ }
84
+ if (start === -1) {
85
+ const next = [...lines.slice(0, end), ...field, ...lines.slice(end)];
86
+ return { ok: true, raw: next.join(eol), replaced: false };
87
+ }
88
+ // Extent of the existing field: continuation lines are indented (block /
89
+ // folded scalar bodies) or blank-but-followed-by-indented. Never swallow the
90
+ // frontmatter fence or the next top-level `key:`.
91
+ let stop = start + 1;
92
+ while (stop < end && /^(\s+\S.*|\s*)$/.test(lines[stop])) stop += 1;
93
+ while (stop > start + 1 && lines[stop - 1].trim() === '') stop -= 1;
94
+ const next = [...lines.slice(0, start), ...field, ...lines.slice(stop)];
95
+ return { ok: true, raw: next.join(eol), replaced: true };
96
+ }
97
+
98
+ /**
99
+ * Handler for `heyspex_author_summary`.
100
+ *
101
+ * @param {string} vaultRoot
102
+ * @param {{ spec_id?: string, summary?: string }} args
103
+ * @param {{ consoleFetch?: Function, consoleUrl?: string, resolveWriteMode?: () => Promise<string|null> }} [deps]
104
+ */
105
+ export async function handleAuthorSummary(vaultRoot, args = {}, deps = {}) {
106
+ const specId = typeof args.spec_id === 'string' ? args.spec_id.trim() : '';
107
+ if (!specId) {
108
+ return errorResult({ error: 'INVALID_ARGS', message: 'spec_id is required (the spec whose `summary:` you authored).' });
109
+ }
110
+ const verdict = validateAuthoredSummary(args.summary);
111
+ if (!verdict.ok) {
112
+ return errorResult({ error: verdict.error, message: verdict.message, spec_id: specId, written: false });
113
+ }
114
+ const { summary, warnings } = verdict;
115
+
116
+ let writeMode = 'local';
117
+ try {
118
+ writeMode = (await (deps.resolveWriteMode ?? resolveVaultWriteMode)()) ?? 'local';
119
+ } catch {
120
+ writeMode = 'local';
121
+ }
122
+
123
+ if (writeMode === 'cloud') {
124
+ const fetchDep = deps.consoleFetch ?? consoleFetch;
125
+ const consoleUrl = deps.consoleUrl ?? getConsoleUrl();
126
+ const read = await fetchDep(`/api/specs/${encodeURIComponent(specId)}`);
127
+ if (!read?.ok) {
128
+ return errorResult({
129
+ error: read?.status === 404 ? 'SPEC_NOT_FOUND' : consoleErrorCode(read, 'CONSOLE_UNREACHABLE_CLOUD_MODE'),
130
+ message:
131
+ read?.status === 404
132
+ ? `Spec ${specId} is not in this caller's vault on the console (${consoleUrl}). NOTHING WAS WRITTEN.`
133
+ : consoleErrorMessage(read, `GET /api/specs/${specId} returned ${read?.status ?? 'no response'}`),
134
+ spec_id: specId,
135
+ write_mode: 'cloud',
136
+ on_disk: false,
137
+ written: false,
138
+ console_url: consoleUrl,
139
+ ...(read?.body ? { console_response: read.body } : {}),
140
+ });
141
+ }
142
+ const spec = read.body?.spec;
143
+ if (!spec || typeof spec !== 'object') {
144
+ return errorResult({
145
+ error: 'CONSOLE_UNREACHABLE_CLOUD_MODE',
146
+ message: `GET /api/specs/${specId} answered without a spec payload — cannot merge \`summary\` safely. NOTHING WAS WRITTEN.`,
147
+ spec_id: specId,
148
+ write_mode: 'cloud',
149
+ on_disk: false,
150
+ written: false,
151
+ });
152
+ }
153
+ const frontmatter = { ...(spec.frontmatter && typeof spec.frontmatter === 'object' ? spec.frontmatter : {}), summary };
154
+ const body = typeof spec.body === 'string' ? spec.body : '';
155
+ const rowId = typeof spec.id === 'string' && spec.id.trim() ? spec.id.trim() : specId;
156
+ let written;
157
+ try {
158
+ written = await updateSpecViaConsole({ consoleFetch: fetchDep, consoleUrl, specId: rowId, frontmatter, body });
159
+ } catch (err) {
160
+ const deferred = deferredPayloadFromError(err);
161
+ if (deferred) {
162
+ return controlOk({ ...deferred, written: false, spec_id: rowId, summary, write_mode: 'cloud', on_disk: false });
163
+ }
164
+ return errorResult({
165
+ error: err?.notFound ? 'SPEC_NOT_FOUND' : 'CONSOLE_UNREACHABLE_CLOUD_MODE',
166
+ message: String(err?.message ?? err),
167
+ spec_id: rowId,
168
+ write_mode: 'cloud',
169
+ on_disk: false,
170
+ written: false,
171
+ });
172
+ }
173
+ return controlOk({
174
+ written: true,
175
+ spec_id: written.specId,
176
+ summary,
177
+ warnings,
178
+ write_mode: 'cloud',
179
+ on_disk: false,
180
+ route: `PUT /api/specs/${written.specId}`,
181
+ rel_path: written.relPath,
182
+ console_url: written.consoleUrl,
183
+ next: NEXT_STEP,
184
+ });
185
+ }
186
+
187
+ // Local write mode — the vault file is the source of record.
188
+ const relPath = findSpecFile(vaultRoot, specId);
189
+ const absPath = relPath ? join(vaultRoot, relPath) : null;
190
+ if (!absPath || !existsSync(absPath)) {
191
+ return errorResult({
192
+ error: 'SPEC_NOT_FOUND',
193
+ message: `Spec ${specId} was not found on disk under ${vaultRoot}. NOTHING WAS WRITTEN.`,
194
+ spec_id: specId,
195
+ write_mode: 'local',
196
+ written: false,
197
+ });
198
+ }
199
+ const raw = readFileSync(absPath, 'utf8');
200
+ const patched = upsertSummaryInRawSpec(raw, summary);
201
+ if (!patched.ok) {
202
+ return errorResult({ error: patched.error, message: patched.message, spec_id: specId, file_path: absPath, write_mode: 'local', written: false });
203
+ }
204
+ try {
205
+ writeFileSync(absPath, patched.raw, 'utf8');
206
+ } catch (err) {
207
+ return errorResult({
208
+ error: 'WRITE_FAILED',
209
+ message: `Failed to write ${absPath}: ${err?.message ?? err}`,
210
+ spec_id: specId,
211
+ file_path: absPath,
212
+ write_mode: 'local',
213
+ written: false,
214
+ });
215
+ }
216
+ return controlOk({
217
+ written: true,
218
+ spec_id: specId,
219
+ summary,
220
+ warnings,
221
+ write_mode: 'local',
222
+ on_disk: true,
223
+ file_path: absPath,
224
+ rel_path: String(relPath).replace(/\\/g, '/'),
225
+ replaced_existing: patched.replaced,
226
+ next: NEXT_STEP,
227
+ });
228
+ }