heyspex 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (322) hide show
  1. package/README.md +21 -0
  2. package/bin/hs.mjs +931 -98
  3. package/bin/spexcode.mjs +262 -0
  4. package/dev-factory/engine/worktree.mjs +740 -0
  5. package/dev-factory/lib/code-repo-root.mjs +67 -0
  6. package/dev-factory/lib/context-budget.mjs +84 -0
  7. package/dev-factory/lib/epic-detect.mjs +190 -0
  8. package/dev-factory/lib/fleet-queue-file.mjs +846 -0
  9. package/dev-factory/lib/fleet-queue.mjs +426 -0
  10. package/dev-factory/lib/fleet-state.mjs +14 -18
  11. package/dev-factory/lib/model-catalog.mjs +338 -0
  12. package/dev-factory/lib/seat-context.mjs +199 -0
  13. package/dev-factory/lib/spec-io.mjs +93 -0
  14. package/dev-factory/lib/sufficiency-score.mjs +8 -2
  15. package/dev-factory/lib/worktree-allocator.mjs +744 -0
  16. package/dev-factory/model-catalog.json +222 -0
  17. package/dist/cli/hs-check.mjs +1085 -103
  18. package/dist/cli/hs-claim.mjs +936 -63
  19. package/dist/cli/hs-connect.mjs +9786 -1910
  20. package/dist/cli/hs-defer.mjs +5267 -3053
  21. package/dist/cli/hs-dev.mjs +564 -0
  22. package/dist/cli/hs-env.mjs +385 -299
  23. package/dist/cli/hs-index-rebuild.mjs +7129 -0
  24. package/dist/cli/hs-index-reconcile.mjs +7852 -0
  25. package/dist/cli/hs-init.mjs +777 -138
  26. package/dist/cli/hs-install.mjs +6583 -485
  27. package/dist/cli/hs-join.mjs +10137 -2368
  28. package/dist/cli/hs-vault-pull.mjs +4659 -5882
  29. package/dist/cli/hs-vault.mjs +3793 -1537
  30. package/dist/cli/hs-workspace.mjs +2550 -524
  31. package/dist/cli-templates/customer-vault/specs/entity-model.md +594 -601
  32. package/dist/cli-templates/customer-vault/specs/protocols/canonical-frontmatter-templates.md +110 -177
  33. package/dist/cli-templates/customer-vault/specs/protocols/destructive-action-checklist.md +122 -0
  34. package/dist/cli-templates/customer-vault/specs/protocols/memory-protocol.md +13 -15
  35. package/dist/cli-templates/customer-vault/specs/protocols/session-close-protocol.md +91 -0
  36. package/dist/cli-templates/customer-vault/specs/protocols/spec-hierarchy.md +150 -168
  37. package/dist/cli-templates/customer-vault/specs/protocols/spec-summary-authoring.md +83 -0
  38. package/dist/cli-templates/harness-commands/claude/hs-auto.md +36 -0
  39. package/dist/cli-templates/harness-commands/claude/hs-backfill.md +28 -0
  40. package/dist/cli-templates/harness-commands/claude/hs-capture.md +62 -0
  41. package/dist/cli-templates/harness-commands/claude/hs-defer.md +29 -0
  42. package/dist/cli-templates/harness-commands/claude/hs-dev.md +20 -0
  43. package/dist/cli-templates/harness-commands/claude/hs-doctor.md +32 -0
  44. package/dist/cli-templates/harness-commands/claude/hs-ground.md +28 -0
  45. package/dist/cli-templates/harness-commands/claude/hs-handoff.md +33 -0
  46. package/dist/cli-templates/harness-commands/claude/hs-log.md +27 -0
  47. package/dist/cli-templates/harness-commands/claude/hs-next.md +29 -0
  48. package/dist/cli-templates/harness-commands/claude/hs-promote.md +70 -0
  49. package/dist/cli-templates/harness-commands/claude/hs-push.md +35 -0
  50. package/dist/cli-templates/harness-commands/claude/hs-qc.md +27 -0
  51. package/dist/cli-templates/harness-commands/claude/hs-res.md +63 -0
  52. package/dist/cli-templates/harness-commands/claude/hs-resolve.md +23 -0
  53. package/dist/cli-templates/harness-commands/claude/hs-review.md +23 -0
  54. package/dist/cli-templates/harness-commands/claude/hs-view.md +26 -0
  55. package/dist/cli-templates/harness-commands/claude/kill-ports.md +32 -0
  56. package/dist/cli-templates/harness-commands/claude/prototype.md +91 -0
  57. package/dist/cli-templates/harness-commands/cursor/hs-auto.md +40 -0
  58. package/dist/cli-templates/harness-commands/cursor/hs-backfill.md +31 -0
  59. package/dist/cli-templates/harness-commands/cursor/hs-capture.md +65 -0
  60. package/dist/cli-templates/harness-commands/cursor/hs-defer.md +32 -0
  61. package/dist/cli-templates/harness-commands/cursor/hs-dev.md +24 -0
  62. package/dist/cli-templates/harness-commands/cursor/hs-doctor.md +36 -0
  63. package/dist/cli-templates/harness-commands/cursor/hs-ground.md +31 -0
  64. package/dist/cli-templates/harness-commands/cursor/hs-handoff.md +37 -0
  65. package/dist/cli-templates/harness-commands/cursor/hs-log.md +31 -0
  66. package/dist/cli-templates/harness-commands/cursor/hs-next.md +32 -0
  67. package/dist/cli-templates/harness-commands/cursor/hs-promote.md +73 -0
  68. package/dist/cli-templates/harness-commands/cursor/hs-push.md +38 -0
  69. package/dist/cli-templates/harness-commands/cursor/hs-qc.md +30 -0
  70. package/dist/cli-templates/harness-commands/cursor/hs-res.md +67 -0
  71. package/dist/cli-templates/harness-commands/cursor/hs-resolve.md +26 -0
  72. package/dist/cli-templates/harness-commands/cursor/hs-review.md +26 -0
  73. package/dist/cli-templates/harness-commands/cursor/hs-view.md +30 -0
  74. package/dist/cli-templates/harness-commands/cursor/kill-ports.md +35 -0
  75. package/dist/cli-templates/harness-commands/cursor/prototype.md +95 -0
  76. package/docs/mcp-client-config.md +259 -6
  77. package/package.json +231 -124
  78. package/plugins/heyspex/.claude-plugin/plugin.json +12 -0
  79. package/plugins/heyspex/.cursor-plugin/plugin.json +15 -0
  80. package/plugins/heyspex/.mcp.json +12 -0
  81. package/plugins/heyspex/bin/launch-mcp.mjs +95 -0
  82. package/plugins/heyspex/bin/run-hook.mjs +62 -0
  83. package/plugins/heyspex/hooks/cursor-hooks.json +227 -0
  84. package/plugins/heyspex/hooks/hooks.json +361 -0
  85. package/plugins/heyspex/mcp.json +13 -0
  86. package/scripts/check-mcp-freshness.mjs +17 -9
  87. package/scripts/claude-hooks/_env-vault-events.mjs +68 -0
  88. package/scripts/claude-hooks/_guardrail-events.mjs +96 -0
  89. package/scripts/claude-hooks/_guardrail-loader.mjs +494 -0
  90. package/scripts/claude-hooks/_intent-classifier-router.mjs +410 -0
  91. package/scripts/claude-hooks/_intent-classifier-semantic.mjs +351 -0
  92. package/scripts/claude-hooks/_run.mjs +120 -0
  93. package/scripts/claude-hooks/_transcript-text.mjs +60 -0
  94. package/scripts/claude-hooks/_vault-retrieval.mjs +322 -0
  95. package/scripts/claude-hooks/agent-spawn-delegation-gate.mjs +133 -0
  96. package/scripts/claude-hooks/capture-workflow-gate.mjs +11 -0
  97. package/scripts/claude-hooks/check-auto-memory-budget.mjs +396 -0
  98. package/scripts/claude-hooks/check-claude-md-budget.mjs +327 -0
  99. package/scripts/claude-hooks/check-mcp-freshness-hook.mjs +14 -0
  100. package/scripts/claude-hooks/claim-before-code-gate.mjs +11 -0
  101. package/scripts/claude-hooks/claim-liveness-shared.mjs +575 -0
  102. package/scripts/claude-hooks/close-terminal-on-ship.mjs +64 -0
  103. package/scripts/claude-hooks/coo-denylist-gate.mjs +11 -0
  104. package/scripts/claude-hooks/coo-guard-check.mjs +11 -0
  105. package/scripts/claude-hooks/deep-research-cost-gate.mjs +11 -0
  106. package/scripts/claude-hooks/dev-server-task-reminder.mjs +15 -0
  107. package/scripts/claude-hooks/emit-claim-status.mjs +41 -0
  108. package/scripts/claude-hooks/enforce-session-sequence.mjs +11 -0
  109. package/scripts/claude-hooks/enforce-vault-naming.mjs +11 -0
  110. package/scripts/claude-hooks/env-vault-guard.mjs +11 -0
  111. package/scripts/claude-hooks/epic-milestone-phrasing-gate.mjs +11 -0
  112. package/scripts/claude-hooks/git-identity-guard.mjs +11 -0
  113. package/scripts/claude-hooks/git-reflog-audit.mjs +15 -0
  114. package/scripts/claude-hooks/git-stash-gate.mjs +11 -0
  115. package/scripts/claude-hooks/guard-auto-memory-writes.mjs +137 -0
  116. package/scripts/claude-hooks/id-display-format-gate.mjs +11 -0
  117. package/scripts/claude-hooks/inject-communication-style.mjs +66 -0
  118. package/scripts/claude-hooks/inject-coo-seat.mjs +78 -0
  119. package/scripts/claude-hooks/inject-dev-server-awareness.mjs +11 -0
  120. package/scripts/claude-hooks/inject-doctrine.mjs +13 -0
  121. package/scripts/claude-hooks/inject-vault-context.mjs +14 -0
  122. package/scripts/claude-hooks/intent-classifier-trigger.mjs +247 -0
  123. package/scripts/claude-hooks/log-session-activity.mjs +341 -0
  124. package/scripts/claude-hooks/naughty-list-gate.mjs +11 -0
  125. package/scripts/claude-hooks/notify-status-flip.mjs +72 -0
  126. package/scripts/claude-hooks/plan-exit-ingest-gate.mjs +170 -0
  127. package/scripts/claude-hooks/plan-ingest-close-reminder.mjs +66 -0
  128. package/scripts/claude-hooks/plan-review-gate.mjs +11 -0
  129. package/scripts/claude-hooks/preflight-reminder.mjs +262 -0
  130. package/scripts/claude-hooks/protected-action-intent.mjs +221 -0
  131. package/scripts/claude-hooks/research-method-guard.mjs +398 -0
  132. package/scripts/claude-hooks/research-protocol-gate.mjs +11 -0
  133. package/scripts/claude-hooks/session-end.mjs +449 -0
  134. package/scripts/claude-hooks/settings-write-gate.mjs +11 -0
  135. package/scripts/claude-hooks/settlement-check-gate.mjs +39 -0
  136. package/scripts/claude-hooks/status-flip-detect.mjs +17 -0
  137. package/scripts/claude-hooks/terminal-close-shared.mjs +426 -0
  138. package/scripts/claude-hooks/terminal-close-watcher.mjs +122 -0
  139. package/scripts/claude-hooks/wip-checkpoint.mjs +298 -0
  140. package/scripts/cursor-hooks/manifest.mjs +235 -0
  141. package/scripts/dev-server-status.mjs +34 -13
  142. package/scripts/dev-server-stop.mjs +313 -0
  143. package/scripts/heyspex-mode.mjs +268 -0
  144. package/scripts/hs-auto-resolve.mjs +240 -0
  145. package/scripts/hs-auto-source.mjs +398 -0
  146. package/scripts/hs-auto.mjs +134 -0
  147. package/scripts/hs-capture-cli.mjs +326 -0
  148. package/scripts/hs-classifier.mjs +136 -0
  149. package/scripts/hs-companion.mjs +88 -0
  150. package/scripts/hs-gateway.mjs +115 -0
  151. package/scripts/hs-mode.mjs +125 -0
  152. package/scripts/hs-next.mjs +441 -0
  153. package/scripts/hs-settings.mjs +338 -0
  154. package/scripts/hs-view.mjs +333 -0
  155. package/scripts/hs-worktree.mjs +201 -0
  156. package/scripts/install-claude-code-hooks.mjs +270 -0
  157. package/scripts/install-cursor-hooks.mjs +471 -0
  158. package/scripts/lib/agent-env-markers.mjs +38 -0
  159. package/scripts/lib/console-url-resolver.mjs +197 -60
  160. package/scripts/lib/customer-kit.mjs +886 -0
  161. package/scripts/lib/dev-mode.mjs +244 -28
  162. package/scripts/lib/heyspex-home.mjs +72 -0
  163. package/scripts/lib/hs-command.mjs +277 -0
  164. package/scripts/lib/hs-companion-settings.mjs +472 -0
  165. package/scripts/lib/hs-source-commit.mjs +59 -5
  166. package/scripts/lib/hs-source-identity.mjs +526 -0
  167. package/scripts/lib/hs-source-log.mjs +11 -21
  168. package/scripts/lib/hs-source-push.mjs +107 -58
  169. package/scripts/lib/hs-worktree-args.mjs +115 -0
  170. package/scripts/lib/shared-path-helpers.mjs +134 -0
  171. package/scripts/lib/standalone-doctor.mjs +540 -3
  172. package/scripts/lib/subagent-git-lock.mjs +109 -0
  173. package/scripts/lib/template-pin.mjs +34 -0
  174. package/scripts/provenance-lib.mjs +91 -0
  175. package/scripts/stamp-provenance-on-edit.mjs +222 -0
  176. package/scripts/wip-ledger-health.mjs +653 -0
  177. package/spexharness/doctrine/baseline/SPEXCODE-DOCTRINE.baseline.json +29 -0
  178. package/spexharness/src/adapters/audit.mjs +197 -0
  179. package/spexharness/src/adapters/claude-code.mjs +441 -0
  180. package/spexharness/src/chat/tui/ansi.mjs +448 -0
  181. package/spexharness/src/chat/tui/palette.mjs +22 -0
  182. package/spexharness/src/chat/tui/select.mjs +208 -0
  183. package/spexharness/src/config-root.mjs +54 -0
  184. package/spexharness/src/context-budget.mjs +141 -0
  185. package/spexharness/src/dev-server-control.mjs +389 -0
  186. package/spexharness/src/doctrine/behaviour.mjs +371 -0
  187. package/spexharness/src/doctrine/corpus.mjs +733 -0
  188. package/spexharness/src/doctrine/inject.mjs +338 -0
  189. package/spexharness/src/doctrine/merge.mjs +184 -0
  190. package/spexharness/src/doctrine/sources.mjs +252 -0
  191. package/spexharness/src/doctrine/synthesize.mjs +401 -0
  192. package/spexharness/src/doctrine/vault-source.mjs +574 -0
  193. package/spexharness/src/doctrine/writer.mjs +176 -0
  194. package/spexharness/src/events.mjs +84 -0
  195. package/spexharness/src/gates/capture-workflow-gate.mjs +202 -0
  196. package/spexharness/src/gates/claim-before-code-gate.decide.mjs +33 -0
  197. package/spexharness/src/gates/claim-before-code-gate.mjs +129 -0
  198. package/spexharness/src/gates/deep-research-cost-gate.mjs +274 -0
  199. package/spexharness/src/gates/enforce-session-sequence.mjs +895 -0
  200. package/spexharness/src/gates/enforce-vault-naming.mjs +215 -0
  201. package/spexharness/src/gates/epic-milestone-phrasing-gate.mjs +233 -0
  202. package/spexharness/src/gates/git-identity-guard.decide.mjs +436 -0
  203. package/spexharness/src/gates/git-identity-guard.mjs +61 -0
  204. package/spexharness/src/gates/git-reflog-audit.mjs +151 -0
  205. package/spexharness/src/gates/git-stash-gate.decide.mjs +361 -0
  206. package/spexharness/src/gates/git-stash-gate.mjs +159 -0
  207. package/spexharness/src/gates/id-display-format-gate.mjs +190 -0
  208. package/spexharness/src/gates/inject-doctrine-gate.mjs +89 -0
  209. package/spexharness/src/gates/inject-vault-context.mjs +239 -0
  210. package/spexharness/src/gates/naughty-list-gate.mjs +213 -0
  211. package/spexharness/src/gates/plan-review-gate.mjs +233 -0
  212. package/spexharness/src/gates/preflight-reminder-gate.mjs +265 -0
  213. package/spexharness/src/gates/research-protocol-gate.mjs +175 -0
  214. package/spexharness/src/gates/settings-write-gate.decide.mjs +96 -0
  215. package/spexharness/src/gates/settings-write-gate.mjs +104 -0
  216. package/spexharness/src/git-reflog-audit.mjs +294 -0
  217. package/spexharness/src/intent-classifier.mjs +194 -0
  218. package/spexharness/src/intent-directive.mjs +541 -0
  219. package/spexharness/src/profile/learn.mjs +551 -0
  220. package/spexharness/src/profile/proposals.mjs +357 -0
  221. package/spexharness/src/profile/vault-inclusion.mjs +386 -0
  222. package/spexharness/src/redact.mjs +151 -0
  223. package/spexharness/src/rule-table.mjs +819 -0
  224. package/spexharness/src/seat-context.mjs +122 -0
  225. package/spexharness/src/session-bootstrap.mjs +512 -0
  226. package/spexharness/src/session-record.mjs +815 -0
  227. package/spexharness/src/token-inventory.mjs +207 -0
  228. package/vendor/heyspex-mcp/account-switch.mjs +95 -0
  229. package/vendor/heyspex-mcp/alignment-gate.mjs +315 -1
  230. package/vendor/heyspex-mcp/blocker-resolver.mjs +37 -2
  231. package/vendor/heyspex-mcp/breakdown-ladder.mjs +119 -4
  232. package/vendor/heyspex-mcp/breakdown-source.mjs +271 -0
  233. package/vendor/heyspex-mcp/breakdown-writer.mjs +23 -3
  234. package/vendor/heyspex-mcp/capture-buffer-patches.mjs +46 -5
  235. package/vendor/heyspex-mcp/capture-channel.mjs +55 -5
  236. package/vendor/heyspex-mcp/capture-console-client.mjs +80 -1
  237. package/vendor/heyspex-mcp/capture-focus.mjs +1035 -29
  238. package/vendor/heyspex-mcp/capture-outbox.mjs +5 -1
  239. package/vendor/heyspex-mcp/capture-tools.mjs +1219 -0
  240. package/vendor/heyspex-mcp/claim-tools.mjs +1124 -0
  241. package/vendor/heyspex-mcp/cli-verbs.mjs +2076 -0
  242. package/vendor/heyspex-mcp/cli.mjs +201 -15524
  243. package/vendor/heyspex-mcp/cloud-tool-surface.mjs +128 -0
  244. package/vendor/heyspex-mcp/cloud-vault-writes.mjs +412 -0
  245. package/vendor/heyspex-mcp/config-seed.yaml +15 -0
  246. package/vendor/heyspex-mcp/config-settings.mjs +290 -7
  247. package/vendor/heyspex-mcp/console-fetch.mjs +657 -16
  248. package/vendor/heyspex-mcp/console-transport.mjs +166 -0
  249. package/vendor/heyspex-mcp/credentials.mjs +584 -0
  250. package/vendor/heyspex-mcp/defer-spec.mjs +44 -4
  251. package/vendor/heyspex-mcp/deferred-reclaim.mjs +29 -3
  252. package/vendor/heyspex-mcp/design-handlers.mjs +31 -0
  253. package/vendor/heyspex-mcp/design-tools.mjs +4 -1
  254. package/vendor/heyspex-mcp/dev-factory-bridge.mjs +4 -4
  255. package/vendor/heyspex-mcp/dev-mode-core.mjs +45 -0
  256. package/vendor/heyspex-mcp/dev-tools.mjs +124 -0
  257. package/vendor/heyspex-mcp/diagnose-handlers.mjs +54 -0
  258. package/vendor/heyspex-mcp/events-tools.mjs +207 -0
  259. package/vendor/heyspex-mcp/filing-tools.mjs +688 -0
  260. package/vendor/heyspex-mcp/fleet-handlers.mjs +37 -0
  261. package/vendor/heyspex-mcp/fleet-queue-tools.mjs +69 -14
  262. package/vendor/heyspex-mcp/focus-tools.mjs +190 -0
  263. package/vendor/heyspex-mcp/folder-trust-store.mjs +59 -0
  264. package/vendor/heyspex-mcp/http-transport.mjs +300 -0
  265. package/vendor/heyspex-mcp/list-tools.mjs +257 -0
  266. package/vendor/heyspex-mcp/login-handoff.mjs +15 -1
  267. package/vendor/heyspex-mcp/mcp-config.mjs +94 -0
  268. package/vendor/heyspex-mcp/memory-tools.mjs +523 -0
  269. package/vendor/heyspex-mcp/mvp-mode.mjs +53 -6
  270. package/vendor/heyspex-mcp/occupied-keys.mjs +141 -0
  271. package/vendor/heyspex-mcp/on-deck-brief.mjs +3360 -0
  272. package/vendor/heyspex-mcp/on-deck-scheduler.mjs +1708 -0
  273. package/vendor/heyspex-mcp/package-root.mjs +32 -0
  274. package/vendor/heyspex-mcp/peer-presence.mjs +8 -1
  275. package/vendor/heyspex-mcp/plan-ground.mjs +621 -652
  276. package/vendor/heyspex-mcp/plan-push.mjs +906 -21
  277. package/vendor/heyspex-mcp/plan-review.mjs +124 -30
  278. package/vendor/heyspex-mcp/preflight-gate.mjs +0 -0
  279. package/vendor/heyspex-mcp/procedure-handlers.mjs +325 -0
  280. package/vendor/heyspex-mcp/procedure-tools.mjs +540 -351
  281. package/vendor/heyspex-mcp/promote-capture.mjs +386 -69
  282. package/vendor/heyspex-mcp/promote-diagnose.mjs +74 -0
  283. package/vendor/heyspex-mcp/promote-research-to-diagnose.mjs +9 -2
  284. package/vendor/heyspex-mcp/promote-research.mjs +660 -53
  285. package/vendor/heyspex-mcp/promotion-helpers.mjs +251 -0
  286. package/vendor/heyspex-mcp/prompts.mjs +1460 -0
  287. package/vendor/heyspex-mcp/read-path.mjs +29 -9
  288. package/vendor/heyspex-mcp/receipt-shape.mjs +146 -0
  289. package/vendor/heyspex-mcp/recipe-handlers.mjs +117 -0
  290. package/vendor/heyspex-mcp/regen-tasks.mjs +210 -3
  291. package/vendor/heyspex-mcp/register-spec.mjs +266 -54
  292. package/vendor/heyspex-mcp/request-context.mjs +59 -0
  293. package/vendor/heyspex-mcp/research-focus.mjs +207 -4
  294. package/vendor/heyspex-mcp/research-handlers.mjs +623 -0
  295. package/vendor/heyspex-mcp/research-tools.mjs +4 -4
  296. package/vendor/heyspex-mcp/response-budget.mjs +348 -0
  297. package/vendor/heyspex-mcp/review-focus.mjs +25 -1
  298. package/vendor/heyspex-mcp/search-specs.mjs +280 -0
  299. package/vendor/heyspex-mcp/seat-block-ledger.mjs +113 -0
  300. package/vendor/heyspex-mcp/seat-identity.mjs +2 -6
  301. package/vendor/heyspex-mcp/session-owner.mjs +108 -0
  302. package/vendor/heyspex-mcp/session-tools.mjs +152 -0
  303. package/vendor/heyspex-mcp/settings-registry.mjs +232 -0
  304. package/vendor/heyspex-mcp/settings-tools.mjs +85 -12
  305. package/vendor/heyspex-mcp/spec-edit-tools.mjs +446 -0
  306. package/vendor/heyspex-mcp/spec-id-lib.mjs +93 -17
  307. package/vendor/heyspex-mcp/split-research.mjs +301 -56
  308. package/vendor/heyspex-mcp/stdio-rpc.mjs +22 -0
  309. package/vendor/heyspex-mcp/submit-for-review.mjs +132 -6
  310. package/vendor/heyspex-mcp/summary-contract.mjs +105 -0
  311. package/vendor/heyspex-mcp/summary-write-tools.mjs +224 -0
  312. package/vendor/heyspex-mcp/symptom-query.mjs +146 -0
  313. package/vendor/heyspex-mcp/test-helpers.mjs +108 -1
  314. package/vendor/heyspex-mcp/tool-catalog.mjs +2813 -0
  315. package/vendor/heyspex-mcp/tool-dispatch.mjs +639 -0
  316. package/vendor/heyspex-mcp/tool-helpers.mjs +325 -0
  317. package/vendor/heyspex-mcp/validate-linkage.mjs +414 -27
  318. package/vendor/heyspex-mcp/vault-io.mjs +724 -0
  319. package/vendor/heyspex-mcp/vault-less-endpoints.mjs +52 -0
  320. package/vendor/heyspex-mcp/vault-resolve.mjs +366 -68
  321. package/vendor/heyspex-mcp/verify-reproduction.mjs +270 -30
  322. package/vendor/heyspex-mcp/whoami.mjs +91 -0
