heyspex 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (352) hide show
  1. package/.claude-plugin/marketplace.json +14 -0
  2. package/README.md +21 -0
  3. package/bin/hs.mjs +1089 -104
  4. package/bin/spexcode.mjs +262 -0
  5. package/dev-factory/engine/worktree.mjs +740 -0
  6. package/dev-factory/lib/code-repo-root.mjs +67 -0
  7. package/dev-factory/lib/context-budget.mjs +84 -0
  8. package/dev-factory/lib/epic-detect.mjs +190 -0
  9. package/dev-factory/lib/fleet-queue-file.mjs +846 -0
  10. package/dev-factory/lib/fleet-queue.mjs +426 -0
  11. package/dev-factory/lib/fleet-state.mjs +14 -18
  12. package/dev-factory/lib/model-catalog.mjs +338 -0
  13. package/dev-factory/lib/seat-context.mjs +199 -0
  14. package/dev-factory/lib/spec-io.mjs +93 -0
  15. package/dev-factory/lib/sufficiency-score.mjs +8 -2
  16. package/dev-factory/lib/worktree-allocator.mjs +744 -0
  17. package/dev-factory/model-catalog.json +222 -0
  18. package/dist/cli/hs-check.mjs +1127 -103
  19. package/dist/cli/hs-claim.mjs +981 -63
  20. package/dist/cli/hs-connect.mjs +10059 -1729
  21. package/dist/cli/hs-defer.mjs +5507 -3072
  22. package/dist/cli/hs-dev.mjs +576 -0
  23. package/dist/cli/hs-env.mjs +416 -302
  24. package/dist/cli/hs-index-rebuild.mjs +7334 -0
  25. package/dist/cli/hs-index-reconcile.mjs +8138 -0
  26. package/dist/cli/hs-init.mjs +2840 -149
  27. package/dist/cli/hs-install.mjs +7214 -594
  28. package/dist/cli/hs-join.mjs +10640 -2421
  29. package/dist/cli/hs-vault-pull.mjs +8157 -6370
  30. package/dist/cli/hs-vault-push.mjs +6433 -0
  31. package/dist/cli/hs-vault.mjs +4029 -1555
  32. package/dist/cli/hs-workspace.mjs +2702 -538
  33. package/dist/cli-templates/customer-vault/specs/entity-model.md +594 -601
  34. package/dist/cli-templates/customer-vault/specs/protocols/canonical-frontmatter-templates.md +110 -177
  35. package/dist/cli-templates/customer-vault/specs/protocols/destructive-action-checklist.md +122 -0
  36. package/dist/cli-templates/customer-vault/specs/protocols/memory-protocol.md +13 -15
  37. package/dist/cli-templates/customer-vault/specs/protocols/session-close-protocol.md +91 -0
  38. package/dist/cli-templates/customer-vault/specs/protocols/spec-hierarchy.md +150 -168
  39. package/dist/cli-templates/customer-vault/specs/protocols/spec-summary-authoring.md +83 -0
  40. package/dist/cli-templates/harness-commands/claude/hs-auto.md +36 -0
  41. package/dist/cli-templates/harness-commands/claude/hs-backfill.md +28 -0
  42. package/dist/cli-templates/harness-commands/claude/hs-capture.md +62 -0
  43. package/dist/cli-templates/harness-commands/claude/hs-defer.md +29 -0
  44. package/dist/cli-templates/harness-commands/claude/hs-dev.md +20 -0
  45. package/dist/cli-templates/harness-commands/claude/hs-doctor.md +32 -0
  46. package/dist/cli-templates/harness-commands/claude/hs-ground.md +30 -0
  47. package/dist/cli-templates/harness-commands/claude/hs-handoff.md +33 -0
  48. package/dist/cli-templates/harness-commands/claude/hs-log.md +27 -0
  49. package/dist/cli-templates/harness-commands/claude/hs-next.md +29 -0
  50. package/dist/cli-templates/harness-commands/claude/hs-promote.md +70 -0
  51. package/dist/cli-templates/harness-commands/claude/hs-push.md +35 -0
  52. package/dist/cli-templates/harness-commands/claude/hs-qc.md +27 -0
  53. package/dist/cli-templates/harness-commands/claude/hs-res.md +63 -0
  54. package/dist/cli-templates/harness-commands/claude/hs-resolve.md +23 -0
  55. package/dist/cli-templates/harness-commands/claude/hs-review.md +23 -0
  56. package/dist/cli-templates/harness-commands/claude/hs-view.md +28 -0
  57. package/dist/cli-templates/harness-commands/claude/kill-ports.md +32 -0
  58. package/dist/cli-templates/harness-commands/claude/prototype.md +91 -0
  59. package/dist/cli-templates/harness-commands/cursor/hs-auto.md +40 -0
  60. package/dist/cli-templates/harness-commands/cursor/hs-backfill.md +31 -0
  61. package/dist/cli-templates/harness-commands/cursor/hs-capture.md +65 -0
  62. package/dist/cli-templates/harness-commands/cursor/hs-defer.md +32 -0
  63. package/dist/cli-templates/harness-commands/cursor/hs-dev.md +24 -0
  64. package/dist/cli-templates/harness-commands/cursor/hs-doctor.md +36 -0
  65. package/dist/cli-templates/harness-commands/cursor/hs-ground.md +33 -0
  66. package/dist/cli-templates/harness-commands/cursor/hs-handoff.md +37 -0
  67. package/dist/cli-templates/harness-commands/cursor/hs-log.md +31 -0
  68. package/dist/cli-templates/harness-commands/cursor/hs-next.md +32 -0
  69. package/dist/cli-templates/harness-commands/cursor/hs-promote.md +73 -0
  70. package/dist/cli-templates/harness-commands/cursor/hs-push.md +38 -0
  71. package/dist/cli-templates/harness-commands/cursor/hs-qc.md +30 -0
  72. package/dist/cli-templates/harness-commands/cursor/hs-res.md +67 -0
  73. package/dist/cli-templates/harness-commands/cursor/hs-resolve.md +26 -0
  74. package/dist/cli-templates/harness-commands/cursor/hs-review.md +26 -0
  75. package/dist/cli-templates/harness-commands/cursor/hs-view.md +32 -0
  76. package/dist/cli-templates/harness-commands/cursor/kill-ports.md +35 -0
  77. package/dist/cli-templates/harness-commands/cursor/prototype.md +95 -0
  78. package/docs/copilot-plugin-install.md +162 -0
  79. package/docs/driving-heyspex-from-copilot.md +165 -0
  80. package/docs/mcp-client-config.md +259 -6
  81. package/package.json +249 -124
  82. package/plugins/heyspex/.claude-plugin/plugin.json +12 -0
  83. package/plugins/heyspex/.cursor-plugin/plugin.json +15 -0
  84. package/plugins/heyspex/.mcp.json +12 -0
  85. package/plugins/heyspex/README.md +309 -0
  86. package/plugins/heyspex/bin/launch-mcp.mjs +95 -0
  87. package/plugins/heyspex/bin/run-hook.mjs +80 -0
  88. package/plugins/heyspex/hooks/cursor-hooks.json +308 -0
  89. package/plugins/heyspex/hooks/hooks.json +418 -0
  90. package/plugins/heyspex/mcp.json +13 -0
  91. package/scripts/check-mcp-freshness.mjs +17 -9
  92. package/scripts/claude-hooks/_env-vault-events.mjs +68 -0
  93. package/scripts/claude-hooks/_guardrail-events.mjs +96 -0
  94. package/scripts/claude-hooks/_guardrail-loader.mjs +494 -0
  95. package/scripts/claude-hooks/_intent-classifier-router.mjs +456 -0
  96. package/scripts/claude-hooks/_intent-classifier-semantic.mjs +356 -0
  97. package/scripts/claude-hooks/_run.mjs +413 -0
  98. package/scripts/claude-hooks/_transcript-text.mjs +60 -0
  99. package/scripts/claude-hooks/_vault-retrieval.mjs +322 -0
  100. package/scripts/claude-hooks/agent-spawn-delegation-gate.mjs +133 -0
  101. package/scripts/claude-hooks/capture-execution-gate.mjs +51 -0
  102. package/scripts/claude-hooks/capture-workflow-gate.mjs +11 -0
  103. package/scripts/claude-hooks/check-auto-memory-budget.mjs +396 -0
  104. package/scripts/claude-hooks/check-claude-md-budget.mjs +327 -0
  105. package/scripts/claude-hooks/check-mcp-freshness-hook.mjs +14 -0
  106. package/scripts/claude-hooks/claim-before-code-gate.mjs +11 -0
  107. package/scripts/claude-hooks/claim-liveness-shared.mjs +577 -0
  108. package/scripts/claude-hooks/close-terminal-on-ship.mjs +64 -0
  109. package/scripts/claude-hooks/coo-denylist-gate.mjs +11 -0
  110. package/scripts/claude-hooks/coo-guard-check.mjs +11 -0
  111. package/scripts/claude-hooks/deep-research-cost-gate.mjs +11 -0
  112. package/scripts/claude-hooks/dev-server-task-reminder.mjs +15 -0
  113. package/scripts/claude-hooks/diagnose-capture-reminder.mjs +11 -0
  114. package/scripts/claude-hooks/directive-obligation-gate.mjs +11 -0
  115. package/scripts/claude-hooks/emit-claim-status.mjs +41 -0
  116. package/scripts/claude-hooks/enforce-session-sequence.mjs +11 -0
  117. package/scripts/claude-hooks/enforce-vault-naming.mjs +11 -0
  118. package/scripts/claude-hooks/env-vault-guard.mjs +11 -0
  119. package/scripts/claude-hooks/epic-milestone-phrasing-gate.mjs +11 -0
  120. package/scripts/claude-hooks/git-identity-guard.mjs +11 -0
  121. package/scripts/claude-hooks/git-reflog-audit.mjs +15 -0
  122. package/scripts/claude-hooks/git-stash-gate.mjs +11 -0
  123. package/scripts/claude-hooks/guard-auto-memory-writes.mjs +137 -0
  124. package/scripts/claude-hooks/id-display-format-gate.mjs +11 -0
  125. package/scripts/claude-hooks/inject-communication-style.mjs +86 -0
  126. package/scripts/claude-hooks/inject-coo-seat.mjs +78 -0
  127. package/scripts/claude-hooks/inject-dev-server-awareness.mjs +11 -0
  128. package/scripts/claude-hooks/inject-doctrine.mjs +13 -0
  129. package/scripts/claude-hooks/inject-vault-context.mjs +14 -0
  130. package/scripts/claude-hooks/intent-classifier-trigger.mjs +279 -0
  131. package/scripts/claude-hooks/log-session-activity.mjs +341 -0
  132. package/scripts/claude-hooks/naughty-list-gate.mjs +11 -0
  133. package/scripts/claude-hooks/notify-status-flip.mjs +72 -0
  134. package/scripts/claude-hooks/plan-exit-ingest-gate.mjs +170 -0
  135. package/scripts/claude-hooks/plan-ingest-close-reminder.mjs +66 -0
  136. package/scripts/claude-hooks/plan-review-gate.mjs +11 -0
  137. package/scripts/claude-hooks/preflight-reminder.mjs +262 -0
  138. package/scripts/claude-hooks/protected-action-intent.mjs +221 -0
  139. package/scripts/claude-hooks/research-method-guard.mjs +398 -0
  140. package/scripts/claude-hooks/research-protocol-gate.mjs +11 -0
  141. package/scripts/claude-hooks/session-end.mjs +451 -0
  142. package/scripts/claude-hooks/settings-write-gate.mjs +11 -0
  143. package/scripts/claude-hooks/settlement-check-gate.mjs +39 -0
  144. package/scripts/claude-hooks/status-flip-detect.mjs +17 -0
  145. package/scripts/claude-hooks/terminal-close-shared.mjs +426 -0
  146. package/scripts/claude-hooks/terminal-close-watcher.mjs +122 -0
  147. package/scripts/claude-hooks/vault-mirror-catchup.mjs +11 -0
  148. package/scripts/claude-hooks/vault-write-tier-guard.mjs +11 -0
  149. package/scripts/claude-hooks/wip-checkpoint.mjs +302 -0
  150. package/scripts/cursor-hooks/manifest.mjs +281 -0
  151. package/scripts/dev-server-status.mjs +34 -13
  152. package/scripts/dev-server-stop.mjs +313 -0
  153. package/scripts/heyspex-mode.mjs +288 -0
  154. package/scripts/hs-auto-resolve.mjs +240 -0
  155. package/scripts/hs-auto-source.mjs +398 -0
  156. package/scripts/hs-auto.mjs +134 -0
  157. package/scripts/hs-capture-cli.mjs +326 -0
  158. package/scripts/hs-classifier.mjs +136 -0
  159. package/scripts/hs-companion.mjs +88 -0
  160. package/scripts/hs-gateway.mjs +115 -0
  161. package/scripts/hs-mode.mjs +125 -0
  162. package/scripts/hs-next.mjs +441 -0
  163. package/scripts/hs-settings.mjs +338 -0
  164. package/scripts/hs-view.mjs +496 -0
  165. package/scripts/hs-worktree.mjs +201 -0
  166. package/scripts/install-claude-code-hooks.mjs +272 -0
  167. package/scripts/install-cursor-hooks.mjs +471 -0
  168. package/scripts/lib/agent-env-markers.mjs +38 -0
  169. package/scripts/lib/console-url-resolver.mjs +199 -61
  170. package/scripts/lib/customer-kit.mjs +1203 -0
  171. package/scripts/lib/dev-mode.mjs +249 -32
  172. package/scripts/lib/directive-obligations.mjs +349 -0
  173. package/scripts/lib/heyspex-home.mjs +72 -0
  174. package/scripts/lib/hook-dispositions.mjs +210 -0
  175. package/scripts/lib/hook-vault-header.mjs +30 -0
  176. package/scripts/lib/hs-command.mjs +277 -0
  177. package/scripts/lib/hs-companion-settings.mjs +472 -0
  178. package/scripts/lib/hs-source-branch.mjs +196 -0
  179. package/scripts/lib/hs-source-commit.mjs +59 -5
  180. package/scripts/lib/hs-source-identity.mjs +526 -0
  181. package/scripts/lib/hs-source-log.mjs +13 -22
  182. package/scripts/lib/hs-source-preflight.mjs +18 -4
  183. package/scripts/lib/hs-source-push.mjs +386 -307
  184. package/scripts/lib/hs-worktree-args.mjs +115 -0
  185. package/scripts/lib/shared-path-helpers.mjs +134 -0
  186. package/scripts/lib/standalone-doctor.mjs +540 -3
  187. package/scripts/lib/subagent-git-lock.mjs +109 -0
  188. package/scripts/lib/template-pin.mjs +34 -0
  189. package/scripts/lib/window-placement.mjs +109 -0
  190. package/scripts/provenance-lib.mjs +91 -0
  191. package/scripts/stamp-provenance-on-edit.mjs +222 -0
  192. package/scripts/wip-ledger-health.mjs +653 -0
  193. package/spexharness/doctrine/baseline/SPEXCODE-DOCTRINE.baseline.json +29 -0
  194. package/spexharness/src/adapters/audit.mjs +197 -0
  195. package/spexharness/src/adapters/claude-code.mjs +449 -0
  196. package/spexharness/src/adapters/copilot.mjs +236 -0
  197. package/spexharness/src/adapters/detect-harness.mjs +34 -0
  198. package/spexharness/src/chat/tui/ansi.mjs +448 -0
  199. package/spexharness/src/chat/tui/palette.mjs +22 -0
  200. package/spexharness/src/chat/tui/select.mjs +208 -0
  201. package/spexharness/src/config-root.mjs +54 -0
  202. package/spexharness/src/context-budget.mjs +141 -0
  203. package/spexharness/src/dev-server-control.mjs +389 -0
  204. package/spexharness/src/doctrine/behaviour.mjs +371 -0
  205. package/spexharness/src/doctrine/corpus.mjs +733 -0
  206. package/spexharness/src/doctrine/inject.mjs +338 -0
  207. package/spexharness/src/doctrine/merge.mjs +184 -0
  208. package/spexharness/src/doctrine/sources.mjs +252 -0
  209. package/spexharness/src/doctrine/synthesize.mjs +401 -0
  210. package/spexharness/src/doctrine/vault-source.mjs +574 -0
  211. package/spexharness/src/doctrine/writer.mjs +176 -0
  212. package/spexharness/src/events.mjs +84 -0
  213. package/spexharness/src/gates/_shell-write-targets.mjs +77 -0
  214. package/spexharness/src/gates/capture-workflow-gate.mjs +202 -0
  215. package/spexharness/src/gates/claim-before-code-gate.decide.mjs +33 -0
  216. package/spexharness/src/gates/claim-before-code-gate.mjs +129 -0
  217. package/spexharness/src/gates/deep-research-cost-gate.mjs +275 -0
  218. package/spexharness/src/gates/diagnose-capture-reminder.mjs +112 -0
  219. package/spexharness/src/gates/directive-obligation-gate.decide.mjs +132 -0
  220. package/spexharness/src/gates/directive-obligation-gate.mjs +183 -0
  221. package/spexharness/src/gates/enforce-session-sequence.mjs +895 -0
  222. package/spexharness/src/gates/enforce-vault-naming.mjs +215 -0
  223. package/spexharness/src/gates/epic-milestone-phrasing-gate.mjs +233 -0
  224. package/spexharness/src/gates/git-identity-guard.decide.mjs +436 -0
  225. package/spexharness/src/gates/git-identity-guard.mjs +61 -0
  226. package/spexharness/src/gates/git-reflog-audit.mjs +151 -0
  227. package/spexharness/src/gates/git-stash-gate.decide.mjs +361 -0
  228. package/spexharness/src/gates/git-stash-gate.mjs +159 -0
  229. package/spexharness/src/gates/id-display-format-gate.mjs +190 -0
  230. package/spexharness/src/gates/inject-doctrine-gate.mjs +89 -0
  231. package/spexharness/src/gates/inject-vault-context.mjs +239 -0
  232. package/spexharness/src/gates/naughty-list-gate.mjs +213 -0
  233. package/spexharness/src/gates/plan-review-gate.mjs +233 -0
  234. package/spexharness/src/gates/preflight-reminder-gate.mjs +265 -0
  235. package/spexharness/src/gates/research-protocol-gate.mjs +175 -0
  236. package/spexharness/src/gates/settings-write-gate.decide.mjs +96 -0
  237. package/spexharness/src/gates/settings-write-gate.mjs +104 -0
  238. package/spexharness/src/gates/vault-mirror-catchup.mjs +124 -0
  239. package/spexharness/src/gates/vault-write-tier-guard.decide.mjs +125 -0
  240. package/spexharness/src/gates/vault-write-tier-guard.mjs +42 -0
  241. package/spexharness/src/git-reflog-audit.mjs +294 -0
  242. package/spexharness/src/intent-classifier.mjs +196 -0
  243. package/spexharness/src/intent-directive.mjs +626 -0
  244. package/spexharness/src/profile/learn.mjs +551 -0
  245. package/spexharness/src/profile/proposals.mjs +357 -0
  246. package/spexharness/src/profile/vault-inclusion.mjs +386 -0
  247. package/spexharness/src/redact.mjs +151 -0
  248. package/spexharness/src/rule-table.mjs +849 -0
  249. package/spexharness/src/seat-context.mjs +122 -0
  250. package/spexharness/src/session-bootstrap.mjs +512 -0
  251. package/spexharness/src/session-record.mjs +815 -0
  252. package/spexharness/src/token-inventory.mjs +207 -0
  253. package/vendor/heyspex-mcp/account-switch.mjs +96 -0
  254. package/vendor/heyspex-mcp/alignment-gate.mjs +315 -1
  255. package/vendor/heyspex-mcp/blocker-resolver.mjs +37 -2
  256. package/vendor/heyspex-mcp/breakdown-ladder.mjs +119 -4
  257. package/vendor/heyspex-mcp/breakdown-source.mjs +271 -0
  258. package/vendor/heyspex-mcp/breakdown-writer.mjs +23 -3
  259. package/vendor/heyspex-mcp/capture-buffer-patches.mjs +77 -8
  260. package/vendor/heyspex-mcp/capture-channel.mjs +55 -5
  261. package/vendor/heyspex-mcp/capture-console-client.mjs +117 -3
  262. package/vendor/heyspex-mcp/capture-focus.mjs +1061 -31
  263. package/vendor/heyspex-mcp/capture-outbox.mjs +15 -5
  264. package/vendor/heyspex-mcp/capture-sources.mjs +195 -0
  265. package/vendor/heyspex-mcp/capture-tools.mjs +1264 -0
  266. package/vendor/heyspex-mcp/claim-tools.mjs +1147 -0
  267. package/vendor/heyspex-mcp/cli-verbs.mjs +2074 -0
  268. package/vendor/heyspex-mcp/cli.mjs +207 -15524
  269. package/vendor/heyspex-mcp/close-branch-report.mjs +102 -0
  270. package/vendor/heyspex-mcp/cloud-tool-surface.mjs +128 -0
  271. package/vendor/heyspex-mcp/cloud-vault-writes.mjs +602 -0
  272. package/vendor/heyspex-mcp/config-seed.yaml +15 -0
  273. package/vendor/heyspex-mcp/config-settings.mjs +290 -7
  274. package/vendor/heyspex-mcp/console-fetch.mjs +822 -17
  275. package/vendor/heyspex-mcp/console-transport.mjs +172 -0
  276. package/vendor/heyspex-mcp/credentials.mjs +585 -0
  277. package/vendor/heyspex-mcp/defer-spec.mjs +44 -4
  278. package/vendor/heyspex-mcp/deferred-reclaim.mjs +29 -3
  279. package/vendor/heyspex-mcp/design-handlers.mjs +31 -0
  280. package/vendor/heyspex-mcp/design-tools.mjs +4 -1
  281. package/vendor/heyspex-mcp/dev-factory-bridge.mjs +4 -4
  282. package/vendor/heyspex-mcp/dev-mode-core.mjs +45 -0
  283. package/vendor/heyspex-mcp/dev-tools.mjs +131 -0
  284. package/vendor/heyspex-mcp/diagnose-handlers.mjs +54 -0
  285. package/vendor/heyspex-mcp/events-tools.mjs +207 -0
  286. package/vendor/heyspex-mcp/filing-tools.mjs +723 -0
  287. package/vendor/heyspex-mcp/fleet-handlers.mjs +37 -0
  288. package/vendor/heyspex-mcp/fleet-queue-tools.mjs +71 -15
  289. package/vendor/heyspex-mcp/focus-tools.mjs +190 -0
  290. package/vendor/heyspex-mcp/folder-trust-store.mjs +60 -0
  291. package/vendor/heyspex-mcp/http-transport.mjs +302 -0
  292. package/vendor/heyspex-mcp/list-tools.mjs +257 -0
  293. package/vendor/heyspex-mcp/login-handoff.mjs +15 -1
  294. package/vendor/heyspex-mcp/mcp-config.mjs +107 -0
  295. package/vendor/heyspex-mcp/memory-tools.mjs +523 -0
  296. package/vendor/heyspex-mcp/mvp-mode.mjs +53 -6
  297. package/vendor/heyspex-mcp/obligation-backstop.mjs +103 -0
  298. package/vendor/heyspex-mcp/occupied-keys.mjs +142 -0
  299. package/vendor/heyspex-mcp/on-deck-brief.mjs +3377 -0
  300. package/vendor/heyspex-mcp/on-deck-scheduler.mjs +1708 -0
  301. package/vendor/heyspex-mcp/package-root.mjs +32 -0
  302. package/vendor/heyspex-mcp/peer-presence.mjs +8 -1
  303. package/vendor/heyspex-mcp/plan-ground.mjs +621 -652
  304. package/vendor/heyspex-mcp/plan-push.mjs +908 -22
  305. package/vendor/heyspex-mcp/plan-review.mjs +124 -30
  306. package/vendor/heyspex-mcp/preflight-gate.mjs +0 -0
  307. package/vendor/heyspex-mcp/procedure-handlers.mjs +327 -0
  308. package/vendor/heyspex-mcp/procedure-tools.mjs +1181 -970
  309. package/vendor/heyspex-mcp/promote-capture.mjs +388 -69
  310. package/vendor/heyspex-mcp/promote-diagnose.mjs +74 -0
  311. package/vendor/heyspex-mcp/promote-research-to-diagnose.mjs +9 -2
  312. package/vendor/heyspex-mcp/promote-research.mjs +666 -53
  313. package/vendor/heyspex-mcp/promotion-helpers.mjs +251 -0
  314. package/vendor/heyspex-mcp/prompts.mjs +1460 -0
  315. package/vendor/heyspex-mcp/read-path.mjs +191 -15
  316. package/vendor/heyspex-mcp/receipt-shape.mjs +146 -0
  317. package/vendor/heyspex-mcp/recipe-handlers.mjs +117 -0
  318. package/vendor/heyspex-mcp/regen-tasks.mjs +210 -3
  319. package/vendor/heyspex-mcp/register-spec.mjs +266 -54
  320. package/vendor/heyspex-mcp/request-context.mjs +83 -0
  321. package/vendor/heyspex-mcp/research-focus.mjs +207 -4
  322. package/vendor/heyspex-mcp/research-handlers.mjs +625 -0
  323. package/vendor/heyspex-mcp/research-tools.mjs +4 -4
  324. package/vendor/heyspex-mcp/response-budget.mjs +348 -0
  325. package/vendor/heyspex-mcp/review-focus.mjs +25 -1
  326. package/vendor/heyspex-mcp/roadmap-default.mjs +70 -0
  327. package/vendor/heyspex-mcp/search-specs.mjs +280 -0
  328. package/vendor/heyspex-mcp/seat-block-ledger.mjs +113 -0
  329. package/vendor/heyspex-mcp/seat-identity.mjs +2 -6
  330. package/vendor/heyspex-mcp/session-owner.mjs +109 -0
  331. package/vendor/heyspex-mcp/session-tools.mjs +162 -0
  332. package/vendor/heyspex-mcp/settings-registry.mjs +232 -0
  333. package/vendor/heyspex-mcp/settings-tools.mjs +85 -12
  334. package/vendor/heyspex-mcp/spec-edit-tools.mjs +453 -0
  335. package/vendor/heyspex-mcp/spec-id-lib.mjs +93 -17
  336. package/vendor/heyspex-mcp/split-research.mjs +304 -56
  337. package/vendor/heyspex-mcp/stdio-rpc.mjs +22 -0
  338. package/vendor/heyspex-mcp/submit-for-review.mjs +150 -7
  339. package/vendor/heyspex-mcp/summary-contract.mjs +105 -0
  340. package/vendor/heyspex-mcp/summary-write-tools.mjs +228 -0
  341. package/vendor/heyspex-mcp/symptom-query.mjs +146 -0
  342. package/vendor/heyspex-mcp/test-helpers.mjs +123 -1
  343. package/vendor/heyspex-mcp/tool-catalog.mjs +2819 -0
  344. package/vendor/heyspex-mcp/tool-dispatch.mjs +657 -0
  345. package/vendor/heyspex-mcp/tool-helpers.mjs +325 -0
  346. package/vendor/heyspex-mcp/validate-linkage.mjs +414 -27
  347. package/vendor/heyspex-mcp/vault-io.mjs +720 -0
  348. package/vendor/heyspex-mcp/vault-less-endpoints.mjs +52 -0
  349. package/vendor/heyspex-mcp/vault-resolve.mjs +554 -68
  350. package/vendor/heyspex-mcp/verify-reproduction.mjs +270 -30
  351. package/vendor/heyspex-mcp/whoami.mjs +92 -0
  352. package/vendor/heyspex-mcp/write-outbox.mjs +512 -0
