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
@@ -1,10 +1,21 @@
1
1
  /**
2
2
  * HeySpex settings MCP tools — the agent face over the settings core
3
- * (config-settings.mjs), mounted in cli.mjs as:
3
+ * (config-settings.mjs) and the settings registry (settings-registry.mjs),
4
+ * mounted in cli.mjs as:
4
5
  * - heyspex_settings_list (read — open)
5
6
  * - heyspex_settings_get (read — open)
6
7
  * - heyspex_settings_set (WRITE — permission-gated two-step confirm)
7
8
  *
9
+ * THIRD FACE (HS-OTEQ): these tools resolve every read through
10
+ * `getEffectiveSetting` (file value → registry default → absent) and the
11
+ * registry's key list, and every write through `validateSettingWrite` — the
12
+ * same functions the CLI face (`hs settings`) and, later, the console face
13
+ * resolve through. No fallback of its own: a registry-governed key answers
14
+ * identically no matter which face asks. A key the registry does not declare
15
+ * (e.g. `agent_behaviors.*`, `mvp_mode.*`) keeps working exactly as before —
16
+ * registry entries are additive, never a replacement for the freeform
17
+ * `.heyspex/config.yaml` surface.
18
+ *
8
19
  * WRITE-PERMISSION GUARDRAIL (owner directive 2026-07-11, documented in
9
20
  * specs/protocols/agent-settings-protocol.md): a settings write is a protected
10
21
  * class. `heyspex_settings_set` without `confirm_token` never writes — it records
@@ -16,31 +27,62 @@
16
27
  * (scripts/claude-hooks/settings-write-gate.mjs) additionally routes the confirm
17
28
  * call through the harness permission prompt in Claude Code.
18
29
  *
30
+ * `requestSettingsWrite` (step 1) and `confirmSettingsWrite`/`setConfigLeaf`
31
+ * (step 2) already run every write through `validateSettingWrite` inside the
32
+ * core, so this file does not re-validate — it only shapes the core's
33
+ * `{ ok:false, error, message }` refusal into the tool's response unchanged
34
+ * (never re-worded), which keeps the reason identical to what the CLI face
35
+ * gives for the same refused write.
36
+ *
19
37
  * Pattern source: procedure-tools.mjs (pure-logic execute* functions; cli.mjs wraps).
20
38
  */
21
39
 
22
40
  import {
23
41
  listConfigLeaves,
24
- getConfigLeaf,
42
+ getEffectiveSetting,
25
43
  requestSettingsWrite,
26
44
  confirmSettingsWrite,
27
45
  validateSettingKey,
28
46
  CONFIG_REL,
29
47
  } from './config-settings.mjs';
48
+ import { getSettingEntry, listRegistryKeys } from './settings-registry.mjs';
30
49
 
31
50
  const MCP_FACE = 'mcp';
32
51
 