@@ -5,7 +5,7 @@
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
9
  import { readMvpModeState } from './mvp-mode.mjs';
10
10
  import { isBelowReviewLevel, reviewLevelForGrain } from './review-rollup.mjs';
11
11
 
@@ -18,12 +18,87 @@ export function resolveActiveReviewLevel(vaultRoot) {
18
18
  return reviewLevelForGrain(mvp?.review_grain);
19
19
  }
20
20
 
21
+ const REVIEW_LEVELS = new Set(['task', 'epic', 'milestone']);
22
+
23
+ /**
24
+ * Reconcile the grain this machine read with the grain the console reports it
25
+ * gated under ([[HS-A53F]]). The console's `review_level` wins when present —
26
+ * the status route decides the coercion, so its grain is the one that produced
27
+ * `effective_status`; the client's own read is kept beside it as
28
+ * `client_review_level` and the two are compared explicitly. An older console
29
+ * that reports no grain leaves the client's value in place (`source: client`).
30
+ */
31
+ export function reviewLevelReport(clientReviewLevel, consoleBody) {
32
+ const serverLevel = consoleBody?.review_level;
33
+ const hasServer = typeof serverLevel === 'string' && REVIEW_LEVELS.has(serverLevel);
34
+ const reviewLevel = hasServer ? serverLevel : clientReviewLevel;
35
+ return {
36
+ review_level: reviewLevel,
37
+ review_level_source: hasServer ? (consoleBody?.review_level_source ?? 'server') : 'client',
38
+ client_review_level: clientReviewLevel,
39
+ review_level_mismatch: hasServer && serverLevel !== clientReviewLevel,
40
+ };
41
+ }
42
+
21
43
  /** Count acceptance criteria the same way the close-out gate expects (flat bullets). */
