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
@@ -0,0 +1,688 @@
1
+ // Filing and plan tools: heyspex_file_spec, register_spec, push_plan, ground_plan,
2
+ // plan_review, plan_qc, validate_linkage, validate_user_skills. 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 { buildGatewayReport } from '../../scripts/gateway-skills-lib.mjs';
6
+ import { resolveSpecIdForMutation, wrapDesignResult } from './tool-helpers.mjs';
7
+ import { validateLinkage } from './validate-linkage.mjs';
8
+ import { runPlanQc, runPlanReview } from './plan-review.mjs';
9
+ import { injectNeonFocusPin, writeFocusSelectionVendor } from './focus-tools.mjs';
10
+ import { executeGroundPlan } from './plan-ground.mjs';
11
+ import { executePushPlanCloudAware } from './plan-push.mjs';
12
+ import { registerSpecViaMode } from './register-spec.mjs';
13
+ import { SUMMARY_REQUIRED_FILE_SPEC_TYPES, buildSummaryRequiredMcpError } from './summary-contract.mjs';
14
+ import { buildCaptureStageRefusal, decideCaptureStageGate } from './capture-stage-gate.mjs';
15
+ import { resolveLiveActiveCapture } from './capture-tools.mjs';
16
+ import { detectAgentIdentity } from './mcp-config.mjs';
17
+ import { randomUUID } from 'node:crypto';
18
+ import { consoleFetch, enrichConsoleFailurePayloadAsync } from './console-transport.mjs';
19
+ import { consoleErrorCode, consoleErrorMessage } from './console-fetch.mjs';
20
+ import { resolveReadTier } from './read-path.mjs';
21
+ import { join } from 'node:path';
22
+ import { statSync, existsSync } from 'node:fs';
23
+ import { importDevModeCore } from './dev-mode-core.mjs';
24
+ import { PACKAGE_ROOT } from './package-root.mjs';
25
+ import { resolveCodeRoot } from '../../scripts/lib/shared-path-helpers.mjs';
26
+
27
+ // scripts/lib/dev-mode.mjs is a CODE-repo artifact that ships with this
28
+ // package — since the repo split (2026-08-31) the vault root no longer holds
29
+ // it, so resolve it relative to THIS file's checkout first and fall back to
30
+ // the vault root only for pre-split layouts where they were the same repo.
31
+ // `HS_PKG_ROOT` wins when set (same precedence as dev-tools.mjs's
32
+ // CHECKOUT_ROOT / plan-review.mjs's resolveCodeAuditRoots).
33
+ const CHECKOUT_ROOT = resolveCodeRoot({ packageRoot: PACKAGE_ROOT });
34
+
35
+ /**
36
+ * heyspex_validate_linkage — scan the vault for orphan specs, broken wikilinks,
37
+ * missing manifest refs, and parent/child mismatches ([[HS-TASK-0U1F]]).
38
+ * Core lives in validate-linkage.mjs; this is the MCP face.
39
+ */
40
+ export function handleValidateLinkage(vaultRoot, args = {}) {
41
+ const result = validateLinkage(vaultRoot, {
42
+ spec_id: typeof args.spec_id === 'string' && args.spec_id.trim() ? args.spec_id.trim() : null,
43
+ includeWarnings: args.include_warnings !== false,
44
+ });
45
+ return {
46
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
47
+ };
48
+ }
49
+
50
+ /**
51
+ * heyspex_validate_user_skills — validate user-authored gateway skills and
52
+ * their scope coverage (HS-WEC3). Core lives in scripts/gateway-skills-lib.mjs;
53
+ * this is the MCP face (CLI face: `hs gateway validate`; doctor face:
54
+ * `hs doctor --check-gateway`). Read-only by contract.
55
+ */
56
+ export function handleValidateUserSkills(vaultRoot, args = {}) {
57
+ const report = buildGatewayReport(vaultRoot);
58
+ const slug = typeof args.skill === 'string' && args.skill.trim() ? args.skill.trim() : null;
59
+ const result = slug
60
+ ? {
61
+ ...report,
62
+ skills: { ...report.skills, skills: report.skills.skills.filter((s) => s.slug === slug) },
63
+ orphans: { ...report.orphans, orphans: report.orphans.orphans.filter((o) => o.slug === slug) },
64
+ }
65
+ : report;
66
+ if (slug && result.skills.skills.length === 0) {
67
+ return wrapDesignResult({ error: 'skill_not_found', message: `No user skill named "${slug}" under ${report.skills.root}.` });
68
+ }
69
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
70
+ }
71
+
72
+ /**
73
+ * heyspex_register_spec — link an orphan/mismatched spec into the roadmap
74
+ * hierarchy ([[HS-TASK-5K13]]). Core lives in register-spec.mjs; this is the
75
+ * MCP face. The fix counterpart to heyspex_validate_linkage.
76
+ */
77
+ export async function handleRegisterSpec(vaultRoot, args = {}) {
78
+ const registerResolved = await resolveSpecIdForMutation(args.spec_id);
79
+ if (registerResolved.response) return registerResolved.response;
80
+ // Cloud-aware entry ([[HS-BYU4-CWPX-TASK-promotion-writes-go-through-the-console]]
81
+ // sweep): resolves `resolveVaultWriteMode()` ONCE and either runs the
82
+ // untouched synchronous disk path (`local`) or sends each touched spec
83
+ // through `PUT /api/specs/:id` (`cloud`). Awaiting is safe in both — the
84
+ // local branch delegates straight to `registerSpec` and returns the same
85
+ // plain object it always did.
86
+ const result = await registerSpecViaMode(vaultRoot, {
87
+ spec_id: registerResolved.specId,
88
+ parent_id: typeof args.parent_id === 'string' && args.parent_id.trim() ? args.parent_id.trim() : null,
89
+ roadmap_id: typeof args.roadmap_id === 'string' && args.roadmap_id.trim() ? args.roadmap_id.trim() : undefined,
90
+ position: typeof args.position === 'string' && args.position.trim() ? args.position.trim() : undefined,
91
+ dryRun: args.dry_run === true,
92
+ });
93
+ return {
94
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
95
+ };
96
+ }
97
+
98
+ /**
99
+ * File a NEW spec of any non-capture kind ([[HS-EPIC-1EM0]] / [[HS-TASK-B6DG]]).
100
+ * Thin forwarder to console `POST /api/specs` — the server owns id allocation,
101
+ * canonical-template frontmatter, parent-array linkage, and per-kind validation
102
+ * (all extended by [[HS-TASK-U13O]]). The client contract is preserved verbatim
103
+ * ({ type, name, frontmatter?, body? }); no kind/title rename. POST errors
104
+ * (invalid parent, `type: capture` rejection, etc.) surface verbatim so the
105
+ * agent sees the server's guidance (e.g. "use heyspex_active_capture").
106
+ */
107
+ export function resolveFileSpecSummary(args = {}) {
108
+ const top = typeof args.summary === 'string' ? args.summary.trim() : '';
109
+ const fromFm =
110
+ args.frontmatter && typeof args.frontmatter.summary === 'string'
111
+ ? args.frontmatter.summary.trim()
112
+ : '';
113
+ return top || fromFm;
114
+ }
115
+
116
+ /**
117
+ * The only top-level `heyspex_file_spec` arguments the tool actually reads
118
+ * ([[tool-catalog.mjs]]'s declared inputSchema `properties`, kept in
119
+ * lockstep by `file-spec-artifact-field.test.mjs`). Parent/milestone linkage
120
+ * is NOT a top-level argument — it goes inside `frontmatter.parent` /
121
+ * `frontmatter.milestone` (the schema description says so), but nothing
122
+ * enforced that at runtime.
123
+ */
124
+ export const FILE_SPEC_ACCEPTED_ARGS = [
125
+ 'type',
126
+ 'name',
127
+ 'summary',
128
+ 'frontmatter',
129
+ 'body',
130
+ 'recipe_context',
131
+ 'checks',
132
+ 'expect_tier',
133
+ ];
134
+
135
+ /** Top-level keys a caller plausibly means for frontmatter linkage — used only to tailor the refusal message. */
136
+ const FILE_SPEC_FRONTMATTER_HINT_KEYS = new Set(['parent', 'milestone', 'pillar', 'severity', 'id']);
137
+
138
+ /**
139
+ * Pure verdict on a `heyspex_file_spec` argument bag: refuses an unrecognized
140
+ * top-level key BY NAME instead of letting it silently vanish before
141
+ * `buildFileSpecPostBody` even runs — the same failure mode
142
+ * [[additionalproperties-false-in-an-mcp]] documents for
143
+ * `heyspex_update_capture` (`updateCaptureArgsVerdict`), and the exact shape
144
+ * of [[HS-XQMK-A1QE-DIAG-heyspex-file-spec-drops-the-parent]]: a top-level
145
+ * `parent:` argument passed the declared `additionalProperties: false`
146
+ * schema (never enforced at runtime by the MCP transport), then vanished
147
+ * silently because `buildFileSpecPostBody` never read `args.parent` — every
148
+ * filed task landed orphaned with no error anywhere. Call this at the
149
+ * handler's first hop, before any other validation or the POST.
150
+ */
151
+ export function fileSpecArgsVerdict(args = {}) {
152
+ const unknown = Object.keys(args).filter((k) => !FILE_SPEC_ACCEPTED_ARGS.includes(k));
153
+ if (unknown.length > 0) {
154
+ const hinted = unknown.filter((k) => FILE_SPEC_FRONTMATTER_HINT_KEYS.has(k));
155
+ const hint =
156
+ hinted.length > 0
157
+ ? ` Spec-kind linkage/overrides go inside frontmatter, not top-level: pass frontmatter: { ${hinted
158
+ .map((k) => `${k}: ...`)
159
+ .join(', ')} } instead.`
160
+ : '';
161
+ return {
162
+ ok: false,
163
+ error: {
164
+ error: 'UNKNOWN_FIELD',
165
+ message: `heyspex_file_spec does not accept: ${unknown.join(', ')}. NOTHING WAS FILED — re-send using only the accepted arguments.${hint}`,
166
+ field: unknown[0],
167
+ unknown_fields: unknown,
168
+ accepted_fields: FILE_SPEC_ACCEPTED_ARGS,
169
+ },
170
+ };
171
+ }
172
+ return { ok: true };
173
+ }
174
+
175
+ /**
176
+ * Build the POST /api/specs request body from heyspex_file_spec args. Pure and
177
+ * exported so the field-forwarding contract is unit-testable without a live
178
+ * console. `recipe_context` (the heyspex_start_recipe start context) is
179
+ * forwarded verbatim when it is a non-null object — the server reads recipe_id
180
+ * + tool_scope from it and stamps recipe provenance ([[HS-98G6-TASK]] /
181
+ * [[HS-5G0P-EPIC]] AC5); it is omitted entirely when absent so non-recipe
182
+ * filings are byte-identical to before.
183
+ */
184
+ export function buildFileSpecPostBody(args = {}, actingAgent = null) {
185
+ const authoredSummary = resolveFileSpecSummary(args);
186
+ return {
187
+ type: args.type,
188
+ name: args.name,
189
+ ...(authoredSummary ? { summary: authoredSummary } : {}),
190
+ ...(args.frontmatter && typeof args.frontmatter === 'object'
191
+ ? { frontmatter: args.frontmatter }
192
+ : {}),
193
+ ...(typeof args.body === 'string' ? { body: args.body } : {}),
194
+ ...(actingAgent ? { acting_agent: actingAgent } : {}),
195
+ ...(args.recipe_context && typeof args.recipe_context === 'object' && !Array.isArray(args.recipe_context)
196
+ ? { recipe_context: args.recipe_context }
197
+ : {}),
198
+ // The frozen checks manifest ([[checks-manifest-authoring]]). Forwarded only
199
+ // when the caller authored rows, so filings that pass none stay byte-identical
200
+ // to before and pick up the server's `no_checks_manifest` warning instead.
201
+ ...(Array.isArray(args.checks) && args.checks.length > 0 ? { checks: args.checks } : {}),
202
+ };
203
+ }
204
+
205
+ /**
206
+ * Local host names whose console owns THIS machine's disk. A write posted to
207
+ * one of these landed (or should have landed) in the local clone; anything
208
+ * else is a cloud console with no access to it.
209
+ */
210
+ export const LOCAL_CONSOLE_HOSTS = new Set(['localhost', '127.0.0.1', '0.0.0.0', '[::1]', '::1']);
211
+
212
+ /** True when a console origin points at this machine. Never throws. */
213
+ export function isLocalConsoleOrigin(url) {
214
+ if (typeof url !== 'string' || !url) return false;
215
+ try {
216
+ return LOCAL_CONSOLE_HOSTS.has(new URL(url).hostname.toLowerCase());
217
+ } catch {
218
+ return false;
219
+ }
220
+ }
221
+
222
+ /**
223
+ * WHERE DID THE WRITE LAND — the pure verdict ([[HS-IOJW-5YDH-DIAG]]).
224
+ *
225
+ * The bug this closes: a vault write reported plain success whether the bytes
226
+ * reached a file in this clone or only a Neon row on another machine. Two
227
+ * sessions lost work to it on 2026-08-21 and recovered it only by re-reading
228
+ * the file out of habit.
229
+ *
230
+ * Inputs are all MEASURED by the caller, never assumed:
231
+ * consoleUrl the origin the POST actually went to (consoleFetch result.url)
232
+ * consoleWrite the console's own `write` block when it is new enough to send one
233
+ * relPath vault-relative path the console says it wrote
234
+ * vaultRoot this session's clone, or null when there is none
235
+ * absPath the absolute path actually written, when the console reports one
236
+ * localFileExists a real stat of that path; null when unmeasurable
237
+ * devMode { mode, reliance, source, updated_at } from ~/.heyspex/dev-mode.json
238
+ * expectTier the caller's declared expectation: "disk" | "cloud" | undefined
239
+ *
240
+ * Returns { landed, error } — `error` non-null means the tool must answer
241
+ * isError, WITH the spec id still attached so nobody re-files a duplicate.
242
+ */
243
+ export function fileSpecLandingVerdict({
244
+ consoleUrl = null,
245
+ consoleWrite = null,
246
+ relPath = null,
247
+ vaultRoot = null,
248
+ localFileExists = null,
249
+ devMode = null,
250
+ expectTier = null,
251
+ absPath = null,
252
+ } = {}) {
253
+ // Tier: prefer what the console measured; fall back to the origin we posted
254
+ // to, so this still works against a deployed console that predates the
255
+ // `write` block.
256
+ let tier;
257
+ let reportedBy;
258
+ if (consoleWrite?.mode === 'local' || consoleWrite?.mode === 'cloud') {
259
+ tier = consoleWrite.mode === 'local' ? 'disk' : 'cloud';
260
+ reportedBy = 'console';
261
+ } else {
262
+ tier = isLocalConsoleOrigin(consoleUrl) ? 'disk' : 'cloud';
263
+ reportedBy = 'inferred_from_console_url';
264
+ }
265
+
266
+ const landed = {
267
+ tier,
268
+ reported_by: reportedBy,
269
+ console_url: consoleUrl,
270
+ rel_path: relPath,
271
+ // The console's own absolute path wins. Its vault root is the SPECS dir,
272
+ // not the repo root, and `relPath` is relative to THAT — joining it onto
273
+ // the MCP's vaultRoot points at a file that never existed. Caught live on
274
+ // the first real filing through this code.
275
+ abs_path: absPath ?? (vaultRoot && relPath ? join(vaultRoot, relPath) : null),
276
+ in_local_vault: localFileExists,
277
+ vault_root: vaultRoot,
278
+ console_write: consoleWrite ?? null,
279
+ dev_mode: devMode
280
+ ? {
281
+ mode: devMode.mode ?? null,
282
+ reliance: devMode.reliance ?? null,
283
+ set_by: devMode.source ?? null,
284
+ set_at: devMode.updated_at ?? null,
285
+ }
286
+ : null,
287
+ };
288
+
289
+ const modeNote = landed.dev_mode?.mode
290
+ ? ` Mode is ${landed.dev_mode.mode}/${landed.dev_mode.reliance}, last set by ${landed.dev_mode.set_by ?? 'unknown'} at ${landed.dev_mode.set_at ?? 'unknown time'}.`
291
+ : '';
292
+
293
+ // 1. The caller said where it wanted the write and it went elsewhere.
294
+ if (expectTier && tier !== expectTier) {
295
+ return {
296
+ landed,
297
+ error: {
298
+ error: 'WRITE_TIER_MISMATCH',
299
+ message:
300
+ `The spec was created, but on ${tier} — you asked for ${expectTier}.${modeNote} ` +
301
+ 'The spec id below is real; do NOT file it again. Switch the mode with ' +
302
+ 'heyspex_dev_mode and re-check, or pass expect_tier to match where you want it.',
303
+ },
304
+ };
305
+ }
306
+
307
+ // 2. Undeclared cloud landing while this session has a clone in front of it.
308
+ // This is the exact shape that lost work: agent believes it is writing
309
+ // files, the mode was flipped underneath it, nothing appears on disk.
310
+ if (!expectTier && tier === 'cloud' && vaultRoot) {
311
+ return {
312
+ landed,
313
+ error: {
314
+ error: 'WRITE_LANDED_OFF_DISK',
315
+ message:
316
+ `The spec was created in the cloud index, NOT as a file in this clone (${vaultRoot}).${modeNote} ` +
317
+ 'The spec id below is real; do NOT file it again. Either switch back with ' +
318
+ 'heyspex_dev_mode({ action: "set", mode: "local" }) and re-file, or pass ' +
319
+ 'expect_tier: "cloud" to say you meant it.',
320
+ },
321
+ };
322
+ }
323
+
324
+ // 3. It claims disk, and the file is not there. A write that reports success
325
+ // over a missing file is the defect itself.
326
+ if (tier === 'disk' && localFileExists === false) {
327
+ return {
328
+ landed,
329
+ error: {
330
+ error: 'WRITE_NOT_ON_DISK',
331
+ message:
332
+ `The console answered success but no file exists at ${landed.abs_path ?? relPath}.${modeNote} ` +
333
+ 'The spec id below may still be real in the index — check it before re-filing, and ' +
334
+ 'read the console logs for a swallowed disk error.',
335
+ },
336
+ };
337
+ }
338
+
339
+ return { landed, error: null };
340
+ }
341
+
342
+ /**
343
+ * Shell around {@link fileSpecLandingVerdict}: reads the machine-local dev-mode
344
+ * record and stats the file. Never throws — a landing report that fails must
345
+ * not fail the write it reports on.
346
+ */
347
+ export async function measureFileSpecLanding({ consoleUrl, consoleBody, relPath, vaultRoot, expectTier }) {
348
+ let devMode = null;
349
+ try {
350
+ if (vaultRoot) {
351
+ // Bundler-safe loader (see dev-mode-core.mjs): the runtime paths first,
352
+ // then a static specifier. Without the fallback the bundled remote
353
+ // endpoint skipped the landing probe entirely.
354
+ const mod = await importDevModeCore([
355
+ join(CHECKOUT_ROOT, 'scripts', 'lib', 'dev-mode.mjs'),
356
+ join(vaultRoot, 'scripts', 'lib', 'dev-mode.mjs'),
357
+ ]);
358
+ devMode = mod.readLocalDevModeRecord()?.record ?? null;
359
+ }
360
+ } catch {
361
+ devMode = null;
362
+ }
363
+ // Which absolute path to stat. The console's own `absPath` is authoritative
364
+ // when it sent one — its vault root is <repo>/specs, so `relPath` alone is
365
+ // NOT resolvable against the MCP's repo root. Without it, try both roots
366
+ // rather than guessing one and reporting a phantom missing file.
367
+ const consoleWrite = consoleBody?.write ?? null;
368
+ const candidates = [];
369
+ if (typeof consoleWrite?.absPath === 'string' && consoleWrite.absPath) {
370
+ candidates.push(consoleWrite.absPath);
371
+ } else if (vaultRoot && relPath) {
372
+ candidates.push(join(vaultRoot, relPath), join(vaultRoot, 'specs', relPath));
373
+ }
374
+ let localFileExists = candidates.length > 0 ? false : null;
375
+ let absPath = candidates[0] ?? null;
376
+ for (const candidate of candidates) {
377
+ try {
378
+ if (statSync(candidate).isFile()) {
379
+ localFileExists = true;
380
+ absPath = candidate;
381
+ break;
382
+ }
383
+ } catch {
384
+ // keep looking
385
+ }
386
+ }
387
+ return fileSpecLandingVerdict({
388
+ consoleUrl,
389
+ consoleWrite,
390
+ relPath,
391
+ vaultRoot,
392
+ localFileExists,
393
+ devMode,
394
+ expectTier,
395
+ absPath,
396
+ });
397
+ }
398
+
399
+ export async function handleFileSpec(args = {}, vaultRoot = null) {
400
+ // First hop: refuse an unrecognized top-level arg (e.g. a top-level
401
+ // `parent:`) BY NAME instead of letting it silently vanish before
402
+ // buildFileSpecPostBody even runs [[HS-XQMK-A1QE-DIAG-heyspex-file-spec-drops-the-parent]].
403
+ const argsVerdict = fileSpecArgsVerdict(args);
404
+ if (!argsVerdict.ok) {
405
+ return {
406
+ isError: true,
407
+ content: [{ type: 'text', text: JSON.stringify(argsVerdict.error, null, 2) }],
408
+ };
409
+ }
410
+ if (!args.type || typeof args.type !== 'string') {
411
+ return {
412
+ isError: true,
413
+ content: [{ type: 'text', text: JSON.stringify({ error: 'INVALID_ARGS', message: 'type is required' }) }],
414
+ };
415
+ }
416
+ if (!args.name || typeof args.name !== 'string') {
417
+ return {
418
+ isError: true,
419
+ content: [{ type: 'text', text: JSON.stringify({ error: 'INVALID_ARGS', message: 'name is required' }) }],
420
+ };
421
+ }
422
+ // Summary required at filing — planning phase (milestone/epic) or execution
423
+ // phase (task via What's Next `/hs-next` only). Never co-file epics and tasks.
424
+ if (SUMMARY_REQUIRED_FILE_SPEC_TYPES.has(args.type) && !resolveFileSpecSummary(args)) {
425
+ return {
426
+ isError: true,
427
+ content: [{
428
+ type: 'text',
429
+ text: JSON.stringify(buildSummaryRequiredMcpError(args.type), null, 2),
430
+ }],
431
+ };
432
+ }
433
+ // Capture-stage rule ([[capture-stage-gate]]): an open capture must not become
434
+ // an epic/task/milestone. Checked HERE, before the POST, because the hook that
435
+ // used to own this could not read the live pin and could be switched off by a
436
+ // file. Runs on its own resolution failure as "allow" — a gate that refuses on
437
+ // its own uncertainty is worse than no gate.
438
+ const captureStageVerdict = decideCaptureStageGate({
439
+ toolArgs: args,
440
+ activeCapture: vaultRoot ? await resolveLiveActiveCapture(vaultRoot) : null,
441
+ });
442
+ if (captureStageVerdict.decision === 'refuse') {
443
+ return {
444
+ isError: true,
445
+ content: [{ type: 'text', text: JSON.stringify(buildCaptureStageRefusal(captureStageVerdict), null, 2) }],
446
+ };
447
+ }
448
+
449
+ // Acting agent for `assisted_by` auto-stamp ([[HS-EPIC-IKIM-authorship-provenance-frontmatter-owner]]
450
+ // AC #2). Env-detected (same source as claim_spec); the console seeds
451
+ // `assisted_by: [acting_agent]` on the new spec, omit-when-empty if unset.
452
+ const actingAgent = detectAgentIdentity(args.agent, args.session_id).agent;
453
+ const body = buildFileSpecPostBody(args, actingAgent);
454
+ // Idempotency key ([[HS-70TD-DIAG]]): generated ONCE per logical file_spec call
455
+ // and reused across the transport's timeout-retry (the header lives on the
456
+ // shared `init`), so a slow-console retry replays the first create server-side
457
+ // instead of double-allocating a second byte-identical spec.
458
+ const idempotencyKey = randomUUID();
459
+ const result = await consoleFetch('/api/specs', {
460
+ method: 'POST',
461
+ body,
462
+ headers: { 'idempotency-key': idempotencyKey },
463
+ // [[HS-2FQY]] double-mint guard: against a REMOTE console the blind
464
+ // timeout-retry can land on a different serverless instance, where the
465
+ // in-memory idempotency registry cannot replay the first create — the
466
+ // retry mints a SECOND id for the same spec (observed: HS-2FQY + HS-K70K,
467
+ // identical content, one call). Loopback keeps the retry (single process,
468
+ // registry replays). On a remote timeout, check before re-filing (below).
469
+ retryOnTimeout: false,
470
+ });
471
+ if (!result.ok) {
472
+ const payload = await enrichConsoleFailurePayloadAsync(result, {
473
+ error: consoleErrorCode(result, 'FILE_SPEC_FAILED'),
474
+ message: consoleErrorMessage(result, `POST /api/specs returned ${result.status}`),
475
+ status: result.status,
476
+ url: result.url,
477
+ console_response: result.body,
478
+ attempted: { type: args.type, name: args.name },
479
+ ...(result.errorKind === 'timeout'
480
+ ? {
481
+ hint:
482
+ 'The create may still have completed server-side after this client timed out. '
483
+ + 'Search for a spec with this exact name before re-filing — a blind re-file can mint a duplicate id ([[HS-2FQY]]).',
484
+ }
485
+ : {}),
486
+ });
487
+ return {
488
+ isError: true,
489
+ content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
490
+ };
491
+ }
492
+ // POST returns { spec: { id, ... } }; surface the canonical { spec_id, path }
493
+ // contract alongside the full created spec for callers that want more.
494
+ const spec = result.body?.spec ?? null;
495
+ const relPath = spec?.relPath ?? spec?.path ?? null;
496
+ // [[HS-H227-MCPC-TASK]]: a caller that passed no `expect_tier` at all used
497
+ // to fall through to `WRITE_LANDED_OFF_DISK` the moment this session's own
498
+ // reliance is cloud (fileSpecLandingVerdict's rule 2 — "undeclared cloud
499
+ // landing while a clone is in front of us") even though a cloud-reliant
500
+ // session filing into the cloud index is exactly the intended shape, not a
501
+ // surprise. Default to "cloud" from the SAME tier verdict every other read
502
+ // in this file already trusts (`resolveReadTier`) — only when the caller
503
+ // passed nothing; an explicit `expect_tier` (including an explicit "disk")
504
+ // is never overridden, so local-reliance behavior is untouched.
505
+ let expectTier = args.expect_tier === 'disk' || args.expect_tier === 'cloud' ? args.expect_tier : null;
506
+ if (!expectTier) {
507
+ const tier = await resolveReadTier();
508
+ if (tier === 'neon') expectTier = 'cloud';
509
+ }
510
+ // WHERE IT LANDED ([[HS-IOJW-5YDH-DIAG]]) — measured, not assumed. A write
511
+ // that reports on the request instead of the result is the defect; this
512
+ // stats the file and names the console it went to.
513
+ const { landed, error: landingError } = await measureFileSpecLanding({
514
+ consoleUrl: result.url,
515
+ consoleBody: result.body,
516
+ relPath,
517
+ vaultRoot,
518
+ expectTier,
519
+ });
520
+ const payload = {
521
+ spec_id: spec?.id ?? null,
522
+ path: relPath,
523
+ landed,
524
+ spec,
525
+ console_response: result.body,
526
+ };
527
+ if (landingError) {
528
+ return {
529
+ isError: true,
530
+ content: [{ type: 'text', text: JSON.stringify({ ...landingError, ...payload }, null, 2) }],
531
+ };
532
+ }
533
+ // Warn rung of the capture-stage gate: the filing went through, and the reason
534
+ // it would otherwise have been refused rides along on the response.
535
+ if (captureStageVerdict.decision === 'warn') {
536
+ payload.capture_stage_warning = buildCaptureStageRefusal(captureStageVerdict);
537
+ }
538
+ // Non-coercive post-create suggestion ([[HS-TASK-FWPC]] / [[HS-EPIC-MNBD]]):
539
+ // a freshly-filed milestone has no epics yet, so suggest filing the first one.
540
+ // The agent decides whether to act — the MCP layer NEVER auto-executes the
541
+ // follow-on (CLAUDE.md autonomy boundary). Skipping leaves the milestone in
542
+ // `epics: []`, where MCEP/RCNB/VWSE surface it.
543
+ if (args.type === 'milestone' && spec?.id) {
544
+ payload.suggested_next_action = 'file_epic';
545
+ payload.suggested_next_action_args = { milestone_id: spec.id };
546
+ }
547
+ return {
548
+ content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
549
+ };
550
+ }
551
+
552
+ export function handlePlanReview(vaultRoot, args = {}) {
553
+ // Map the snake_case MCP args onto the executor's camelCase contract.
554
+ const result = runPlanReview({
555
+ vaultRoot,
556
+ planPath: typeof args.plan_path === 'string' ? args.plan_path : undefined,
557
+ planContent: typeof args.plan_content === 'string' ? args.plan_content : undefined,
558
+ scope: args.scope === 'breakdown' ? 'breakdown' : 'plan',
559
+ epicId: typeof args.epic_id === 'string' ? args.epic_id : undefined,
560
+ epicName: typeof args.epic_name === 'string' ? args.epic_name : undefined,
561
+ proposedTasks: Array.isArray(args.proposed_tasks) ? args.proposed_tasks : undefined,
562
+ qualityPass: args.quality_pass === true,
563
+ agentQualityFindings: Array.isArray(args.quality_findings) ? args.quality_findings : undefined,
564
+ });
565
+ return wrapDesignResult(result);
566
+ }
567
+
568
+ export function handlePlanQc(vaultRoot, args = {}) {
569
+ const result = runPlanQc({
570
+ vaultRoot,
571
+ planPath: typeof args.plan_path === 'string' ? args.plan_path : undefined,
572
+ planContent: typeof args.plan_content === 'string' ? args.plan_content : undefined,
573
+ findingsSummary: typeof args.findings_summary === 'string' ? args.findings_summary : undefined,
574
+ axisBUserConfirmed: args.axis_b_user_confirmed === true,
575
+ axisBConfirmationNote:
576
+ typeof args.axis_b_confirmation_note === 'string' ? args.axis_b_confirmation_note : undefined,
577
+ });
578
+ return wrapDesignResult(result);
579
+ }
580
+
581
+ /**
582
+ * `heyspex_push_plan`. `deps.writeFocusSelection` is the injection seam (idiom
583
+ * from fleet-queue-tools.mjs) so a test can drive the failed-pin path without a
584
+ * console; cli.mjs calls with two args and gets the real Neon writer.
585
+ */
586
+ export async function handlePushPlan(vaultRoot, args, deps = {}) {
587
+ const { writeFocusSelection = writeFocusSelectionVendor } = deps;
588
+ // Cloud-aware entry ([[HS-BYU4-CWPX-TASK-promotion-writes-go-through-the-console]]
589
+ // sweep): resolves `resolveVaultWriteMode()` ONCE and either runs the
590
+ // untouched synchronous disk path (`local`) or files the epic through
591
+ // `POST /api/specs` (`cloud`). Awaiting is safe in both — the local branch
592
+ // still returns the same plain object it always did.
593
+ const result = await executePushPlanCloudAware(vaultRoot, args);
594
+ if (result.error) {
595
+ return {
596
+ isError: true,
597
+ content: [{ type: 'text', text: JSON.stringify(result) }],
598
+ };
599
+ }
600
+ // executePushPlan no longer writes focus itself (it is sync); do it here so the
601
+ // selection can reach Neon. Neon XOR file via `writeFocusSelectionVendor`.
602
+ //
603
+ // REPORT THE RESULT, NOT THE REQUEST ([[HS-IOJW-5YDH-DIAG]] sweep). The return
604
+ // boolean used to be discarded and `focus_written` came straight off the
605
+ // `on_deck` request flag, so a console that could not be reached — the one case
606
+ // `writeFocusSelectionVendor` is documented to fail in, with NO focus.yaml
607
+ // fallback — produced `success: true, focus_written: true` over a pin that was
608
+ // never stored, and sent the agent to /hs-next against nothing. The next
609
+ // session's entire run hangs off that pin.
610
+ if (result.on_deck && result.epic?.spec_path) {
611
+ const pinned = await writeFocusSelection(
612
+ vaultRoot,
613
+ result.epic.spec_path,
614
+ result.focus_reason,
615
+ );
616
+ result.focus_written = pinned === true;
617
+ if (!result.focus_written) {
618
+ // A failed pin is a LOUD FAILURE, never a field inside a success.
619
+ //
620
+ // It does NOT fail the whole push, and it must not: the epic markdown and
621
+ // the milestone mirror-update are already on disk and are correct. Erroring
622
+ // the push away would strand a filed epic behind a red tool call and invite
623
+ // a re-push that mints a SECOND epic for the same plan. So the filed facts
624
+ // are preserved verbatim in the payload (same shape as the `landingError`
625
+ // merge in handleFileSpec above) while `isError` + `success: false` stop any
626
+ // caller from reading this as a pinned epic. The only thing that failed is
627
+ // recoverable by re-running the pin — which is what `pin_recovery` says.
628
+ return {
629
+ isError: true,
630
+ content: [{
631
+ type: 'text',
632
+ text: JSON.stringify({
633
+ ...result,
634
+ success: false,
635
+ error: 'FOCUS_PIN_NOT_WRITTEN',
636
+ epic_filed: true,
637
+ message:
638
+ `The epic WAS filed (${result.epic.spec_path}) but the on-deck focus pin was NOT written. ` +
639
+ 'Neon is the only focus store (no .heyspex/focus.yaml fallback), so NOTHING is pinned — ' +
640
+ 'do NOT run /hs-next expecting this epic, it will resolve to whatever was pinned before. ' +
641
+ 'Do NOT re-run heyspex_push_plan: that would file a second epic for the same plan.',
642
+ pin_recovery: {
643
+ spec_path: result.epic.spec_path,
644
+ reason: result.focus_reason,
645
+ how: 'Check heyspex_dev_server_status, then re-pin: PUT /api/control/focus-selection ' +
646
+ '{ spec_path, kind: "epic", picked_by: "human", reason } — or re-run heyspex_push_plan ' +
647
+ 'with on_deck once the console is reachable ONLY if the epic above was not filed.',
648
+ },
649
+ }, null, 2),
650
+ }],
651
+ };
652
+ }
653
+ }
654
+ return {
655
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
656
+ };
657
+ }
658
+
659
+ export async function handleGroundPlan(vaultRoot, args) {
660
+ // Resolve the Neon focus selection (layer-2 `spec_path`) at the async boundary
661
+ // and thread it into the sync grounding engine, so /hs-ground's focused-epic
662
+ // conflict scan reads the same Neon selection the console does — not the dormant
663
+ // file. `injectNeonFocusPin` adds `neon_selection_spec_path` (best-effort; an
664
+ // unreachable console / free-tier 402 leaves args untouched → file fallback).
665
+ // [[HS-TASK-FSEL]] residual / [[HS-TASK-FSKP]] session of [[HS-EPIC-FRTR]].
666
+ const resolvedArgs = await injectNeonFocusPin(args);
667
+ // Retrieval is remote and bounded since
668
+ // [[HS-YIR1-8KK5-TASK-planning-grounding-retrieves-through]] — the grounding
669
+ // engine issues `GET /api/search` calls of its own, so it is awaited here.
670
+ const result = await executeGroundPlan(vaultRoot, resolvedArgs);
671
+ if (result.error) {
672
+ return {
673
+ isError: true,
674
+ content: [{ type: 'text', text: JSON.stringify(result) }],
675
+ };
676
+ }
677
+ // Bounded report mode: emit the capped markdown directly so /hs-ground pastes it
678
+ // verbatim — no JSON envelope for the agent to unpack or over-read.
679
+ if (result._report) {
680
+ const footer = `\n\n---\n_grounded ${result.grounded_at} · ~${result.token_estimate}/${result.max_tokens} tok${result.truncated ? ' (truncated to fit)' : ''} · conflicts: ${result.conflict_count}${result.has_blocking_conflicts ? ' (BLOCKING — resolve first)' : ''}_`;
681
+ return {
682
+ content: [{ type: 'text', text: `${result.report_markdown}${footer}` }],
683
+ };
684
+ }
685
+ return {
686
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
687
+ };
688
+ }