52
+ /** True when `key` equals `prefix` or is nested under it (dotted-segment match). */
53
+ function matchesPrefix(key, prefix) {
54
+ return !prefix || key === prefix || key.startsWith(prefix + '.');
55
+ }
56
+
33
57
  export function executeSettingsList(vaultRoot, args = {}) {
34
58
  const prefix = typeof args.prefix === 'string' ? args.prefix.trim() : '';
35
- let settings = listConfigLeaves(vaultRoot);
36
- if (prefix) {
37
- settings = settings.filter((s) => s.key === prefix || s.key.startsWith(prefix + '.'));
59
+
60
+ const fileLeaves = listConfigLeaves(vaultRoot);
61
+ const seen = new Set();
62
+ const settings = [];
63
+
64
+ // Registry-declared keys first, in registry order, resolved through the same
65
+ // file → registry-default chain every face reads through — so a key never
66
+ // written to disk still appears, carrying its shipped default.
67
+ for (const key of listRegistryKeys()) {
68
+ if (!matchesPrefix(key, prefix)) continue;
69
+ const effective = getEffectiveSetting(vaultRoot, key);
70
+ settings.push({ key, value: effective.value, source: effective.source, found: effective.found });
71
+ seen.add(key);
38
72
  }
73
+
74
+ // Then any file leaf outside the registry's namespaces (agent_behaviors.*,
75
+ // mvp_mode.*, …) — additive, unchanged from the pre-registry behavior.
76
+ for (const leaf of fileLeaves) {
77
+ if (seen.has(leaf.key) || !matchesPrefix(leaf.key, prefix)) continue;
78
+ settings.push({ key: leaf.key, value: leaf.value, source: 'file', found: true });
79
+ }
80
+
39
81
  return {
40
82
  ok: true,
41
83
  config_file: CONFIG_REL.replace(/\\/g, '/'),
42
84
  count: settings.length,
43
- settings: settings.map(({ key, value }) => ({ key, value })),
85
+ settings,
44
86
  };
45
87
  }
46
88
 
@@ -48,13 +90,26 @@ export function executeSettingsGet(vaultRoot, args = {}) {
48
90
  const key = typeof args.key === 'string' ? args.key.trim() : '';
49
91
  const check = validateSettingKey(key);
50
92
  if (!check.ok) return { ok: false, error: 'invalid_key', message: check.reason };
51
- const leaf = getConfigLeaf(vaultRoot, key);
93
+
94
+ const effective = getEffectiveSetting(vaultRoot, key);
95
+ let note;
96
+ if (effective.source === 'registry_default') {
97
+ const entry = getSettingEntry(key);
98
+ note = `Key absent from .heyspex/config.yaml — resolved to the registry default (${entry?.description ?? 'see settings-registry.mjs'}).`;
99
+ } else if (effective.source === 'absent') {
100
+ note = 'Key absent from .heyspex/config.yaml and not declared in the settings registry — consumers fall back to their documented default.';
101
+ } else if (effective.source === 'computed') {
102
+ note =
103
+ 'Computed live from the machine-local hs trust store (~/.heyspex/trusted-folders.json) — ' +
104
+ 'never read from .heyspex/config.yaml. Read-only: revoke a folder with "hs trust revoke <path>".';
105
+ }
52
106
  return {
53
107
  ok: true,
54
108
  key,
55
- found: leaf.found,
56
- value: leaf.found ? leaf.value : null,
57
- ...(leaf.found ? {} : { note: 'Key absent from .heyspex/config.yaml — consumers fall back to their documented default.' }),
109
+ found: effective.found,
110
+ value: effective.value,
111
+ source: effective.source,
112
+ ...(note ? { note } : {}),
58
113
  };
59
114
  }
60
115
 
@@ -81,14 +136,32 @@ export function executeSettingsSet(vaultRoot, args = {}) {
81
136
  face: MCP_FACE,
82
137
  });
83
138
  if (!result.ok) return result;
139
+ // MEASURED, NOT ASSUMED ([[HS-IOJW-5YDH-DIAG]] sweep). "applied and audited"
140
+ // used to be printed unconditionally while `appendSettingsAudit`'s boolean
141
+ // was thrown away inside `setConfigLeaf`, so a settings write with no audit
142
+ // row — the one artifact that makes a protected-class write reviewable after
143
+ // the fact — read exactly like a fully audited one. The config write itself
144
+ // DID land, so `ok` stays true and nothing is retried; `audited` + `status`
145
+ // are what let the caller tell a full success from a partial one.
146
+ const audited = result.audited === true;
84
147
  return {
85
148
  ok: true,
86
- status: 'applied',
149
+ status: audited ? 'applied' : 'applied_not_audited',
150
+ partial: !audited,
151
+ audited,
87
152
  key: result.key,
88
153
  previous: result.previous,
89
154
  value: result.value,
90
155
  created: result.created,
91
- note: 'Setting applied and audited to .heyspex/settings-history.jsonl.',
156
+ // HS-4C76 fix 3: the core seeds a missing config.yaml from the template on
157
+ // the first write instead of refusing; surface that so the caller can say so.
158
+ ...(result.seeded === true ? { seeded: true } : {}),
159
+ note: audited
160
+ ? `Setting applied and audited to .heyspex/settings-history.jsonl.${result.seeded === true ? ' (.heyspex/config.yaml was missing and was seeded from the template first.)' : ''}`
161
+ : 'Setting APPLIED to .heyspex/config.yaml, but the audit append to ' +
162
+ '.heyspex/settings-history.jsonl FAILED — this write has NO audit row. The value is live; do not ' +
163
+ 're-apply it. Tell the user the change is in effect but unaudited, and check that ' +
164
+ '.heyspex/settings-history.jsonl is writable.',
92
165
  };