22
44
  export function countAcceptanceCriteria(body) {
23
45
  const { sections } = parseAcceptanceSubsections(body ?? '');
24
46
  return sections.reduce((n, s) => n + (s.bullets?.length ?? 0), 0);
25
47
  }
26
48
 
49
+ // Section anchors, mirrored from `parseAcceptanceSubsections` (alignment-gate.mjs)
50
+ // so the checkbox reader below looks at EXACTLY the criteria that function counts.
51
+ // The regexes are duplicated rather than imported because they are module-private
52
+ // there; `submit-for-review.test.mjs` pins the two against each other over the
53
+ // shared fixtures, so a drift is a red test rather than a silent disagreement.
54
+ const ACCEPTANCE_HEADING_TITLE = /^acceptance(?:\s+criteria)?$/i;
55
+ const ACCEPTANCE_HEADING_TITLE_ANY = /^acceptance/i;
56
+ const DONE_WHEN_HEADING_TITLE_ALIAS = /^done\s+when\b/i;
57
+ // Group 1 is the checkbox content when the item renders one; undefined otherwise.
58
+ const CHECKBOX_LIST_ITEM = /^\s*(?:[-*]\s+(?:\[([ xX])\]\s+)?|\d+[.)]\s+)(.+?)\s*$/;
59
+
60
+ /**
61
+ * Read the acceptance criteria's rendered checkboxes as REAL STATE
62
+ * ([[HS-LBNJ-6AT2-DIAG]] AC3).
63
+ *
64
+ * Before this, `[ ]` and `[x]` were matched and stripped identically by every
65
+ * parser on both runtimes — only a COUNT survived — so "progress" was always the
66
+ * agent's self-reported `acProgress` integer and never the document. A spec could
67
+ * render every box unchecked and still pass the AC gate. A rendered `- [ ]` that
68
+ * no code reads is worse than no checkbox at all, because every human and agent
69
+ * reads it as a verification signal.
70
+ *
71
+ * Returns `{ present, total, checked, unchecked }` where `unchecked` lists the
72
+ * 1-based positions still showing `[ ]`. `present: false` (no criterion renders a
73
+ * box — the 241 specs using numbered criteria) means the caller keeps its prior
74
+ * behavior exactly: this is additive, not a new requirement on every spec.
75
+ */
76
+ export function readAcceptanceCheckboxes(body) {
77
+ const text = body ?? '';
78
+ let section = extractSection(text, ACCEPTANCE_HEADING_TITLE);
79
+ if (!section && !hasHeading(text, ACCEPTANCE_HEADING_TITLE_ANY)) {
80
+ section = extractSection(text, DONE_WHEN_HEADING_TITLE_ALIAS);
81
+ }
82
+ if (!section) return { present: false, total: 0, checked: 0, unchecked: [] };
83
+
84
+ let index = 0;
85
+ let total = 0;
86
+ let checked = 0;
87
+ const unchecked = [];
88
+ for (const line of section.split(/\r?\n/)) {
89
+ const m = line.match(CHECKBOX_LIST_ITEM);
90
+ if (!m || !m[2].trim()) continue;
91
+ index += 1;
92
+ // An item with no checkbox makes no claim either way — it can neither
93
+ // satisfy nor block the gate, so it is counted as a criterion but not as a box.
94
+ if (m[1] === undefined) continue;
95
+ total += 1;
96
+ if (m[1].toLowerCase() === 'x') checked += 1;
97
+ else unchecked.push(index);
98
+ }
99
+ return { present: total > 0, total, checked, unchecked };
100
+ }
101
+
27
102
  /**
28
103
  * Post-close vault steps still required after the status API flip (HS-REV vs seed).
29
104
  */
