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
@@ -669,7 +669,20 @@ function buildTerminalLeafClaimTarget(node, alignmentState, breadcrumb, level =
669
669
  * `{ kind: 'has_claimable', ref, child }` when a non-terminal child exists so
670
670
  * the caller can recurse without re-walking.
671
671
  */
672
- export function classifyEpicChildRollup(epic, loadRef, schedulerOrderIds = [], reviewLevel = 'task') {
672
+ /**
673
+ * `excludeIds` (lower-cased spec ids, optional): tasks another agent is
674
+ * actively holding ([[HS-3C9M-A4TX-CAP]]). A held child is passed over for the
675
+ * next launchable sibling so a parallel agent gets fresh work; when every
676
+ * launchable child is held the rollup says so (`all_held`) instead of
677
+ * pretending the epic is review-blocked or closed.
678
+ */
679
+ export function classifyEpicChildRollup(
680
+ epic,
681
+ loadRef,
682
+ schedulerOrderIds = [],
683
+ reviewLevel = 'task',
684
+ excludeIds = null,
685
+ ) {
673
686
  const fm = epic?.frontmatter || {};
674
687
  const childRefs = rankRefsBySchedulerOrder(
675
688
  Array.isArray(fm.children) ? fm.children.map(wikilinkTarget).filter(Boolean) : [],
@@ -679,6 +692,7 @@ export function classifyEpicChildRollup(epic, loadRef, schedulerOrderIds = [], r
679
692
 
680
693
  let loadedCount = 0;
681
694
  let inReviewCount = 0;
695
+ let heldCount = 0;
682
696
  const statuses = [];
683
697
  for (const ref of childRefs) {
684
698
  if (typeof loadRef !== 'function') break;
@@ -688,11 +702,17 @@ export function classifyEpicChildRollup(epic, loadRef, schedulerOrderIds = [], r
688
702
  const status = specStatus(child);
689
703
  statuses.push(status);
690
704
  if (!CLAIM_TERMINAL_STATUSES.has(status)) {
705
+ const childId = String(specId(child) || ref).toLowerCase();
706
+ if (excludeIds && excludeIds.has(childId)) {
707
+ heldCount += 1;
708
+ continue;
709
+ }
691
710
  return { kind: 'has_claimable', ref, child };
692
711
  }
693
712
  if (REVIEW_AWAITING_STATUSES.has(status)) inReviewCount += 1;
694
713
  }
695
714
 
715
+ if (heldCount > 0) return { kind: 'all_held', held: heldCount };
696
716
  if (loadedCount === 0) return { kind: 'no_children' };
697
717
  const rollupKind = classifyParentRollup(statuses, 'task', reviewLevel);
698
718
  if (rollupKind === 'reviewer_blocked') {
@@ -752,6 +772,7 @@ export function computeClaimTarget(
752
772
  loadRef,
753
773
  schedulerOrderIds = [],
754
774
  rollupOpts,
775
+ excludeIds = null,
755
776
  ) {
756
777
  if (!spec) return null;
757
778
  void vaultRoot;
@@ -768,6 +789,7 @@ export function computeClaimTarget(
768
789
  new Set(),
769
790
  schedulerOrderIds,
770
791
  reviewLevel,
792
+ excludeIds,
771
793
  );
772
794
  }
773
795
 
@@ -779,6 +801,7 @@ function resolveClaimTargetRecursive(
779
801
  visited,
780
802
  schedulerOrderIds = [],
781
803
  reviewLevel = 'task',
804
+ excludeIds = null,
782
805
  ) {
783
806
  if (!spec) return null;
784
807
  const id = specId(spec);
@@ -800,7 +823,17 @@ function resolveClaimTargetRecursive(
800
823
  }
801
824
 
802
825
  if (type === 'epic') {
803
- const rollup = classifyEpicChildRollup(spec, loadRef, schedulerOrderIds, reviewLevel);
826
+ const rollup = classifyEpicChildRollup(
827
+ spec,
828
+ loadRef,
829
+ schedulerOrderIds,
830
+ reviewLevel,
831
+ excludeIds,
832
+ );
833
+ // Every launchable child is held by another agent: no target here, so a
834
+ // milestone walk moves on to its next epic (and a root epic yields null —
835
+ // the caller falls back to the held task with a "held by" note).
836
+ if (rollup.kind === 'all_held') return null;
804
837
  if (rollup.kind === 'has_claimable') {
805
838
  const child = rollup.child;
806
839
  const childBreadcrumb = [...breadcrumb, specId(child)].filter(Boolean);
@@ -812,6 +845,7 @@ function resolveClaimTargetRecursive(
812
845
  visited,
813
846
  schedulerOrderIds,
814
847
  reviewLevel,
848
+ excludeIds,
815
849
  );
816
850
  if (childTarget) return childTarget;
817
851
  }
@@ -844,6 +878,7 @@ function resolveClaimTargetRecursive(
844
878
  visited,
845
879
  schedulerOrderIds,
846
880
  reviewLevel,
881
+ excludeIds,
847
882
  );
848
883
  if (childTarget) return childTarget;
849
884
  }
@@ -23,7 +23,10 @@
23
23
  import { existsSync, readdirSync, readFileSync } from 'node:fs';
24
24
  import { join } from 'node:path';
25
25
 
26
+ import { resolveConsoleDeps } from './breakdown-source.mjs';
27
+ import { listSpecsViaConsole } from './cloud-vault-writes.mjs';
26
28
  import { getFrontmatterBlock, getScalarField } from './promotion-helpers.mjs';
29
+ import { readViaSelector } from './read-path.mjs';
27
30
 
28
31
  /**
29
32
  * Statuses a candidate must NOT be in to count as covering the area. Mirrors the
@@ -136,12 +139,28 @@ function scanDir(vaultRoot, dirParts, type) {
136
139
  * attach a bug fix to the wrong epic.
137
140
  */
138
141
  export function scanExistingCoverage(vaultRoot, source, opts = {}) {
142
+ const prepared = prepareCoverageScan(source, opts);
143
+ if (!prepared) return { milestones: [], epics: [], source_terms: [] };
144
+
145
+ return {
146
+ source_terms: prepared.source_terms,
147
+ milestones: prepared.rank(scanDir(vaultRoot, ['specs', 'milestones'], 'milestone')),
148
+ epics: prepared.rank(scanDir(vaultRoot, ['specs', 'epics'], 'epic')),
149
+ };
150
+ }
151
+
152
+ /**
153
+ * The source-side half of {@link scanExistingCoverage} — its term set and its
154
+ * ranker — lifted out so BOTH read tiers rank identically. Returns `null` for
155
+ * the "source carries no subject terms" early-out. Pure; no I/O.
156
+ */
157
+ function prepareCoverageScan(source, opts = {}) {
139
158
  const minOverlap = Number.isFinite(opts.minOverlap) ? opts.minOverlap : 2;
140
159
  const limit = Number.isFinite(opts.limit) ? opts.limit : 5;
141
160
 
142
161
  const block = frontmatterOf(source?.raw ?? '');
143
162
  const sourceTerms = new Set(terms(subjectText(block, source?.basename)));
144
- if (sourceTerms.size === 0) return { milestones: [], epics: [], source_terms: [] };
163
+ if (sourceTerms.size === 0) return null;
145
164
 
146
165
  const rank = (candidates) =>
147
166
  candidates
@@ -162,13 +181,109 @@ export function scanExistingCoverage(vaultRoot, source, opts = {}) {
162
181
  .sort((a, b) => b.overlap - a.overlap || a.basename.localeCompare(b.basename))
163
182
  .slice(0, limit);
164
183
 
184
+ return { rank, source_terms: [...sourceTerms].sort() };
185
+ }
186
+
187
+ // ─────────────────────────────────────────────────────────────────────────────
188
+ // Cloud (`neon`) read tier ([[HS-T6GS-MCPC-EPIC]]).
189
+ //
190
+ // `scanDir` above `readdirSync`es `specs/milestones` and `specs/epics`. In the
191
+ // `neon` tier there may be no vault checkout at all, so that scan is an ENOENT,
192
+ // not a slow path. The cloud twin is `GET /api/specs?type=milestone|epic`
193
+ // (`listSpecsViaConsole`, which follows `next_cursor` so a caller that used to
194
+ // see every FILE still sees every ROW).
195
+ //
196
+ // `scanExistingCoverage` itself is untouched and stays synchronous — it IS the
197
+ // `diskRead` arm, so `promote-diagnose.mjs`'s existing call site keeps working
198
+ // unchanged. The tier is resolved ONCE by the owning handler and threaded in as
199
+ // `deps.tier`, defaulting to `'disk'`.
200
+ // ─────────────────────────────────────────────────────────────────────────────
201
+
202
+ /**
203
+ * `GET /api/specs` summary rows carry `name`, `tags` and `milestone`, but
204
+ * `SpecSummary` has NO `summary` field. The disk scan's subject text is
205
+ * `name + tags + summary`, so cloud-tier ranking sees a slightly smaller term
206
+ * set. Reported as a warning on the result rather than swallowed — a thinner
207
+ * signal that looks identical to the full one is how a "no coverage found"
208
+ * answer mints a milestone the vault already has.
209
+ */
210
+ export const COVERAGE_CLOUD_FIDELITY_WARNING =
211
+ 'cloud-tier coverage ranking omits each candidate\'s `summary:` text — GET /api/specs summary rows carry name + tags + milestone but no summary field, so overlap is computed from a smaller term set than the disk scan uses';
212
+
213
+ /** One `GET /api/specs` row → the candidate shape {@link scanDir} produces. */
214
+ function candidateFromRow(row, type) {
215
+ const basename = row?.basename ?? row?.id ?? null;
216
+ if (!basename) return null;
217
+ const status = String(row?.status ?? 'open').trim().toLowerCase();
218
+ if (!COVERAGE_ELIGIBLE_STATUSES.has(status)) return null;
219
+ if (row?.archived === true) return null;
220
+ if (row?.canonical === false) return null;
221
+ const name = row?.name ?? basename;
222
+ const tags = Array.isArray(row?.tags) ? row.tags.join(' ') : '';
165
223
  return {
166
- source_terms: [...sourceTerms].sort(),
167
- milestones: rank(scanDir(vaultRoot, ['specs', 'milestones'], 'milestone')),
168
- epics: rank(scanDir(vaultRoot, ['specs', 'epics'], 'epic')),
224
+ type,
225
+ id: row?.id ?? basename,
226
+ basename,
227
+ name,
228
+ status,
229
+ milestone: row?.milestone ?? null,
230
+ // `row.summary` is read opportunistically: absent from SpecSummary today,
231
+ // free fidelity the day the route projects it.
232
+ terms: new Set(terms([name, tags, row?.summary ?? ''].join(' '))),
169
233
  };
170
234
  }
171
235
 
236
+ /** List one entity type through the console and shape it into scan candidates. */
237
+ async function scanTypeViaConsole({ consoleFetch, consoleUrl }, type) {
238
+ const rows = await listSpecsViaConsole({ consoleFetch, consoleUrl, type });
239
+ return (rows ?? []).map((r) => candidateFromRow(r, type)).filter(Boolean);
240
+ }
241
+
242
+ /**
243
+ * Tier-aware {@link scanExistingCoverage}.
244
+ *
245
+ * @param {string} vaultRoot
246
+ * @param {{ raw?: string, basename?: string }} source
247
+ * @param {{ minOverlap?: number, limit?: number }} [opts]
248
+ * @param {{ tier?: 'disk'|'neon', consoleUrl?: string, consoleFetch?: Function }} [deps]
249
+ *
250
+ * `tier` defaults to `'disk'`, whose arm is `scanExistingCoverage(...)`
251
+ * verbatim — byte-identical, and no console call is made. A `neon`-tier
252
+ * console failure surfaces as `ConsoleUnreachableCloudModeError`
253
+ * (`CONSOLE_UNREACHABLE_CLOUD_MODE`); there is no silent disk fallback.
254
+ */
255
+ export async function scanExistingCoverageViaTier(vaultRoot, source, opts = {}, deps = {}) {
256
+ const { tier = 'disk' } = deps;
257
+ const console_ = resolveConsoleDeps(tier, deps);
258
+ return readViaSelector({
259
+ tier,
260
+ diskRead: () => scanExistingCoverage(vaultRoot, source, opts),
261
+ neonRead: async () => {
262
+ const prepared = prepareCoverageScan(source, opts);
263
+ if (!prepared) {
264
+ return {
265
+ milestones: [],
266
+ epics: [],
267
+ source_terms: [],
268
+ cloud_read_warnings: [COVERAGE_CLOUD_FIDELITY_WARNING],
269
+ };
270
+ }
271
+ const [milestones, epics] = await Promise.all([
272
+ scanTypeViaConsole(console_, 'milestone'),
273
+ scanTypeViaConsole(console_, 'epic'),
274
+ ]);
275
+ return {
276
+ source_terms: prepared.source_terms,
277
+ milestones: prepared.rank(milestones),
278
+ epics: prepared.rank(epics),
279
+ cloud_read_warnings: [COVERAGE_CLOUD_FIDELITY_WARNING],
280
+ };
281
+ },
282
+ routeLabel: 'GET /api/specs?type=milestone|epic',
283
+ consoleUrl: console_.consoleUrl,
284
+ });
285
+ }
286
+
172
287
  /**
173
288
  * Extract the source's own `## Fix` section, verbatim. Returns `''` when there is
174
289
  * none — a write-up with no Fix section has not been investigated far enough to be
@@ -23,7 +23,12 @@
23
23
  import { existsSync, readdirSync, readFileSync } from 'node:fs';
24
24
  import { join } from 'node:path';
25
25
 
26
+ import { listSpecsViaConsole } from './cloud-vault-writes.mjs';
27
+ import { consoleFetch as realConsoleFetch } from './console-transport.mjs';
28
+ import { getConsoleUrl } from './mcp-config.mjs';
26
29
  import { getFrontmatterBlock, getScalarField } from './promotion-helpers.mjs';
30
+ import { readViaSelector } from './read-path.mjs';
31
+ import { yamlScalar } from './yaml-scalar.mjs';
27
32
 
28
33
  /**
29
34
  * The kinds of document the breakdown stage accepts, and the folder each lives in.
@@ -234,3 +239,269 @@ export function readSourceFrontmatter(record) {
234
239
  type: getScalarField(block, 'type') ?? null,
235
240
  };
236
241
  }
242
+
243
+ // ─────────────────────────────────────────────────────────────────────────────
244
+ // Cloud (`neon`) read tier ([[HS-T6GS-MCPC-EPIC]]).
245
+ //
246
+ // Everything above `readdirSync`es `specs/research` and `specs/diagnoses`. In
247
+ // the `neon` tier there may be NO vault checkout at all, so the folder scan is
248
+ // not slow — it is an ENOENT. The cloud twin of a folder scan is
249
+ // `GET /api/specs?type=…` (`listSpecsViaConsole`, which follows `next_cursor`
250
+ // so a caller that used to see every FILE still sees every ROW).
251
+ //
252
+ // The sync exports above are UNCHANGED and keep working for every existing
253
+ // caller (`promote-diagnose.mjs`, `promote-research.mjs`) — they are the
254
+ // `diskRead` arm verbatim. Tier-aware callers use the async `*ViaTier`
255
+ // siblings, the same convention the rest of the server uses
256
+ // (`loadSpecByIdViaTier`, `handleRecordAlignmentViaTier`, …). The tier is
257
+ // resolved ONCE by the handler that owns the request and threaded in as
258
+ // `deps.tier`; nothing here calls `resolveReadTier()` itself, and `tier`
259
+ // defaults to `'disk'`.
260
+ // ─────────────────────────────────────────────────────────────────────────────
261
+
262
+ /**
263
+ * Console dependencies for a `neon`-tier read, injectable end to end so a test
264
+ * can drive a recorder and never open a socket. `getConsoleUrl()` is only
265
+ * CALLED on the cloud path (it walks the vault resolver), never on disk.
266
+ */
267
+ export function resolveConsoleDeps(tier, deps = {}) {
268
+ if (tier !== 'neon') return { consoleFetch: null, consoleUrl: null };
269
+ return {
270
+ consoleFetch: deps.consoleFetch ?? realConsoleFetch,
271
+ consoleUrl: deps.consoleUrl ?? getConsoleUrl(),
272
+ };
273
+ }
274
+
275
+ /**
276
+ * Rebuild a `---`-delimited markdown document from the `{ frontmatter, body }`
277
+ * pair `GET /api/specs/:id` returns (`SpecDetail` carries no raw markdown).
278
+ *
279
+ * Every consumer of `record.raw` in this stage reads it through
280
+ * `getFrontmatterBlock` / `getScalarField` / a `## Heading` regex
281
+ * (`readSourceFrontmatter`, `scanExistingCoverage`, `countFixes`,
282
+ * `causeIsKnown`), so the reconstruction only has to satisfy those: one line
283
+ * per scalar, a block list per array, `yamlScalar` quoting — the SAME quoter
284
+ * every disk writer in this package uses, so a value round-trips through
285
+ * `getScalarField` identically whether it came from a file or from the wire.
286
+ * Embedded newlines in a scalar are folded to spaces (a single-line YAML
287
+ * scalar is the only shape `getScalarField` reads on disk either).
288
+ */
289
+ export function stringifyCloudSpec(frontmatter = {}, body = '') {
290
+ const lines = ['---'];
291
+ for (const [key, value] of Object.entries(frontmatter ?? {})) {
292
+ if (value === null || value === undefined) continue;
293
+ if (Array.isArray(value)) {
294
+ lines.push(`${key}:`);
295
+ for (const item of value) lines.push(` - ${yamlScalar(item)}`);
296
+ continue;
297
+ }
298
+ if (typeof value === 'object') continue; // no consumer here reads a nested map
299
+ lines.push(
300
+ `${key}: ${typeof value === 'string' ? yamlScalar(value.replace(/\r?\n/g, ' ')) : String(value)}`,
301
+ );
302
+ }
303
+ lines.push('---', '');
304
+ return `${lines.join('\n')}\n${String(body ?? '')}`;
305
+ }
306
+
307
+ /** `GET /api/specs/:id` → a record shaped exactly like the disk `loadRecord` one. */
308
+ async function fetchCloudRecord({ consoleFetch, consoleUrl }, kind, ref) {
309
+ const res = await consoleFetch(`/api/specs/${encodeURIComponent(ref)}`);
310
+ if (res?.status === 404) return null; // not_found / ambiguous_id — a real answer.
311
+ if (!res?.ok) {
312
+ throw new Error(
313
+ `GET /api/specs/${ref} returned ${res?.status}: ${res?.error || JSON.stringify(res?.body ?? {})}`,
314
+ );
315
+ }
316
+ const spec = res.body?.spec;
317
+ if (!spec) return null;
318
+ const frontmatter = spec.frontmatter || {};
319
+ const basename = spec.basename ?? String(spec.relPath ?? ref).split(/[\\/]/).pop().replace(/\.md$/i, '');
320
+ return {
321
+ kind: kind ?? spec.type ?? null,
322
+ basename,
323
+ // No local file exists in cloud mode — say so rather than hand back a path
324
+ // that looks writable. `relPath` stays populated for error messages.
325
+ absPath: null,
326
+ relPath: spec.relPath ?? null,
327
+ raw: stringifyCloudSpec(frontmatter, spec.body ?? ''),
328
+ frontmatter,
329
+ body: spec.body ?? '',
330
+ cloud: true,
331
+ consoleUrl: consoleUrl ?? null,
332
+ };
333
+ }
334
+
335
+ /**
336
+ * The four-step resolution ladder of {@link resolveWithinKind}, run against the
337
+ * console's spec listing instead of a directory listing. Same steps, same
338
+ * first-hit-wins order, same "report ambiguity rather than resolve it" rule.
339
+ *
340
+ * One fidelity note: step 3 matches the row's `id` only. `SpecSummary` carries
341
+ * no `file_id`, so a reference given as a bare `file_id:` that differs from
342
+ * `id:` falls through to step 4 (id-prefix) here where disk would have caught
343
+ * it at step 3. Both steps resolve the same document when they hit; the
344
+ * difference is only which step reports it.
345
+ */
346
+ function resolveWithinKindFromRows(rows, ref) {
347
+ const named = (rows ?? []).filter((r) => typeof r?.basename === 'string' && r.basename);
348
+
349
+ const exact = named.filter((r) => r.basename === ref);
350
+ if (exact.length === 1) return { row: exact[0] };
351
+
352
+ const lowered = ref.toLowerCase();
353
+ const ciExact = named.filter((r) => r.basename.toLowerCase() === lowered);
354
+ if (ciExact.length === 1) return { row: ciExact[0] };
355
+ if (ciExact.length > 1) {
356
+ return { ambiguous: ciExact.map((r) => r.basename), step: 'case-insensitive basename' };
357
+ }
358
+
359
+ const idHits = named.filter((r) => r.id === ref);
360
+ if (idHits.length === 1) return { row: idHits[0] };
361
+ if (idHits.length > 1) {
362
+ return { ambiguous: idHits.map((r) => r.basename), step: 'frontmatter id' };
363
+ }
364
+
365
+ const prefixHits = named.filter((r) => r.basename.startsWith(`${ref}-`));
366
+ if (prefixHits.length === 1) return { row: prefixHits[0] };
367
+ if (prefixHits.length > 1) {
368
+ return { ambiguous: prefixHits.map((r) => r.basename), step: 'id prefix' };
369
+ }
370
+
371
+ return { miss: true };
372
+ }
373
+
374
+ /**
375
+ * Cloud twin of {@link resolveBreakdownSource}. Returns the SAME result shapes
376
+ * — `{ record }` or one of `INVALID_ARGS` / `SOURCE_ID_AMBIGUOUS` /
377
+ * `SOURCE_NOT_FOUND` / `SOURCE_TYPE_MISMATCH` — so a caller branches
378
+ * identically on either tier.
379
+ */
380
+ async function resolveBreakdownSourceViaConsole(rawRef, opts, deps) {
381
+ const kinds = Array.isArray(opts.kinds) && opts.kinds.length > 0
382
+ ? opts.kinds.filter((k) => k in BREAKDOWN_SOURCE_KINDS)
383
+ : Object.keys(BREAKDOWN_SOURCE_KINDS);
384
+
385
+ const ref = normalizeSourceRef(rawRef);
386
+ if (!ref) {
387
+ return { error: 'INVALID_ARGS', message: 'a source id or basename is required' };
388
+ }
389
+
390
+ // Every known kind is searched, not only the requested ones — same reason as
391
+ // the disk path: a hit under a kind the caller did not ask for is the
392
+ // type-mismatch case, and reporting it as such beats a bare "not found".
393
+ const hits = [];
394
+ const ambiguities = [];
395
+ for (const kind of Object.keys(BREAKDOWN_SOURCE_KINDS)) {
396
+ const rows = await listSpecsViaConsole({
397
+ consoleFetch: deps.consoleFetch,
398
+ consoleUrl: deps.consoleUrl,
399
+ type: kind,
400
+ });
401
+ const found = resolveWithinKindFromRows(rows, ref);
402
+ if (found.row) hits.push({ kind, row: found.row });
403
+ else if (found.ambiguous) ambiguities.push({ kind, ...found });
404
+ }
405
+
406
+ if (hits.length > 1) {
407
+ return {
408
+ error: 'SOURCE_ID_AMBIGUOUS',
409
+ message: `${rawRef} resolves to ${hits.length} documents across ${hits.map((h) => h.kind).join(' + ')} — pass the full basename instead`,
410
+ candidates: hits.map((h) => h.row.basename),
411
+ };
412
+ }
413
+
414
+ if (hits.length === 0) {
415
+ if (ambiguities.length > 0) {
416
+ const first = ambiguities[0];
417
+ return {
418
+ error: 'SOURCE_ID_AMBIGUOUS',
419
+ message: `${rawRef} matches ${first.ambiguous.length} files under ${BREAKDOWN_SOURCE_KINDS[first.kind].dir.join('/')}/ by ${first.step} — pass the exact basename or the frontmatter id: instead`,
420
+ candidates: first.ambiguous,
421
+ };
422
+ }
423
+ // The type listings only cover research + diagnoses. On disk a document of
424
+ // some OTHER type sitting in one of those folders is still found and
425
+ // reported as a type mismatch; here one direct id lookup buys the same
426
+ // answer, so "you passed a task to the breakdown stage" keeps reading as
427
+ // that instead of a misleading "not found".
428
+ const other = await fetchCloudRecord(deps, null, ref);
429
+ if (other?.kind && !(String(other.kind).toLowerCase() in BREAKDOWN_SOURCE_KINDS)) {
430
+ return {
431
+ error: 'SOURCE_TYPE_MISMATCH',
432
+ message: `${rawRef} resolved to ${other.relPath ?? other.basename}, which is \`type: ${String(other.kind).toLowerCase()}\` — the breakdown stage accepts a research doc or a bug write-up`,
433
+ resolved_basename: other.basename,
434
+ resolved_type: String(other.kind).toLowerCase(),
435
+ };
436
+ }
437
+ return {
438
+ error: 'SOURCE_NOT_FOUND',
439
+ message: `${rawRef} not found under ${kinds.map((k) => BREAKDOWN_SOURCE_KINDS[k].dir.join('/') + '/').join(' or ')} (tried exact basename, case-insensitive basename, frontmatter id:, and id-prefix match)`,
440
+ searched_kinds: kinds,
441
+ };
442
+ }
443
+
444
+ const { kind, row } = hits[0];
445
+ const record = await fetchCloudRecord(deps, kind, row.id ?? row.basename);
446
+ if (!record) {
447
+ return {
448
+ error: 'SOURCE_NOT_FOUND',
449
+ message: `${rawRef} was listed by GET /api/specs?type=${kind} as ${row.basename} but GET /api/specs/${row.id ?? row.basename} did not return it`,
450
+ searched_kinds: kinds,
451
+ };
452
+ }
453
+
454
+ // The document's own `type:` wins, exactly as on disk; the listing's `type`
455
+ // is the fallback when the document declares none.
456
+ const declared = String(record.frontmatter?.type ?? '').trim().toLowerCase();
457
+ if (declared) {
458
+ if (!(declared in BREAKDOWN_SOURCE_KINDS)) {
459
+ return {
460
+ error: 'SOURCE_TYPE_MISMATCH',
461
+ message: `${rawRef} resolved to ${record.relPath ?? record.basename}, which is \`type: ${declared}\` — the breakdown stage accepts a research doc or a bug write-up`,
462
+ resolved_basename: record.basename,
463
+ resolved_type: declared,
464
+ };
465
+ }
466
+ record.kind = declared;
467
+ } else {
468
+ record.kind = kind;
469
+ }
470
+
471
+ if (!kinds.includes(record.kind)) {
472
+ return {
473
+ error: 'SOURCE_TYPE_MISMATCH',
474
+ message: `${rawRef} resolved to ${record.relPath ?? record.basename}, a ${BREAKDOWN_SOURCE_KINDS[record.kind].label} — this call expects ${kinds.map((k) => BREAKDOWN_SOURCE_KINDS[k].label).join(' or ')}`,
475
+ resolved_basename: record.basename,
476
+ resolved_type: record.kind,
477
+ };
478
+ }
479
+
480
+ return { record };
481
+ }
482
+
483
+ /**
484
+ * Tier-aware {@link resolveBreakdownSource}.
485
+ *
486
+ * @param {string} vaultRoot
487
+ * @param {string} rawRef
488
+ * @param {{ kinds?: string[] }} [opts]
489
+ * @param {{ tier?: 'disk'|'neon', consoleUrl?: string, consoleFetch?: Function }} [deps]
490
+ *
491
+ * `tier` defaults to `'disk'`, whose arm is `resolveBreakdownSource(...)`
492
+ * verbatim — byte-identical, and no console call is made. A `neon`-tier
493
+ * console failure surfaces as `ConsoleUnreachableCloudModeError`
494
+ * (`CONSOLE_UNREACHABLE_CLOUD_MODE`), never as a silent fall back to a disk
495
+ * copy that may not exist.
496
+ */
497
+ export async function resolveBreakdownSourceViaTier(vaultRoot, rawRef, opts = {}, deps = {}) {
498
+ const { tier = 'disk' } = deps;
499
+ const console_ = resolveConsoleDeps(tier, deps);
500
+ return readViaSelector({
501
+ tier,
502
+ diskRead: () => resolveBreakdownSource(vaultRoot, rawRef, opts),
503
+ neonRead: () => resolveBreakdownSourceViaConsole(rawRef, opts, console_),
504
+ routeLabel: 'GET /api/specs?type=research|diagnose',
505
+ consoleUrl: console_.consoleUrl,
506
+ });
507
+ }
@@ -26,7 +26,7 @@
26
26
  import { existsSync, readFileSync } from 'node:fs';
27
27
  import { join } from 'node:path';
28
28
 
29
- import { collectOccupiedKeysEitherKind, allocateFileKey, parseSpecId } from './spec-id-lib.mjs';
29
+ import { allocateFileKey, parseSpecId } from './spec-id-lib.mjs';
30
30
  import { listVaultPillars, resolvePillar } from './promote-research.mjs';
31
31
  import {
32
32
  appendChangeLogRow,
@@ -250,12 +250,32 @@ export function requireResolvableMilestone(vaultRoot, milestoneRef) {
250
250
  * @returns `{ spawned, writes }` on success, or `{ error, message, ... }`. Nothing is
251
251
  * written until every gate has passed; the commit itself rolls back as a unit.
252
252
  */
253
- export function executeBreakdownWrites({ vaultRoot, source, sourceFrontmatter, ladder, placement, args = {}, deps = {} }) {
253
+ export function executeBreakdownWrites({ vaultRoot, source, sourceFrontmatter, ladder, placement, occupiedKeys, args = {}, deps = {} }) {
254
254
  const now = (deps.clock ?? isoNow)();
255
255
  const today = now.slice(0, 10);
256
256
  const sourceBasename = source.basename;
257
257
  const familyKey = inheritedFamilyKey(sourceFrontmatter, sourceBasename) ?? 'BFIX';
258
- const occupied = collectOccupiedKeysEitherKind(vaultRoot);
258
+ // TIER-AWARE OCCUPANCY, SUPPLIED BY THE CALLER ([[HS-FJWL]]). This used to be
259
+ // `collectOccupiedKeysEitherKind(vaultRoot)` inline — a `specs/**` walk that
260
+ // is complete on the disk tier and blind on the cloud tier, where a filed
261
+ // spec has a Neon row and no local `.md`. A file key is the unique per-file
262
+ // identity under Naming V3, so minting against a stale local clone re-issues
263
+ // a live id. The resolution is the CALLER's (promote-diagnose.mjs), which is
264
+ // async and can reach the console; this writer stays synchronous.
265
+ //
266
+ // No disk fallback when it is missing: a silent default is exactly the blind
267
+ // scan this fix removes, so an unsupplied set refuses the whole breakdown.
268
+ if (!(occupiedKeys instanceof Set)) {
269
+ return {
270
+ error: 'OCCUPANCY_NOT_RESOLVED',
271
+ message:
272
+ 'executeBreakdownWrites requires a resolved `occupiedKeys` Set ([[HS-FJWL]]) — the ' +
273
+ 'caller resolves it for the active read tier via collectOccupiedKeysEitherKindViaTier. ' +
274
+ 'Refusing to fall back to a local vault scan, which is blind to cloud-only specs and ' +
275
+ 'would mint a duplicate file key.',
276
+ };
277
+ }
278
+ const occupied = new Set(occupiedKeys);
259
279
  const mintKey = () => {
260
280
  const key = allocateFileKey(occupied, { randomUnit: deps.randomUnit });
261
281
  occupied.add(key);