@@ -0,0 +1,453 @@
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 { deferredPayloadFromError, 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
+ // Outage: the edit was queued to the write outbox (truthful receipt, not an error).
172
+ const deferred = deferredPayloadFromError(err);
173
+ if (deferred) {
174
+ return {
175
+ content: [{ type: 'text', text: JSON.stringify({ ...deferred, file_path: args.file_path, spec_id: specId, write_mode: 'cloud', on_disk: false }, null, 2) }],
176
+ };
177
+ }
178
+ return {
179
+ isError: true,
180
+ content: [{ type: 'text', text: JSON.stringify({
181
+ error: 'CONSOLE_UNREACHABLE_CLOUD_MODE',
182
+ message: String(err?.message ?? err),
183
+ file_path: args.file_path,
184
+ spec_id: specId,
185
+ write_mode: 'cloud',
186
+ on_disk: false,
187
+ ...(err?.notFound ? { not_found: true } : {}),
188
+ }, null, 2) }],
189
+ };
190
+ }
191
+ } else {
192
+ try {
193
+ writeFileSync(args.file_path, args.content, 'utf8');
194
+ } catch (err) {
195
+ // Release the claim on write failure so the file isn't locked forever
196
+ await releaseClaim();
197
+ return {
198
+ isError: true,
199
+ content: [{ type: 'text', text: JSON.stringify({
200
+ error: 'WRITE_FAILED',
201
+ message: `Failed to write file: ${err?.message ?? err}`,
202
+ file_path: args.file_path,
203
+ }) }],
204
+ };
205
+ }
206
+ }
207
+
208
+ // Step 3: Release claim
209
+ const releaseResult = await releaseClaim();
210
+
211
+ return controlOk({
212
+ written: true,
213
+ file_path: args.file_path,
214
+ released: releaseResult.ok ? releaseResult.body?.released ?? true : false,
215
+ agent: identity.agent,
216
+ session_id: identity.sessionId,
217
+ // Cloud-only additions — the local payload stays byte-identical to the
218
+ // shape every existing caller already parses.
219
+ ...(cloudWrite
220
+ ? {
221
+ write_mode: 'cloud',
222
+ on_disk: false,
223
+ spec_id: cloudWrite.specId,
224
+ rel_path: cloudWrite.relPath,
225
+ console_url: cloudWrite.consoleUrl,
226
+ }
227
+ : {}),
228
+ });
229
+ }
230
+
231
+ /**
232
+ * Known-tags paging contract, mirrored from the console route
233
+ * (`spex-console/src/lib/scheduler/known-tags-query.ts`): the cursor is
234
+ * base64url JSON `{ c: count, t: tag }` over the `(count DESC, tag ASC)` sort
235
+ * key of the aggregate query. Encoded here only to RE-POINT the cursor when the
236
+ * byte budget trims a page — the console mints every cursor it hands out.
237
+ */
238
+ export const KNOWN_TAGS_TOOL = Object.freeze({
239
+ default_limit: 100,
240
+ max_limit: 500,
241
+ // Measured on `content[0].text`; the dispatcher re-measures the whole envelope (escaped), so leave headroom.
242
+ byte_budget: Math.floor(DEFAULT_TOOL_BUDGET_BYTES * 0.8),
243
+ });
244
+
245
+ export function encodeKnownTagsCursor({ count, tag }) {
246
+ return Buffer.from(JSON.stringify({ c: Number(count) || 0, t: String(tag ?? '') }), 'utf8').toString('base64url');
247
+ }
248
+
249
+ /**
250
+ * `heyspex_list_known_tags` — the closed tag vocabulary for the focus-areas NLU
251
+ * path ([[HS-TASK-I0EK]] / [[HS-EPIC-7WJN]]). GET /api/scheduler/known-tags →
252
+ * ONE page `{ tags: [{ name, count }], next_cursor, page }` from an aggregate
253
+ * query paged with a keyset cursor over (count, tag)
254
+ * ([[HS-4H8T-8KK5-TASK-every-mcp-read-tool-declares-a-byte]], resolving
255
+ * [[HS-7QM6-1Q1C-DIAG-heyspex-list-known-tags-response-busts-t]] — the reply
256
+ * that once hit 138 KB). `limit` and `after` pass straight through to the
257
+ * console; the page is then fitted to the tool's byte budget, re-pointing
258
+ * `next_cursor` at the last tag kept so nothing is lost. Agents call this
259
+ * before proposing a focus pivot so they only suggest tags that exist.
260
+ * Requires a reachable console.
261
+ *
262
+ * Augments the console response with an additive `drift` array ([[HS-TASK-H0SI]]
263
+ * / [[HS-EPIC-OOPW]]): near-duplicate tag pairs within the page flagged by
264
+ * lexical string similarity (NOT embeddings) so the agent can propose a merge to
265
+ * keep the closed vocabulary clean. No tag is mutated here — `drift` is a
266
+ * suggestion signal only.
267
+ */
268
+ export async function handleListKnownTags(args = {}, { consoleFetch: fetchDep = consoleFetch, byteBudget = KNOWN_TAGS_TOOL.byte_budget } = {}) {
269
+ const limit = resolvePageLimit(args.limit, KNOWN_TAGS_TOOL);
270
+ const params = new URLSearchParams({ limit: String(limit) });
271
+ if (typeof args.after === 'string' && args.after) params.set('after', args.after);
272
+ const result = await fetchDep(`/api/scheduler/known-tags?${params.toString()}`);
273
+ if (!result.ok) {
274
+ if (result.status === 400 && result.body?.error === 'invalid_cursor') {
275
+ return {
276
+ isError: true,
277
+ content: [{ type: 'text', text: JSON.stringify({ error: 'INVALID_CURSOR', message: new InvalidListCursorError().message, tool: 'heyspex_list_known_tags' }, null, 2) }],
278
+ };
279
+ }
280
+ return controlError(result, 'KNOWN_TAGS_FAILED');
281
+ }
282
+ const body = result.body && typeof result.body === 'object' ? result.body : {};
283
+ const tags = Array.isArray(body.tags) ? body.tags : [];
284
+ const base = {
285
+ ...body,
286
+ tags,
287
+ next_cursor: typeof body.next_cursor === 'string' && body.next_cursor ? body.next_cursor : null,
288
+ page: { limit, returned: tags.length, byte_budget: byteBudget, budget_trimmed: false, ...(body.page ?? {}) },
289
+ };
290
+ const fitOpts = {
291
+ rows_key: 'tags',
292
+ // A page of tags that would fill the whole budget leaves no room for the
293
+ // drift signal the tool exists to surface: reserve a tenth for the
294
+ // strongest pairs. A page that needs no trim gets the whole budget.
295
+ byte_budget: byteBudget - Math.floor(byteBudget * 0.1),
296
+ cursorFor: (row) => encodeKnownTagsCursor({ count: row.count, tag: row.name }),
297
+ };
298
+ // 1. Fit the TAGS to the budget first (with an empty drift envelope in
299
+ // place so its bytes are accounted for) — the cursor must land on a tag
300
+ // the caller received, and drift over an un-returned tag is noise.
301
+ let current = fitPageToByteBudget({ ...base, drift: [] }, fitOpts).result;
302
+ // 2. Drift over the returned page only. It is pairwise (quadratic in page
303
+ // size) and sorted by similarity desc, so when it would bust the budget
304
+ // keep the strongest pairs that fit. Only when not even zero pairs fit
305
+ // (the truncation markers cost a few bytes) does one more tag go, and
306
+ // drift is recomputed over what remains.
307
+ for (;;) {
308
+ const fitted = fitKnownTagsDrift(current, detectTagDrift(current.tags), byteBudget);
309
+ if (fitted) return { content: [{ type: 'text', text: fitted.text }] };
310
+ if (current.tags.length <= 1) {
311
+ // A lone tag that cannot fit even with no drift: return it rather than
312
+ // an empty page with no way forward (the list-tools rule).
313
+ return { content: [{ type: 'text', text: JSON.stringify({ ...current, drift: [] }, null, 2) }] };
314
+ }
315
+ const kept = current.tags.slice(0, -1);
316
+ current = {
317
+ ...current,
318
+ tags: kept,
319
+ next_cursor: fitOpts.cursorFor(kept[kept.length - 1]),
320
+ page: { ...current.page, returned: kept.length, budget_trimmed: true },
321
+ };
322
+ }
323
+ }
324
+
325
+ /**
326
+ * Largest similarity-desc prefix of `drift` whose page fits `byteBudget`:
327
+ * the whole list untouched when it fits, else `page.drift_truncated: true` +
328
+ * `drift_dropped`. `null` when not even an empty drift list fits.
329
+ */
330
+ function fitKnownTagsDrift(page, drift, byteBudget) {
331
+ const serialize = (payload) => JSON.stringify(payload, null, 2);
332
+ const fits = (text) => Buffer.byteLength(text, 'utf8') <= byteBudget;
333
+ const full = { ...page, drift };
334
+ const fullText = serialize(full);
335
+ if (fits(fullText)) return { payload: full, text: fullText };
336
+ let lo = 0;
337
+ let hi = drift.length - 1;
338
+ let best = null;
339
+ while (lo <= hi) {
340
+ const mid = (lo + hi) >>> 1;
341
+ const candidate = { ...page, drift: drift.slice(0, mid), page: { ...page.page, drift_truncated: true, drift_dropped: drift.length - mid } };
342
+ const text = serialize(candidate);
343
+ if (fits(text)) {
344
+ best = { payload: candidate, text };
345
+ lo = mid + 1;
346
+ } else {
347
+ hi = mid - 1;
348
+ }
349
+ }
350
+ return best;
351
+ }
352
+
353
+ /**
354
+ * AI metadata proposal — [[HS-TASK-TAOD]] Phase 4 of [[HS-EPIC-Y9OJ]].
355
+ *
356
+ * Thin MCP wrapper around console `POST /api/ai/propose-metadata` (Haiku 4.5).
357
+ * One-shot at create time — agents must NOT call this on edit, status change,
358
+ * or update flows; that's the closed-loop bias the epic explicitly rejects.
359
+ */
360
+ export async function handleProposeMetadata(args = {}) {
361
+ const name = typeof args?.name === 'string' ? args.name : '';
362
+ const body = typeof args?.body === 'string' ? args.body : '';
363
+ if (!name.trim() && !body.trim()) {
364
+ return {
365
+ isError: true,
366
+ content: [
367
+ {
368
+ type: 'text',
369
+ text: JSON.stringify(
370
+ { ok: false, reason: 'invalid_request', message: 'name or body is required' },
371
+ null,
372
+ 2,
373
+ ),
374
+ },
375
+ ],
376
+ };
377
+ }
378
+
379
+ const payload = { action: 'propose', name, body };
380
+ if (typeof args?.specType === 'string') payload.specType = args.specType;
381
+ if (typeof args?.parentExcerpt === 'string') payload.parentExcerpt = args.parentExcerpt;
382
+
383
+ const result = await consoleFetch('/api/ai/propose-metadata', {
384
+ method: 'POST',
385
+ body: payload,
386
+ });
387
+
388
+ if (!result.ok) {
389
+ const reason =
390
+ result.errorKind === 'timeout'
391
+ ? 'console_timeout'
392
+ : result.status === 0
393
+ ? 'console_unreachable'
394
+ : 'console_error';
395
+ const errorPayload = {
396
+ ok: false,
397
+ reason,
398
+ message: consoleErrorMessage(
399
+ result,
400
+ `Console /api/ai/propose-metadata returned ${result.status}`,
401
+ ),
402
+ console_response: result.body,
403
+ };
404
+ return {
405
+ isError: true,
406
+ content: [{ type: 'text', text: JSON.stringify(errorPayload, null, 2) }],
407
+ };
408
+ }
409
+
410
+ return {
411
+ content: [{ type: 'text', text: JSON.stringify(result.body, null, 2) }],
412
+ };
413
+ }
414
+
415
+ // Ground-truth dev-server status. The heavy logic lives in the repo's
416
+ // scripts/dev-server-status.mjs (the shared core for the hs CLI verb and the
417
+ // PreToolUse reminder hook too); we dynamic-import it from the vault root so
418
+ // the three faces stay in lockstep. Must never throw out of the tool call.
419
+ export async function handleCanvasInbox(vaultRoot, args = {}) {
420
+ // Session id: explicit arg wins; otherwise the ambient Claude Code session.
421
+ const explicit = typeof args.session_id === 'string' ? args.session_id.trim() : '';
422
+ const sessionId =
423
+ explicit ||
424
+ (process.env.CLAUDE_CODE_SESSION_ID || process.env.CLAUDE_SESSION_ID || '').trim();
425
+
426
+ const result = await executeCanvasInbox({ vaultRoot, sessionId });
427
+ return {
428
+ ...(result.ok ? {} : { isError: true }),
429
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
430
+ };
431
+ }
432
+
433
+ /**
434
+ * `heyspex_ship_epic` / `backfill_epic`. Cloud-aware entry
435
+ * ([[HS-BYU4-CWPX-TASK-promotion-writes-go-through-the-console]] sweep):
436
+ * `executeShipEpicCloudAware` resolves `resolveVaultWriteMode()` ONCE and either
437
+ * runs the untouched synchronous disk path (`local`) or creates the epic, its
438
+ * tasks and the review doc through `POST /api/specs` in create-first/link-second
439
+ * order (`cloud`). The handler became `async` for that — awaiting is safe in
440
+ * both modes, since the local branch still returns the same plain object.
441
+ */
442
+ export async function handleShipEpic(vaultRoot, args) {
443
+ const result = await executeShipEpicCloudAware(vaultRoot, args);
444
+ if (result.error) {
445
+ return {
446
+ isError: true,
447
+ content: [{ type: 'text', text: JSON.stringify(result) }],
448
+ };
449
+ }
450
+ return {
451
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
452
+ };
453
+ }
@@ -114,6 +114,26 @@ export function findReviewDocForSpec(vaultRoot, parentSpecId) {
114
114
  */
115
115
  export const FILE_KEY_RE = /^(?=[0-9]*[A-Z])[A-Z0-9]{4,}$/;
116
116
 
117
+ /**
118
+ * `hs next CJV2` → `HS-CJV2` (owner ruling 2026-09-01): typing the bare file
119
+ * key is the natural gesture — the `HS-` prefix carries no information the
120
+ * CLI needs, so requiring it is friction. Accepts the long display form too
121
+ * (`CJV2-90JL`, `CJV2-90JL-EPIC`) by normalizing on the FIRST segment.
122
+ *
123
+ * Returns the `HS-`-prefixed uppercase id, or `null` when the token is not a
124
+ * spec-id shape at all (so the caller can keep its own "unexpected argument"
125
+ * error). A token that already starts with `HS-` comes back uppercased and
126
+ * otherwise untouched. Mirror: spex-console/src/lib/vault-id.ts
127
+ * `normalizeBareSpecIdToken`.
128
+ */
129
+ export function normalizeBareSpecIdToken(input) {
130
+ const token = String(input ?? '').trim().toUpperCase();
131
+ if (!token) return null;
132
+ if (token.startsWith('HS-')) return token;
133
+ const head = token.split('-')[0];
134
+ return FILE_KEY_RE.test(head) ? `HS-${token}` : null;
135
+ }
136
+
117
137
  /**
118
138
  * File key carried by a spec id/basename in EITHER ordering, or null for
119
139
  * legacy no-file-key names. The file key is the first title-tail token when it
@@ -202,14 +222,36 @@ export function allocateFileKey(occupied, options = {}) {
202
222
  }
203
223
 
204
224
  /**
205
- * One-call mint for filing modules: walk the vault for occupied keys, then
206
- * allocate. `randomUnit` is injectable for tests. Occupancy is the UNION of
207
- * file keys AND family keys: era-3 ids are bare file keys, so a file key that
208
- * equals any family key makes a bare `HS-XXXX` citation ambiguous between one
209
- * file and a whole family ([[HS-SJOT-AEP1-DIAG]]).
225
+ * Mint a FILE KEY against an ALREADY-RESOLVED occupancy set. `randomUnit` is
226
+ * injectable for tests. `occupied` must be the UNION of file keys AND family
227
+ * keys: era-3 ids are bare file keys, so a file key that equals any family key
228
+ * makes a bare `HS-XXXX` citation ambiguous between one file and a whole
229
+ * family ([[HS-SJOT-AEP1-DIAG]]).
230
+ *
231
+ * TAKES THE OCCUPANCY, NOT A VAULT ROOT ([[HS-FJWL]]). This used to be
232
+ * `mintFileKey(vaultRoot)` and resolved occupancy itself, by walking
233
+ * `specs/**` — correct on the disk tier, blind on the cloud tier, where a
234
+ * filed spec has a Neon row and no local `.md` and the stale local clone still
235
+ * answers with a large, plausible-looking set. File keys are the unique
236
+ * per-file identity under Naming V3, so a blind scan there re-issues a live
237
+ * id. Callers now resolve occupancy for THEIR tier —
238
+ * `collectOccupiedKeysEitherKindViaTier` (occupied-keys.mjs) on a tier-aware
239
+ * path, `collectOccupiedKeysEitherKind` below on a path that is disk-only by
240
+ * construction — and hand the set in.
241
+ *
242
+ * The `Set` check is the migration's fail-loud: a call site left passing a
243
+ * vaultRoot string would otherwise mint against `collectOccupiedKeysEitherKind`
244
+ * of nothing, i.e. against an empty set, i.e. duplicates. Refuse instead.
210
245
  */
211
- export function mintFileKey(vaultRoot, options = {}) {
212
- return allocateFileKey(collectOccupiedKeysEitherKind(vaultRoot), options);
246
+ export function mintFileKey(occupied, options = {}) {
247
+ if (!(occupied instanceof Set)) {
248
+ throw new TypeError(
249
+ 'mintFileKey(occupied, options) takes a resolved occupancy Set, not a vaultRoot ' +
250
+ '([[HS-FJWL]]). Use collectOccupiedKeysEitherKindViaTier(vaultRoot, { tier, consoleUrl }) ' +
251
+ 'on a tier-aware path, or collectOccupiedKeysEitherKind(vaultRoot) on a disk-only one.',
252
+ );
253
+ }
254
+ return allocateFileKey(occupied, options);
213
255
  }
214
256
 
215
257
  /**
@@ -267,6 +309,20 @@ export function collectOccupiedKeysEitherKind(vaultRoot) {
267
309
  * machinery as {@link allocateFileKey} (4 chars, at least one letter),
268
310
  * checked against {@link collectOccupiedFamilyKeysFromVault} so a fresh mint
269
311
  * doesn't accidentally collide with an existing unrelated family.
312
+ *
313
+ * DELIBERATELY STILL DISK-ONLY — do not "fix" this into a network call
314
+ * ([[HS-FJWL]]). {@link mintFileKey} was made tier-aware because a file key is
315
+ * the unique per-file identity: re-issuing one is a duplicate id. A FAMILY key
316
+ * is the opposite — any number of specs may legally share one (V3 shared-
317
+ * surname model, see {@link collectOccupiedFamilyKeysFromVault}), so this
318
+ * occupancy scan is a "pick something fresh" heuristic, NOT a uniqueness gate.
319
+ * Missing a cloud-only family key costs at most a family name reused by an
320
+ * unrelated spec, which the model already permits; paying a paged console walk
321
+ * per family mint would buy nothing. The cross-namespace half
322
+ * ([[HS-SJOT-AEP1-DIAG]] — a family key must not equal a FILE key) is carried
323
+ * by the union below on the disk tier and, in cloud reliance, by the console's
324
+ * own `createSpec` mint, which resolves occupancy through `loadAll()` and is
325
+ * tier-correct by construction.
270
326
  */
271
327
  export function mintFamilyKey(vaultRoot, options = {}) {
272
328
  return allocateFileKey(collectOccupiedKeysEitherKind(vaultRoot), options);
@@ -553,21 +609,41 @@ export function isBareFamilyToken(input) {
553
609
  }
554
610
 
555
611
  /**
556
- * Guard for mutating MCP/CLI ops: reject a `spec_id` that is a bare family token
557
- * (ambiguous — resolves to a family, not one spec). Returns an error object
558
- * `{ error, message }` to surface to the caller, or `null` when the id is
559
- * unambiguous enough to act on. Read-only resolvers do NOT call this — they fan
560
- * a family token out to a set or just miss.
612
+ * Guard for mutating MCP/CLI ops: reject a `spec_id` that is a bare `HS-XXXX`
613
+ * token the caller has not proven names exactly one spec. Returns an error
614
+ * object `{ error, message }` to surface to the caller, or `null` when the id
615
+ * is unambiguous enough to act on. Read-only resolvers do NOT call this — they
616
+ * fan a family token out to a set or just miss.
617
+ *
618
+ * WORDING ([[HS-05ZW-XHR4-DIAG-the-capture-dropped-it-warning-blamed]] follow-on):
619
+ * {@link isBareFamilyToken} is SHAPE-ONLY — `HS-05ZW` is exactly as likely to be
620
+ * an era-3 FILE key (the stored `id:` of every newly minted spec) as a family
621
+ * key, and nothing here can tell them apart without a lookup. So the default
622
+ * message states only what was actually established: the token could not be
623
+ * resolved to one spec from this machine, and it may be either. A caller that
624
+ * HAS verified the family reading (e.g. `classifyBareSpecId` found the token
625
+ * among the vault's occupied family keys) passes `confirmedFamily: true` to get
626
+ * the stronger, now-earned wording. The actionable hint is identical either way.
627
+ *
628
+ * @param {string|null|undefined} input
629
+ * @param {{ confirmedFamily?: boolean, detail?: string|null }} [opts]
561
630
  */
562
- export function requireResolvableSpecId(input) {
631
+ export function requireResolvableSpecId(input, opts = {}) {
563
632
  if (isBareFamilyToken(input)) {
564
633
  const base = normalizeIdInput(input);
634
+ const hint =
635
+ `Pass a full basename that includes the type label ` +
636
+ `(e.g. ${base}-TASK-<slug> or HS-TASK-${base.replace(/^HS-/, '')}-<slug>; ` +
637
+ `the era-3 form is ${base}-<FAMILY>-<TYPE>-<slug>).`;
638
+ const claim = opts.confirmedFamily
639
+ ? `"${base}" is a bare family key, which names a whole shared-suffix family ` +
640
+ `rather than one spec.`
641
+ : `"${base}" is a bare token that could not be resolved to exactly one spec ` +
642
+ `from this machine — it may be the file key of a spec with no local copy, ` +
643
+ `or a family key naming a whole shared-suffix family.`;
565
644
  return {
566
645
  error: 'AMBIGUOUS_SPEC_ID',
567
- message:
568
- `"${base}" is a bare family key, which names a whole shared-suffix family ` +
569
- `rather than one spec. Pass a full basename that includes the type label ` +
570
- `(e.g. ${base}-TASK-<slug> or HS-TASK-${base.replace(/^HS-/, '')}-<slug>).`,
646
+ message: opts.detail ? `${claim} ${opts.detail} ${hint}` : `${claim} ${hint}`,
571
647
  };
572
648
  }
573
649
  return null;