@@ -75,6 +150,12 @@ export async function executeSubmitForReview({ vaultRoot, consoleFetch, postClai
75
150
 
76
151
  const skipAcCheck = args.skip_ac_check === true;
77
152
  const releaseClaim = args.release_claim !== false;
153
+ // Historical token calibration (HS-HBD2): forward the caller's real
154
+ // context-window spend, when known, so the console pairs it with the
155
+ // pre-build estimate on the build-complete spec event. Only a genuine
156
+ // finite number is forwarded — the console PATCH route is the source of
157
+ // truth for range validation (>= 0), this just avoids sending junk types.
158
+ const hasActualTokens = typeof args.actual_tokens === 'number' && Number.isFinite(args.actual_tokens);
78
159
 
79
160
  const specRes = await consoleFetch(`/api/specs/${encodeURIComponent(specId)}`);
80
161
  if (!specRes.ok) {
@@ -115,8 +196,28 @@ export async function executeSubmitForReview({ vaultRoot, consoleFetch, postClai
115
196
  claimInfo = findClaimAcProgress(claimsRes.body, specId);
116
197
  }
117
198
 
199
+ const checkboxes = readAcceptanceCheckboxes(spec.body);
200
+
118
201
  if (!skipAcCheck && criteriaCount > 0) {
119
202
  const progress = claimInfo.found ? claimInfo.acProgress : undefined;
203
+ // Checkbox-authoritative ([[HS-LBNJ-6AT2-DIAG]] AC3): where the spec renders
204
+ // criteria checkboxes, THEY are the state — an unchecked box refuses the
205
+ // hand-off no matter what the self-reported integer says. Where it renders
206
+ // none, the acProgress rule below is unchanged.
207
+ if (checkboxes.present && checkboxes.unchecked.length > 0) {
208
+ return {
209
+ error: 'AC_CHECKBOXES_UNCHECKED',
210
+ message:
211
+ `Acceptance criteria ${checkboxes.unchecked.join(', ')} are still rendered unchecked ` +
212
+ `(${checkboxes.checked} of ${checkboxes.total} boxes ticked in ${specId}). ` +
213
+ `Tick the boxes you actually verified — the document is the record, not the progress count — ` +
214
+ `or pass skip_ac_check: true with a reason in notes.`,
215
+ criteria_count: criteriaCount,
216
+ ac_progress: progress ?? null,
217
+ checkboxes,
218
+ claim_found: claimInfo.found,
219
+ };
220
+ }
120
221
  if (progress === undefined || progress < criteriaCount) {
121
222
  return {
122
223
  error: 'AC_PROGRESS_INCOMPLETE',
@@ -124,6 +225,7 @@ export async function executeSubmitForReview({ vaultRoot, consoleFetch, postClai
124
225
  `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
226
  criteria_count: criteriaCount,
126
227
  ac_progress: progress ?? null,
228
+ checkboxes,
127
229
  claim_found: claimInfo.found,
128
230
  };
129
231
  }
@@ -140,16 +242,32 @@ export async function executeSubmitForReview({ vaultRoot, consoleFetch, postClai
140
242
 
141
243
  const statusRes = await consoleFetch(`/api/specs/${encodeURIComponent(specId)}/status`, {
142
244
  method: 'PATCH',
143
- body: { status: 'in_review' },
245
+ body: {
246
+ status: 'in_review',
247
+ ...(hasActualTokens ? { actual_tokens: args.actual_tokens } : {}),
248
+ },
144
249
  });
145
250
 
146
251
  if (!statusRes.ok) {
252
+ // The server names the grain it gated under ([[HS-A53F]]); put it beside the
253
+ // grain THIS machine read so a 409 under a disagreeing grain is legible as a
254
+ // resolver mismatch, not as "the agent tried to close".
255
+ const grainReport = reviewLevelReport(reviewLevel, statusRes.body);
147
256
  return {
148
257
  error: 'STATUS_PATCH_FAILED',
149
258
  message: `PATCH /api/specs/${specId}/status returned ${statusRes.status}`,
150
259
  status: statusRes.status,
151
260
  console_response: statusRes.body,
152
261
  attempted: { requested_status: 'in_review' },
262
+ ...grainReport,
263
+ ...(grainReport.review_level_mismatch
264
+ ? {
265
+ hint:
266
+ `This machine's config.yaml says review_grain "${reviewLevel}" but the console ` +
267
+ `decided "${grainReport.review_level}" (source: ${grainReport.review_level_source}). ` +
268
+ `The server is authoritative; align the cloud setting via PUT /api/mvp-mode { review_grain }.`,
269
+ }
270
+ : {}),
153
271
  };
154
272
  }
155
273
 
@@ -158,6 +276,11 @@ export async function executeSubmitForReview({ vaultRoot, consoleFetch, postClai
158
276
  transition.spec?.frontmatter?.status ?? transition.spec?.status ?? 'in_review',
159
277
  ).toLowerCase();
160
278
  const coercedTo = transition.coerced_to ?? transition.coercedTo ?? null;
279
+ const grainReport = reviewLevelReport(reviewLevel, transition);
280
+ // The SERVER's grain drives the close-out guidance when it was reported — the
281
+ // server is what actually coerced (or did not coerce) the status.
282
+ const effectiveReviewLevel = grainReport.review_level;
283
+ const effectiveIsReviewUnit = !isBelowReviewLevel(specKind, effectiveReviewLevel);
161
284
 
162
285
  let claimReleased;
163
286
  if (releaseClaim && typeof postClaimEndpoint === 'function') {
@@ -167,9 +290,9 @@ export async function executeSubmitForReview({ vaultRoot, consoleFetch, postClai
167
290
 
168
291
  const guidance = buildCloseOutGuidance({
169
292
  specKind,
170
- reviewLevel,
293
+ reviewLevel: effectiveReviewLevel,
171
294
  effectiveStatus,
172
- isReviewUnit,
295
+ isReviewUnit: effectiveIsReviewUnit,
173
296
  });
174
297
 
175
298
  return {
@@ -177,10 +300,13 @@ export async function executeSubmitForReview({ vaultRoot, consoleFetch, postClai
177
300
  requested_status: 'in_review',
178
301
  effective_status: effectiveStatus,
179
302
  coerced_to: coercedTo,
180
- review_level: reviewLevel,
181
- is_review_unit: isReviewUnit,
303
+ ...grainReport,
304
+ is_review_unit: effectiveIsReviewUnit,
182
305
  criteria_count: criteriaCount,
183
306
  ac_progress: claimInfo.acProgress ?? null,
307
+ // Reported on success too, so a caller can see WHICH signal cleared the gate
308
+ // — the document's boxes or the unverified self-report count.
309
+ checkboxes,
184
310
  rolled_up: transition.rolledUp ?? transition.rolled_up ?? undefined,
185
311
  propagated: transition.propagated ?? undefined,
186
312
  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,224 @@
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 { 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
+ return errorResult({
161
+ error: err?.notFound ? 'SPEC_NOT_FOUND' : 'CONSOLE_UNREACHABLE_CLOUD_MODE',
162
+ message: String(err?.message ?? err),
163
+ spec_id: rowId,
164
+ write_mode: 'cloud',
165
+ on_disk: false,
166
+ written: false,
167
+ });
168
+ }
169
+ return controlOk({
170
+ written: true,
171
+ spec_id: written.specId,
172
+ summary,
173
+ warnings,
174
+ write_mode: 'cloud',
175
+ on_disk: false,
176
+ route: `PUT /api/specs/${written.specId}`,
177
+ rel_path: written.relPath,
178
+ console_url: written.consoleUrl,
179
+ next: NEXT_STEP,
180
+ });
181
+ }
182
+
183
+ // Local write mode — the vault file is the source of record.
184
+ const relPath = findSpecFile(vaultRoot, specId);
185
+ const absPath = relPath ? join(vaultRoot, relPath) : null;
186
+ if (!absPath || !existsSync(absPath)) {
187
+ return errorResult({
188
+ error: 'SPEC_NOT_FOUND',
189
+ message: `Spec ${specId} was not found on disk under ${vaultRoot}. NOTHING WAS WRITTEN.`,
190
+ spec_id: specId,
191
+ write_mode: 'local',
192
+ written: false,
193
+ });
194
+ }
195
+ const raw = readFileSync(absPath, 'utf8');
196
+ const patched = upsertSummaryInRawSpec(raw, summary);
197
+ if (!patched.ok) {
198
+ return errorResult({ error: patched.error, message: patched.message, spec_id: specId, file_path: absPath, write_mode: 'local', written: false });
199
+ }
200
+ try {
201
+ writeFileSync(absPath, patched.raw, 'utf8');
202
+ } catch (err) {
203
+ return errorResult({
204
+ error: 'WRITE_FAILED',
205
+ message: `Failed to write ${absPath}: ${err?.message ?? err}`,
206
+ spec_id: specId,
207
+ file_path: absPath,
208
+ write_mode: 'local',
209
+ written: false,
210
+ });
211
+ }
212
+ return controlOk({
213
+ written: true,
214
+ spec_id: specId,
215
+ summary,
216
+ warnings,
217
+ write_mode: 'local',
218
+ on_disk: true,
219
+ file_path: absPath,
220
+ rel_path: String(relPath).replace(/\\/g, '/'),
221
+ replaced_existing: patched.replaced,
222
+ next: NEXT_STEP,
223
+ });
224
+ }