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,3377 @@
1
+ // How the on-deck brief is rendered: heyspex_next_on_deck / next_review /
2
+ // next_resolution / suggest_pillar / resolve_prerequisite, the enrichOnDeckWith*
3
+ // pipeline (plans, breakage, conflicts, amendments, alignment gate, WIP block,
4
+ // claim target, run front, agent orchestration, memories) and the HTTP augments. Extracted from cli.mjs under
5
+ // [[HS-BLNF-6W61-DIAG-cli-mjs-has-grown-into-a-13-665-line]] (no behavior change).
6
+ //
7
+ // ─────────────────────────────────────────────────────────────────────────────
8
+ // Amendment tier ([[HS-6DMR-EPIC-amendment-tier-reverse-resolver-amended]]) —
9
+ // partial (non-terminal) supersession. `amends:` lives ONLY on the amending
10
+ // (newer, canonical) doc; the targets are never edited. The reverse map ("what
11
+ // amends spec X") is COMPUTED by scanning every spec's `amends:` list. On the
12
+ // /hs-next brief we warn the breakdown / execution agent that the on-deck epic
13
+ // is amended and inject the source's `## Amends` delta rows so the override is
14
+ // read BEFORE decomposing. Mirrors enrichOnDeckWithConflicts. Contract: AMENDS
15
+ // v1 (specs/research/HS-3RU4-RES-partial-supersession-amendment-tier-for-.md).
16
+ // ─────────────────────────────────────────────────────────────────────────────
17
+ //
18
+ // ────────────────────────────────────────────────────────────────────────────
19
+ // Claim-target banner ([[HS-TASK-NJPH]]) — the legibility surface for the
20
+ // /hs-next on-deck response. Bookended at top + bottom of prompt_markdown
21
+ // so the user always sees the hierarchy + intent verb before authorizing a
22
+ // claim. Vault invariant: a spec is claimable iff it is its own current
23
+ // ClaimTarget (console endpoint enforces this in T4).
24
+ // ────────────────────────────────────────────────────────────────────────────
25
+ //
26
+ // ────────────────────────────────────────────────────────────────────────────
27
+ // Agent orchestration composer — produces two outputs:
28
+ // • `user_prompt` — user-facing markdown (claim question, drift summary)
29
+ // • `agent_dispatch` — structured routing object (reply→action, failure modes)
30
+ //
31
+ // The split exists because the prior single-`prompt_markdown` shape leaked the
32
+ // "🤖 Agent dispatch (do not paraphrase)" tables + "Failure modes" bullets into
33
+ // the user's terminal output. The agent now reads `agent_dispatch` as a typed
34
+ // object it decodes, and the leak surface is gone. Schema:
35
+ // `specs/schemas/agent-dispatch.schema.json`. Filed under [[HS-TASK-HSNX]];
36
+ // part of the [[HS-EPIC-MCPX]] wire-protocol redesign.
37
+ //
38
+ // The orchestration originally lived in `.claude/commands/hs-next.md` —
39
+ // migrated here to close the SaaS-customer corruption surface
40
+ // ([[workflow-orchestration-belongs-in-mcp-not-claude-commands]],
41
+ // [[HS-RES-J6CD-migrate-slash-command-orchestration-from]]).
42
+ //
43
+ // State-dispatched: fresh-summary, drift-summary, needs-breakdown-summary, and
44
+ // full-mode each get phase-appropriate dispatch.
45
+ // ────────────────────────────────────────────────────────────────────────────
46
+
47
+ import { ConsoleUnreachableCloudModeError, readViaSelector, resolveReadTier, resolveVaultWriteMode, withDegradationScope } from './read-path.mjs';
48
+ import { consoleFetch, controlError, controlOk, enrichConsoleFailurePayloadAsync } from './console-transport.mjs';
49
+ import { disambiguateClaimSpecId, findSpecFile, loadRoadmap, loadSpec, loadSpecByIdViaTier, loadTask, loadTaskViaGraph, parseFrontmatter, prefetchCloudTaskGraph, wikilinkTarget } from './vault-io.mjs';
50
+ import { isAbsolute, join } from 'node:path';
51
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
52
+ import { detectUITags, extractSummary, loadDesignMemories, loadDesignMemoriesViaTier } from './memory-tools.mjs';
53
+ import { attachWhyNowFields, buildDesignContextSection, buildOnDeckExplanation, deriveTaskBrief, extractNextStepBlock, loadFocusOverride, loadPillarTierMapViaTier, loadVaultSpecsForSchedulerViaTier, readSchedulerCacheOrderIds, resolveNextOnDeck } from './on-deck-scheduler.mjs';
54
+ import { normalizeRunFrontToken, shouldAutoClaimOnDeck } from './run-front.mjs';
55
+ import { assessBriefReadiness, buildClarificationPrompt, buildDecompositionPromptMarkdown, buildDriftResolutionPrompt, childKindFor, detectCorpusDrift, extractDecompositionContext, getChildRefs, readReadinessFloorSettings } from './alignment-gate.mjs';
56
+ import { formatDisplayId, parseSpecId } from './spec-id-lib.mjs';
57
+ import { hydratePromptWithPlans } from './plan-ingest.mjs';
58
+ import { collectEpicRelatedLinks, detectSpecConflicts } from './plan-push.mjs';
59
+ import { computeClaimTarget, mapStateToHint } from './blocker-resolver.mjs';
60
+ import { detectAgentIdentity, effectiveConsoleUrl } from './mcp-config.mjs';
61
+ import { buildSummaryConsoleWritePrompt, buildSummaryFillInPrompt } from './summary-contract.mjs';
62
+ import { consoleErrorCode, consoleErrorMessage } from './console-fetch.mjs';
63
+ import { resolveNextResolutionPayload } from './next-resolution.mjs';
64
+ import { resolveNextReviewPayload } from './next-review.mjs';
65
+ import { injectNeonFocusPin } from './focus-tools.mjs';
66
+ import { walkSpecListPages } from './list-tools.mjs';
67
+ import { importDevModeCore } from './dev-mode-core.mjs';
68
+ import { PACKAGE_ROOT } from './package-root.mjs';
69
+ import { resolveCodeRoot } from '../../scripts/lib/shared-path-helpers.mjs';
70
+
71
+ // scripts/lib/dev-mode.mjs is a CODE-repo artifact that ships with this
72
+ // package — since the repo split (2026-08-31) the vault root no longer holds
73
+ // it, so resolve it relative to THIS file's checkout first and fall back to
74
+ // the vault root only for pre-split layouts where they were the same repo.
75
+ // `HS_PKG_ROOT` wins when set (same precedence as dev-tools.mjs's
76
+ // CHECKOUT_ROOT / plan-review.mjs's resolveCodeAuditRoots).
77
+ const CHECKOUT_ROOT = resolveCodeRoot({ packageRoot: PACKAGE_ROOT });
78
+
79
+ /**
80
+ * Internals exposed for the [[HS-TASK-I2Y8]] corpus-diff regression test (the
81
+ * derived path vs the historical NEXT-STEP-excerpt path across the whole vault).
82
+ * Not part of the public MCP surface.
83
+ */
84
+ export const __briefTestables = {
85
+ deriveTaskBrief,
86
+ extractNextStepBlock,
87
+ loadSpec,
88
+ };
89
+
90
+ /**
91
+ * Append canonical / resolved external plan content to on-deck prompts.
92
+ * @param {string} vaultRoot
93
+ * @param {object} result
94
+ * @param {object} [args]
95
+ */
96
+ export function enrichOnDeckWithPlans(vaultRoot, result, args = {}) {
97
+ if (!result?.spec_path || !result?.prompt_markdown) return result;
98
+
99
+ const spec = loadSpec(vaultRoot, result.spec_path);
100
+ if (!spec) return result;
101
+
102
+ const persist =
103
+ args.persist_plan_ingest === true || args.persist_plan_ingest === 'true';
104
+
105
+ const hydrated = hydratePromptWithPlans(
106
+ vaultRoot,
107
+ result.spec_path,
108
+ spec.body,
109
+ result.prompt_markdown,
110
+ { persist },
111
+ );
112
+
113
+ const enriched = {
114
+ ...result,
115
+ prompt_markdown: hydrated.prompt_markdown,
116
+ plan_hydration: {
117
+ had_canonical_in_spec: hydrated.had_canonical_in_spec,
118
+ plans_in_prompt: hydrated.plans_in_prompt,
119
+ missing_plans: hydrated.missing_plans,
120
+ },
121
+ };
122
+
123
+ if (hydrated.persist_result?.written) {
124
+ enriched.plan_hydration.persisted_to_spec = true;
125
+ enriched.plan_hydration.spec_path = hydrated.persist_result.spec_path;
126
+ }
127
+
128
+ return enriched;
129
+ }
130
+
131
+ /**
132
+ * Pull the `## Expected Breakage & Consequences` section out of a spec body
133
+ * verbatim ([[spec-hierarchy]] §Expected Breakage & Consequences). Returns the
134
+ * heading-through-next-heading slice, or null when the section is absent.
135
+ */
136
+ export function extractBreakageSection(body) {
137
+ if (!body) return null;
138
+ const lines = body.split('\n');
139
+ let start = -1;
140
+ for (let i = 0; i < lines.length; i++) {
141
+ if (/^##\s+Expected Breakage & Consequences\s*$/i.test(lines[i].trim())) {
142
+ start = i;
143
+ break;
144
+ }
145
+ }
146
+ if (start === -1) return null;
147
+ let end = lines.length;
148
+ for (let i = start + 1; i < lines.length; i++) {
149
+ if (/^#{1,2}\s+/.test(lines[i])) {
150
+ end = i;
151
+ break;
152
+ }
153
+ }
154
+ const section = lines.slice(start, end).join('\n').trim();
155
+ return section.length > 0 ? section : null;
156
+ }
157
+
158
+ /**
159
+ * Surface the spec's `## Expected Breakage & Consequences` section at the top of
160
+ * the execution body, just above NEXT STEP, so the agent reads the anticipated
161
+ * cascade *before* coding and a transient rise in tsc/test errors reads as the
162
+ * expected consequence, not a regression ([[spec-hierarchy]]). No-op when the
163
+ * spec carries no such section.
164
+ */
165
+ export function enrichOnDeckWithExpectedBreakage(vaultRoot, result) {
166
+ if (!result?.spec_path || !result?.prompt_markdown) return result;
167
+ const spec = loadSpec(vaultRoot, result.spec_path);
168
+ if (!spec) return result;
169
+ const section = extractBreakageSection(spec.body);
170
+ if (!section) return result;
171
+ return {
172
+ ...result,
173
+ prompt_markdown: `${section}\n\n---\n\n${result.prompt_markdown}`,
174
+ expected_breakage: { present: true },
175
+ };
176
+ }
177
+
178
+ export function buildConflictPromptSection(conflicts, relatedLinks) {
179
+ const lines = ['## Spec conflicts (pre-flight)', ''];
180
+ if (conflicts.length === 0) {
181
+ lines.push(
182
+ 'No behavioral conflicts detected between this epic’s NEXT STEP / acceptance criteria and linked specs.',
183
+ );
184
+ if (relatedLinks.length > 0) {
185
+ lines.push('', '**Linked context:**', ...relatedLinks.map((r) => `- [[${r}]]`));
186
+ }
187
+ return lines.join('\n');
188
+ }
189
+ lines.push(
190
+ `⚠️ **${conflicts.length} conflict(s)** — resolve before implementation (Console On Deck · \`/hs-resolve\`):`,
191
+ '',
192
+ );
193
+ for (const c of conflicts) {
194
+ lines.push(`- **Epic:** ${c.epic_claim}`);
195
+ lines.push(` **[[${c.spec}]]:** ${c.spec_claim} (${c.section})`);
196
+ }
197
+ lines.push('', 'Run **`/hs-resolve`** or update the epic / protocol before proceeding.');
198
+ return lines.join('\n');
199
+ }
200
+
201
+ export function enrichOnDeckWithConflicts(vaultRoot, result) {
202
+ if (!result?.spec_path) return result;
203
+
204
+ const spec = loadSpec(vaultRoot, result.spec_path);
205
+ if (!spec || spec.frontmatter.type !== 'epic') return result;
206
+
207
+ const relatedLinks = collectEpicRelatedLinks(
208
+ spec.frontmatter.related || [],
209
+ spec.body,
210
+ );
211
+ const { conflicts, linked_specs_checked } = detectSpecConflicts(
212
+ vaultRoot,
213
+ spec.body,
214
+ relatedLinks,
215
+ );
216
+
217
+ const conflictSection = buildConflictPromptSection(conflicts, relatedLinks);
218
+ const referencesBlock =
219
+ relatedLinks.length > 0
220
+ ? `\n\n## Related context (read before implementing)\n\n${relatedLinks.map((r) => `- [[${r}]]`).join('\n')}`
221
+ : '';
222
+
223
+ return {
224
+ ...result,
225
+ prompt_markdown: `${conflictSection}${referencesBlock}\n\n---\n\n${result.prompt_markdown}`,
226
+ conflict_analysis: {
227
+ has_conflicts: conflicts.length > 0,
228
+ conflict_count: conflicts.length,
229
+ conflicts,
230
+ linked_specs_checked,
231
+ related_links: relatedLinks,
232
+ resolve_command: `/hs-resolve ${result.spec_id}`,
233
+ },
234
+ };
235
+ }
236
+
237
+ // Normalize a spec reference (wikilink | basename | id) to its stable id token
238
+ // (`HS-AGDG-MIL`), falling back to the bare basename when the token is
239
+ // unrecognizable. Matching on the id token — not the full slugged basename —
240
+ // keeps the reverse map robust to slug drift between an epic's `milestone:`
241
+ // back-ref and a source's `amends:` entry, while still honoring the contract's
242
+ // basename comparison (both share the same leading id token).
243
+ export function amendTargetKey(ref) {
244
+ const inner = wikilinkTarget(String(ref ?? ''));
245
+ if (!inner) return null;
246
+ const trimmed = inner.trim();
247
+ if (!trimmed) return null;
248
+ const m = trimmed.match(
249
+ /^(HS-[A-Z0-9]+-(?:TASK|EPIC|MIL|ROADMAP|RES|CAP|REV|IDEA|FLOW))/i,
250
+ );
251
+ return m ? m[1] : trimmed;
252
+ }
253
+
254
+ // Bare basename of a vault-relative spec path (tolerates `/` or `\` separators).
255
+ export function specPathBasename(specPath) {
256
+ if (typeof specPath !== 'string') return '';
257
+ const tail = specPath.split(/[\\/]/).pop() || '';
258
+ return tail.replace(/\.md$/i, '');
259
+ }
260
+
261
+ // Parse the `## Amends` delta table out of an amending doc's body. Returns one
262
+ // entry per data row: { target (raw first-cell wikilink basename), targetKey,
263
+ // epics, override }. Tolerates the canonical HS-VZCL column shape
264
+ // (`Amended target | Epic(s) | What this milestone overrides`), the header +
265
+ // separator rows, and any trailing prose after the table.
266
+ export function parseAmendsDeltaTable(body) {
267
+ if (typeof body !== 'string' || !body) return [];
268
+ const lines = body.split(/\r?\n/);
269
+ const startIdx = lines.findIndex((l) => /^#{1,6}\s+Amends\b/i.test(l.trim()));
270
+ if (startIdx === -1) return [];
271
+ const rows = [];
272
+ let seenTable = false;
273
+ for (let i = startIdx + 1; i < lines.length; i++) {
274
+ const trimmed = lines[i].trim();
275
+ if (/^#{1,6}\s/.test(trimmed)) {
276
+ if (seenTable) break;
277
+ continue;
278
+ }
279
+ if (!trimmed.startsWith('|')) {
280
+ if (seenTable) break; // table (and any leading prose) ended
281
+ continue;
282
+ }
283
+ seenTable = true;
284
+ const cells = trimmed
285
+ .replace(/^\|/, '')
286
+ .replace(/\|$/, '')
287
+ .split('|')
288
+ .map((c) => c.trim());
289
+ // Skip the `|---|---|` separator row.
290
+ if (cells.every((c) => c === '' || /^:?-{2,}:?$/.test(c))) continue;
291
+ const firstCell = cells[0] || '';
292
+ // Only wikilink-bearing rows are data; the `Amended target` header is skipped.
293
+ if (!/\[\[/.test(firstCell)) continue;
294
+ rows.push({
295
+ target: wikilinkTarget(firstCell),
296
+ targetKey: amendTargetKey(firstCell),
297
+ epics: cells[1] || '',
298
+ override: cells[2] || '',
299
+ });
300
+ }
301
+ return rows;
302
+ }
303
+
304
+ // Scan the vault for amending sources whose `amends:` list contains any of the
305
+ // on-deck spec's target keys (its own id and/or its parent milestone). Returns
306
+ // `{ source, matchedKey, rows }[]`, rows being the delta-table entries that
307
+ // resolve to one of those targets.
308
+ export function collectAmendmentSources(vaultRoot, targetKeys, selfKey) {
309
+ const out = [];
310
+ const dirs = [
311
+ 'specs/milestones',
312
+ 'specs/epics',
313
+ 'specs/tasks',
314
+ 'specs/roadmaps',
315
+ 'specs/research',
316
+ ];
317
+ for (const dir of dirs) {
318
+ const full = join(vaultRoot, dir);
319
+ if (!existsSync(full)) continue;
320
+ let files;
321
+ try {
322
+ files = readdirSync(full);
323
+ } catch {
324
+ continue;
325
+ }
326
+ for (const file of files) {
327
+ if (!file.endsWith('.md')) continue;
328
+ let parsed;
329
+ try {
330
+ parsed = parseFrontmatter(readFileSync(join(full, file), 'utf-8'));
331
+ } catch {
332
+ continue;
333
+ }
334
+ const amends = parsed.frontmatter?.amends;
335
+ if (!Array.isArray(amends) || amends.length === 0) continue;
336
+ const sourceBasename = file.replace(/\.md$/i, '');
337
+ const sourceKey = amendTargetKey(parsed.frontmatter.id || sourceBasename);
338
+ // Never report a spec as amended by itself.
339
+ if (sourceKey && selfKey && sourceKey === selfKey) continue;
340
+ let matchedKey = null;
341
+ for (const entry of amends) {
342
+ const k = amendTargetKey(entry);
343
+ if (k && targetKeys.has(k)) {
344
+ matchedKey = k;
345
+ break;
346
+ }
347
+ }
348
+ if (!matchedKey) continue;
349
+ const rows = parseAmendsDeltaTable(parsed.body).filter(
350
+ (r) => r.targetKey && targetKeys.has(r.targetKey),
351
+ );
352
+ out.push({ source: sourceBasename, matchedKey, rows });
353
+ }
354
+ }
355
+ return out;
356
+ }
357
+
358
+ /**
359
+ * Cloud-reliant sibling of `collectAmendmentSources` ([[HS-DJWP-MCPC-TASK]]
360
+ * "Amendment scan" family, round 2 — `amends` is now a projectable
361
+ * `GET /api/specs?fields=` field, added to `SpecSummary`
362
+ * (`spex-console/src/lib/vault-shared.ts` + `vault.ts` `toSummary()`,
363
+ * [[HS-DJWP-MCPC-TASK]] round-2 correction: the task Scope explicitly allows
364
+ * extending that ONE route's projection).
365
+ *
366
+ * `collectAmendmentSources` itself stays untouched (the `diskRead` branch).
367
+ * In `neon` tier:
368
+ * 1. ONE `GET /api/specs?fields=amends,basename,type` lists every spec's
369
+ * normalized `amends` targets — no per-file frontmatter read.
370
+ * 2. Filtered client-side to the five kinds the disk scan walks
371
+ * (milestone/epic/task/roadmap/research) and to non-empty `amends`.
372
+ * 3. Body text (`parseAmendsDeltaTable`) is fetched via `GET
373
+ * /api/specs/[id]` ONLY for sources whose `amends` genuinely matches one
374
+ * of `targetKeys` — the rare case — never for the full candidate set.
375
+ *
376
+ * NOT wired into `enrichOnDeckWithAmendments` (this function's only caller
377
+ * today): that enricher runs inside `resolveNextOnDeckPayload`'s fully
378
+ * synchronous `runEnricherChain`, which 14+ call sites in `cli.test.mjs` call
379
+ * directly and destructure `{ result, error }` with no `await` — the exact
380
+ * same "must stay sync" hazard `handleRunPreflight` / `handleRecordAlignment`
381
+ * hit in round 1. Converting it would need `resolveNextOnDeckPayload` itself
382
+ * to go async, breaking all of them. This function is a genuine, tested,
383
+ * ready-to-call primitive; wiring it into the live on-deck enricher chain is
384
+ * the same larger, deferred piece of work as the rest of that chain
385
+ * (conflicts / WIP / alignment-drift / claim-target), not a gap specific to
386
+ * amendments.
387
+ */
388
+ export async function collectAmendmentSourcesViaTier(vaultRoot, targetKeys, selfKey, { tier, consoleUrl }) {
389
+ return readViaSelector({
390
+ tier,
391
+ diskRead: () => collectAmendmentSources(vaultRoot, targetKeys, selfKey),
392
+ neonRead: async () => {
393
+ // The route answers a bounded page now
394
+ // ([[HS-C6FO-8KK5-TASK-keyset-spec-list-endpoint-query-layer]]); this
395
+ // scan needs every candidate, so it follows `next_cursor` to the end
396
+ // ([[HS-E66W-8KK5-TASK-mcp-list-tools-page-with-a-cursor-and-a]]).
397
+ const rows = await walkSpecListPages(consoleFetch, '/api/specs?fields=amends,basename,type');
398
+ const wantedTypes = new Set(['milestone', 'epic', 'task', 'roadmap', 'research']);
399
+ const candidates = rows.filter(
400
+ (r) => wantedTypes.has(r.type) && Array.isArray(r.amends) && r.amends.length > 0,
401
+ );
402
+
403
+ const out = [];
404
+ for (const row of candidates) {
405
+ const sourceBasename = row.basename;
406
+ const sourceKey = amendTargetKey(row.id || sourceBasename);
407
+ // Never report a spec as amended by itself.
408
+ if (sourceKey && selfKey && sourceKey === selfKey) continue;
409
+ let matchedKey = null;
410
+ for (const entry of row.amends) {
411
+ const k = amendTargetKey(entry);
412
+ if (k && targetKeys.has(k)) {
413
+ matchedKey = k;
414
+ break;
415
+ }
416
+ }
417
+ if (!matchedKey) continue;
418
+
419
+ // Body text is only fetched for a genuine match — the rare case.
420
+ let body = '';
421
+ const specRes = await consoleFetch(`/api/specs/${encodeURIComponent(row.id)}`);
422
+ if (specRes.ok) body = specRes.body?.spec?.body ?? '';
423
+ const deltaRows = parseAmendsDeltaTable(body).filter(
424
+ (r) => r.targetKey && targetKeys.has(r.targetKey),
425
+ );
426
+ out.push({ source: sourceBasename, matchedKey, rows: deltaRows });
427
+ }
428
+ return out;
429
+ },
430
+ routeLabel: 'GET /api/specs?fields=amends',
431
+ consoleUrl,
432
+ });
433
+ }
434
+
435
+ export function buildAmendmentPromptSection(sources) {
436
+ const lines = ['## Amendments (partial supersession — read before implementing)', ''];
437
+ for (const s of sources) {
438
+ lines.push(
439
+ `⚠️ **Amended by [[${s.source}]]** — read it first; it overrides these sections on conflict.`,
440
+ '',
441
+ );
442
+ if (s.rows.length > 0) {
443
+ lines.push('| Amended target | Epic(s) | Override |', '|---|---|---|');
444
+ for (const r of s.rows) {
445
+ const target = r.target ? `[[${r.target}]]` : '';
446
+ lines.push(`| ${target} | ${r.epics || ''} | ${r.override || ''} |`);
447
+ }
448
+ lines.push('');
449
+ }
450
+ }
451
+ return lines.join('\n').trimEnd();
452
+ }
453
+
454
+ // Prepend an amendment warning + the source's `## Amends` delta rows when the
455
+ // on-deck epic (or its parent milestone) is amended. Mirrors
456
+ // enrichOnDeckWithConflicts: loads the spec, epics only, prepends above the
457
+ // existing brief with a `---` separator, and attaches a structured
458
+ // `amendment_analysis` field. Additive / no-op when nothing amends the spec.
459
+ export function enrichOnDeckWithAmendments(vaultRoot, result) {
460
+ if (!result?.spec_path || !result?.prompt_markdown) return result;
461
+
462
+ const spec = loadSpec(vaultRoot, result.spec_path);
463
+ if (!spec || spec.frontmatter.type !== 'epic') return result;
464
+
465
+ // The on-deck epic is "amended" when it — OR its parent milestone — is a
466
+ // target of some source's `amends:` list. In HS-VZCL's real case the targets
467
+ // are the MILESTONES and the /hs-next unit is an epic under them, so resolve
468
+ // the epic UP to its milestone (and parent) and match on all three keys.
469
+ const selfKey = amendTargetKey(spec.frontmatter.id || specPathBasename(result.spec_path));
470
+ const targetKeys = new Set(
471
+ [
472
+ selfKey,
473
+ amendTargetKey(spec.frontmatter.milestone),
474
+ amendTargetKey(spec.frontmatter.parent),
475
+ ].filter(Boolean),
476
+ );
477
+ if (targetKeys.size === 0) return result;
478
+
479
+ const sources = collectAmendmentSources(vaultRoot, targetKeys, selfKey);
480
+ if (sources.length === 0) return result;
481
+
482
+ const section = buildAmendmentPromptSection(sources);
483
+ return {
484
+ ...result,
485
+ prompt_markdown: `${section}\n\n---\n\n${result.prompt_markdown}`,
486
+ amendment_analysis: {
487
+ is_amended: true,
488
+ amended_by: sources.map((s) => s.source),
489
+ matched_target_keys: [...targetKeys],
490
+ sources: sources.map((s) => ({
491
+ source: s.source,
492
+ matched_target_key: s.matchedKey,
493
+ rows: s.rows,
494
+ })),
495
+ },
496
+ };
497
+ }
498
+
499
+ export const ALIGNMENT_SKIP_STATUSES = new Set([
500
+ 'in_review',
501
+ 'closed',
502
+ 'approved',
503
+ 'hibernated',
504
+ 'deferred',
505
+ ]);
506
+
507
+ /**
508
+ * Pre-execution alignment gate ([[pre-execution-alignment-gate]]) — the FIRST
509
+ * pre-flight, upstream of conflict resolution. Runs on the focus-pinned epic or
510
+ * milestone before the agent executes its chain:
511
+ *
512
+ * - **no children** → HARD breakdown gate (robust decomposition brief).
513
+ * - **children + checkpoint fires** → HARD alignment-review gate (revisit the
514
+ * breakdown for drift). "Fire" = never reviewed, the breakdown changed, or
515
+ * the 7-day fuse expired.
516
+ * - **checkpoint ok** → pass the underlying result through untouched.
517
+ *
518
+ * Hard for both per the user's "AI agents are good at executing the wrong thing
519
+ * perfectly" stance; the auditable escape valve is `heyspex_record_alignment`
520
+ * with an `override_reason`. The gate REPLACES the result (no task brief) so the
521
+ * agent cannot skim past it — mirrors how a conflict-on-push blocks.
522
+ *
523
+ * SSOT invariant (locked by cli.test.mjs "alignment gate reads children from
524
+ * disk"): the breakdown decision is governed by the focus epic's children as
525
+ * parsed from the FILE (`loadSpec` → `readFileSync`), never by any index /
526
+ * cohort / console-response field. A stale derived index therefore cannot trip
527
+ * a phantom `needs_breakdown` gate. Do not refactor `getChildRefs(fm, …)` below
528
+ * to read from an index — that reintroduces the cache-vs-SSOT divergence that
529
+ * cost a session ~15 tool calls of forensic archaeology on HS-EPIC-FCSM.
530
+ */
531
+ /**
532
+ * Which spec does the alignment gate actually run against?
533
+ *
534
+ * A milestone or epic pin gates itself. A TASK pin used to skip the gate
535
+ * outright ("task pins are not gated in v1") — and that was the back door: pin
536
+ * the task rather than its epic and the drift check never ran, even though a
537
+ * task brief is precisely what goes stale when its epic's corpus moves. A task
538
+ * has no children to break down, so it gates its PARENT epic instead.
539
+ *
540
+ * Returns `{ grain, fm, body, gatedVia }`, or null when there is nothing to gate
541
+ * (an unknown kind, a task with no resolvable parent, or a parent that is not
542
+ * actionable).
543
+ */
544
+ export function resolveAlignmentGateTarget(vaultRoot, focus, focusSpec, deps = {}) {
545
+ const loadRef = deps.loadRef ?? ((ref) => {
546
+ try {
547
+ return loadTask(vaultRoot, ref);
548
+ } catch {
549
+ return null;
550
+ }
551
+ });
552
+ const fm = focusSpec?.frontmatter || {};
553
+ const kind = String(focus?.kind || fm.type || '').toLowerCase();
554
+
555
+ if (kind === 'milestone') {
556
+ return { grain: 'milestone', fm, body: focusSpec?.body || '', gatedVia: 'self' };
557
+ }
558
+ if (kind === 'epic') {
559
+ return { grain: 'epic', fm, body: focusSpec?.body || '', gatedVia: 'self' };
560
+ }
561
+ if (kind !== 'task') return null;
562
+
563
+ const parentRef = fm.parent || fm.epic || fm.milestone || null;
564
+ if (!parentRef) return null;
565
+ const parent = loadRef(parentRef);
566
+ const parentFm = parent?.frontmatter || null;
567
+ if (!parentFm) return null;
568
+
569
+ const parentType = String(parentFm.type || '').toLowerCase();
570
+ if (parentType !== 'epic' && parentType !== 'milestone') return null;
571
+ // A closed/parked parent is not actionable — same rule the focus spec gets.
572
+ if (ALIGNMENT_SKIP_STATUSES.has(String(parentFm.status || '').toLowerCase())) return null;
573
+
574
+ return {
575
+ grain: parentType,
576
+ fm: parentFm,
577
+ body: parent.body || '',
578
+ gatedVia: 'task_parent',
579
+ };
580
+ }
581
+
582
+ export function enrichOnDeckWithAlignment(vaultRoot, result, args = {}) {
583
+ if (!result || result.error) return result;
584
+ if (args.ignore_focus === true || args.ignore_focus === 'true') return result;
585
+
586
+ const focus = loadFocusOverride(vaultRoot, {
587
+ neonPinSpecId: args.neon_pin_spec_id,
588
+ neonSelectionSpecPath: args.neon_selection_spec_path,
589
+ });
590
+ if (!focus || !focus.spec_path) return result;
591
+ const focusSpec = loadSpec(vaultRoot, focus.spec_path);
592
+ if (!focusSpec) return result;
593
+
594
+ const focusFm = focusSpec.frontmatter || {};
595
+ const status = (focusFm.status || '').toLowerCase();
596
+ if (ALIGNMENT_SKIP_STATUSES.has(status)) return result; // not actionable — don't gate
597
+
598
+ // A task pin gates its parent epic rather than skipping the gate entirely.
599
+ const target = resolveAlignmentGateTarget(vaultRoot, focus, focusSpec);
600
+ if (!target) return result;
601
+ const { grain, fm } = target;
602
+
603
+ const id = fm.id || focus.spec_path;
604
+ const name = fm.name || id;
605
+ const loadRef = (ref) => loadTask(vaultRoot, ref);
606
+ const node = { id, name, frontmatter: fm, body: target.body };
607
+ const context = extractDecompositionContext(node, loadRef, grain);
608
+ const roadmapId = result.roadmap_id || args.roadmap_id;
609
+
610
+ const childRefs = getChildRefs(fm, grain);
611
+ const childSpecs = [];
612
+ for (const ref of childRefs) {
613
+ const child = loadRef(ref);
614
+ if (!child) continue;
615
+ childSpecs.push({
616
+ id: child.id || ref,
617
+ name: child.name || child.frontmatter?.name || ref,
618
+ status: child.status || child.frontmatter?.status || 'unknown',
619
+ body: child.body || '',
620
+ });
621
+ }
622
+
623
+ // No children (or none resolve) → HARD breakdown gate.
624
+ if (childSpecs.length === 0) {
625
+ const floor = readReadinessFloorSettings(vaultRoot);
626
+ const readiness = assessBriefReadiness({ name, context, floor });
627
+ if (!readiness.ready) {
628
+ const promptMarkdown = buildClarificationPrompt({
629
+ id,
630
+ name,
631
+ status: fm.status,
632
+ roadmapId,
633
+ missing: readiness.missing,
634
+ focusSpecPath: focus.spec_path,
635
+ grain,
636
+ });
637
+ return {
638
+ ...buildAlignmentGateResponse({
639
+ gate: 'needs_breakdown',
640
+ grain,
641
+ id,
642
+ name,
643
+ status: fm.status,
644
+ roadmapId,
645
+ focusSpecPath: focus.spec_path,
646
+ focusReason: focus.reason,
647
+ promptMarkdown,
648
+ suggestedActions: [],
649
+ }),
650
+ status: 'needs_clarification',
651
+ source_kind: 'needs_clarification_gate',
652
+ };
653
+ }
654
+ const promptMarkdown = buildDecompositionPromptMarkdown({
655
+ grain,
656
+ id,
657
+ name,
658
+ context,
659
+ roadmapId,
660
+ focusSpecPath: focus.spec_path,
661
+ });
662
+ return buildAlignmentGateResponse({
663
+ gate: 'needs_breakdown',
664
+ grain,
665
+ id,
666
+ name,
667
+ status: fm.status,
668
+ roadmapId,
669
+ focusSpecPath: focus.spec_path,
670
+ focusReason: focus.reason,
671
+ promptMarkdown,
672
+ suggestedActions: [
673
+ {
674
+ action: `file_${childKindFor(grain)}`,
675
+ mcp_tool: 'heyspex_record_alignment',
676
+ },
677
+ ],
678
+ });
679
+ }
680
+
681
+ // Children exist — AGV2 redefined drift as temporal corpus-staleness
682
+ // (related/depends_on/milestone last-modified newer than alignment_reviewed_at).
683
+ // Never a hard block when children exist; if drift is detected, prepend a
684
+ // soft question loop to the task brief and the agent poses keep/update/drop
685
+ // to the user. Silent passthrough when there's no baseline or no drift.
686
+ const loadFileMtime = (ref) => {
687
+ try {
688
+ const p = findSpecFile(vaultRoot, ref);
689
+ if (!p) return null;
690
+ const st = statSync(join(vaultRoot, p));
691
+ return st.mtimeMs;
692
+ } catch {
693
+ return null;
694
+ }
695
+ };
696
+ const drift = detectCorpusDrift({
697
+ epic: { frontmatter: fm },
698
+ reviewedAt: fm.alignment_reviewed_at,
699
+ loadRef,
700
+ lastModified: loadFileMtime,
701
+ });
702
+ if (!drift.hasDrift) return result; // no corpus changes since baseline — silent passthrough
703
+
704
+ const driftSection = buildDriftResolutionPrompt({
705
+ grain,
706
+ id,
707
+ name,
708
+ context,
709
+ existingChildren: childSpecs,
710
+ stale: drift.stale,
711
+ roadmapId,
712
+ focusSpecPath: focus.spec_path,
713
+ });
714
+ return {
715
+ ...result,
716
+ prompt_markdown: `${driftSection}\n\n---\n\n${result.prompt_markdown || ''}`,
717
+ alignment_drift: {
718
+ stale_corpus: drift.stale,
719
+ last_reviewed_at: fm.alignment_reviewed_at || null,
720
+ },
721
+ };
722
+ }
723
+
724
+ /**
725
+ * Lightweight alignment-state probe for summary mode. Returns the alignment
726
+ * state of the focus-pinned epic/milestone WITHOUT building the full
727
+ * decomposition or drift-resolution brief. Mirrors the disk-SSOT discipline of
728
+ * enrichOnDeckWithAlignment (reads children from spec file, never from a
729
+ * derived index) but skips the heavy prompt composition.
730
+ *
731
+ * Returns { state: 'fresh' | 'drift_detected' | 'needs_breakdown', changed_count? }
732
+ * or null when there is no focus pin to gate against.
733
+ */
734
+ export function probeAlignmentState(vaultRoot, args = {}) {
735
+ if (args.ignore_focus === true || args.ignore_focus === 'true') return null;
736
+
737
+ const focus = loadFocusOverride(vaultRoot, {
738
+ neonPinSpecId: args.neon_pin_spec_id,
739
+ neonSelectionSpecPath: args.neon_selection_spec_path,
740
+ });
741
+ if (!focus || !focus.spec_path) return null;
742
+ const focusSpec = loadSpec(vaultRoot, focus.spec_path);
743
+ if (!focusSpec) return null;
744
+
745
+ const focusFm = focusSpec.frontmatter || {};
746
+ const status = (focusFm.status || '').toLowerCase();
747
+ if (ALIGNMENT_SKIP_STATUSES.has(status)) return null;
748
+
749
+ const loadRef = (ref) => {
750
+ try {
751
+ return loadTask(vaultRoot, ref);
752
+ } catch {
753
+ return null;
754
+ }
755
+ };
756
+ // A task pin probes its parent epic — same rule as the gate itself, so the
757
+ // banner and the gate can never disagree about whether drift exists.
758
+ const target = resolveAlignmentGateTarget(vaultRoot, focus, focusSpec, { loadRef });
759
+ if (!target) return null;
760
+ const { grain, fm } = target;
761
+
762
+ const childRefs = getChildRefs(fm, grain);
763
+ const childCount = childRefs.reduce((n, ref) => (loadRef(ref) ? n + 1 : n), 0);
764
+ if (childCount === 0) {
765
+ return { state: 'needs_breakdown', grain, focus_spec_id: fm.id || focus.spec_path };
766
+ }
767
+
768
+ const loadFileMtime = (ref) => {
769
+ try {
770
+ const p = findSpecFile(vaultRoot, ref);
771
+ if (!p) return null;
772
+ const st = statSync(join(vaultRoot, p));
773
+ return st.mtimeMs;
774
+ } catch {
775
+ return null;
776
+ }
777
+ };
778
+ const drift = detectCorpusDrift({
779
+ epic: { frontmatter: fm },
780
+ reviewedAt: fm.alignment_reviewed_at,
781
+ loadRef,
782
+ lastModified: loadFileMtime,
783
+ });
784
+ if (drift.hasDrift) {
785
+ return {
786
+ state: 'drift_detected',
787
+ grain,
788
+ focus_spec_id: fm.id || focus.spec_path,
789
+ changed_count: Array.isArray(drift.stale) ? drift.stale.length : 0,
790
+ };
791
+ }
792
+ return { state: 'fresh', grain, focus_spec_id: fm.id || focus.spec_path };
793
+ }
794
+
795
+ /**
796
+ * Fetch the WIP ledger from the console (`GET /api/wip`) when this machine's
797
+ * dev-mode reliance is CLOUD — there the hand-off landed in the Neon
798
+ * `wip_ledger` table ([[HS-VOCF]]: the deployed console cannot write
799
+ * `.heyspex/wip-state.json`, so the local file is stale by design) and the
800
+ * /hs-next brief must surface the Neon row, not the file. Returns the
801
+ * `{ [specId]: entry }` map, or null when reliance is local / dev-mode cannot
802
+ * be read / the console is unreachable — every failure falls back to the
803
+ * file-ledger path in {@link enrichOnDeckWithWip}, best-effort, never throws.
804
+ */
805
+ export async function fetchCloudWipStateForBrief(vaultRoot) {
806
+ try {
807
+ // Bundler-safe loader: the two runtime paths first (stdio behaviour is
808
+ // unchanged), then a static specifier webpack can trace — without it the
809
+ // console's `POST /api/mcp` build baked the BUILD machine's checkout path,
810
+ // missed on Vercel, and every remote /hs-next silently lost its WIP block.
811
+ const mod = await importDevModeCore([
812
+ join(CHECKOUT_ROOT, 'scripts', 'lib', 'dev-mode.mjs'),
813
+ vaultRoot ? join(vaultRoot, 'scripts', 'lib', 'dev-mode.mjs') : null,
814
+ ]);
815
+ const resolved = mod.resolveDevMode();
816
+ if (resolved?.reliance !== 'cloud') return null;
817
+ } catch {
818
+ return null;
819
+ }
820
+ try {
821
+ const res = await consoleFetch('/api/wip', { timeout: 10000 });
822
+ // A denied caller scope is a 200 with an EMPTY ledger and an
823
+ // authorized:false marker (review S2) - treat it as "cloud unavailable"
824
+ // (null -> file fallback), never as "there is no hand-off".
825
+ if (res?.ok && res.body?.vaultScope?.authorized === false) return null;
826
+ const state = res?.ok ? res.body?.wip_state : null;
827
+ if (!state || typeof state !== 'object' || Array.isArray(state)) return null;
828
+ return state;
829
+ } catch {
830
+ return null;
831
+ }
832
+ }
833
+
834
+ /**
835
+ * Best-effort injection of the cloud ledger into next-on-deck args as
836
+ * `cloud_wip_state` (internal arg, not in the tool schema — same pattern as
837
+ * `neon_pin_spec_id`). No-op outside cloud reliance and in summary mode
838
+ * (the summary enricher chain skips the WIP block entirely). Exported for
839
+ * the `hs next` CLI face (`scripts/hs-next.mjs`), which builds the same brief.
840
+ */
841
+ export async function injectCloudWipState(vaultRoot, args) {
842
+ if (args?.summary === true || args?.summary === 'true') return args;
843
+ if (args?.ignore_wip === true || args?.ignore_wip === 'true') return args;
844
+ const cloud = await fetchCloudWipStateForBrief(vaultRoot);
845
+ return cloud ? { ...args, cloud_wip_state: cloud } : args;
846
+ }
847
+
848
+ /** Read `.heyspex/wip-state.json` from disk. {} on missing / parse error. */
849
+ export function loadWipState(vaultRoot) {
850
+ try {
851
+ const p = join(vaultRoot, '.heyspex', 'wip-state.json');
852
+ if (!existsSync(p)) return {};
853
+ const parsed = JSON.parse(readFileSync(p, 'utf-8'));
854
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
855
+ return parsed;
856
+ } catch {
857
+ return {};
858
+ }
859
+ }
860
+
861
+ export function renderWipBlock(specId, entry) {
862
+ const lines = [
863
+ `## 📋 Previous session WIP — ${specId} (read this, don't reconstruct)`,
864
+ '',
865
+ `> A prior session left work on this epic. This is the machine-readable handoff — use it instead of grepping wiring or \`git diff\` to rediscover state. Recorded ${entry.updatedAt || 'unknown'}${entry.agent ? ` by ${entry.agent}` : ''}.`,
866
+ '',
867
+ ];
868
+ const list = (label, arr) => {
869
+ if (!Array.isArray(arr) || arr.length === 0) return;
870
+ lines.push(`**${label}:**`);
871
+ for (const item of arr.slice(0, 25)) lines.push(`- ${item}`);
872
+ if (arr.length > 25) lines.push(`- …(${arr.length - 25} more)`);
873
+ lines.push('');
874
+ };
875
+ list('Untracked artifacts (built, not committed)', entry.untrackedArtifacts);
876
+ list('Files touched (uncommitted)', entry.filesTouched);
877
+ list('Specs filed', entry.specsFiled);
878
+ list('Verified', entry.verified);
879
+ list('Remaining', entry.remaining);
880
+ if (entry.notes) {
881
+ lines.push(`**Notes:** ${entry.notes}`, '');
882
+ }
883
+ return lines.join('\n');
884
+ }
885
+
886
+ export function wipEntryIsEmpty(entry) {
887
+ if (!entry || typeof entry !== 'object') return true;
888
+ const arrays = ['untrackedArtifacts', 'filesTouched', 'specsFiled', 'verified', 'remaining'];
889
+ const hasArray = arrays.some((k) => Array.isArray(entry[k]) && entry[k].length > 0);
890
+ return !hasArray && !entry.notes;
891
+ }
892
+
893
+ /**
894
+ * Statuses whose WIP entry the ledger keeps. Wider than plain {open, in_progress}:
895
+ * in_review / review_pending are build-complete but not done-with-handoff — review
896
+ * is a non-gating, deferrable suggestion (mvp-review-is-optional, owner correction
897
+ * 2026-08-15), so an epic can keep collecting WIP after submit_for_review. Mirrors
898
+ * the console write-side retention in `spex-console/src/lib/wip-state.ts`
899
+ * ([[HS-0QFI-AST6]]) — keep both in sync.
900
+ */
901
+ export const WIP_RETAIN_STATUSES = new Set(['open', 'in_progress', 'in_review', 'review_pending']);
902
+
903
+ /**
904
+ * Does this epic still have an in-flight handoff worth surfacing? Fail-safe: a
905
+ * missing spec or read error returns true (surface), so a transient lookup
906
+ * failure never hides live WIP — the console write-side sweep is the
907
+ * authoritative deletion path.
908
+ */
909
+ export function wipEpicIsOpen(vaultRoot, epicId) {
910
+ try {
911
+ const specPath = findSpecFile(vaultRoot, epicId);
912
+ if (!specPath) return true;
913
+ const spec = loadSpec(vaultRoot, specPath);
914
+ const status = spec?.frontmatter?.status;
915
+ if (typeof status !== 'string' || !status) return true;
916
+ return WIP_RETAIN_STATUSES.has(status);
917
+ } catch {
918
+ return true;
919
+ }
920
+ }
921
+
922
+ /**
923
+ * Surface the per-epic WIP ledger ([[HS-EPIC-FCSM]]) in the on-deck brief. When
924
+ * the result belongs to an epic that has a recorded WIP entry, prepend a
925
+ * "Previous session WIP" block so the next agent reads structured state instead
926
+ * of reconstructing it from specs + git diff. Display-only — never gates.
927
+ */
928
+ export function enrichOnDeckWithWip(vaultRoot, result, args = {}) {
929
+ if (!result || result.error) return result;
930
+ if (args.ignore_wip === true || args.ignore_wip === 'true') return result;
931
+
932
+ // Cloud reliance: the hand-off lives in the Neon `wip_ledger` table and was
933
+ // pre-fetched by `injectCloudWipState` ([[HS-VOCF]]); the local file is stale
934
+ // by design there. Absent (local reliance, console unreachable) → the file.
935
+ const cloudState =
936
+ args.cloud_wip_state &&
937
+ typeof args.cloud_wip_state === 'object' &&
938
+ !Array.isArray(args.cloud_wip_state)
939
+ ? args.cloud_wip_state
940
+ : null;
941
+ const state = cloudState ?? loadWipState(vaultRoot);
942
+ if (!state || Object.keys(state).length === 0) return result;
943
+
944
+ // Candidate epic ids the result could belong to, most-specific first. Includes
945
+ // the focus-pinned epic so a freshly-claimed epic surfaces its prior WIP even
946
+ // before the resolver drills into a child task.
947
+ const focus = loadFocusOverride(vaultRoot, {
948
+ neonPinSpecId: args.neon_pin_spec_id,
949
+ neonSelectionSpecPath: args.neon_selection_spec_path,
950
+ });
951
+ const focusSpec = focus?.spec_path ? loadSpec(vaultRoot, focus.spec_path) : null;
952
+ const focusFm = focusSpec?.frontmatter || {};
953
+ const focusIsEpic = focusFm.type === 'epic' || focus?.kind === 'epic';
954
+
955
+ const candidates = [
956
+ result.pinned_epic?.id,
957
+ result.seed_epic?.id,
958
+ focusIsEpic ? (focusFm.id || focus.spec_path) : null,
959
+ // result.spec_id itself when the on-deck pick IS an epic (focus_override).
960
+ typeof result.spec_id === 'string' && parseSpecId(result.spec_id)?.typeLabel === 'EPIC'
961
+ ? result.spec_id
962
+ : null,
963
+ ].filter(Boolean);
964
+
965
+ for (const epicId of candidates) {
966
+ const entry = state[epicId];
967
+ if (entry && !wipEntryIsEmpty(entry)) {
968
+ // Retention guard ([[HS-0QFI-AST6]]): the ledger holds open/in_progress/
969
+ // in_review/review_pending epics. If this epic has since gone terminal or
970
+ // paused, don't surface its WIP — the work isn't in flight. The console
971
+ // write-side sweep deletes the stale entry on the next record; this
972
+ // read-side skip covers the window until then.
973
+ if (!wipEpicIsOpen(vaultRoot, epicId)) continue;
974
+ const block = renderWipBlock(epicId, entry);
975
+ return {
976
+ ...result,
977
+ prompt_markdown: `${block}\n\n---\n\n${result.prompt_markdown || ''}`,
978
+ wip_state: { epic_id: epicId, ...entry },
979
+ };
980
+ }
981
+ }
982
+ return result;
983
+ }
984
+
985
+ /**
986
+ * The claim target's id in long display form — `HS-<fileKey>-<familyKey>`,
987
+ * without the TYPE label because every surface that renders it also renders
988
+ * `level` in adjacent prose ([[HS-57WO-094P-RES-long-form-spec-id-display-hs-file-key]]).
989
+ * Reads the family key off the leaf's own path (era-3 basenames carry it
990
+ * positionally) and degrades to the short id when the path is absent.
991
+ */
992
+ export function displayClaimTargetId(claimTarget = {}) {
993
+ const relPath = claimTarget.relPath || null;
994
+ const basename = relPath ? String(relPath).split(/[\\/]/).pop().replace(/\.md$/, '') : null;
995
+ return formatDisplayId(claimTarget.spec_id, { basename, type: claimTarget.level });
996
+ }
997
+
998
+ /**
999
+ * Render the ONE `**Sprint:**` line for the claim-target banner
1000
+ * ([[HS-2IF0-7NZ3-EPIC-token-estimation-and-auto-bundling-for-o]] criterion 9 /
1001
+ * [[HS-ARWE-CPQD]]). `sprint` is `result.sprint` from
1002
+ * {@link attachSprintToOnDeck} — the shape it sets on success (never called
1003
+ * when `sprint` is null/absent, so this never has to render a "no sprint"
1004
+ * state). `selfId` (the claim target's own `spec_id`) is excluded from the
1005
+ * "with …" list. Tokens are rounded to the nearest k, per the epic's contract
1006
+ * — this is a heuristic planning aid, not an exact count.
1007
+ */
1008
+ export function formatSprintLine(sprint, selfId) {
1009
+ const toK = (n) => `${Math.round((Number(n) || 0) / 1000)}k`;
1010
+ const budgetK = toK(sprint.budget);
1011
+ if (sprint.size <= 1) {
1012
+ if (sprint.overBudget) {
1013
+ return `**Bundle:** ⚠ ~${toK(sprint.estimatedTokens)} tokens exceeds the ${budgetK} budget - plan a solo session or split this task first`;
1014
+ }
1015
+ return `**Bundle:** solo · ~${toK(sprint.estimatedTokens)} of ${budgetK} tokens`;
1016
+ }
1017
+ const selfLower = String(selfId || '').toLowerCase();
1018
+ const others = (Array.isArray(sprint.taskIds) ? sprint.taskIds : []).filter(
1019
+ (id) => String(id).toLowerCase() !== selfLower,
1020
+ );
1021
+ const shown = others.slice(0, 6);
1022
+ const extra = others.length - shown.length;
1023
+ const withPart = shown.length
1024
+ ? ` · with ${shown.join(', ')}${extra > 0 ? ` +${extra}` : ''}`
1025
+ : '';
1026
+ // "Bundle", not "sprint": owner ruling 2026-09-02 — a sprint is the roadmap
1027
+ // unit above the milestone (two or more milestones); the context-window task
1028
+ // group is a bundle ([[HS-3C9M-A4TX-CAP]], supersedes the 2026-05-29 term).
1029
+ return `**Bundle:** task ${sprint.position} of ${sprint.size} in bundle ${sprint.index + 1} · ~${toK(sprint.totalTokens)} of ${budgetK} tokens${withPart}`;
1030
+ }
1031
+
1032
+ export function formatClaimBanner(claimTarget, alignmentResult = {}, sprint = null) {
1033
+ if (!claimTarget) return '';
1034
+ // Long display form ([[HS-57WO-094P-RES-long-form-spec-id-display-hs-file-key]]):
1035
+ // the family key names the milestone lineage, so the owner can reference the
1036
+ // parent from the banner alone. `includeType` stays off — the banner already
1037
+ // renders `(${level})` and the claim question already bolds the level, so
1038
+ // suffixing `-TASK` would state the kind twice. Display only: every claim,
1039
+ // tool call, and API write still uses the short `claimTarget.spec_id`.
1040
+ const displayedId = displayClaimTargetId(claimTarget);
1041
+ const breadcrumb =
1042
+ Array.isArray(claimTarget.hierarchy_breadcrumb) && claimTarget.hierarchy_breadcrumb.length
1043
+ ? claimTarget.hierarchy_breadcrumb.join(' › ')
1044
+ : displayedId;
1045
+ const lines = [
1046
+ `### 🎯 Claim target — \`${displayedId}\` (${claimTarget.level})`,
1047
+ '',
1048
+ `**Breadcrumb:** ${breadcrumb}`,
1049
+ `**Title:** ${claimTarget.title || displayedId}`,
1050
+ ];
1051
+ // Surface the leaf's authored plain-language summary so the user recognizes the
1052
+ // work before authorizing the claim. The value is a YAML block scalar on disk
1053
+ // (contract-capped ~400 chars); collapse its newlines for the one-line banner.
1054
+ // When absent, omit the line — composeAgentOrchestration handles the
1055
+ // missing-summary case in the claim question (author-then-claim fallback).
1056
+ if (claimTarget.summary && String(claimTarget.summary).trim()) {
1057
+ const summaryOneLine = String(claimTarget.summary).replace(/\s+/g, ' ').trim();
1058
+ lines.push(`**Summary:** ${summaryOneLine}`);
1059
+ }
1060
+ lines.push(
1061
+ `**Intent:** ${claimTarget.intent_verb}`,
1062
+ `**Alignment:** \`${claimTarget.alignment_state || alignmentResult.state || 'unknown'}\``,
1063
+ );
1064
+ // Sprint (token-budgeted work-group), computed at the planning gate and
1065
+ // surfaced here BEFORE execution begins — never decided by the implementing
1066
+ // agent, never silently re-derived mid-implementation
1067
+ // ([[HS-2IF0-7NZ3-EPIC-token-estimation-and-auto-bundling-for-o]] criteria
1068
+ // 7-9 / [[HS-ARWE-CPQD]]). `sprint` arrives only via a second
1069
+ // `formatClaimBanner` call from `attachSprintToOnDeck` once the async
1070
+ // console fetch resolves — the FIRST (synchronous) call in
1071
+ // `enrichOnDeckWithClaimTarget` always passes `null` here.
1072
+ if (sprint) {
1073
+ lines.push(formatSprintLine(sprint, claimTarget.spec_id));
1074
+ }
1075
+ if ((claimTarget.alignment_state || alignmentResult.state) === 'drift_detected') {
1076
+ lines.push(
1077
+ '',
1078
+ '> ⚠️ Drift detected. Review the brief sections above before authorizing — pick option [1] proceed only after you have answered keep/update/drop on each stale corpus doc.',
1079
+ );
1080
+ }
1081
+ if (claimTarget.rollup_state === 'reviewer_blocked') {
1082
+ const n = claimTarget.rollup_detail?.in_review ?? 'some';
1083
+ lines.push(
1084
+ '',
1085
+ `⏳ **Reviewer-blocked** — ${n} child task(s) in review awaiting human review. The right next actor is the reviewer (walk each child's HS-REV companion, accept/reject memory candidates, commit to close).`,
1086
+ );
1087
+ } else if (claimTarget.rollup_state === 'ready_for_close') {
1088
+ lines.push(
1089
+ '',
1090
+ '📋 **Ready for close-out** — every child is build-complete (closed or awaiting batched review below the active review grain). Flip this unit to `in_review`, add the Review back-link, and file an HS-REV companion. See [[session-close-protocol]].',
1091
+ );
1092
+ } else if (claimTarget.rollup_state === 'terminal_leaf') {
1093
+ if (claimTarget.next_action_hint === 'wait_for_reviewer') {
1094
+ lines.push(
1095
+ '',
1096
+ '⏳ **Awaiting review** — this task is done from the agent\'s perspective. Walk its HS-REV companion (or run `/hs-next review <task-id>`); the human reviewer commits to close.',
1097
+ );
1098
+ } else {
1099
+ const st = claimTarget.terminal_status || 'closed';
1100
+ lines.push(
1101
+ '',
1102
+ `✅ **Already terminal** — status is \`${st}\`. There is no implementation work to claim.`,
1103
+ );
1104
+ }
1105
+ }
1106
+ return lines.join('\n');
1107
+ }
1108
+
1109
+ // Parent-contract tethering ([[HS-FRGG-IU2S-TASK-parent-contract-tethering-in-the-hs]]):
1110
+ // a contract-shaped link is a `related:` ref that resolves under specs/adr/,
1111
+ // specs/protocols/, or to the entity model — the docs a task could drift against.
1112
+ export const CONTRACT_PATH_RE = /(^|[\\/])(adr|protocols)[\\/]|(^|[\\/])entity-model(\.md)?$/i;
1113
+
1114
+ /**
1115
+ * Filter a list of refs (wikilinks or bare basenames) down to contract-shaped
1116
+ * docs. Pure: link resolution is injected as `resolveRelPath(ref) → relPath|null`.
1117
+ * Dedupes by ref; a ref that fails to resolve is silently dropped (it is a
1118
+ * dangling link, the linkage gate's problem — not this brief's).
1119
+ */
1120
+ export function classifyContractRefs(refs, resolveRelPath) {
1121
+ const out = [];
1122
+ const seen = new Set();
1123
+ for (const raw of Array.isArray(refs) ? refs : []) {
1124
+ const ref = String(raw ?? '')
1125
+ .replace(/^\[\[/, '')
1126
+ .replace(/\]\]$/, '')
1127
+ .split('|')[0]
1128
+ .split('#')[0]
1129
+ .trim();
1130
+ if (!ref || seen.has(ref)) continue;
1131
+ seen.add(ref);
1132
+ let relPath = null;
1133
+ try {
1134
+ relPath = resolveRelPath ? resolveRelPath(ref) : null;
1135
+ } catch {
1136
+ relPath = null;
1137
+ }
1138
+ if (relPath && CONTRACT_PATH_RE.test(String(relPath))) {
1139
+ out.push({ ref, relPath: String(relPath).replace(/\\/g, '/') });
1140
+ }
1141
+ }
1142
+ return out;
1143
+ }
1144
+
1145
+ /**
1146
+ * Render the task-grain "Parent contract" brief section: the parent epic's goal
1147
+ * plus the contract docs the task must load — or, when no contract-shaped link
1148
+ * resolves, an EXPLICIT hard-surface line (never a silent omission — the epic's
1149
+ * "warning at file time, hard surface at claim time" rule).
1150
+ */
1151
+ export function composeParentContractSection(claimTarget = {}) {
1152
+ if (!claimTarget || claimTarget.level !== 'task') return '';
1153
+ const parent = claimTarget.parent || null;
1154
+ const contracts = Array.isArray(claimTarget.parent_contracts)
1155
+ ? claimTarget.parent_contracts
1156
+ : [];
1157
+ const lines = ['#### 📜 Parent contract'];
1158
+ if (parent && (parent.goal || parent.title)) {
1159
+ const goal = String(parent.goal || '').replace(/\s+/g, ' ').trim();
1160
+ lines.push(
1161
+ '',
1162
+ `**Parent${parent.kind ? ` ${parent.kind}` : ''}${parent.title ? ` — ${parent.title}` : ''}:** ${
1163
+ goal || 'no goal recorded — read the parent spec before starting'
1164
+ }`,
1165
+ );
1166
+ }
1167
+ if (contracts.length) {
1168
+ lines.push('', '**Contracts to honor (load before editing the interfaces they own):**');
1169
+ for (const c of contracts) {
1170
+ lines.push(`- [[${c.ref}]] — \`${c.relPath}\``);
1171
+ }
1172
+ } else {
1173
+ lines.push(
1174
+ '',
1175
+ '⚠ **No contract link — parent contract not tethered.** Neither this task nor its parent links an ADR, protocol, or the entity model. Ground against the parent spec before touching shared interfaces, and add the missing contract link to the task.',
1176
+ );
1177
+ }
1178
+ return lines.join('\n');
1179
+ }
1180
+
1181
+ export function deriveAlignmentState(result) {
1182
+ if (!result) return 'unknown';
1183
+ if (result.alignment_drift) return 'drift_detected';
1184
+ if (typeof result.source_kind === 'string') {
1185
+ if (result.source_kind.endsWith('_gate')) return 'drift_detected';
1186
+ if (result.source_kind === 'milestone_needs_breakdown') return 'drift_detected';
1187
+ }
1188
+ if (typeof result.status === 'string') {
1189
+ if (result.status === 'needs_breakdown' || result.status === 'alignment_review') {
1190
+ return 'drift_detected';
1191
+ }
1192
+ }
1193
+ return 'fresh';
1194
+ }
1195
+
1196
+ /**
1197
+ * Bookend the response with a claim-target banner so the user reads hierarchy
1198
+ * + intent before reaching the claim line. Reads spec via loadTask — disk,
1199
+ * or (cloud tier) `args.__cloud_task_graph`'s pre-fetch — never from a
1200
+ * derived index (SSOT). Best-effort: on failure the banner is skipped and
1201
+ * the original prompt_markdown stays intact.
1202
+ */
1203
+ export function enrichOnDeckWithClaimTarget(vaultRoot, result, args = {}) {
1204
+ if (!result || result.error) return result;
1205
+ if (!result.spec_id) return result;
1206
+ if (args.ignore_claim_target === true || args.ignore_claim_target === 'true') return result;
1207
+
1208
+ // [[HS-H227-MCPC-TASK]]: `__cloud_task_graph` (pre-fetched over HTTP by
1209
+ // `handleNextOnDeck` when cloud-reliant) routes both this load and the
1210
+ // `loadRef` hierarchy/parent-contract walk below through
1211
+ // `loadTaskViaGraph` instead of straight-to-disk `loadTask`. Undefined
1212
+ // (every disk-reliant call site) falls through to the original disk read.
1213
+ let spec = null;
1214
+ try {
1215
+ spec = loadTaskViaGraph(vaultRoot, result.spec_id, args.__cloud_task_graph);
1216
+ } catch {
1217
+ spec = null;
1218
+ }
1219
+ if (!spec) return result;
1220
+
1221
+ const loadRef = (ref) => {
1222
+ try {
1223
+ return loadTaskViaGraph(vaultRoot, ref, args.__cloud_task_graph);
1224
+ } catch {
1225
+ return null;
1226
+ }
1227
+ };
1228
+ const alignmentState = deriveAlignmentState(result);
1229
+ // Converge the claim target onto scheduler output ([[HS-EPIC-CNVG]]): order
1230
+ // the milestone/epic descent by the same global scheduler order every display
1231
+ // surface reads. Priority: the global cache → else the already-scheduler-sorted
1232
+ // `candidate_epics` (milestone drill, warm even on cold cache) → else declared
1233
+ // order. Without this the walk picks the first *written* child (the
1234
+ // seed_epic-vs-claim_target divergence this fixes).
1235
+ const cacheOrderIds = readSchedulerCacheOrderIds(vaultRoot);
1236
+ const schedulerOrderIds =
1237
+ cacheOrderIds.length > 0
1238
+ ? cacheOrderIds
1239
+ : Array.isArray(result.candidate_epics)
1240
+ ? result.candidate_epics.map((e) => e?.id).filter(Boolean)
1241
+ : [];
1242
+ // Tasks other agents actively hold ([[HS-3C9M-A4TX-CAP]]) — set by
1243
+ // resolveNextOnDeckAroundActiveClaims so the walk offers the next task
1244
+ // nobody holds instead of the one already being worked.
1245
+ const excludeIds = Array.isArray(args.exclude_spec_ids)
1246
+ ? new Set(args.exclude_spec_ids.map((id) => String(id).toLowerCase()))
1247
+ : null;
1248
+ const claimTarget = computeClaimTarget(
1249
+ spec,
1250
+ vaultRoot,
1251
+ alignmentState,
1252
+ loadRef,
1253
+ schedulerOrderIds,
1254
+ undefined,
1255
+ excludeIds && excludeIds.size > 0 ? excludeIds : null,
1256
+ );
1257
+ if (!claimTarget) return result;
1258
+
1259
+ // [[HS-68XO-DIAG]] residual — make the claim target addressable by a UNIQUELY-
1260
+ // resolvable handle (see disambiguateClaimSpecId).
1261
+ claimTarget.spec_id = disambiguateClaimSpecId(
1262
+ vaultRoot,
1263
+ claimTarget.spec_id,
1264
+ claimTarget.relPath,
1265
+ );
1266
+
1267
+ // Resolve the immediate parent's goal so the missing-summary authoring prompt
1268
+ // (composeAgentOrchestration, below) can lead with it — a task's epic, an
1269
+ // epic's milestone. Best-effort and read-from-disk (SSOT): a top-level
1270
+ // milestone has no parent and keeps the standalone summary contract.
1271
+ let parentSpecLoaded = null;
1272
+ try {
1273
+ const crumb = Array.isArray(claimTarget.hierarchy_breadcrumb)
1274
+ ? claimTarget.hierarchy_breadcrumb
1275
+ : [];
1276
+ const parentId = crumb.length >= 2 ? crumb[crumb.length - 2] : null;
1277
+ if (parentId && parentId !== claimTarget.spec_id) {
1278
+ const parentSpec = loadRef(parentId);
1279
+ parentSpecLoaded = parentSpec || null;
1280
+ claimTarget.parent = parentSpec
1281
+ ? {
1282
+ id: parentSpec.id || parentId,
1283
+ kind: parentSpec.type || parentSpec.frontmatter?.type || null,
1284
+ title: parentSpec.name || parentSpec.frontmatter?.name || null,
1285
+ goal: parentSpec.frontmatter?.summary || null,
1286
+ relPath: parentSpec.specPath || null,
1287
+ }
1288
+ : { id: parentId, kind: null, title: null, goal: null, relPath: null };
1289
+ }
1290
+ } catch {
1291
+ /* parent context is a nicety, not a gate — skip on any failure */
1292
+ }
1293
+
1294
+ // Parent-contract tethering ([[HS-FRGG-IU2S-TASK-parent-contract-tethering-in-the-hs]]):
1295
+ // at task grain, collect contract-shaped `related:` links from the task AND its
1296
+ // parent so the brief carries the interfaces the work could drift against —
1297
+ // enforced-at-execution, not just recorded in the graph. Best-effort resolve;
1298
+ // an empty result still hard-surfaces via composeParentContractSection.
1299
+ if (claimTarget.level === 'task') {
1300
+ try {
1301
+ const leafSpec = loadRef(claimTarget.spec_id) || spec;
1302
+ const refs = [
1303
+ ...(Array.isArray(leafSpec?.frontmatter?.related) ? leafSpec.frontmatter.related : []),
1304
+ ...(Array.isArray(parentSpecLoaded?.frontmatter?.related)
1305
+ ? parentSpecLoaded.frontmatter.related
1306
+ : []),
1307
+ ];
1308
+ claimTarget.parent_contracts = classifyContractRefs(refs, (ref) => {
1309
+ // findSpecFile returns a VAULT-RELATIVE path (resolveSpecFileRaw contract).
1310
+ // It does not search specs/protocols/ — check that bucket explicitly so
1311
+ // protocol wikilinks (the most common contract shape) classify.
1312
+ const hit = findSpecFile(vaultRoot, ref);
1313
+ if (hit) return hit;
1314
+ const protoRel = join('specs', 'protocols', `${ref}.md`);
1315
+ return existsSync(join(vaultRoot, protoRel)) ? protoRel : null;
1316
+ });
1317
+ claimTarget.contract_tethered = claimTarget.parent_contracts.length > 0;
1318
+ } catch {
1319
+ claimTarget.parent_contracts = [];
1320
+ claimTarget.contract_tethered = false;
1321
+ }
1322
+ }
1323
+
1324
+ const banner = formatClaimBanner(claimTarget, { state: alignmentState });
1325
+ if (!banner) return result;
1326
+
1327
+ const contractSection = composeParentContractSection(claimTarget);
1328
+ const body = (result.prompt_markdown || '').trim();
1329
+ // Summary-mode path: no body to bookend — emit the banner once so the
1330
+ // claim-target line stands alone instead of wrapping an empty middle.
1331
+ const prompt = body.length === 0
1332
+ ? (contractSection ? `${banner}\n\n${contractSection}` : banner)
1333
+ : `${banner}\n\n${contractSection ? `${contractSection}\n\n` : ''}---\n\n${result.prompt_markdown}\n\n---\n\n${banner}`;
1334
+ const enriched = {
1335
+ ...result,
1336
+ // user_prompt_markdown is the canonical user-facing field after HS-TASK-HSNX
1337
+ // split it from agent_dispatch. prompt_markdown stays as the @deprecated
1338
+ // mirror so unmigrated consumers print the same user-facing content.
1339
+ user_prompt_markdown: prompt,
1340
+ prompt_markdown: prompt,
1341
+ claim_target: claimTarget,
1342
+ alignment_result: { state: alignmentState },
1343
+ };
1344
+ if (claimTarget.next_action_hint) {
1345
+ enriched.next_action_hint = claimTarget.next_action_hint;
1346
+ }
1347
+ return enriched;
1348
+ }
1349
+
1350
+ /** @returns {'handheld' | 'assisted' | 'autonomous'} */
1351
+ export function resolveOnDeckFront(args = {}) {
1352
+ return normalizeRunFrontToken(args.front) ?? 'handheld';
1353
+ }
1354
+
1355
+ /** @param {Record<string, unknown>} args @param {Record<string, unknown>} [overrides] */
1356
+ export function packNextOnDeckToolArgs(args = {}, overrides = {}) {
1357
+ const packed = { ...overrides };
1358
+ const front = resolveOnDeckFront(args);
1359
+ if (front !== 'handheld') packed.front = front;
1360
+ if (args.spec_id && packed.spec_id === undefined) packed.spec_id = args.spec_id;
1361
+ if (shouldAutoClaimOnDeck(args) && packed.auto_claim === undefined) packed.auto_claim = true;
1362
+ return packed;
1363
+ }
1364
+
1365
+ /**
1366
+ * @param {object} p
1367
+ * @param {string} p.specId
1368
+ * @param {string} p.level
1369
+ * @param {string} p.launchTool
1370
+ * @param {string} p.shortSession
1371
+ * @param {Record<string, unknown>} p.args
1372
+ * @param {Record<string, unknown>} p.failureModes
1373
+ */
1374
+ export function buildAutoClaimOrchestration({
1375
+ specId,
1376
+ level,
1377
+ launchTool,
1378
+ shortSession,
1379
+ args,
1380
+ failureModes,
1381
+ }) {
1382
+ return {
1383
+ user_prompt: [
1384
+ '',
1385
+ '---',
1386
+ '',
1387
+ `🔓 **Auto-claim** — claiming **${level}** \`${specId}\` as **${launchTool} · \`${shortSession}\`** and beginning execution.`,
1388
+ ].join('\n'),
1389
+ agent_dispatch: {
1390
+ phase: 'auto_claim_execute',
1391
+ execute_instruction:
1392
+ 'Output user_prompt_markdown verbatim, then immediately run every on_complete action in the SAME turn — claim via heyspex_claim_spec, re-fetch the full brief with summary:false, and begin executing. Do NOT ask the human to confirm.',
1393
+ on_complete: [
1394
+ {
1395
+ tool: 'mcp__heyspex__heyspex_claim_spec',
1396
+ args: { spec_id: specId },
1397
+ },
1398
+ {
1399
+ tool: 'mcp__heyspex__heyspex_next_on_deck',
1400
+ args: packNextOnDeckToolArgs(args, { summary: false }),
1401
+ },
1402
+ ],
1403
+ on_handoff: [
1404
+ {
1405
+ tool: 'mcp__heyspex__heyspex_release_spec',
1406
+ args: { spec_id: specId },
1407
+ comment: 'On session hand-off or when moving the spec to in_review.',
1408
+ },
1409
+ ],
1410
+ failure_modes: failureModes,
1411
+ },
1412
+ };
1413
+ }
1414
+
1415
+ /**
1416
+ * @param {Record<string, unknown>} claimTarget
1417
+ * @param {Record<string, unknown>} result
1418
+ */
1419
+ export function isOnDeckTaskGrain(claimTarget = {}, result = {}) {
1420
+ const level = String(claimTarget.level || '').toLowerCase();
1421
+ if (level === 'task') return true;
1422
+ const id = String(claimTarget.spec_id || result.spec_id || '');
1423
+ return /-TASK-/i.test(id);
1424
+ }
1425
+
1426
+ /**
1427
+ * Run-front banner for non-handheld `/hs-next` invocations.
1428
+ * @param {'handheld' | 'assisted' | 'autonomous'} front
1429
+ * @param {Record<string, unknown>} claimTarget
1430
+ * @param {Record<string, unknown>} result
1431
+ * @returns {string}
1432
+ */
1433
+ /**
1434
+ * Under a run front (assisted / autonomous) the unit the dispatcher takes is a
1435
+ * MILESTONE. When the id the caller gave is a milestone or an epic, the brief's
1436
+ * job is to arm that milestone's run — not to claim the task the walk landed
1437
+ * on ([[HS-3C9M-A4TX-CAP]]). A task named directly keeps the task-grain rules.
1438
+ * Mirrors `resolveArmTarget` in scripts/hs-next.mjs.
1439
+ * @returns {{ milestoneId: string, from: 'milestone' | 'epic', specId: string } | null}
1440
+ */
1441
+ export function resolveArmGrainTarget(result, front) {
1442
+ if (!result || (front !== 'assisted' && front !== 'autonomous')) return null;
1443
+ const specPath = String(result.spec_path || '').replace(/\\/g, '/');
1444
+ const specId = result.spec_id ? String(result.spec_id) : '';
1445
+ if (!specId) return null;
1446
+ if (/(^|\/)milestones\//.test(specPath)) return { milestoneId: specId, from: 'milestone', specId };
1447
+ if (/(^|\/)epics\//.test(specPath)) {
1448
+ const crumb = result.claim_target?.hierarchy_breadcrumb;
1449
+ const top = Array.isArray(crumb) && crumb.length > 1 ? String(crumb[0]) : '';
1450
+ return top ? { milestoneId: top, from: 'epic', specId } : null;
1451
+ }
1452
+ return null;
1453
+ }
1454
+
1455
+ function buildArmRunOrchestration({ arm, front, failureModes }) {
1456
+ const word = front === 'autonomous' ? 'auto' : 'assist';
1457
+ const command = `hs next ${word} ${arm.milestoneId} --launch`;
1458
+ const label = front === 'autonomous' ? 'Autonomous' : 'Assisted';
1459
+ const loop =
1460
+ front === 'autonomous'
1461
+ ? 'unattended — the dispatcher applies the recommended option at each gate and you review the retrospective after'
1462
+ : 'you on the loop — the dispatcher pauses at each decision gate for your answer';
1463
+ const lines = ['', '---', '', `🏭 **${label} run front** — ${loop}.`, ''];
1464
+ if (arm.from !== 'milestone') {
1465
+ lines.push(
1466
+ `Runs arm at milestone grain: \`${arm.specId}\` sits under milestone \`${arm.milestoneId}\`, so that whole milestone is what gets queued.`,
1467
+ '',
1468
+ );
1469
+ }
1470
+ lines.push(
1471
+ `No task is claimed here. Arm the run: \`${command}\` — it writes the same queue + front files the What's Next console writes for the running dispatcher, and prints the \`--ensure\` command when the dispatcher is not live.`,
1472
+ );
1473
+ return {
1474
+ user_prompt: lines.join('\n'),
1475
+ agent_dispatch: {
1476
+ phase: 'arm_run',
1477
+ execute_instruction:
1478
+ 'Output user_prompt_markdown verbatim. Do NOT claim the task shown above. Give the human the arm command (it is a terminal step) and stop — the dispatcher owns execution from there.',
1479
+ arm_target: {
1480
+ milestone_id: arm.milestoneId,
1481
+ from: arm.from,
1482
+ spec_id: arm.specId,
1483
+ front,
1484
+ command,
1485
+ },
1486
+ failure_modes: failureModes,
1487
+ },
1488
+ };
1489
+ }
1490
+
1491
+ export function renderRunFrontSection(front, claimTarget = {}, result = {}) {
1492
+ if (!front || front === 'handheld') return '';
1493
+ // At milestone/epic grain the orchestration section carries the arm command;
1494
+ // a second banner (or the "task launches handheld-only" warning) would only
1495
+ // contradict it.
1496
+ if (resolveArmGrainTarget(result, front)) return '';
1497
+ const specId = claimTarget.spec_id || result.spec_id || 'this spec';
1498
+ const atTask = isOnDeckTaskGrain(claimTarget, result);
1499
+
1500
+ if (atTask) {
1501
+ return [
1502
+ '',
1503
+ '---',
1504
+ '',
1505
+ `⚠️ **Run front \`${front}\` on a task** — single tasks launch in **handheld** mode only.`,
1506
+ '',
1507
+ `- To **implement** \`${specId}\`: \`/hs-next ${specId}\` (handheld is default).`,
1508
+ `- To **batch-run** its parent milestone (the dispatcher runs at milestone grain): \`hs next ${front === 'autonomous' ? 'auto' : 'assist'} <HS-MIL…> --launch\` at a terminal, or \`/hs-next ${front === 'autonomous' ? 'auto' : 'assist'} HS-MIL…\` here.`,
1509
+ `- Working **outside** the console: \`/hs-auto\` orchestrates dev-factory end-to-end.`,
1510
+ ].join('\n');
1511
+ }
1512
+
1513
+ const frontLabel = front === 'autonomous' ? 'Autonomous' : 'Assisted';
1514
+ const loop =
1515
+ front === 'autonomous'
1516
+ ? 'you are *over* the loop — the dispatcher auto-applies Recommended options; review the Retrospective when you return'
1517
+ : 'you are *on* the loop — the dispatcher pauses at decision gates for live answers';
1518
+
1519
+ return [
1520
+ '',
1521
+ '---',
1522
+ '',
1523
+ `🏭 **${frontLabel} run front** — ${loop}.`,
1524
+ '',
1525
+ `- From a terminal: \`hs next ${front === 'autonomous' ? 'auto' : 'assist'} <HS-MIL…> --launch\` arms the run — it writes the same queue + front files the What's Next console writes, and several milestone ids queue in order.`,
1526
+ "- Or launch / steer from the What's Next console, or run `/hs-auto` when working outside HeySpex.",
1527
+ '- Ensure the dispatcher is live: `node dev-factory/coo-dispatcher.mjs --ensure`.',
1528
+ `- Scope resolved for \`${specId}\`; factory launch floor is epic/milestone grain.`,
1529
+ ].join('\n');
1530
+ }
1531
+
1532
+ /**
1533
+ * @param {Record<string, unknown>} result
1534
+ * @param {Record<string, unknown>} args
1535
+ */
1536
+ export function enrichOnDeckWithRunFront(result, args = {}) {
1537
+ if (!result || result.error) return result;
1538
+ const front = resolveOnDeckFront(args);
1539
+ const section = renderRunFrontSection(front, result.claim_target || {}, result);
1540
+ const updated = { ...result, run_front: front };
1541
+ if (section) {
1542
+ const existing = updated.user_prompt_markdown ?? updated.prompt_markdown ?? '';
1543
+ updated.user_prompt_markdown = `${existing}${section}`;
1544
+ updated.prompt_markdown = updated.user_prompt_markdown;
1545
+ }
1546
+ return updated;
1547
+ }
1548
+
1549
+ /**
1550
+ * Does the missing-`summary:` authoring phase have to write through the
1551
+ * console instead of a vault file? True when ([[HS-T41O-NZ8S-DIAG]]):
1552
+ * - the session's vault write mode is `cloud` (`__vault_write_mode`,
1553
+ * injected at the async handler boundary by `handleNextOnDeck` from
1554
+ * `resolveVaultWriteMode()`), or
1555
+ * - the target was read from the console (`__cloud_direct_spec` /
1556
+ * `__cloud_task_graph` — the neon read tier), or
1557
+ * - the target is Neon-only: its `relPath` does not exist under the
1558
+ * resolved vault root, so there is no file an Edit could land in.
1559
+ * Absent every signal (every disk-reliant call site, every caller that
1560
+ * predates this) the file-edit brief is kept unchanged. Pure.
1561
+ */
1562
+ export function summaryWriteTargetsConsole(result, claimTarget = {}, args = {}) {
1563
+ if (args.__vault_write_mode === 'cloud') return true;
1564
+ if (args.__vault_write_mode === 'local') return false;
1565
+ if (args.__cloud_direct_spec !== undefined || args.__cloud_task_graph || args.__cloud_scheduler_rows) return true;
1566
+ const relPath = claimTarget?.relPath ? String(claimTarget.relPath) : '';
1567
+ const vaultRoot = result?.vault_root ? String(result.vault_root) : '';
1568
+ if (relPath && vaultRoot) {
1569
+ try {
1570
+ return !existsSync(isAbsolute(relPath) ? relPath : join(vaultRoot, relPath));
1571
+ } catch {
1572
+ return false;
1573
+ }
1574
+ }
1575
+ return false;
1576
+ }
1577
+
1578
+ export function composeAgentOrchestration(result, args = {}) {
1579
+ if (!result || result.error) return null;
1580
+ const isSummary = args.summary === true || args.summary === 'true';
1581
+ const claimTarget = result.claim_target || {};
1582
+ const alignmentState =
1583
+ claimTarget.alignment_state ||
1584
+ result.alignment_result?.state ||
1585
+ deriveAlignmentState(result);
1586
+ const specId = claimTarget.spec_id || result.spec_id || 'unknown';
1587
+ // Long form for the human-facing prompt text ONLY — `specId` stays short and
1588
+ // remains what every tool call, claim, and on_complete arg below is built
1589
+ // from ([[HS-57WO-094P-RES-long-form-spec-id-display-hs-file-key]]).
1590
+ const displaySpecId = claimTarget.spec_id ? displayClaimTargetId(claimTarget) : specId;
1591
+ const level = (claimTarget.level || 'spec').toUpperCase();
1592
+ const intent = claimTarget.intent_verb || 'work this spec';
1593
+ const focusSpecId =
1594
+ result.alignment_summary?.focus_spec_id ||
1595
+ result.claim_target?.spec_id ||
1596
+ specId;
1597
+ const changedCount = result.alignment_summary?.changed_count;
1598
+
1599
+ const failureModes = {
1600
+ CONSOLE_URL_MISMATCH: {
1601
+ handling: 'retry_tool_call',
1602
+ note: 'IF an MCP tool returns CONSOLE_URL_MISMATCH: the console dev server is UP on another port; retry the same tool — never hand-write task/epic files.',
1603
+ },
1604
+ CONSOLE_TIMEOUT: {
1605
+ handling: 'retry_tool_call',
1606
+ note: 'IF an MCP tool returns CONSOLE_TIMEOUT: the console is up but slow (cold compile). Retry shortly — do NOT tell the user the server is down.',
1607
+ },
1608
+ CONSOLE_UNREACHABLE: {
1609
+ handling: 'verify_with_dev_server_status',
1610
+ note: 'IF heyspex_claim_spec returns CONSOLE_UNREACHABLE: call heyspex_dev_server_status before telling the user the console is down. Claim-badge-only proceed (status flip on disk) — NEVER hand-write files for heyspex_file_spec failures.',
1611
+ },
1612
+ claim_blocked_by_descendants: {
1613
+ handling: 'rerun_with_descendant_id',
1614
+ note: '409 from heyspex_claim_spec ([[HS-TASK-0RVD]]); use claim_target.spec_id from the error and re-run /hs-next <that-id>',
1615
+ },
1616
+ NO_ACTIONABLE_TASK: {
1617
+ handling: 'suggest_include_horizon',
1618
+ note: 'roadmap is empty/closed; suggest /hs-next --include-horizon',
1619
+ },
1620
+ ROADMAP_NOT_FOUND: { handling: 'surface_and_stop' },
1621
+ VAULT_NOT_FOUND: { handling: 'surface_and_stop' },
1622
+ };
1623
+
1624
+ if (!isSummary) {
1625
+ // Full mode — the user has already authorized at the prior summary step.
1626
+ // No new user-facing prose is added — the body + banner is already on
1627
+ // user_prompt_markdown. Only the structured dispatch is emitted, so the
1628
+ // agent can execute + handle a drift menu without printing any of it.
1629
+ return {
1630
+ user_prompt: null,
1631
+ agent_dispatch: {
1632
+ phase: 'post_claim_execute',
1633
+ execute_instruction:
1634
+ 'Output user_prompt_markdown verbatim, then begin executing the work in the SAME turn. The user already authorized the claim — do not ask again.',
1635
+ on_handoff: [
1636
+ {
1637
+ tool: 'mcp__heyspex__heyspex_release_spec',
1638
+ args: { spec_id: specId },
1639
+ comment: 'On session hand-off or when moving the spec to in_review.',
1640
+ },
1641
+ ],
1642
+ drift_resolution_menu: {
1643
+ proceed: {
1644
+ tokens: ['1', 'proceed', 'implement'],
1645
+ actions: [
1646
+ {
1647
+ tool: 'mcp__heyspex__heyspex_record_alignment',
1648
+ args: {
1649
+ spec_id: focusSpecId,
1650
+ override_reason:
1651
+ "<one-line user reason or 'drift accepted at /hs-next'>",
1652
+ },
1653
+ },
1654
+ {
1655
+ tool: 'mcp__heyspex__heyspex_claim_spec',
1656
+ args: { spec_id: specId },
1657
+ },
1658
+ { tool: 'mcp__heyspex__heyspex_next_on_deck', args: packNextOnDeckToolArgs(args) },
1659
+ ],
1660
+ execute_after: true,
1661
+ },
1662
+ refresh_task: {
1663
+ tokens: ['2', 'refresh task'],
1664
+ reply:
1665
+ 'Option 2 (`heyspex_regen_task`) is implemented under [[HS-EPIC-HSN1]] / [[HS-TASK-0CKW]]. Until it ships, pick `1` to proceed past the drift or `4` to defer.',
1666
+ },
1667
+ refresh_epic: {
1668
+ tokens: ['3', 'refresh epic'],
1669
+ reply:
1670
+ 'Option 3 (`heyspex_regen_tasks_for_epic`) is implemented under [[HS-EPIC-HSN1]] / [[HS-TASK-0CKW]]. Until it ships, pick `1` to proceed past the drift or `4` to defer.',
1671
+ },
1672
+ defer: {
1673
+ tokens: ['4', 'defer', 'skip'],
1674
+ reply:
1675
+ 'Deferred. Re-run `/hs-next` when ready, or re-pin `.heyspex/focus.yaml` first.',
1676
+ },
1677
+ },
1678
+ failure_modes: failureModes,
1679
+ },
1680
+ };
1681
+ }
1682
+
1683
+ // Summary mode — render the claim question / drift summary as user-facing
1684
+ // prose, and the dispatch as a structured object.
1685
+ if (alignmentState === 'drift_detected' || alignmentState === 'needs_breakdown') {
1686
+ const summary =
1687
+ alignmentState === 'needs_breakdown'
1688
+ ? `**${alignmentState}** on \`${focusSpecId}\` — the focus epic has no children on disk. The breakdown brief loads on demand.`
1689
+ : `**drift_detected** on \`${focusSpecId}\`${typeof changedCount === 'number' ? ` — ${changedCount} spec${changedCount === 1 ? '' : 's'} changed since the last alignment review` : ''}. Full review brief loads on demand.`;
1690
+ return {
1691
+ user_prompt: [
1692
+ '',
1693
+ '---',
1694
+ '',
1695
+ `⚠️ ${summary}`,
1696
+ '',
1697
+ 'Reply `review` to load the full alignment-review brief and decide, or `defer` / `4` to skip.',
1698
+ ].join('\n'),
1699
+ agent_dispatch: {
1700
+ phase: alignmentState === 'needs_breakdown' ? 'needs_breakdown_review' : 'drift_review',
1701
+ on_user_reply: {
1702
+ review: {
1703
+ tokens: ['review', 'show review'],
1704
+ actions: [
1705
+ {
1706
+ tool: 'mcp__heyspex__heyspex_next_on_deck',
1707
+ args: packNextOnDeckToolArgs(args, { summary: false }),
1708
+ },
1709
+ ],
1710
+ comment:
1711
+ 'Re-call without summary; output the returned user_prompt_markdown verbatim and follow the drift_resolution_menu in the new agent_dispatch.',
1712
+ },
1713
+ defer: {
1714
+ tokens: ['defer', 'skip', '4'],
1715
+ reply: 'Deferred. Re-run `/hs-next` when ready, or re-pin `.heyspex/focus.yaml` first.',
1716
+ },
1717
+ },
1718
+ failure_modes: failureModes,
1719
+ },
1720
+ };
1721
+ }
1722
+
1723
+ // Fresh alignment — straight claim question, unless rollup blocked the work.
1724
+ // Resolve the session label MCP-side via the same env auto-detect the claim/
1725
+ // file-spec handlers use (the next_on_deck schema accepts no agent/session_id,
1726
+ // so detectAgentIdentity falls back to CLAUDE_CODE_SESSION_ID / 'local'). This
1727
+ // replaces the old literal `{short-session}` placeholder + footnote that leaked
1728
+ // unsubstituted into the user's terminal.
1729
+ const launchIdentity = detectAgentIdentity(args.agent, args.session_id);
1730
+ const shortSession = (launchIdentity.sessionId || 'local').slice(0, 8) || 'local';
1731
+ // Real harness name ("Cursor" / "Claude Code" / "External agent") so the claim
1732
+ // brief names the tool that actually launched, not a hard-coded one. Sourced
1733
+ // from the harness's own session env vars, or HEYSPEX_AGENT set by the spex
1734
+ // console spawner ([[HS-EPIC-NWUE]] / [[HS-EPIC-JLTL]]).
1735
+ const launchTool = launchIdentity.agent || 'your agent';
1736
+
1737
+ if (claimTarget.rollup_state === 'reviewer_blocked') {
1738
+ const n = claimTarget.rollup_detail?.in_review ?? 'Some';
1739
+ return {
1740
+ user_prompt: [
1741
+ '',
1742
+ '---',
1743
+ '',
1744
+ `⏳ **Reviewer-blocked** — ${n} child task(s) under \`${specId}\` are in review. Breakdown is already done — do not claim this epic to break it into tasks.`,
1745
+ '',
1746
+ "Walk each in-review child's HS-REV companion (or run `/hs-next review <task-id>`). Re-run `/hs-next` after children close to surface epic close-out.",
1747
+ ].join('\n'),
1748
+ agent_dispatch: {
1749
+ phase: 'rollup_reviewer_blocked',
1750
+ next_action_hint: 'wait_for_reviewer',
1751
+ failure_modes: failureModes,
1752
+ },
1753
+ };
1754
+ }
1755
+
1756
+ if (claimTarget.rollup_state === 'terminal_leaf') {
1757
+ if (claimTarget.next_action_hint === 'wait_for_reviewer') {
1758
+ return {
1759
+ user_prompt: [
1760
+ '',
1761
+ '---',
1762
+ '',
1763
+ `⏳ **Awaiting review** — \`${specId}\` is \`in_review\`. Implementation is done; walk its HS-REV companion and let the human reviewer commit to close.`,
1764
+ '',
1765
+ 'Do not claim this task to implement it. Re-run `/hs-next` without a spec id for the next open task.',
1766
+ ].join('\n'),
1767
+ agent_dispatch: {
1768
+ phase: 'terminal_leaf_awaiting_review',
1769
+ next_action_hint: 'wait_for_reviewer',
1770
+ failure_modes: failureModes,
1771
+ },
1772
+ };
1773
+ }
1774
+ const st = claimTarget.terminal_status || 'closed';
1775
+ return {
1776
+ user_prompt: [
1777
+ '',
1778
+ '---',
1779
+ '',
1780
+ `✅ **Already terminal** — \`${specId}\` has status \`${st}\`. There is nothing to implement or claim.`,
1781
+ '',
1782
+ 'Re-run `/hs-next` without a spec id for the next open task, or pick a different spec.',
1783
+ ].join('\n'),
1784
+ agent_dispatch: {
1785
+ phase: 'terminal_leaf_not_claimable',
1786
+ next_action_hint: 'not_claimable',
1787
+ failure_modes: failureModes,
1788
+ },
1789
+ };
1790
+ }
1791
+
1792
+ if (claimTarget.rollup_state === 'ready_for_close') {
1793
+ if (shouldAutoClaimOnDeck(args)) {
1794
+ return buildAutoClaimOrchestration({
1795
+ specId,
1796
+ level,
1797
+ launchTool,
1798
+ shortSession,
1799
+ args,
1800
+ failureModes,
1801
+ });
1802
+ }
1803
+ return {
1804
+ user_prompt: [
1805
+ '',
1806
+ '---',
1807
+ '',
1808
+ `📋 **Ready for close-out** — every child of \`${specId}\` is closed. Next: \`heyspex_submit_for_review({ spec_id: "<review-unit>" })\` per session-close-protocol (never hand-edit frontmatter status), then append change-log + HS-REV when the active grain requires it.`,
1809
+ '',
1810
+ `Type \`proceed\` (or \`yes\` / \`confirm\`) if you want to claim **${level}** \`${specId}\` and perform close-out now.`,
1811
+ ].join('\n'),
1812
+ agent_dispatch: {
1813
+ phase: 'rollup_ready_for_close',
1814
+ next_action_hint: 'flip_to_in_review',
1815
+ on_user_reply: {
1816
+ confirm: {
1817
+ tokens: ['yes', 'proceed', 'confirm'],
1818
+ actions: [
1819
+ {
1820
+ tool: 'mcp__heyspex__heyspex_claim_spec',
1821
+ args: { spec_id: specId },
1822
+ },
1823
+ {
1824
+ tool: 'mcp__heyspex__heyspex_next_on_deck',
1825
+ args: packNextOnDeckToolArgs(args, { summary: false }),
1826
+ },
1827
+ ],
1828
+ execute_after: true,
1829
+ },
1830
+ default: 'respond_to_user_question',
1831
+ },
1832
+ failure_modes: failureModes,
1833
+ },
1834
+ };
1835
+ }
1836
+
1837
+ // Missing-summary fallback: when the leaf has no authored summary, don't jump to
1838
+ // the claim. Hand the agent the shared authoring contract so it writes a real
1839
+ // plain-language summary to the spec file first, then re-shows the brief.
1840
+ // A run front asked at milestone/epic grain arms the milestone's dispatcher
1841
+ // run — it never claims the task the walk landed on ([[HS-3C9M-A4TX-CAP]]).
1842
+ const armGrain = resolveArmGrainTarget(result, resolveOnDeckFront(args));
1843
+ if (armGrain) {
1844
+ return buildArmRunOrchestration({
1845
+ arm: armGrain,
1846
+ front: resolveOnDeckFront(args),
1847
+ failureModes,
1848
+ });
1849
+ }
1850
+
1851
+ if (!claimTarget.summary || !String(claimTarget.summary).trim()) {
1852
+ const relPath = claimTarget.relPath || null;
1853
+ // [[HS-T41O-NZ8S-DIAG]]: when the console row is the source of record the
1854
+ // brief names the console write, never a file edit. Local reliance with
1855
+ // the file on disk keeps the file-edit brief below byte-for-byte.
1856
+ if (summaryWriteTargetsConsole(result, claimTarget, args)) {
1857
+ const consoleUrl = (() => {
1858
+ try {
1859
+ return effectiveConsoleUrl();
1860
+ } catch {
1861
+ return null;
1862
+ }
1863
+ })();
1864
+ const authoringPrompt = buildSummaryConsoleWritePrompt({
1865
+ specId,
1866
+ name: claimTarget.title || specId,
1867
+ type: claimTarget.level || 'task',
1868
+ parent: claimTarget.parent || null,
1869
+ consoleUrl,
1870
+ });
1871
+ return {
1872
+ user_prompt: [
1873
+ '',
1874
+ '---',
1875
+ '',
1876
+ `📝 \`${displaySpecId}\` has no \`summary:\` yet. Author it first (this session is cloud-reliant — the console row is the source of record, so write it with \`heyspex_author_summary\`, not a file edit), then I'll show the summary and ask to claim.`,
1877
+ '',
1878
+ authoringPrompt,
1879
+ ].join('\n'),
1880
+ agent_dispatch: {
1881
+ phase: 'author_summary_then_claim',
1882
+ execute_instruction:
1883
+ `Output user_prompt_markdown verbatim, author the summary, then write it through the console with heyspex_author_summary { spec_id: "${specId}", summary: "<your summary>" } (load it first via heyspex_load_tools { names: ["heyspex_author_summary"] }; it PUTs /api/specs/:id — the console row is the source of record in cloud reliance, so do NOT Edit a vault file for this), then re-run heyspex_next_on_deck { summary: true } to re-show the brief with the summary and ask to claim. Do not claim before the summary is written.`,
1884
+ on_complete: [
1885
+ {
1886
+ tool: 'mcp__heyspex__heyspex_load_tools',
1887
+ args: { names: ['heyspex_author_summary'] },
1888
+ comment: 'heyspex_author_summary is a deferred tool — make it visible before calling it.',
1889
+ },
1890
+ {
1891
+ tool: 'mcp__heyspex__heyspex_author_summary',
1892
+ args: { spec_id: specId, summary: '<the summary you authored — prose only, no YAML>' },
1893
+ comment: 'Console write: PUT /api/specs/:id with only `summary` changed. Nothing is written to disk.',
1894
+ },
1895
+ {
1896
+ tool: 'mcp__heyspex__heyspex_next_on_deck',
1897
+ args: packNextOnDeckToolArgs(args, { summary: true }),
1898
+ comment: 'Re-show the claim brief once the console holds the summary.',
1899
+ },
1900
+ ],
1901
+ failure_modes: failureModes,
1902
+ },
1903
+ };
1904
+ }
1905
+ const authoringPrompt = relPath
1906
+ ? buildSummaryFillInPrompt({
1907
+ specId,
1908
+ name: claimTarget.title || specId,
1909
+ type: claimTarget.level || 'task',
1910
+ relPath,
1911
+ parent: claimTarget.parent || null,
1912
+ })
1913
+ : null;
1914
+ const notice = authoringPrompt
1915
+ ? [
1916
+ '',
1917
+ '---',
1918
+ '',
1919
+ `📝 \`${displaySpecId}\` has no \`summary:\` yet. Author it first, then I'll show the summary and ask to claim.`,
1920
+ '',
1921
+ authoringPrompt,
1922
+ ].join('\n')
1923
+ : [
1924
+ '',
1925
+ '---',
1926
+ '',
1927
+ `📝 \`${displaySpecId}\` has no \`summary:\` yet — author one with \`heyspex_author_summary\` (cloud reliance) or in the file's frontmatter (local reliance) before claiming.`,
1928
+ ].join('\n');
1929
+ return {
1930
+ user_prompt: notice,
1931
+ agent_dispatch: {
1932
+ phase: 'author_summary_then_claim',
1933
+ execute_instruction:
1934
+ 'Output user_prompt_markdown verbatim, author the summary into the spec frontmatter via Edit (the vault file on disk is the source of record), then re-run heyspex_next_on_deck { summary: true } to re-show the brief with the summary and ask to claim. Do not claim before the summary is written.',
1935
+ on_complete: [
1936
+ {
1937
+ tool: 'mcp__heyspex__heyspex_next_on_deck',
1938
+ args: packNextOnDeckToolArgs(args, { summary: true }),
1939
+ comment: 'Re-show the claim brief once the summary is written to the spec file.',
1940
+ },
1941
+ ],
1942
+ failure_modes: failureModes,
1943
+ },
1944
+ };
1945
+ }
1946
+
1947
+ if (shouldAutoClaimOnDeck(args)) {
1948
+ return buildAutoClaimOrchestration({
1949
+ specId,
1950
+ level,
1951
+ launchTool,
1952
+ shortSession,
1953
+ args,
1954
+ failureModes,
1955
+ });
1956
+ }
1957
+
1958
+ return {
1959
+ user_prompt: [
1960
+ '',
1961
+ '---',
1962
+ '',
1963
+ `Ready to claim **${level}** \`${displaySpecId}\` to **${intent}**, as **${launchTool} · \`${shortSession}\`**. Type \`proceed\` (or \`yes\` / \`confirm\`) to claim it and begin — or name another spec to redirect.`,
1964
+ ].join('\n'),
1965
+ agent_dispatch: {
1966
+ phase: 'claim_authorization',
1967
+ on_user_reply: {
1968
+ confirm: {
1969
+ tokens: ['yes', 'proceed', 'confirm'],
1970
+ actions: [
1971
+ {
1972
+ tool: 'mcp__heyspex__heyspex_claim_spec',
1973
+ args: { spec_id: specId },
1974
+ },
1975
+ {
1976
+ tool: 'mcp__heyspex__heyspex_next_on_deck',
1977
+ args: packNextOnDeckToolArgs(args, { summary: false }),
1978
+ },
1979
+ ],
1980
+ execute_after: true,
1981
+ comment:
1982
+ 'Tools are pre-allowlisted in .claude/settings.local.json — no second permission ask.',
1983
+ },
1984
+ default: 'respond_to_user_question',
1985
+ },
1986
+ failure_modes: failureModes,
1987
+ },
1988
+ };
1989
+ }
1990
+
1991
+ /**
1992
+ * Apply the composer output to the response shape:
1993
+ * • appends `user_prompt` (if any) to `user_prompt_markdown`
1994
+ * • sets the structured `agent_dispatch` field
1995
+ * • mirrors `user_prompt_markdown` into `prompt_markdown` (the @deprecated
1996
+ * alias) — `prompt_markdown` now never carries agent-routing prose, so
1997
+ * legacy consumers that print it can no longer leak dispatch tables.
1998
+ *
1999
+ * Phase-aware via `args.summary`. See [[HS-TASK-HSNX]],
2000
+ * [[HS-RES-J6CD-migrate-slash-command-orchestration-from]].
2001
+ */
2002
+ export function enrichOnDeckWithAgentOrchestration(result, args = {}) {
2003
+ if (!result || result.error) return result;
2004
+ const composed = composeAgentOrchestration(result, args);
2005
+ if (!composed) return result;
2006
+ const { user_prompt, agent_dispatch } = composed;
2007
+ const updated = { ...result };
2008
+ if (user_prompt) {
2009
+ const existing = updated.user_prompt_markdown ?? updated.prompt_markdown ?? '';
2010
+ updated.user_prompt_markdown = `${existing}${user_prompt}`;
2011
+ updated.prompt_markdown = updated.user_prompt_markdown;
2012
+ }
2013
+ if (agent_dispatch) {
2014
+ updated.agent_dispatch = agent_dispatch;
2015
+ }
2016
+ return updated;
2017
+ }
2018
+
2019
+ /** Wrap a gate prompt into the structured /hs-next response shape. */
2020
+ export function buildAlignmentGateResponse({
2021
+ gate,
2022
+ grain,
2023
+ id,
2024
+ name,
2025
+ status,
2026
+ roadmapId,
2027
+ focusSpecPath,
2028
+ focusReason,
2029
+ promptMarkdown,
2030
+ suggestedActions = [],
2031
+ checkpointReason,
2032
+ drift,
2033
+ }) {
2034
+ const response = {
2035
+ status: gate, // 'needs_breakdown' | 'alignment_review'
2036
+ kind: grain,
2037
+ spec_id: id,
2038
+ spec_title: name,
2039
+ title: name,
2040
+ source_kind: `${gate}_gate`,
2041
+ roadmap_id: roadmapId,
2042
+ spec_path: focusSpecPath,
2043
+ prompt_markdown: promptMarkdown,
2044
+ next_eligible_spec: null,
2045
+ next_action_hint: mapStateToHint(
2046
+ gate === 'needs_breakdown'
2047
+ ? grain === 'milestone'
2048
+ ? 'milestone_needs_breakdown'
2049
+ : 'epic_empty'
2050
+ : 'epic_empty',
2051
+ ),
2052
+ suggested_actions: suggestedActions,
2053
+ };
2054
+ if (typeof status === 'string') response.spec_status = status;
2055
+ if (focusReason) response.focus_reason = focusReason;
2056
+ if (checkpointReason) response.alignment_reason = checkpointReason;
2057
+ if (drift) {
2058
+ response.alignment_analysis = {
2059
+ has_drift: drift.hasDrift,
2060
+ finding_count: drift.findings.length,
2061
+ findings: drift.findings,
2062
+ };
2063
+ }
2064
+ return response;
2065
+ }
2066
+
2067
+ /**
2068
+ * Summary-mode response diet ([[HS-TASK-CBPK]] / [[mcp-tool-response-size-budget]]).
2069
+ * Keeps the agent-routing surface under ~600 structured tokens so /hs-next does
2070
+ * not burn context before work starts. Full mode is unchanged.
2071
+ */
2072
+ export function slimOnDeckSummaryResponse(result) {
2073
+ if (!result || result.error) return result;
2074
+
2075
+ const slimFailureModes = (failureModes) => {
2076
+ if (!failureModes || typeof failureModes !== 'object') return undefined;
2077
+ const out = {};
2078
+ for (const [code, entry] of Object.entries(failureModes)) {
2079
+ out[code] = typeof entry === 'string' ? entry : entry?.handling;
2080
+ }
2081
+ return out;
2082
+ };
2083
+
2084
+ const slimDispatch = (dispatch) => {
2085
+ if (!dispatch || typeof dispatch !== 'object') return undefined;
2086
+ const out = { phase: dispatch.phase };
2087
+ if (dispatch.next_action_hint) out.next_action_hint = dispatch.next_action_hint;
2088
+ if (dispatch.on_user_reply) out.on_user_reply = dispatch.on_user_reply;
2089
+ if (dispatch.execute_instruction) out.execute_instruction = dispatch.execute_instruction;
2090
+ if (dispatch.on_complete) out.on_complete = dispatch.on_complete;
2091
+ if (dispatch.on_handoff) out.on_handoff = dispatch.on_handoff;
2092
+ if (dispatch.arm_target) out.arm_target = dispatch.arm_target;
2093
+ if (dispatch.drift_resolution_menu) out.drift_resolution_menu = dispatch.drift_resolution_menu;
2094
+ const fm = slimFailureModes(dispatch.failure_modes);
2095
+ if (fm) out.failure_modes = fm;
2096
+ return out;
2097
+ };
2098
+
2099
+ const slimClaimTarget = (ct) => {
2100
+ if (!ct || typeof ct !== 'object') return undefined;
2101
+ const out = {
2102
+ level: ct.level,
2103
+ spec_id: ct.spec_id,
2104
+ title: ct.title,
2105
+ intent_verb: ct.intent_verb,
2106
+ tone_token: ct.tone_token,
2107
+ alignment_state: ct.alignment_state,
2108
+ summary: ct.summary,
2109
+ hierarchy_breadcrumb: ct.hierarchy_breadcrumb,
2110
+ relPath: ct.relPath,
2111
+ };
2112
+ if (ct.rollup_state) out.rollup_state = ct.rollup_state;
2113
+ if (ct.next_action_hint) out.next_action_hint = ct.next_action_hint;
2114
+ if (ct.rollup_detail) out.rollup_detail = ct.rollup_detail;
2115
+ if (ct.parent && typeof ct.parent === 'object') {
2116
+ out.parent = {
2117
+ id: ct.parent.id,
2118
+ kind: ct.parent.kind,
2119
+ title: ct.parent.title,
2120
+ relPath: ct.parent.relPath,
2121
+ };
2122
+ }
2123
+ return out;
2124
+ };
2125
+
2126
+ const slim = {
2127
+ spec_id: result.spec_id,
2128
+ title: result.title,
2129
+ status: result.status,
2130
+ source_kind: result.source_kind,
2131
+ roadmap_id: result.roadmap_id,
2132
+ spec_path: result.spec_path,
2133
+ user_prompt_markdown: result.user_prompt_markdown,
2134
+ run_front: result.run_front || 'handheld',
2135
+ };
2136
+ if (result.alignment_summary) slim.alignment_summary = result.alignment_summary;
2137
+ const claimTarget = slimClaimTarget(result.claim_target);
2138
+ if (claimTarget) slim.claim_target = claimTarget;
2139
+ if (result.console_status) slim.console_status = result.console_status;
2140
+ const dispatch = slimDispatch(result.agent_dispatch);
2141
+ if (dispatch) slim.agent_dispatch = dispatch;
2142
+
2143
+ for (const key of [
2144
+ 'pinned_epic',
2145
+ 'pinned_milestone',
2146
+ 'pinned_task',
2147
+ 'seed_epic',
2148
+ 'candidate_epics',
2149
+ 'child_state',
2150
+ 'next_action_hint',
2151
+ 'unmet_blockers',
2152
+ 'blocker_severity',
2153
+ 'gating_blockers',
2154
+ 'stale_focus_pin',
2155
+ 'review_candidates',
2156
+ 'milestone_next_epics',
2157
+ // [[HS-2IF0-7NZ3-EPIC-token-estimation-and-auto-bundling-for-o]] criteria
2158
+ // 7-9 / HS-ARWE: the sprint attached by `attachSprintToOnDeck` must
2159
+ // survive summary-mode slimming the same as `gating_blockers` does.
2160
+ 'sprint',
2161
+ ]) {
2162
+ if (result[key] !== undefined) slim[key] = result[key];
2163
+ }
2164
+ return slim;
2165
+ }
2166
+
2167
+ export function finalizeOnDeckPayload(result, isSummary, runEnricherChain) {
2168
+ const enriched = runEnricherChain(result);
2169
+ return isSummary ? slimOnDeckSummaryResponse(enriched) : enriched;
2170
+ }
2171
+
2172
+ export function resolveNextOnDeckPayload(vaultRoot, config, args = {}) {
2173
+ args = { ...args, front: resolveOnDeckFront(args) };
2174
+ const isSummary = args.summary === true || args.summary === 'true';
2175
+
2176
+ // Summary mode skips plan hydration, conflict scan, WIP block, full
2177
+ // alignment-gate brief, and design context. Keeps the claim-target banner
2178
+ // (cheap, needed to render the question) and replaces the heavy alignment
2179
+ // enricher with a lightweight state probe — see [[hs-next-summary-first-flow]].
2180
+ const runEnricherChain = (result) => {
2181
+ if (isSummary) {
2182
+ const stripped = { ...result, prompt_markdown: '' };
2183
+ const probe = probeAlignmentState(vaultRoot, args);
2184
+ const tagged = probe
2185
+ ? {
2186
+ ...stripped,
2187
+ alignment_summary: probe,
2188
+ // Signal drift_detected to deriveAlignmentState so the claim-target
2189
+ // banner reflects the gate state. The full drift body stays unbuilt;
2190
+ // the slash command re-calls without summary to load it on demand.
2191
+ ...(probe.state === 'drift_detected' || probe.state === 'needs_breakdown'
2192
+ ? { alignment_drift: { stale_corpus: [], last_reviewed_at: null } }
2193
+ : {}),
2194
+ }
2195
+ : stripped;
2196
+ return enrichOnDeckWithRunFront(
2197
+ enrichOnDeckWithAgentOrchestration(
2198
+ enrichOnDeckWithClaimTarget(vaultRoot, tagged, args),
2199
+ args,
2200
+ ),
2201
+ args,
2202
+ );
2203
+ }
2204
+ return enrichOnDeckWithRunFront(
2205
+ enrichOnDeckWithAgentOrchestration(
2206
+ enrichOnDeckWithClaimTarget(
2207
+ vaultRoot,
2208
+ enrichOnDeckWithRecalledMemories(
2209
+ vaultRoot,
2210
+ enrichOnDeckWithWip(
2211
+ vaultRoot,
2212
+ enrichOnDeckWithAlignment(
2213
+ vaultRoot,
2214
+ // Amendments wrap conflicts so the "read the amending doc first"
2215
+ // warning prepends ABOVE the conflict section (most-important-first).
2216
+ enrichOnDeckWithAmendments(
2217
+ vaultRoot,
2218
+ enrichOnDeckWithConflicts(
2219
+ vaultRoot,
2220
+ enrichOnDeckWithExpectedBreakage(
2221
+ vaultRoot,
2222
+ enrichOnDeckWithPlans(vaultRoot, result, args),
2223
+ ),
2224
+ ),
2225
+ ),
2226
+ ),
2227
+ args,
2228
+ ),
2229
+ args,
2230
+ ),
2231
+ args,
2232
+ ),
2233
+ args,
2234
+ ),
2235
+ args,
2236
+ );
2237
+ };
2238
+
2239
+ // If spec_id is provided, fetch that specific spec directly.
2240
+ //
2241
+ // [[HS-DJWP-MCPC-TASK]] round 2, AC1: `args.__cloud_direct_spec` — pre-fetched
2242
+ // over HTTP by `handleNextOnDeck` via `loadSpecByIdViaTier` when this session
2243
+ // is cloud-reliant — replaces the `findSpecFile` + `loadSpec` disk pair below
2244
+ // with the SAME shape, so this function stays synchronous (14+ call sites in
2245
+ // cli.test.mjs call it directly and destructure `{ result, error }` with no
2246
+ // `await` — converting it to async would break all of them). `undefined`
2247
+ // (every disk-reliant call site, and every caller that predates this task)
2248
+ // falls through to the original disk pair, unchanged.
2249
+ if (args.spec_id) {
2250
+ let specPath;
2251
+ let spec;
2252
+ if (args.__cloud_direct_spec !== undefined) {
2253
+ if (args.__cloud_direct_spec === null) {
2254
+ return { error: { error: 'SPEC_NOT_FOUND', message: `Spec ${args.spec_id} not found in vault` } };
2255
+ }
2256
+ specPath = args.__cloud_direct_spec.specPath;
2257
+ spec = { frontmatter: args.__cloud_direct_spec.frontmatter, body: args.__cloud_direct_spec.body };
2258
+ } else {
2259
+ specPath = findSpecFile(vaultRoot, args.spec_id);
2260
+ if (!specPath) {
2261
+ return { error: { error: 'SPEC_NOT_FOUND', message: `Spec ${args.spec_id} not found in vault` } };
2262
+ }
2263
+
2264
+ spec = loadSpec(vaultRoot, specPath);
2265
+ if (!spec) {
2266
+ return { error: { error: 'SPEC_LOAD_FAILED', message: `Could not load spec at ${specPath}` } };
2267
+ }
2268
+ }
2269
+
2270
+ let promptMarkdown = '';
2271
+ let designContext = null;
2272
+
2273
+ if (!isSummary) {
2274
+ // [[HS-TASK-G0HF]] the derived brief is the only path — a missing
2275
+ // `## NEXT STEP` no longer hard-errors (NO_NEXT_STEP retired); the block,
2276
+ // if present, is the Tier-0 override prepended by buildExecutionBrief.
2277
+ promptMarkdown = deriveTaskBrief(vaultRoot, {
2278
+ id: spec.frontmatter.id || args.spec_id,
2279
+ name: spec.frontmatter.name || args.spec_id,
2280
+ status: spec.frontmatter.status || 'unknown',
2281
+ roadmapId: args.roadmap_id || config.defaultRoadmapId,
2282
+ frontmatter: spec.frontmatter,
2283
+ body: spec.body,
2284
+ specPath,
2285
+ // [[HS-H227-MCPC-TASK]]: pre-fetched over HTTP by `handleNextOnDeck`
2286
+ // (`prefetchCloudTaskGraph`) when cloud-reliant; undefined everywhere
2287
+ // else, which keeps `deriveTaskBrief`'s `loadRef` on disk.
2288
+ cloudTaskGraph: args.__cloud_task_graph,
2289
+ });
2290
+
2291
+ // Detect UI tags and load design context if applicable
2292
+ const uiTags = detectUITags(spec.frontmatter);
2293
+ if (uiTags.length > 0) {
2294
+ // [[HS-DJWP-MCPC-TASK]] round 2: `__cloud_design_decisions` — pre-fetched
2295
+ // over HTTP by `handleNextOnDeck` (only once it already knows uiTags is
2296
+ // non-empty, from the SAME pre-fetched spec) — falls through to the
2297
+ // disk scan when absent, same additive pattern as `__cloud_direct_spec`.
2298
+ const designDecisions =
2299
+ args.__cloud_design_decisions !== undefined
2300
+ ? args.__cloud_design_decisions
2301
+ : loadDesignMemories(vaultRoot, uiTags);
2302
+ if (designDecisions.length > 0) {
2303
+ designContext = {
2304
+ triggered_by_tags: uiTags,
2305
+ decisions: designDecisions,
2306
+ };
2307
+ const designSection = buildDesignContextSection(designDecisions, uiTags);
2308
+ promptMarkdown = designSection + promptMarkdown;
2309
+ }
2310
+ }
2311
+ }
2312
+
2313
+ const result = {
2314
+ spec_id: spec.frontmatter.id || args.spec_id,
2315
+ title: spec.frontmatter.name || args.spec_id,
2316
+ status: spec.frontmatter.status || 'unknown',
2317
+ source_kind: 'direct_lookup',
2318
+ roadmap_id: args.roadmap_id || config.defaultRoadmapId,
2319
+ prompt_markdown: promptMarkdown,
2320
+ spec_path: specPath,
2321
+ vault_root: vaultRoot,
2322
+ resolved_at: new Date().toISOString(),
2323
+ };
2324
+
2325
+ if (designContext) {
2326
+ result.design_context = designContext;
2327
+ }
2328
+
2329
+ const directExplanation = buildOnDeckExplanation(result.spec_id, [
2330
+ { id: result.spec_id, frontmatter: spec.frontmatter || {} },
2331
+ ], vaultRoot);
2332
+ attachWhyNowFields(result, directExplanation, args.neon_pin_spec_id ?? null);
2333
+
2334
+ return { result: finalizeOnDeckPayload(result, isSummary, runEnricherChain) };
2335
+ }
2336
+
2337
+ const roadmapId = args.roadmap_id || config.defaultRoadmapId;
2338
+ const result = resolveNextOnDeck(vaultRoot, roadmapId, {
2339
+ ignoreFocus: args.ignore_focus === true || args.ignore_focus === 'true',
2340
+ includeHorizon: args.include_horizon === true || args.include_horizon === 'true',
2341
+ // The Neon focus ids, injected by `injectNeonFocusPin` at the async handler
2342
+ // boundary (the file is dormant on a Neon-up machine): layer-1 pin +
2343
+ // layer-2 selection spec_path.
2344
+ neonPinSpecId: args.neon_pin_spec_id,
2345
+ neonSelectionSpecPath: args.neon_selection_spec_path,
2346
+ // [[HS-TASK-JHIA]] frozen milestone snapshot epic order, fetched over HTTP by
2347
+ // `injectFrozenMilestoneOrder` at the async handler boundary (the cli has no
2348
+ // Neon client). Undefined → live scheduler order.
2349
+ frozenEpicOrder: args.frozen_epic_order,
2350
+ // [[HS-DJWP-MCPC-TASK]] — pre-fetched over HTTP by `handleNextOnDeck` when
2351
+ // cloud-reliant; undefined (falls through to disk) everywhere else,
2352
+ // including every OTHER caller of `resolveNextOnDeckPayload`.
2353
+ schedulerRows: args.__cloud_scheduler_rows,
2354
+ pillarTierById: args.__cloud_pillar_tier_by_id,
2355
+ });
2356
+
2357
+ if (result.error) {
2358
+ return { error: result };
2359
+ }
2360
+
2361
+ result.vault_root = vaultRoot;
2362
+ result.resolved_at = new Date().toISOString();
2363
+ return { result: finalizeOnDeckPayload(result, isSummary, runEnricherChain) };
2364
+ }
2365
+
2366
+ /** Append a suffix to BOTH the canonical + deprecated prompt fields (kept in sync). */
2367
+ export function appendToOnDeckPrompts(result, suffix) {
2368
+ if (typeof result.user_prompt_markdown === 'string') result.user_prompt_markdown += suffix;
2369
+ if (typeof result.prompt_markdown === 'string') result.prompt_markdown += suffix;
2370
+ }
2371
+
2372
+ /**
2373
+ * Render the passive overlap section for the on-deck brief ([[HS-TASK-FD5O]]).
2374
+ * Returns '' when no warnings (additive-only).
2375
+ *
2376
+ * @param {Array<{ other_agent: string, other_spec_id?: string, overlapping_globs: string[] }>} stompWarnings
2377
+ */
2378
+ export function renderStompWarningsSection(stompWarnings) {
2379
+ if (!Array.isArray(stompWarnings) || stompWarnings.length === 0) return '';
2380
+ const lines = stompWarnings.map((w) => {
2381
+ const agent = w.other_agent || 'Another agent';
2382
+ const globs = Array.isArray(w.overlapping_globs) ? w.overlapping_globs : [];
2383
+ const globText = globs.map((g) => `\`${g}\``).join(', ') || '`(unknown path)`';
2384
+ return `- **${agent}** is also working in ${globText} — coordinate before editing overlapping files.`;
2385
+ });
2386
+ return `\n\n---\n\n## Parallel-agent overlap\n\n${lines.join('\n')}`;
2387
+ }
2388
+
2389
+ /**
2390
+ * Inject stomp warnings into `user_prompt_markdown` when overlaps exist
2391
+ * ([[HS-TASK-FD5O]]). Sync / pure — the HTTP probe lives in
2392
+ * `augmentOnDeckWithStompWarnings`.
2393
+ */
2394
+ export function enrichOnDeckWithStompWarnings(result, stompWarnings) {
2395
+ if (!result || result.error) return result;
2396
+ const section = renderStompWarningsSection(stompWarnings);
2397
+ if (!section) return result;
2398
+ appendToOnDeckPrompts(result, section);
2399
+ return { ...result, stomp_warnings: stompWarnings };
2400
+ }
2401
+
2402
+ /**
2403
+ * Render the passive identity guidance section for the on-deck brief
2404
+ * ([[HS-TASK-E4OE]]). Returns '' when no nudges (additive-only).
2405
+ *
2406
+ * @param {Array<{ identity_name: string, trigger: string, message: string }>} identityNudges
2407
+ */
2408
+ export function renderAgentGuidanceSection(identityNudges) {
2409
+ if (!Array.isArray(identityNudges) || identityNudges.length === 0) return '';
2410
+ const lines = identityNudges.map((n) => `- ${n.message}`);
2411
+ return `\n\n---\n\n## Agent guidance\n\n${lines.join('\n')}`;
2412
+ }
2413
+
2414
+ /**
2415
+ * Inject identity nudges into `user_prompt_markdown` ([[HS-TASK-E4OE]]).
2416
+ * Sync / pure — the HTTP probe lives in `augmentOnDeckWithIdentityNudges`.
2417
+ */
2418
+ export function enrichOnDeckWithIdentityNudges(result, identityNudges) {
2419
+ if (!result || result.error) return result;
2420
+ const section = renderAgentGuidanceSection(identityNudges);
2421
+ if (!section) return result;
2422
+ appendToOnDeckPrompts(result, section);
2423
+ return { ...result, identity_nudges: identityNudges };
2424
+ }
2425
+
2426
+ /**
2427
+ * Render the parallelism serialization hint section ([[HS-TASK-55VE]]).
2428
+ * Distinct from stomp overlap ([[HS-TASK-FD5O]]) — advises run-one-at-a-time
2429
+ * for shared depends_on or same-module affects_paths. Reused by scheduler
2430
+ * Phase 2 §3 ([[HS-MIL-SCHD-scheduler-and-priority-algorithm]]).
2431
+ *
2432
+ * @param {Array<{ kind: 'depends_on' | 'same_module', other_agent: string, other_spec_id: string, shared_dep?: string, overlapping_globs?: string[] }>} hints
2433
+ */
2434
+ export function renderParallelismHintsSection(hints) {
2435
+ if (!Array.isArray(hints) || hints.length === 0) return '';
2436
+ const lines = hints.map((h) => {
2437
+ const agent = h.other_agent || 'Another agent';
2438
+ const spec = h.other_spec_id || 'another spec';
2439
+ if (h.kind === 'depends_on') {
2440
+ const dep = h.shared_dep ? `\`${h.shared_dep}\`` : 'a shared dependency';
2441
+ return `- **${agent}** holds \`${spec}\`, which shares ${dep} with this task — **run one at a time**, not in parallel.`;
2442
+ }
2443
+ const globs = Array.isArray(h.overlapping_globs)
2444
+ ? h.overlapping_globs.map((g) => `\`${g}\``).join(', ')
2445
+ : '';
2446
+ const globText = globs || '`(unknown module)`';
2447
+ return `- **${agent}** holds \`${spec}\` in the same module (${globText}) — **serialize execution** rather than running both tasks in parallel.`;
2448
+ });
2449
+ return `\n\n---\n\n## Parallelism hint\n\n${lines.join('\n')}`;
2450
+ }
2451
+
2452
+ /**
2453
+ * Inject parallelism hints into `user_prompt_markdown` ([[HS-TASK-55VE]]).
2454
+ * Sync / pure — the HTTP probe lives in `augmentOnDeckWithParallelismHints`.
2455
+ */
2456
+ export function enrichOnDeckWithParallelismHints(result, hints) {
2457
+ if (!result || result.error) return result;
2458
+ const section = renderParallelismHintsSection(hints);
2459
+ if (!section) return result;
2460
+ appendToOnDeckPrompts(result, section);
2461
+ return { ...result, parallelism_hints: hints };
2462
+ }
2463
+
2464
+ /**
2465
+ * Extend "one detection path" to the agent's on-deck brief ([[HS-EPIC-RSSO]] §5).
2466
+ * The milestone/epic drill builds the brief WITHOUT a gating-prerequisite scan,
2467
+ * so the agent saw no blocker while the console overlay showed one. Here we read
2468
+ * the SAME converged endpoint the overlay reads — `GET /api/specs/:id/blockers`
2469
+ * (now aggregator-backed) — for the drilled seed / claim-target spec, and
2470
+ * surface its gating subset. Agent and console therefore read the same bytes.
2471
+ *
2472
+ * Console-unreachable is a dev-only condition ([[console-unreachable-is-dev-only]]):
2473
+ * users never run a local console. On unreachable we emit an honest "start the
2474
+ * dev server" notice — never a second, drift-prone detector ([[no-fallbacks-by-default]]).
2475
+ * Skipped in summary mode (the fast pre-claim first response).
2476
+ */
2477
+ export async function augmentOnDeckWithGatingBlockers(result, args = {}) {
2478
+ if (!result || result.error) return;
2479
+ if (args.summary === true || args.summary === 'true') return;
2480
+ const targetId =
2481
+ result.claim_target?.spec_id || result.seed_epic?.id || result.spec_id || null;
2482
+ if (!targetId) return;
2483
+
2484
+ let res;
2485
+ try {
2486
+ res = await consoleFetch(`/api/specs/${encodeURIComponent(targetId)}/blockers?unresolved=true`);
2487
+ } catch {
2488
+ return; // never let the blocker probe break the on-deck brief
2489
+ }
2490
+ if (!res.ok) {
2491
+ if (res.status === 0) {
2492
+ appendToOnDeckPrompts(
2493
+ result,
2494
+ `\n\n---\n\n> ℹ️ Blocker check skipped — start the spex-console dev server to see gating prerequisites for \`${targetId}\`.`,
2495
+ );
2496
+ }
2497
+ return;
2498
+ }
2499
+ const gating = Array.isArray(res.body?.blockers)
2500
+ ? res.body.blockers.filter((b) => b.gating)
2501
+ : [];
2502
+ result.gating_blockers = gating;
2503
+ if (gating.length > 0) {
2504
+ const lines = gating.map(
2505
+ (b) => `- \`${b.id}\` — ${b.name} (status: \`${b.status}\`, ${b.category})`,
2506
+ );
2507
+ const first = gating[0];
2508
+ const doFirst =
2509
+ first && first.status !== 'missing'
2510
+ ? ` **Do this first:** on your confirmation, \`heyspex_pin_set { spec_id: "${first.id}" }\` — a freeze-safe scoped pin change (overlays the snapshot, never rewrites it).`
2511
+ : '';
2512
+ appendToOnDeckPrompts(
2513
+ result,
2514
+ `\n\n---\n\n## ⚠️ Unmet gating prerequisites (${gating.length})\n\n${lines.join('\n')}\n\nThese hard-gate \`${targetId}\` — the same set the console On-Deck overlay shows. Run \`/hs-resolve\` for the full fix brief (Do-this-first / Switch / Defer / Resolve), or clear them before claiming.${doFirst}`,
2515
+ );
2516
+ }
2517
+ }
2518
+
2519
+ /**
2520
+ * Surface passive stomp-detection warnings on the on-deck brief ([[HS-TASK-FD5O]]).
2521
+ * Reads the same preview endpoint the console would use for a would-be claim.
2522
+ * Best-effort — never blocks the brief when console is unreachable.
2523
+ */
2524
+ export async function augmentOnDeckWithStompWarnings(result, args = {}) {
2525
+ if (!result || result.error) return;
2526
+ const targetId =
2527
+ result.claim_target?.spec_id || result.seed_epic?.id || result.spec_id || null;
2528
+ if (!targetId) return;
2529
+
2530
+ const agentProfile =
2531
+ typeof args.agent_profile === 'string' && args.agent_profile.trim()
2532
+ ? args.agent_profile.trim()
2533
+ : undefined;
2534
+ const qs = agentProfile ? `?agent_profile=${encodeURIComponent(agentProfile)}` : '';
2535
+
2536
+ let res;
2537
+ try {
2538
+ res = await consoleFetch(
2539
+ `/api/specs/${encodeURIComponent(targetId)}/stomp-preview${qs}`,
2540
+ );
2541
+ } catch {
2542
+ return;
2543
+ }
2544
+ if (!res.ok) return;
2545
+
2546
+ const warnings = Array.isArray(res.body?.stomp_warnings) ? res.body.stomp_warnings : [];
2547
+ enrichOnDeckWithStompWarnings(result, warnings);
2548
+ }
2549
+
2550
+ /**
2551
+ * Surface passive identity nudges on the on-deck brief ([[HS-TASK-E4OE]]).
2552
+ * Reads the nudge-preview endpoint. Best-effort — never blocks the brief.
2553
+ */
2554
+ export async function augmentOnDeckWithIdentityNudges(result, args = {}) {
2555
+ if (!result || result.error) return;
2556
+ const targetId =
2557
+ result.claim_target?.spec_id || result.seed_epic?.id || result.spec_id || null;
2558
+ if (!targetId) return;
2559
+
2560
+ const agentProfile =
2561
+ typeof args.agent_profile === 'string' && args.agent_profile.trim()
2562
+ ? args.agent_profile.trim()
2563
+ : undefined;
2564
+ const params = new URLSearchParams();
2565
+ if (agentProfile) params.set('agent_profile', agentProfile);
2566
+ const qs = params.toString() ? `?${params.toString()}` : '';
2567
+
2568
+ let res;
2569
+ try {
2570
+ res = await consoleFetch(
2571
+ `/api/specs/${encodeURIComponent(targetId)}/nudge-preview${qs}`,
2572
+ );
2573
+ } catch {
2574
+ return;
2575
+ }
2576
+ if (!res.ok) return;
2577
+
2578
+ const nudges = Array.isArray(res.body?.identity_nudges) ? res.body.identity_nudges : [];
2579
+ enrichOnDeckWithIdentityNudges(result, nudges);
2580
+ }
2581
+
2582
+ /**
2583
+ * Surface depends_on / same-module serialization hints on the on-deck brief
2584
+ * ([[HS-TASK-55VE]]). Best-effort — never blocks the brief when console is
2585
+ * unreachable.
2586
+ */
2587
+ export async function augmentOnDeckWithParallelismHints(result, args = {}) {
2588
+ if (!result || result.error) return;
2589
+ if (args.summary === true || args.summary === 'true') return;
2590
+ const targetId =
2591
+ result.claim_target?.spec_id || result.seed_epic?.id || result.spec_id || null;
2592
+ if (!targetId) return;
2593
+
2594
+ let res;
2595
+ try {
2596
+ res = await consoleFetch(
2597
+ `/api/specs/${encodeURIComponent(targetId)}/parallelism-preview`,
2598
+ );
2599
+ } catch {
2600
+ return;
2601
+ }
2602
+ if (!res.ok) return;
2603
+
2604
+ const hints = Array.isArray(res.body?.parallelism_hints) ? res.body.parallelism_hints : [];
2605
+ enrichOnDeckWithParallelismHints(result, hints);
2606
+ }
2607
+
2608
+ /**
2609
+ * Attach the pre-computed sprint (token-budgeted work-group) the claim target
2610
+ * lands in, from the SAME `sprintPlan` the console computes once at the
2611
+ * planning/alignment gate (`GET /api/specs/:id/children` →
2612
+ * `spex-console/src/lib/spec-children.ts`'s `buildSpecChildren` /
2613
+ * `computeSprintPlan`) — HS-ARWE (console half) /
2614
+ * [[HS-2IF0-7NZ3-EPIC-token-estimation-and-auto-bundling-for-o]] criteria
2615
+ * 7-9 (MCP half): the sprint decision is surfaced to the agent BEFORE
2616
+ * execution begins, never decided by the implementing agent, and re-derives
2617
+ * only when this brief is re-fetched (the gate re-firing) — never silently
2618
+ * mid-implementation.
2619
+ *
2620
+ * Same best-effort HTTP-augment shape as `augmentOnDeckWithGatingBlockers`:
2621
+ * only runs for a task-grain claim target with a resolvable parent epic id;
2622
+ * console-unreachable, a non-ok response, or no matching sprint sets
2623
+ * `result.sprint = null` and appends no line — never throws, never blocks
2624
+ * the brief. A non-task claim target (or a task with no `parent.id`) is left
2625
+ * completely untouched — no `sprint` key at all.
2626
+ *
2627
+ * `fetchImpl` defaults to the module's real `consoleFetch` (unchanged for
2628
+ * every production call site); tests inject a stub, same DI seam as
2629
+ * `resolveNextResolutionPayload(args, { consoleFetch })` in `next-resolution.mjs`.
2630
+ */
2631
+ export async function attachSprintToOnDeck(result, fetchImpl = consoleFetch) {
2632
+ if (!result || result.error) return;
2633
+ const claimTarget = result.claim_target;
2634
+ if (!claimTarget || claimTarget.level !== 'task') return;
2635
+ const parentId = claimTarget.parent?.id;
2636
+ if (!parentId) return;
2637
+
2638
+ let res;
2639
+ try {
2640
+ res = await fetchImpl(`/api/specs/${encodeURIComponent(parentId)}/children`);
2641
+ } catch {
2642
+ result.sprint = null;
2643
+ return;
2644
+ }
2645
+ if (!res.ok) {
2646
+ result.sprint = null;
2647
+ return;
2648
+ }
2649
+
2650
+ const sprintPlan = res.body?.sprintPlan;
2651
+ if (!sprintPlan || !Array.isArray(sprintPlan.sprints)) {
2652
+ result.sprint = null;
2653
+ return;
2654
+ }
2655
+
2656
+ // Same case-insensitive id comparison used throughout this file (e.g. the
2657
+ // `.toLowerCase()` level/status checks above) — the console's task ids and
2658
+ // this brief's claim-target id can differ in case.
2659
+ const targetIdLower = String(claimTarget.spec_id || '').toLowerCase();
2660
+ const matchedSprint = sprintPlan.sprints.find(
2661
+ (s) =>
2662
+ Array.isArray(s.taskIds) &&
2663
+ s.taskIds.some((id) => String(id).toLowerCase() === targetIdLower),
2664
+ );
2665
+ if (!matchedSprint) {
2666
+ result.sprint = null;
2667
+ return;
2668
+ }
2669
+
2670
+ const children = Array.isArray(res.body?.children) ? res.body.children : [];
2671
+ const ownRow = children.find((c) => String(c?.id || '').toLowerCase() === targetIdLower);
2672
+ const estimatedTokens =
2673
+ typeof ownRow?.estimatedTokens === 'number' ? ownRow.estimatedTokens : 0;
2674
+ const position =
2675
+ matchedSprint.taskIds.findIndex((id) => String(id).toLowerCase() === targetIdLower) + 1;
2676
+
2677
+ result.sprint = {
2678
+ index: matchedSprint.index,
2679
+ position,
2680
+ size: matchedSprint.taskIds.length,
2681
+ totalTokens: matchedSprint.totalTokens,
2682
+ budget: sprintPlan.budget,
2683
+ overBudget: matchedSprint.overBudget,
2684
+ taskIds: matchedSprint.taskIds,
2685
+ estimatedTokens,
2686
+ };
2687
+
2688
+ // Re-render the SAME markdown builder (`formatClaimBanner`) with the sprint
2689
+ // now known, and splice it into both prompt fields in place of the
2690
+ // sprint-less banner `enrichOnDeckWithClaimTarget` already embedded
2691
+ // (bookended banner in full mode → two occurrences; summary mode → one).
2692
+ // `.split(...).join(...)` replaces every occurrence, unlike `.replace`.
2693
+ const oldBanner = formatClaimBanner(claimTarget, result.alignment_result || {});
2694
+ const newBanner = formatClaimBanner(claimTarget, result.alignment_result || {}, result.sprint);
2695
+ if (oldBanner && newBanner !== oldBanner) {
2696
+ if (typeof result.user_prompt_markdown === 'string') {
2697
+ result.user_prompt_markdown = result.user_prompt_markdown.split(oldBanner).join(newBanner);
2698
+ }
2699
+ if (typeof result.prompt_markdown === 'string') {
2700
+ result.prompt_markdown = result.prompt_markdown.split(oldBanner).join(newBanner);
2701
+ }
2702
+ }
2703
+ }
2704
+
2705
+ /** HTTP-backed on-deck augments (gating + stomp + identity nudges + parallelism + sprint). Shared by MCP tool + hs next CLI. */
2706
+ /**
2707
+ * The active claims another agent holds right now — the ones that would make
2708
+ * a launch come back `live_claim` ([[HS-3C9M-A4TX-CAP]]). Stale or expired
2709
+ * claims are relaunchable and drop out; the caller's own session's claims drop
2710
+ * out too (re-fetching a brief after claiming must not skip its own task).
2711
+ * @param {Array<Record<string, any>>} claims GET /api/control/active-claims → claims
2712
+ * @param {string | null | undefined} ownSessionId
2713
+ * @param {number} [nowMs]
2714
+ */
2715
+ export function selectClaimsHeldByOthers(claims, ownSessionId, nowMs = Date.now()) {
2716
+ const own = ownSessionId ? String(ownSessionId) : '';
2717
+ const out = [];
2718
+ for (const c of Array.isArray(claims) ? claims : []) {
2719
+ if (!c || typeof c.specId !== 'string' || !c.specId) continue;
2720
+ if (c.effectiveStatus === 'stale') continue;
2721
+ if (c.expiresAt) {
2722
+ const t = Date.parse(c.expiresAt);
2723
+ if (Number.isFinite(t) && nowMs >= t) continue;
2724
+ }
2725
+ const sid = c.sessionId ? String(c.sessionId) : '';
2726
+ const short = c.sessionShort ? String(c.sessionShort) : '';
2727
+ if (own && own !== 'local' && (sid === own || (short && own.startsWith(short)))) continue;
2728
+ out.push(c);
2729
+ }
2730
+ return out;
2731
+ }
2732
+
2733
+ function claimAgoLabel(iso, nowMs) {
2734
+ const t = Date.parse(String(iso || ''));
2735
+ if (!Number.isFinite(t)) return '';
2736
+ const mins = Math.max(0, Math.round((nowMs - t) / 60000));
2737
+ if (mins < 1) return 'just now';
2738
+ if (mins < 60) return `${mins} min ago`;
2739
+ return `${Math.round(mins / 60)} h ago`;
2740
+ }
2741
+
2742
+ /**
2743
+ * Markdown appended to the brief: who holds what, which held task(s) this
2744
+ * brief stepped past, and how to put a parallel agent on another task.
2745
+ * Empty when nothing is held.
2746
+ */
2747
+ export function renderActiveClaimsSection(held, skipped = [], nowMs = Date.now()) {
2748
+ if (!Array.isArray(held) || held.length === 0) return '';
2749
+ const lines = [
2750
+ '',
2751
+ '---',
2752
+ '',
2753
+ `## 👥 Active claims — ${held.length} spec${held.length === 1 ? '' : 's'} held by other agents`,
2754
+ '',
2755
+ ];
2756
+ for (const c of held) {
2757
+ const who = `${c.agent || 'agent'} · \`${c.sessionShort || String(c.sessionId || '').slice(0, 8) || '?'}\``;
2758
+ const state = c.effectiveStatus || c.status || 'working';
2759
+ const since = c.claimedAt ? ` · claimed ${claimAgoLabel(c.claimedAt, nowMs)}` : '';
2760
+ const active = c.lastActivityAt ? ` · active ${claimAgoLabel(c.lastActivityAt, nowMs)}` : '';
2761
+ lines.push(`- \`${c.specId}\` — ${who} · ${state}${since}${active}`);
2762
+ }
2763
+ lines.push('');
2764
+ if (Array.isArray(skipped) && skipped.length > 0) {
2765
+ lines.push(
2766
+ `Skipped ${skipped.map((c) => `\`${c.specId}\``).join(', ')} (held above) — this brief presents the next task nobody holds, so a parallel agent can start on it.`,
2767
+ );
2768
+ }
2769
+ lines.push(
2770
+ 'Put more agents on other unheld tasks any time (`hs next <id> --launch`) — the claim ledger and the dispatcher coordinate between them, and a task already held is refused at launch.',
2771
+ );
2772
+ return lines.join('\n');
2773
+ }
2774
+
2775
+ /**
2776
+ * `resolveNextOnDeckPayload`, then step past any task another agent actively
2777
+ * holds ([[HS-3C9M-A4TX-CAP]] — owner ruling 2026-09-02: the live-claim refusal
2778
+ * at launch is right, but the brief should offer the next task that CAN be
2779
+ * launched in parallel and list who holds what). Same `{ error, result }`
2780
+ * shape as the sync resolver. Best-effort: a dead console means no claims are
2781
+ * known and the plain result comes back untouched.
2782
+ *
2783
+ * A spec id the operator named directly is never skipped — they asked for
2784
+ * that one — it just carries the held-by note. When every other launchable
2785
+ * task under the walk is held too, the held task is kept with the note.
2786
+ */
2787
+ export async function resolveNextOnDeckAroundActiveClaims(
2788
+ vaultRoot,
2789
+ config,
2790
+ args = {},
2791
+ { fetchImpl = consoleFetch, nowMs = Date.now() } = {},
2792
+ ) {
2793
+ const first = resolveNextOnDeckPayload(vaultRoot, config, args);
2794
+ if (!first || first.error || !first.result) return first;
2795
+
2796
+ let claims = [];
2797
+ try {
2798
+ const res = await fetchImpl('/api/control/active-claims', { timeout: 5000 });
2799
+ if (res?.ok && Array.isArray(res.body?.claims)) claims = res.body.claims;
2800
+ } catch {
2801
+ claims = [];
2802
+ }
2803
+ const identity = detectAgentIdentity(args.agent, args.session_id);
2804
+ const held = selectClaimsHeldByOthers(claims, identity?.sessionId, nowMs);
2805
+ if (held.length === 0) return first;
2806
+
2807
+ const heldById = new Map(held.map((c) => [String(c.specId).toLowerCase(), c]));
2808
+ const requested = args.spec_id ? String(args.spec_id).toLowerCase() : '';
2809
+ let current = first;
2810
+ const skipped = [];
2811
+ const exclude = [];
2812
+ for (let i = 0; i < 25; i += 1) {
2813
+ const ct = current.result?.claim_target;
2814
+ if (!ct || ct.level !== 'task' || !ct.spec_id) break;
2815
+ const id = String(ct.spec_id).toLowerCase();
2816
+ if (id === requested) break;
2817
+ const holder = heldById.get(id);
2818
+ if (!holder) break;
2819
+ const next = resolveNextOnDeckPayload(vaultRoot, config, {
2820
+ ...args,
2821
+ exclude_spec_ids: [...exclude, id],
2822
+ });
2823
+ const nct = next?.result?.claim_target;
2824
+ if (!next || next.error || !nct || nct.level !== 'task' || !nct.spec_id) break;
2825
+ skipped.push(holder);
2826
+ exclude.push(id);
2827
+ current = next;
2828
+ }
2829
+
2830
+ current.result.active_claims = held;
2831
+ current.result.skipped_held = skipped;
2832
+ appendToOnDeckPrompts(current.result, renderActiveClaimsSection(held, skipped, nowMs));
2833
+ return current;
2834
+ }
2835
+
2836
+ export async function applyOnDeckHttpAugments(result, args = {}) {
2837
+ await augmentOnDeckWithGatingBlockers(result, args);
2838
+ await augmentOnDeckWithStompWarnings(result, args);
2839
+ await augmentOnDeckWithIdentityNudges(result, args);
2840
+ await augmentOnDeckWithParallelismHints(result, args);
2841
+ await attachSprintToOnDeck(result);
2842
+ }
2843
+
2844
+ /**
2845
+ * [[HS-T41O-NZ8S-DIAG]]: resolve the session's vault WRITE mode once at the
2846
+ * async handler boundary and thread it in as `__vault_write_mode` so the
2847
+ * synchronous brief composer can route the missing-`summary:` authoring
2848
+ * phase to the console (cloud) or the file (local). Never throws — an
2849
+ * unresolvable mode leaves the arg unset and the composer falls back to the
2850
+ * read-tier / file-on-disk signals.
2851
+ */
2852
+ export async function injectVaultWriteMode(args = {}, resolveWriteMode = resolveVaultWriteMode) {
2853
+ if (args.__vault_write_mode === 'cloud' || args.__vault_write_mode === 'local') return args;
2854
+ try {
2855
+ const mode = await resolveWriteMode();
2856
+ if (mode === 'cloud' || mode === 'local') return { ...args, __vault_write_mode: mode };
2857
+ } catch {
2858
+ /* fall through — the composer has its own signals */
2859
+ }
2860
+ return args;
2861
+ }
2862
+
2863
+ /**
2864
+ * Degrade-don't-die wrapper: when any cloud loader inside the brief fell back
2865
+ * to the local clone ({@link readViaSelector}), the response JSON carries
2866
+ * `degraded: true, source: 'disk', cloud_error, warning` at its top level.
2867
+ */
2868
+ export async function handleNextOnDeck(vaultRoot, config, args) {
2869
+ const { value, degraded } = await withDegradationScope(() => handleNextOnDeckInner(vaultRoot, config, args));
2870
+ if (!degraded || !value || value.isError) return value;
2871
+ try {
2872
+ const first = value.content?.[0];
2873
+ const parsed = JSON.parse(first.text);
2874
+ return { ...value, content: [{ ...first, text: JSON.stringify({ ...parsed, ...degraded }, null, 2) }] };
2875
+ } catch {
2876
+ return value;
2877
+ }
2878
+ }
2879
+
2880
+ async function handleNextOnDeckInner(vaultRoot, config, args) {
2881
+ // Resolve the Neon layer-1 pin first (async), then the frozen milestone
2882
+ // snapshot order (async, [[HS-TASK-JHIA]]), then run the sync resolver with
2883
+ // both threaded in via `neon_pin_spec_id` / `frozen_epic_order`. See
2884
+ // `injectNeonFocusPin` + `injectFrozenMilestoneOrder`.
2885
+ const resolvedArgs = await injectVaultWriteMode(
2886
+ await injectCloudWipState(
2887
+ vaultRoot,
2888
+ await injectFrozenMilestoneOrder(await injectNeonFocusPin(args)),
2889
+ ),
2890
+ );
2891
+
2892
+ // [[HS-DJWP-MCPC-TASK]] "Scheduler cohort + pillar tier map" family: in
2893
+ // cloud reliance, pre-fetch both over HTTP before the synchronous resolver
2894
+ // runs, so it never falls back to `readdirSync`ing `specs/{tasks,epics,
2895
+ // milestones,diagnoses}` / `specs/pillars` (which a no-vault-checkout
2896
+ // session cannot do). Skipped for the direct spec_id lookup path (it never
2897
+ // calls computeGlobalSchedulerOrder). A dead console here is the ONE
2898
+ // decision point (readViaSelector, inside the *ViaTier loaders) surfacing
2899
+ // CONSOLE_UNREACHABLE_CLOUD_MODE — never a silent disk fallback.
2900
+ let cloudArgs = resolvedArgs;
2901
+ if (!resolvedArgs.spec_id) {
2902
+ const tier = await resolveReadTier();
2903
+ if (tier === 'neon') {
2904
+ const consoleUrl = effectiveConsoleUrl();
2905
+ try {
2906
+ const [schedulerRows, pillarTierById] = await Promise.all([
2907
+ loadVaultSpecsForSchedulerViaTier(vaultRoot, { tier, consoleUrl }),
2908
+ loadPillarTierMapViaTier(vaultRoot, { tier, consoleUrl }),
2909
+ ]);
2910
+ cloudArgs = {
2911
+ ...resolvedArgs,
2912
+ __cloud_scheduler_rows: schedulerRows,
2913
+ __cloud_pillar_tier_by_id: pillarTierById,
2914
+ };
2915
+ } catch (err) {
2916
+ return {
2917
+ isError: true,
2918
+ content: [{
2919
+ type: 'text',
2920
+ text: JSON.stringify({
2921
+ error: err instanceof ConsoleUnreachableCloudModeError ? err.name : 'CONSOLE_UNREACHABLE_CLOUD_MODE',
2922
+ message: String(err?.message ?? err),
2923
+ }),
2924
+ }],
2925
+ };
2926
+ }
2927
+ }
2928
+ } else {
2929
+ // [[HS-DJWP-MCPC-TASK]] round 2, AC1 "general spec load": the direct
2930
+ // `spec_id` lookup branch of `resolveNextOnDeckPayload` takes the SAME
2931
+ // pre-fetch-and-inject treatment as the scheduler cohort above — see
2932
+ // `__cloud_direct_spec` there for why that branch can't be made async
2933
+ // directly. Once the spec is known, also pre-fetch design-memory
2934
+ // decisions when its tags trigger them (`__cloud_design_decisions`,
2935
+ // best-effort per `loadDesignMemoriesViaTier`).
2936
+ //
2937
+ // [[HS-H227-MCPC-TASK]]: `deriveTaskBrief`'s decomposition context
2938
+ // (`extractDecompositionContext`'s `loadRef` — related links, the
2939
+ // parent-epic/milestone climb + milestone siblings) and
2940
+ // `enrichOnDeckWithClaimTarget`'s parent-contract lookup (same `loadTask`
2941
+ // shape) ALSO get the pre-fetch-and-inject treatment here —
2942
+ // `prefetchCloudTaskGraph` walks that bounded closure over HTTP and the
2943
+ // result rides in as `__cloud_task_graph`, consulted by
2944
+ // `loadTaskViaGraph` instead of `loadTask` at those two sites. The
2945
+ // readiness-floor settings file stays disk-only (machine-local per the
2946
+ // epic rule) — not part of this pre-fetch.
2947
+ const tier = await resolveReadTier();
2948
+ if (tier === 'neon') {
2949
+ const consoleUrl = effectiveConsoleUrl();
2950
+ try {
2951
+ const directSpec = await loadSpecByIdViaTier(vaultRoot, resolvedArgs.spec_id, { tier, consoleUrl });
2952
+ cloudArgs = { ...resolvedArgs, __cloud_direct_spec: directSpec };
2953
+ if (directSpec) {
2954
+ const uiTags = detectUITags(directSpec.frontmatter || {});
2955
+ if (uiTags.length > 0) {
2956
+ cloudArgs.__cloud_design_decisions = await loadDesignMemoriesViaTier(vaultRoot, uiTags, { tier, consoleUrl });
2957
+ }
2958
+ cloudArgs.__cloud_task_graph = await prefetchCloudTaskGraph(vaultRoot, resolvedArgs.spec_id, directSpec, {
2959
+ tier,
2960
+ consoleUrl,
2961
+ });
2962
+ }
2963
+ } catch (err) {
2964
+ return {
2965
+ isError: true,
2966
+ content: [{
2967
+ type: 'text',
2968
+ text: JSON.stringify({
2969
+ error: err instanceof ConsoleUnreachableCloudModeError ? err.name : 'CONSOLE_UNREACHABLE_CLOUD_MODE',
2970
+ message: String(err?.message ?? err),
2971
+ }),
2972
+ }],
2973
+ };
2974
+ }
2975
+ }
2976
+ }
2977
+
2978
+ const { error, result } = await resolveNextOnDeckAroundActiveClaims(vaultRoot, config, cloudArgs);
2979
+
2980
+ if (error) {
2981
+ return {
2982
+ isError: true,
2983
+ content: [{ type: 'text', text: JSON.stringify(error) }],
2984
+ };
2985
+ }
2986
+
2987
+ // One detection path (RSSO §5): surface the same gating prereqs as the console
2988
+ // overlay. Best-effort — never breaks the brief if console is unreachable.
2989
+ await applyOnDeckHttpAugments(result, resolvedArgs);
2990
+ await attachConsoleStatusToOnDeck(result);
2991
+
2992
+ return {
2993
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
2994
+ };
2995
+ }
2996
+
2997
+ export function nextReviewDeps() {
2998
+ return { findSpecFile, loadSpec, loadRoadmap, loadTask };
2999
+ }
3000
+
3001
+ export function handleNextReview(vaultRoot, config, args) {
3002
+ const { error, result } = resolveNextReviewPayload(
3003
+ vaultRoot,
3004
+ config,
3005
+ args,
3006
+ nextReviewDeps(),
3007
+ );
3008
+
3009
+ if (error) {
3010
+ return {
3011
+ isError: true,
3012
+ content: [{ type: 'text', text: JSON.stringify(error) }],
3013
+ };
3014
+ }
3015
+
3016
+ return {
3017
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
3018
+ };
3019
+ }
3020
+
3021
+ /**
3022
+ * heyspex_next_resolution — the agent face of the Resolution lane. HTTP-only:
3023
+ * the resolver reads the console aggregator (the one detection path) via
3024
+ * consoleFetch; we never re-detect here. Async because consoleFetch is async —
3025
+ * the dispatch switch is sync but its caller awaits returned Promises.
3026
+ */
3027
+ export async function handleNextResolution(args = {}) {
3028
+ const { error, result } = await resolveNextResolutionPayload(args, { consoleFetch });
3029
+ if (error) {
3030
+ return {
3031
+ isError: true,
3032
+ content: [{ type: 'text', text: JSON.stringify(error, null, 2) }],
3033
+ };
3034
+ }
3035
+ return {
3036
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
3037
+ };
3038
+ }
3039
+
3040
+ /**
3041
+ * Live console liveness stamped on every /hs-next response so agents cannot
3042
+ * infer "server down" from conditional failure_modes alone. Uses the same
3043
+ * transport as claim/file_spec (fetch → /api/version).
3044
+ */
3045
+ export async function attachConsoleStatusToOnDeck(result) {
3046
+ if (!result || result.error) return;
3047
+ try {
3048
+ const probe = await consoleFetch('/api/version', { timeout: 4000 });
3049
+ let url = effectiveConsoleUrl();
3050
+ if (probe.url) {
3051
+ try {
3052
+ url = new URL(probe.url).origin;
3053
+ } catch {
3054
+ // keep effectiveConsoleUrl() fallback
3055
+ }
3056
+ }
3057
+ const consoleStatus = {
3058
+ reachable: probe.ok,
3059
+ url,
3060
+ checked_at: new Date().toISOString(),
3061
+ ...(probe.ok
3062
+ ? {}
3063
+ : {
3064
+ error_code: consoleErrorCode(probe, 'CONSOLE_PROBE_FAILED'),
3065
+ error: probe.error || null,
3066
+ }),
3067
+ };
3068
+ result.console_status = consoleStatus;
3069
+ if (result.agent_dispatch && typeof result.agent_dispatch === 'object') {
3070
+ result.agent_dispatch.console_status = consoleStatus;
3071
+ result.agent_dispatch.console_connectivity_note = probe.ok
3072
+ ? 'Console was reachable when this brief was built — do NOT tell the user the dev server is down unless a tool call returns CONSOLE_UNREACHABLE after this timestamp.'
3073
+ : 'Console probe failed when this brief was built — call heyspex_dev_server_status before claiming the server is down.';
3074
+ }
3075
+ } catch {
3076
+ result.console_status = {
3077
+ reachable: false,
3078
+ checked_at: new Date().toISOString(),
3079
+ error: 'probe_exception',
3080
+ };
3081
+ }
3082
+ }
3083
+
3084
+ /**
3085
+ * [[HS-TASK-JHIA]] — pre-fetch the FROZEN milestone snapshot from the console
3086
+ * (Neon-sourced) at the async handler boundary, mirroring `injectNeonFocusPin`.
3087
+ *
3088
+ * The vendor cli has no Neon client, so the frozen epic order can only arrive
3089
+ * over HTTP. We resolve the active milestone server-side via the `active`
3090
+ * sentinel and thread the frozen epic order into args as `frozen_epic_order`, so
3091
+ * the sync resolver reorders the milestone-drill candidate epics by it — making
3092
+ * `/hs-next`'s seed epic agree with the console What's Next / On Deck surfaces.
3093
+ *
3094
+ * Best-effort: a disabled flag (`{ enabled: false }`), missing snapshot, or an
3095
+ * unreachable console leaves the order unset and `/hs-next` keeps live scheduler
3096
+ * order — the honest `CONSOLE_UNREACHABLE` degradation ([[HS-TASK-JHIA]] AC #2).
3097
+ * No `.heyspex/*` file fallback is ever consulted ([[no-fallbacks-by-default]]).
3098
+ */
3099
+ export async function injectFrozenMilestoneOrder(args = {}) {
3100
+ if (args.ignore_focus === true || args.ignore_focus === 'true') return args;
3101
+ if (args.spec_id) return args;
3102
+ let res;
3103
+ try {
3104
+ res = await consoleFetch('/api/scheduler/milestone-snapshot/active');
3105
+ } catch {
3106
+ return args; // unreachable → live order
3107
+ }
3108
+ if (!res || !res.ok || !res.body || res.body.enabled !== true) return args;
3109
+ const snapshot = res.body.snapshot;
3110
+ if (!snapshot || !Array.isArray(snapshot.epicOrder)) return args;
3111
+ const frozen = snapshot.epicOrder
3112
+ .slice()
3113
+ .sort((a, b) => (a.position ?? 0) - (b.position ?? 0))
3114
+ .map((e) => e.epic_id)
3115
+ .filter((id) => typeof id === 'string' && id.length > 0);
3116
+ const appended = snapshot.appendedAfterSnapshot?.epics;
3117
+ if (Array.isArray(appended)) {
3118
+ for (const id of appended) {
3119
+ if (typeof id === 'string' && id.length > 0) frozen.push(id);
3120
+ }
3121
+ }
3122
+ if (frozen.length === 0) return args;
3123
+ return { ...args, frozen_epic_order: frozen };
3124
+ }
3125
+
3126
+ /**
3127
+ * heyspex_suggest_pillar — rank the pillar taxonomy against a draft spec.
3128
+ *
3129
+ * Thin relay over POST /api/pillars/suggest (the console owns the charter corpus and the
3130
+ * scorer). Read-only by construction: there is no code path from here to creating a
3131
+ * pillar, because a pillar is a permanent taxonomy change that only the owner approves
3132
+ * ([[pillar-assignment-protocol]] §"Proposing a new pillar").
3133
+ */
3134
+ export async function handleSuggestPillar(args = {}) {
3135
+ const subject = [args.subject, args.name, args.summary, Array.isArray(args.tags) ? args.tags.join(' ') : '']
3136
+ .filter((v) => typeof v === 'string' && v.trim())
3137
+ .join(' ')
3138
+ .trim();
3139
+ if (!subject) {
3140
+ return {
3141
+ isError: true,
3142
+ content: [{
3143
+ type: 'text',
3144
+ text: JSON.stringify({
3145
+ error: 'INVALID_ARGS',
3146
+ message: 'Pass the draft spec\'s `name` (ideally with `summary` and `tags`), or a freeform `subject`. Scoring needs the spec\'s own words.',
3147
+ }, null, 2),
3148
+ }],
3149
+ };
3150
+ }
3151
+
3152
+ const result = await consoleFetch('/api/pillars/suggest', {
3153
+ method: 'POST',
3154
+ body: {
3155
+ name: typeof args.name === 'string' ? args.name : undefined,
3156
+ summary: typeof args.summary === 'string' ? args.summary : undefined,
3157
+ tags: Array.isArray(args.tags) ? args.tags : undefined,
3158
+ subject: typeof args.subject === 'string' ? args.subject : undefined,
3159
+ shortlist: typeof args.shortlist === 'number' ? args.shortlist : undefined,
3160
+ },
3161
+ });
3162
+ if (!result.ok) {
3163
+ const payload = await enrichConsoleFailurePayloadAsync(result, {
3164
+ error: consoleErrorCode(result, 'SUGGEST_PILLAR_FAILED'),
3165
+ message: consoleErrorMessage(result, `POST /api/pillars/suggest returned ${result.status}`),
3166
+ status: result.status,
3167
+ url: result.url,
3168
+ console_response: result.body,
3169
+ });
3170
+ return { isError: true, content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
3171
+ }
3172
+ return { content: [{ type: 'text', text: JSON.stringify(result.body, null, 2) }] };
3173
+ }
3174
+
3175
+ /**
3176
+ * Execute one cross-milestone prerequisite resolution path — [[HS-EPIC-QQBD]].
3177
+ * POST /api/focus/prereq with the chosen `path` (do_first | switch_milestone |
3178
+ * move_in | dispatch | defer). The console owns the deterministic execution; this
3179
+ * tool is the agent-facing face. Success → { ok: true, result }; failures
3180
+ * (including the 409 confirmation_required case for an unconfirmed move_in) pass
3181
+ * the console error body straight through via controlError.
3182
+ */
3183
+ export async function handleResolvePrerequisite(args = {}) {
3184
+ const body = {
3185
+ blocked_id: args.blocked_id,
3186
+ prereq_id: args.prereq_id,
3187
+ path: args.path,
3188
+ actor: 'agent',
3189
+ ...(args.auto_return !== undefined ? { auto_return: args.auto_return } : {}),
3190
+ ...(args.confirm !== undefined ? { confirm: args.confirm } : {}),
3191
+ ...(args.prereq_milestone_id !== undefined
3192
+ ? { prereq_milestone_id: args.prereq_milestone_id }
3193
+ : {}),
3194
+ ...(args.blocked_milestone_id !== undefined
3195
+ ? { blocked_milestone_id: args.blocked_milestone_id }
3196
+ : {}),
3197
+ ...(args.return_to !== undefined ? { return_to: args.return_to } : {}),
3198
+ };
3199
+ const result = await consoleFetch('/api/focus/prereq', { method: 'POST', body });
3200
+ if (!result.ok) {
3201
+ return controlError(result, 'PREREQ_RESOLVE_FAILED', {
3202
+ blocked_id: args.blocked_id,
3203
+ prereq_id: args.prereq_id,
3204
+ path: args.path,
3205
+ });
3206
+ }
3207
+ return controlOk(result.body);
3208
+ }
3209
+
3210
+ export const MEMORY_BASELINE_TAGS = ['feedback', 'decision', 'architecture', 'saas'];
3211
+
3212
+ export const MEMORY_INJECTION_LIMIT = 5;
3213
+
3214
+ export const MEMORY_DESCRIPTION_TRUNCATE = 300;
3215
+
3216
+ /**
3217
+ * Detect that a memory's body carries an amendment/deprecation BELOW the part
3218
+ * recall shows, so the surfaced summary can be flagged as possibly-stale.
3219
+ *
3220
+ * Returns a short label naming what was found (e.g. "Amendment 2026-07-02 —
3221
+ * deprecated as a general architecture principle"), or '' when the memory reads
3222
+ * the same all the way down.
3223
+ *
3224
+ * Only content *after* the shown summary counts: a memory whose opening
3225
+ * paragraph already says it was superseded is not misleading, and flagging it
3226
+ * would be noise.
3227
+ */
3228
+ export function detectMemoryAmendment(body, shownDescription) {
3229
+ try {
3230
+ const shownEnd = shownDescription
3231
+ ? body.indexOf(shownDescription.slice(0, 60))
3232
+ : -1;
3233
+ const tail = shownEnd >= 0 ? body.slice(shownEnd + 1) : body;
3234
+ // Headings first — they carry the most precise label. A memory can have
3235
+ // several (an amendment that narrows it, then a later one that retires it);
3236
+ // surface the most severe, since "deprecated" changes whether the memory
3237
+ // applies at all while "extended" only changes its edges.
3238
+ const headings = [...tail.matchAll(/^#{1,6}\s*((?:Amendment|Addendum|Superseded|Deprecat)[^\n]*)$/gim)].map(
3239
+ (m) => m[1].trim().replace(/\s+/g, ' ').slice(0, 120),
3240
+ );
3241
+ if (headings.length > 0) {
3242
+ const retiring = headings.find((h) => /deprecat|supersed|retire|no longer/i.test(h));
3243
+ return retiring ?? headings[headings.length - 1];
3244
+ }
3245
+ // Otherwise look for retirement language in prose.
3246
+ if (/\b(?:no longer governs|is deprecated|now deprecated|superseded by|has been superseded|retired as)\b/i.test(tail)) {
3247
+ return 'amended/superseded later in body';
3248
+ }
3249
+ return '';
3250
+ } catch {
3251
+ return '';
3252
+ }
3253
+ }
3254
+
3255
+ /**
3256
+ * Score vault memories by tag-overlap with a primary tag set + baseline pool.
3257
+ * Returns the top-K memories as `{ source, name, link, description, score }`
3258
+ * entries. Source is always "vault" for now (specs/memory/); agent-local
3259
+ * memory injection is Phase 2.
3260
+ *
3261
+ * Deterministic: same inputs → same output ordering (sort is stable by
3262
+ * score-desc then name-asc), so the prompt cache stays warm on identical calls.
3263
+ */
3264
+ export function scoreRelevantMemories(vaultRoot, options = {}) {
3265
+ const primaryTags = Array.isArray(options.primaryTags) ? options.primaryTags : [];
3266
+ const baselineTags = Array.isArray(options.baselineTags)
3267
+ ? options.baselineTags
3268
+ : MEMORY_BASELINE_TAGS;
3269
+ const limit = Number.isInteger(options.limit) && options.limit > 0
3270
+ ? options.limit
3271
+ : MEMORY_INJECTION_LIMIT;
3272
+
3273
+ const memoryDir = join(vaultRoot, 'specs', 'memory');
3274
+ if (!existsSync(memoryDir)) return { header: null, items: [] };
3275
+
3276
+ // Lower-case query set for case-insensitive overlap.
3277
+ const querySet = new Set(
3278
+ [...primaryTags, ...baselineTags].map((t) => String(t).toLowerCase()),
3279
+ );
3280
+ if (querySet.size === 0) return { header: null, items: [] };
3281
+
3282
+ const subdirs = ['lessons', 'decisions', 'references', 'ui-patterns'];
3283
+ const candidates = [];
3284
+
3285
+ for (const sub of subdirs) {
3286
+ const catDir = join(memoryDir, sub);
3287
+ if (!existsSync(catDir)) continue;
3288
+ const files = readdirSync(catDir).filter((f) => f.endsWith('.md'));
3289
+ for (const file of files) {
3290
+ try {
3291
+ const content = readFileSync(join(catDir, file), 'utf8');
3292
+ const { frontmatter, body } = parseFrontmatter(content);
3293
+ const tags = Array.isArray(frontmatter.tags) ? frontmatter.tags : [];
3294
+ if (tags.length === 0) continue;
3295
+ const tagSet = new Set(tags.map((t) => String(t).toLowerCase()));
3296
+ let score = 0;
3297
+ for (const q of querySet) if (tagSet.has(q)) score += 1;
3298
+ if (score === 0) continue;
3299
+ const slug = file.replace(/\.md$/, '');
3300
+ const name = frontmatter.name || frontmatter.id || slug;
3301
+ let description = extractSummary(body);
3302
+ if (description.length > MEMORY_DESCRIPTION_TRUNCATE) {
3303
+ description = description.slice(0, MEMORY_DESCRIPTION_TRUNCATE - 1) + '…';
3304
+ }
3305
+ // Amendment awareness. Recall shows only the opening paragraph, so a
3306
+ // memory later amended or deprecated is surfaced by its ORIGINAL claim
3307
+ // — the reverse of what is in force. That is not hypothetical:
3308
+ // [[local-first-applies-beyond-storage]] opens with "we always choose
3309
+ // local first" and, 30 lines below the truncation point, carries
3310
+ // "Amendment 2026-07-02 — deprecated as a general architecture
3311
+ // principle". An agent applying the recalled summary would apply a rule
3312
+ // the owner retired ([[HS-GIXC-CAP-KPZT-heyspex-should-actively-detect-and-preve]]).
3313
+ const amended = detectMemoryAmendment(body, description);
3314
+ if (amended) {
3315
+ description = `${description} ⚠ ${amended} — this summary may not reflect the current ruling; READ THE FULL BODY before applying.`;
3316
+ }
3317
+ candidates.push({
3318
+ source: 'vault',
3319
+ name,
3320
+ link: `[[${slug}]]`,
3321
+ description,
3322
+ amended: amended || undefined,
3323
+ score,
3324
+ });
3325
+ } catch {
3326
+ // Skip unparseable files.
3327
+ }
3328
+ }
3329
+ }
3330
+
3331
+ candidates.sort((a, b) => {
3332
+ if (b.score !== a.score) return b.score - a.score;
3333
+ return a.name.localeCompare(b.name);
3334
+ });
3335
+
3336
+ return {
3337
+ header: 'Retrieved from prior sessions. Apply at decision points; do not echo verbatim.',
3338
+ items: candidates.slice(0, limit),
3339
+ };
3340
+ }
3341
+
3342
+ /**
3343
+ * Surface a `recalled_memories` block on the /hs-next response so the agent
3344
+ * reads the relevant prior-session lessons without having to remember to call
3345
+ * heyspex_recall_memories itself ([[HS-TASK-IMMF]] Part A). Scoring uses the
3346
+ * claimed spec's tags as the primary signal + the baseline pool — so memories
3347
+ * about rule placement, SaaS template cost, etc. always rank as candidates
3348
+ * regardless of which spec is on deck.
3349
+ *
3350
+ * Display-only — never gates. Skipped in summary mode (the full call refetches).
3351
+ */
3352
+ export function enrichOnDeckWithRecalledMemories(vaultRoot, result, args = {}) {
3353
+ if (!result || result.error) return result;
3354
+ if (args.ignore_memories === true || args.ignore_memories === 'true') return result;
3355
+
3356
+ // Derive the claimed spec's tags. The full response carries the spec frontmatter
3357
+ // through under different keys depending on the path (direct lookup vs scheduler);
3358
+ // fall back to the breadcrumb's leaf id and load the spec on disk if needed.
3359
+ let primaryTags = [];
3360
+ const candidatePaths = [result.spec_path, result.spec_id ? findSpecFile(vaultRoot, result.spec_id) : null].filter(Boolean);
3361
+ for (const p of candidatePaths) {
3362
+ try {
3363
+ const spec = typeof p === 'string' ? loadSpec(vaultRoot, p) : null;
3364
+ if (spec?.frontmatter?.tags && Array.isArray(spec.frontmatter.tags)) {
3365
+ primaryTags = spec.frontmatter.tags;
3366
+ break;
3367
+ }
3368
+ } catch {
3369
+ // Continue to next candidate.
3370
+ }
3371
+ }
3372
+
3373
+ const recalled = scoreRelevantMemories(vaultRoot, { primaryTags });
3374
+ if (!recalled.items.length) return result;
3375
+
3376
+ return { ...result, recalled_memories: recalled };
3377
+ }