93
166
  }
94
167
 
@@ -0,0 +1,446 @@
1
+ // Spec editing tools: heyspex_regen_task, regen_tasks_for_epic, backfill_epic,
2
+ // edit_file, propose_metadata, list_known_tags, canvas_inbox. Extracted from cli.mjs under
3
+ // [[HS-BLNF-6W61-DIAG-cli-mjs-has-grown-into-a-13-665-line]] (no behavior change).
4
+
5
+ import { executeCanvasInbox } from './canvas-inbox.mjs';
6
+ import { consoleFetch, controlError, controlOk } from './console-transport.mjs';
7
+ import { detectTagDrift } from './plan-ground.mjs';
8
+ import { consoleErrorMessage } from './console-fetch.mjs';
9
+ import { detectAgentIdentity, getConsoleUrl } from './mcp-config.mjs';
10
+ import { resolveVaultWriteMode } from './read-path.mjs';
11
+ import { splitRawSpec, updateSpecViaConsole } from './cloud-vault-writes.mjs';
12
+ import { writeFileSync } from 'node:fs';
13
+ import { executeShipEpicCloudAware } from './plan-push.mjs';
14
+ import { executeRegenTask, executeRegenTasksForEpic } from './regen-tasks.mjs';
15
+ import { loadTask } from './vault-io.mjs';
16
+ import { InvalidListCursorError, fitPageToByteBudget, resolvePageLimit } from './list-tools.mjs';
17
+ import { DEFAULT_TOOL_BUDGET_BYTES } from './response-budget.mjs';
18
+
19
+ export function handleRegenTask(vaultRoot, args = {}) {
20
+ const result = executeRegenTask(vaultRoot, {
21
+ task_id: args.task_id,
22
+ commit: args.commit === true || args.commit === 'true',
23
+ new_body: args.new_body,
24
+ retire_reason: args.retire_reason,
25
+ loadTask: (ref) => loadTask(vaultRoot, ref),
26
+ });
27
+ return {
28
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
29
+ isError: result.ok === false,
30
+ };
31
+ }
32
+
33
+ export function handleRegenTasksForEpic(vaultRoot, args = {}) {
34
+ const result = executeRegenTasksForEpic(vaultRoot, {
35
+ epic_id: args.epic_id,
36
+ commit: args.commit === true || args.commit === 'true',
37
+ task_specs: args.task_specs,
38
+ loadTask: (ref) => loadTask(vaultRoot, ref),
39
+ });
40
+ return {
41
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
42
+ isError: result.ok === false,
43
+ };
44
+ }
45
+
46
+ /**
47
+ * [[HS-Y00G-EPIC]] — Safe file edit: claim → write → release.
48
+ * Acquires the file claim, writes content to disk, and releases the claim.
49
+ * If claim fails (already claimed by another agent), returns the conflict info.
50
+ *
51
+ * WHERE THE WRITE GOES ([[HS-BYU4-CWPX-TASK-promotion-writes-go-through-the-console]]).
52
+ * The write mode is resolved ONCE here, at the handler entry, and threaded to
53
+ * step 2 below:
54
+ *
55
+ * - `local` — byte-identical to the original behavior: `writeFileSync` to
56
+ * `args.file_path`, and the success payload carries exactly the fields it
57
+ * always did.
58
+ * - `cloud` — the mutated markdown is split back into the
59
+ * `{ frontmatter, body }` pair `PUT /api/specs/:id` takes and sent through
60
+ * {@link updateSpecViaConsole}. Nothing touches this machine's disk: a
61
+ * markdown file written in cloud reliance is a split brain, a document on a
62
+ * machine the console's index has never heard of (and that may hold no
63
+ * vault clone at all).
64
+ *
65
+ * The spec id comes from the POST-EDIT content's own `id:` frontmatter — the
66
+ * caller has already produced the finished document, so its frontmatter is the
67
+ * authority on which spec this edit replaces. Content with no resolvable `id:`
68
+ * (a non-spec file, or a spec fragment) has NO cloud route: there is no console
69
+ * verb for "write these bytes to this arbitrary path". That degrades explicitly
70
+ * as `CLOUD_MODE_UNSUPPORTED_WRITE` naming the file — never a silent fallback
71
+ * to the disk write the mode just forbade.
72
+ *
73
+ * @param {object} args
74
+ * @param {object} [deps] Injection seam so tests drive every branch without a
75
+ * socket: `consoleFetch` (defaults to the real transport), `consoleUrl`
76
+ * (defaults to `getConsoleUrl()`), `resolveWriteMode` (defaults to
77
+ * `resolveVaultWriteMode`).
78
+ */
79
+ export async function handleEditFile(args = {}, deps = {}) {
80
+ if (!args.file_path || typeof args.file_path !== 'string') {
81
+ return {
82
+ isError: true,
83
+ content: [{ type: 'text', text: JSON.stringify({ error: 'INVALID_ARGS', message: 'file_path is required' }) }],
84
+ };
85
+ }
86
+ if (typeof args.content !== 'string') {
87
+ return {
88
+ isError: true,
89
+ content: [{ type: 'text', text: JSON.stringify({ error: 'INVALID_ARGS', message: 'content is required and must be a string' }) }],
90
+ };
91
+ }
92
+
93
+ const identity = detectAgentIdentity(args.agent, args.session_id);
94
+ const fetchDep = deps.consoleFetch ?? consoleFetch;
95
+ // Resolved ONCE per handler entry, then threaded to step 2. `?? 'local'`
96
+ // keeps every pre-existing caller on exactly the disk write it had.
97
+ const writeMode = (await (deps.resolveWriteMode ?? resolveVaultWriteMode)()) ?? 'local';
98
+
99
+ // Step 1: Acquire claim
100
+ const claimBody = {
101
+ file_path: args.file_path,
102
+ agent: identity.agent,
103
+ session_id: identity.sessionId,
104
+ ttl_ms: 300000, // 5-minute default for file claims
105
+ };
106
+ if (args.working_spec_id && typeof args.working_spec_id === 'string') {
107
+ claimBody.working_spec_id = args.working_spec_id;
108
+ }
109
+
110
+ const claimResult = await fetchDep('/api/file-claims/acquire', { method: 'POST', body: claimBody });
111
+
112
+ if (claimResult.status === 409) {
113
+ const conflict = claimResult.body && typeof claimResult.body === 'object' ? claimResult.body : {};
114
+ return {
115
+ isError: true,
116
+ content: [{ type: 'text', text: JSON.stringify({
117
+ error: 'FILE_CLAIMED',
118
+ message: `CONFLICT: file "${args.file_path}" is claimed by another agent.`,
119
+ claimed_by: conflict.existing?.agent ?? 'unknown',
120
+ claimed_session: conflict.existing?.sessionId ?? null,
121
+ expires_at: conflict.existing?.expiresAt ?? null,
122
+ conflict: conflict,
123
+ }, null, 2) }],
124
+ };
125
+ }
126
+
127
+ if (!claimResult.ok) {
128
+ return controlError(claimResult, 'EDIT_FILE_CLAIM_FAILED', { file_path: args.file_path });
129
+ }
130
+
131
+ // Always release before returning a failure, so a rejected write never leaves
132
+ // the file locked for the claim's full TTL.
133
+ const releaseClaim = () =>
134
+ fetchDep('/api/file-claims/release', { method: 'DELETE', body: { file_path: args.file_path } });
135
+
136
+ // Step 2: Write — to disk in local mode, to `PUT /api/specs/:id` in cloud mode.
137
+ let cloudWrite = null;
138
+ if (writeMode === 'cloud') {
139
+ const consoleUrl = deps.consoleUrl ?? getConsoleUrl();
140
+ const { frontmatter, body } = splitRawSpec(args.content);
141
+ const specId = typeof frontmatter?.id === 'string' ? frontmatter.id.trim() : '';
142
+ if (!specId) {
143
+ await releaseClaim();
144
+ return {
145
+ isError: true,
146
+ content: [{ type: 'text', text: JSON.stringify({
147
+ error: 'CLOUD_MODE_UNSUPPORTED_WRITE',
148
+ message:
149
+ `heyspex_edit_file cannot write "${args.file_path}" in cloud vault-write mode: the content carries no `
150
+ + '`id:` frontmatter, and the console has no route that writes arbitrary bytes to an arbitrary path '
151
+ + '(POST /api/specs creates a typed spec, PUT /api/specs/:id replaces one by id). NOTHING WAS WRITTEN — '
152
+ + 'the local disk write is deliberately NOT used as a fallback, because a file written here would be '
153
+ + 'invisible to the console index this session reads from.',
154
+ file_path: args.file_path,
155
+ write_mode: 'cloud',
156
+ console_url: consoleUrl,
157
+ on_disk: false,
158
+ }, null, 2) }],
159
+ };
160
+ }
161
+ try {
162
+ cloudWrite = await updateSpecViaConsole({
163
+ consoleFetch: fetchDep,
164
+ consoleUrl,
165
+ specId,
166
+ frontmatter,
167
+ body,
168
+ });
169
+ } catch (err) {
170
+ await releaseClaim();
171
+ return {
172
+ isError: true,
173
+ content: [{ type: 'text', text: JSON.stringify({
174
+ error: 'CONSOLE_UNREACHABLE_CLOUD_MODE',
175
+ message: String(err?.message ?? err),
176
+ file_path: args.file_path,
177
+ spec_id: specId,
178
+ write_mode: 'cloud',
179
+ on_disk: false,
180
+ ...(err?.notFound ? { not_found: true } : {}),
181
+ }, null, 2) }],
182
+ };
183
+ }
184
+ } else {
185
+ try {
186
+ writeFileSync(args.file_path, args.content, 'utf8');
187
+ } catch (err) {
188
+ // Release the claim on write failure so the file isn't locked forever
189
+ await releaseClaim();
190
+ return {
191
+ isError: true,
192
+ content: [{ type: 'text', text: JSON.stringify({
193
+ error: 'WRITE_FAILED',
194
+ message: `Failed to write file: ${err?.message ?? err}`,
195
+ file_path: args.file_path,
196
+ }) }],
197
+ };
198
+ }
199
+ }
200
+
201
+ // Step 3: Release claim
202
+ const releaseResult = await releaseClaim();
203
+
204
+ return controlOk({
205
+ written: true,
206
+ file_path: args.file_path,
207
+ released: releaseResult.ok ? releaseResult.body?.released ?? true : false,
208
+ agent: identity.agent,
209
+ session_id: identity.sessionId,
210
+ // Cloud-only additions — the local payload stays byte-identical to the
211
+ // shape every existing caller already parses.
212
+ ...(cloudWrite
213
+ ? {
214
+ write_mode: 'cloud',
215
+ on_disk: false,
216
+ spec_id: cloudWrite.specId,
217
+ rel_path: cloudWrite.relPath,
218
+ console_url: cloudWrite.consoleUrl,
219
+ }
220
+ : {}),
221
+ });
222
+ }
223
+
224
+ /**
225
+ * Known-tags paging contract, mirrored from the console route
226
+ * (`spex-console/src/lib/scheduler/known-tags-query.ts`): the cursor is
227
+ * base64url JSON `{ c: count, t: tag }` over the `(count DESC, tag ASC)` sort
228
+ * key of the aggregate query. Encoded here only to RE-POINT the cursor when the
229
+ * byte budget trims a page — the console mints every cursor it hands out.
230
+ */
231
+ export const KNOWN_TAGS_TOOL = Object.freeze({
232
+ default_limit: 100,
233
+ max_limit: 500,
234
+ // Measured on `content[0].text`; the dispatcher re-measures the whole envelope (escaped), so leave headroom.
235
+ byte_budget: Math.floor(DEFAULT_TOOL_BUDGET_BYTES * 0.8),
236
+ });
237
+
238
+ export function encodeKnownTagsCursor({ count, tag }) {
239
+ return Buffer.from(JSON.stringify({ c: Number(count) || 0, t: String(tag ?? '') }), 'utf8').toString('base64url');
240
+ }
241
+
242
+ /**
243
+ * `heyspex_list_known_tags` — the closed tag vocabulary for the focus-areas NLU
244
+ * path ([[HS-TASK-I0EK]] / [[HS-EPIC-7WJN]]). GET /api/scheduler/known-tags →
245
+ * ONE page `{ tags: [{ name, count }], next_cursor, page }` from an aggregate
246
+ * query paged with a keyset cursor over (count, tag)
247
+ * ([[HS-4H8T-8KK5-TASK-every-mcp-read-tool-declares-a-byte]], resolving
248
+ * [[HS-7QM6-1Q1C-DIAG-heyspex-list-known-tags-response-busts-t]] — the reply
249
+ * that once hit 138 KB). `limit` and `after` pass straight through to the
250
+ * console; the page is then fitted to the tool's byte budget, re-pointing
251
+ * `next_cursor` at the last tag kept so nothing is lost. Agents call this
252
+ * before proposing a focus pivot so they only suggest tags that exist.
253
+ * Requires a reachable console.
254
+ *
255
+ * Augments the console response with an additive `drift` array ([[HS-TASK-H0SI]]
256
+ * / [[HS-EPIC-OOPW]]): near-duplicate tag pairs within the page flagged by
257
+ * lexical string similarity (NOT embeddings) so the agent can propose a merge to
258
+ * keep the closed vocabulary clean. No tag is mutated here — `drift` is a
259
+ * suggestion signal only.
260
+ */
261
+ export async function handleListKnownTags(args = {}, { consoleFetch: fetchDep = consoleFetch, byteBudget = KNOWN_TAGS_TOOL.byte_budget } = {}) {
262
+ const limit = resolvePageLimit(args.limit, KNOWN_TAGS_TOOL);
263
+ const params = new URLSearchParams({ limit: String(limit) });
264
+ if (typeof args.after === 'string' && args.after) params.set('after', args.after);
265
+ const result = await fetchDep(`/api/scheduler/known-tags?${params.toString()}`);
266
+ if (!result.ok) {
267
+ if (result.status === 400 && result.body?.error === 'invalid_cursor') {
268
+ return {
269
+ isError: true,
270
+ content: [{ type: 'text', text: JSON.stringify({ error: 'INVALID_CURSOR', message: new InvalidListCursorError().message, tool: 'heyspex_list_known_tags' }, null, 2) }],
271
+ };
272
+ }
273
+ return controlError(result, 'KNOWN_TAGS_FAILED');
274
+ }
275
+ const body = result.body && typeof result.body === 'object' ? result.body : {};
276
+ const tags = Array.isArray(body.tags) ? body.tags : [];
277
+ const base = {
278
+ ...body,
279
+ tags,
280
+ next_cursor: typeof body.next_cursor === 'string' && body.next_cursor ? body.next_cursor : null,
281
+ page: { limit, returned: tags.length, byte_budget: byteBudget, budget_trimmed: false, ...(body.page ?? {}) },
282
+ };
283
+ const fitOpts = {
284
+ rows_key: 'tags',
285
+ // A page of tags that would fill the whole budget leaves no room for the
286
+ // drift signal the tool exists to surface: reserve a tenth for the
287
+ // strongest pairs. A page that needs no trim gets the whole budget.
288
+ byte_budget: byteBudget - Math.floor(byteBudget * 0.1),
289
+ cursorFor: (row) => encodeKnownTagsCursor({ count: row.count, tag: row.name }),
290
+ };
291
+ // 1. Fit the TAGS to the budget first (with an empty drift envelope in
292
+ // place so its bytes are accounted for) — the cursor must land on a tag
293
+ // the caller received, and drift over an un-returned tag is noise.
294
+ let current = fitPageToByteBudget({ ...base, drift: [] }, fitOpts).result;
295
+ // 2. Drift over the returned page only. It is pairwise (quadratic in page
296
+ // size) and sorted by similarity desc, so when it would bust the budget
297
+ // keep the strongest pairs that fit. Only when not even zero pairs fit
298
+ // (the truncation markers cost a few bytes) does one more tag go, and
299
+ // drift is recomputed over what remains.
300
+ for (;;) {
301
+ const fitted = fitKnownTagsDrift(current, detectTagDrift(current.tags), byteBudget);
302
+ if (fitted) return { content: [{ type: 'text', text: fitted.text }] };
303
+ if (current.tags.length <= 1) {
304
+ // A lone tag that cannot fit even with no drift: return it rather than
305
+ // an empty page with no way forward (the list-tools rule).
306
+ return { content: [{ type: 'text', text: JSON.stringify({ ...current, drift: [] }, null, 2) }] };
307
+ }
308
+ const kept = current.tags.slice(0, -1);
309
+ current = {
310
+ ...current,
311
+ tags: kept,
312
+ next_cursor: fitOpts.cursorFor(kept[kept.length - 1]),
313
+ page: { ...current.page, returned: kept.length, budget_trimmed: true },
314
+ };
315
+ }
316
+ }
317
+
318
+ /**
319
+ * Largest similarity-desc prefix of `drift` whose page fits `byteBudget`:
320
+ * the whole list untouched when it fits, else `page.drift_truncated: true` +
321
+ * `drift_dropped`. `null` when not even an empty drift list fits.
322
+ */
323
+ function fitKnownTagsDrift(page, drift, byteBudget) {
324
+ const serialize = (payload) => JSON.stringify(payload, null, 2);
325
+ const fits = (text) => Buffer.byteLength(text, 'utf8') <= byteBudget;
326
+ const full = { ...page, drift };
327
+ const fullText = serialize(full);
328
+ if (fits(fullText)) return { payload: full, text: fullText };
329
+ let lo = 0;
330
+ let hi = drift.length - 1;
331
+ let best = null;
332
+ while (lo <= hi) {
333
+ const mid = (lo + hi) >>> 1;
334
+ const candidate = { ...page, drift: drift.slice(0, mid), page: { ...page.page, drift_truncated: true, drift_dropped: drift.length - mid } };
335
+ const text = serialize(candidate);
336
+ if (fits(text)) {
337
+ best = { payload: candidate, text };
338
+ lo = mid + 1;
339
+ } else {
340
+ hi = mid - 1;
341
+ }
342
+ }
343
+ return best;
344
+ }
345
+
346
+ /**
347
+ * AI metadata proposal — [[HS-TASK-TAOD]] Phase 4 of [[HS-EPIC-Y9OJ]].
348
+ *
349
+ * Thin MCP wrapper around console `POST /api/ai/propose-metadata` (Haiku 4.5).
350
+ * One-shot at create time — agents must NOT call this on edit, status change,
351
+ * or update flows; that's the closed-loop bias the epic explicitly rejects.
352
+ */
353
+ export async function handleProposeMetadata(args = {}) {
354
+ const name = typeof args?.name === 'string' ? args.name : '';
355
+ const body = typeof args?.body === 'string' ? args.body : '';
356
+ if (!name.trim() && !body.trim()) {
357
+ return {
358
+ isError: true,
359
+ content: [
360
+ {
361
+ type: 'text',
362
+ text: JSON.stringify(
363
+ { ok: false, reason: 'invalid_request', message: 'name or body is required' },
364
+ null,
365
+ 2,
366
+ ),
367
+ },
368
+ ],
369
+ };
370
+ }
371
+
372
+ const payload = { action: 'propose', name, body };
373
+ if (typeof args?.specType === 'string') payload.specType = args.specType;
374
+ if (typeof args?.parentExcerpt === 'string') payload.parentExcerpt = args.parentExcerpt;
375
+
376
+ const result = await consoleFetch('/api/ai/propose-metadata', {
377
+ method: 'POST',
378
+ body: payload,
379
+ });
380
+
381
+ if (!result.ok) {
382
+ const reason =
383
+ result.errorKind === 'timeout'
384
+ ? 'console_timeout'
385
+ : result.status === 0
386
+ ? 'console_unreachable'
387
+ : 'console_error';
388
+ const errorPayload = {
389
+ ok: false,
390
+ reason,
391
+ message: consoleErrorMessage(
392
+ result,
393
+ `Console /api/ai/propose-metadata returned ${result.status}`,
394
+ ),
395
+ console_response: result.body,
396
+ };
397
+ return {
398
+ isError: true,
399
+ content: [{ type: 'text', text: JSON.stringify(errorPayload, null, 2) }],
400
+ };
401
+ }
402
+
403
+ return {
404
+ content: [{ type: 'text', text: JSON.stringify(result.body, null, 2) }],
405
+ };
406
+ }
407
+
408
+ // Ground-truth dev-server status. The heavy logic lives in the repo's
409
+ // scripts/dev-server-status.mjs (the shared core for the hs CLI verb and the
410
+ // PreToolUse reminder hook too); we dynamic-import it from the vault root so
411
+ // the three faces stay in lockstep. Must never throw out of the tool call.
412
+ export async function handleCanvasInbox(vaultRoot, args = {}) {
413
+ // Session id: explicit arg wins; otherwise the ambient Claude Code session.
414
+ const explicit = typeof args.session_id === 'string' ? args.session_id.trim() : '';
415
+ const sessionId =
416
+ explicit ||
417
+ (process.env.CLAUDE_CODE_SESSION_ID || process.env.CLAUDE_SESSION_ID || '').trim();
418
+
419
+ const result = await executeCanvasInbox({ vaultRoot, sessionId });
420
+ return {
421
+ ...(result.ok ? {} : { isError: true }),
422
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
423
+ };
424
+ }
425
+
426
+ /**
427
+ * `heyspex_ship_epic` / `backfill_epic`. Cloud-aware entry
428
+ * ([[HS-BYU4-CWPX-TASK-promotion-writes-go-through-the-console]] sweep):
429
+ * `executeShipEpicCloudAware` resolves `resolveVaultWriteMode()` ONCE and either
430
+ * runs the untouched synchronous disk path (`local`) or creates the epic, its
431
+ * tasks and the review doc through `POST /api/specs` in create-first/link-second
432
+ * order (`cloud`). The handler became `async` for that — awaiting is safe in
433
+ * both modes, since the local branch still returns the same plain object.
434
+ */
435
+ export async function handleShipEpic(vaultRoot, args) {
436
+ const result = await executeShipEpicCloudAware(vaultRoot, args);
437
+ if (result.error) {
438
+ return {
439
+ isError: true,
440
+ content: [{ type: 'text', text: JSON.stringify(result) }],
441
+ };
442
+ }
443
+ return {
444
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
445
+ };
446
+ }