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,207 @@
1
+ // spexharness/src/token-inventory.mjs
2
+ //
3
+ // The harness's session-start token inventory: measure every piece a session injects at
4
+ // open (seat context, WIP block, memories, preflight procedures…), order them by value,
5
+ // cut from the BOTTOM of that order when the budget is hit, and produce the one-line
6
+ // inventory the front prints on session open — "what was injected and how much is left".
7
+ //
8
+ // Budget is a PARAMETER, never a constant: a seat opened on a 64k local model and the same
9
+ // seat opened on a long-window hosted model run the same bootstrap and differ only in the
10
+ // number resolved here. The seat row is the source of that number (`tokenBudget`), and 64k
11
+ // is the default when the row doesn't carry one — the smallest window the fleet targets, so
12
+ // a seat that never states a budget is still planned to fit the local model.
13
+ //
14
+ // Sizing is NOT reimplemented here: `estimateTokens` (chars/4) comes from
15
+ // dev-factory/lib/context-budget.mjs, which is already THE prompt-token estimate for the
16
+ // factory (see that file's header for why a second copy of the arithmetic is a defect, not
17
+ // a convenience). Because chars/4 is an approximation, callers that must also reserve room
18
+ // for a model's OUTPUT should resolve their budget with that module's `promptBudgetTokens`
19
+ // and pass the result in as `budget` — this module deliberately does not re-derive it.
20
+ //
21
+ // Pure: no I/O, no console. `printTokenInventory` takes the log function, so a front (or a
22
+ // test) decides where the line goes. Nothing here reads or writes the bootstrap — pieces
23
+ // arrive as an argument, so the caller assembling them and this measurement stay separable.
24
+
25
+ import { estimateTokens } from '../../dev-factory/lib/context-budget.mjs';
26
+
27
+ export { estimateTokens };
28
+
29
+ /** Default session-start token budget: 64k — the smallest window the local fleet targets. */
30
+ export const DEFAULT_TOKEN_BUDGET = 65536;
31
+
32
+ /**
33
+ * Seat-row keys carrying a token budget, in precedence order. `tokenBudget` is the
34
+ * canonical one; the snake_case spelling and the model-window names are accepted because
35
+ * seat rows are hand-edited JSON (dev-factory/seats.json) and a near-miss key silently
36
+ * falling back to 64k is worse than reading it.
37
+ */
38
+ const SEAT_BUDGET_KEYS = ['tokenBudget', 'token_budget', 'contextTokens', 'numCtx', 'num_ctx'];
39
+
40
+ /** A usable budget is a finite, positive number; anything else (null, "", NaN, 0, -1) is not. */
41
+ function usableBudget(value) {
42
+ const n = Number(value);
43
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : null;
44
+ }
45
+
46
+ /**
47
+ * Resolve the token budget for a seat: the seat row's own budget, else {@link DEFAULT_TOKEN_BUDGET}.
48
+ * A missing seat, a seat with no budget key, or an unusable value (0, negative, non-numeric)
49
+ * all fall back to the default rather than throwing — a seat must never fail to open because
50
+ * its row is missing an optional field.
51
+ *
52
+ * @param {object|null} [seat] a seat row (dev-factory/seats.json shape), or null.
53
+ * @returns {number} a positive integer token budget.
54
+ */
55
+ export function seatTokenBudget(seat = null) {
56
+ if (!seat || typeof seat !== 'object') return DEFAULT_TOKEN_BUDGET;
57
+ for (const key of SEAT_BUDGET_KEYS) {
58
+ const budget = usableBudget(seat[key]);
59
+ if (budget !== null) return budget;
60
+ }
61
+ return DEFAULT_TOKEN_BUDGET;
62
+ }
63
+
64
+ /**
65
+ * Measure one piece. `tokens` (a size the caller already knows — e.g. a tokenizer's count)
66
+ * wins over `text`; otherwise the text is sized with the repo-wide chars/4 estimate.
67
+ *
68
+ * @param {{ name?: string, text?: string, tokens?: number, priority?: number }} piece
69
+ * @param {number} [index] used to name an unnamed piece so the inventory line never has a blank slot.
70
+ * @returns {{ name: string, tokens: number, chars: number, priority: number, estimated: boolean }}
71
+ */
72
+ export function measurePiece(piece, index = 0) {
73
+ const { name, text, tokens, priority } = piece ?? {};
74
+ // An explicit `tokens: 0` is a real measurement (an empty piece), while null/undefined
75
+ // means "not measured yet" — so absence, not falsiness, is what falls back to the text.
76
+ const explicit = tokens == null ? null : (usableBudget(tokens) ?? (Number(tokens) === 0 ? 0 : null));
77
+ const body = text == null ? '' : String(text);
78
+ return {
79
+ name: name ? String(name) : `piece-${index + 1}`,
80
+ tokens: explicit === null ? estimateTokens(body) : explicit,
81
+ chars: body.length,
82
+ priority: Number.isFinite(Number(priority)) ? Number(priority) : 0,
83
+ estimated: explicit === null,
84
+ };
85
+ }
86
+
87
+ /**
88
+ * Measure every piece and order it by value: descending `priority`, ties broken by the
89
+ * order the caller supplied (a stable sort), so a caller that already hands pieces over
90
+ * most-valuable-first gets exactly its own order back without stating a priority at all.
91
+ *
92
+ * @param {Array<object>} pieces
93
+ * @returns {Array<ReturnType<typeof measurePiece>>}
94
+ */
95
+ export function measurePieces(pieces = []) {
96
+ const list = Array.isArray(pieces) ? pieces : [];
97
+ return list
98
+ .map((piece, index) => ({ measured: measurePiece(piece, index), index }))
99
+ .sort((a, b) => b.measured.priority - a.measured.priority || a.index - b.index)
100
+ .map(({ measured }) => measured);
101
+ }
102
+
103
+ /**
104
+ * Build the session-start token inventory: measure each piece, keep them down the value
105
+ * order while they fit the budget, and cut from the BOTTOM once one doesn't. The cut is a
106
+ * true tail cut — the first piece that overflows and everything below it are dropped, so a
107
+ * cheap low-value piece never leapfrogs an expensive higher-value one it was ranked under
108
+ * (that would silently reorder the seat's context by size instead of by value).
109
+ *
110
+ * @param {{
111
+ * pieces?: Array<{ name?: string, text?: string, tokens?: number, priority?: number }>,
112
+ * seat?: object|null,
113
+ * budget?: number|null,
114
+ * }} [opts]
115
+ * `budget` — an explicit budget, which wins over the seat row (a front that must also
116
+ * reserve output tokens resolves that itself and passes the result). `seat` — the seat
117
+ * row to read the budget from when `budget` is absent.
118
+ * @returns {{
119
+ * budget: number,
120
+ * injected: Array<{ name: string, tokens: number, chars: number, priority: number, estimated: boolean }>,
121
+ * dropped: Array<{ name: string, tokens: number, chars: number, priority: number, estimated: boolean }>,
122
+ * measured: Array<object>,
123
+ * injectedTokens: number,
124
+ * droppedTokens: number,
125
+ * totalTokens: number,
126
+ * remainingTokens: number,
127
+ * withinBudget: boolean,
128
+ * line: string,
129
+ * }}
130
+ */
131
+ export function buildTokenInventory({ pieces = [], seat = null, budget = null } = {}) {
132
+ const resolvedBudget = usableBudget(budget) ?? seatTokenBudget(seat);
133
+ const measured = measurePieces(pieces);
134
+
135
+ const injected = [];
136
+ const dropped = [];
137
+ let injectedTokens = 0;
138
+ let cutting = false;
139
+ for (const piece of measured) {
140
+ // Once the budget is hit, everything below stays cut — bottom-of-the-order, not
141
+ // best-fit packing.
142
+ if (cutting || injectedTokens + piece.tokens > resolvedBudget) {
143
+ cutting = true;
144
+ dropped.push(piece);
145
+ continue;
146
+ }
147
+ injected.push(piece);
148
+ injectedTokens += piece.tokens;
149
+ }
150
+
151
+ const droppedTokens = dropped.reduce((sum, piece) => sum + piece.tokens, 0);
152
+ const inventory = {
153
+ budget: resolvedBudget,
154
+ injected,
155
+ dropped,
156
+ measured,
157
+ injectedTokens,
158
+ droppedTokens,
159
+ totalTokens: injectedTokens + droppedTokens,
160
+ remainingTokens: resolvedBudget - injectedTokens,
161
+ withinBudget: dropped.length === 0,
162
+ line: '',
163
+ };
164
+ inventory.line = formatInventoryLine(inventory);
165
+ return inventory;
166
+ }
167
+
168
+ /**
169
+ * Render the one line a front prints on session open: what was injected, at what size, and
170
+ * how much of the budget is left — plus what was cut, when anything was. Always exactly one
171
+ * line (no newline characters), so it can sit in a status bar, a log line, or a chat header.
172
+ *
173
+ * @param {ReturnType<typeof buildTokenInventory>} inventory
174
+ * @returns {string}
175
+ */
176
+ export function formatInventoryLine(inventory) {
177
+ const {
178
+ budget = DEFAULT_TOKEN_BUDGET,
179
+ injected = [],
180
+ dropped = [],
181
+ injectedTokens = 0,
182
+ remainingTokens = budget - injectedTokens,
183
+ } = inventory ?? {};
184
+
185
+ const used = budget > 0 ? Math.round((injectedTokens / budget) * 100) : 100;
186
+ const parts = [
187
+ `context ${injectedTokens}/${budget} tokens (${used}%)`,
188
+ `${remainingTokens} left`,
189
+ `in: ${injected.length ? injected.map((p) => `${p.name} ${p.tokens}`).join(', ') : '(none)'}`,
190
+ ];
191
+ if (dropped.length) parts.push(`cut: ${dropped.map((p) => `${p.name} ${p.tokens}`).join(', ')}`);
192
+ return parts.join(' · ').replace(/[\r\n]+/g, ' ');
193
+ }
194
+
195
+ /**
196
+ * Print the inventory line on session open. The log function is an argument so a front
197
+ * chooses the sink (stdout, a chat header, a log file) and a test can capture it.
198
+ *
199
+ * @param {ReturnType<typeof buildTokenInventory>} inventory
200
+ * @param {(line: string) => void} [log]
201
+ * @returns {string} the line that was printed.
202
+ */
203
+ export function printTokenInventory(inventory, log = console.log) {
204
+ const line = inventory?.line ?? formatInventoryLine(inventory);
205
+ log(line);
206
+ return line;
207
+ }
@@ -0,0 +1,96 @@
1
+ // account-switch.mjs — the ONE function that moves "which account is current"
2
+ // on this machine: the credentials store's `current_account_id` (and its token
3
+ // mirror) AND `~/.heyspex/registry.json`'s `current_account_id` move together,
4
+ // so the bearer and the active-vault pointer can never name different accounts
5
+ // ([[HS-UA9S-WW9R-TASK-store-credentials-per-account-login]], invariant 4).
6
+ //
7
+ // The task offered two shapes: a hidden `hs vault current-account` sub (edits
8
+ // `spex-console/src/lib/cli/vault.ts`, held by a sibling task) or one function
9
+ // that writes both files. This is the second. It lives beside credentials.mjs
10
+ // rather than under scripts/lib/ because `hs login` / `hs logout` (cli-verbs.mjs)
11
+ // ship in the npm package, whose `files` list already carries every
12
+ // vendor/heyspex-mcp/*.mjs but would need a package.json entry for a new
13
+ // scripts/lib/ file.
14
+ //
15
+ // The registry half edits ONLY `current_account_id` of a schema-v2 file and
16
+ // keeps every other byte of the JSON value (accounts' pointers, rows) as is. A
17
+ // missing file has no pointers to move; a v1 file is re-keyed only by the
18
+ // explicit `hs vault migrate-registry`; an unreadable file is left for
19
+ // `hs doctor`. Each of those is reported, never "fixed" here.
20
+ //
21
+ // Plain Node ESM, zero dependencies. Never logs token material.
22
+
23
+ import { heyspexHome } from '../../scripts/lib/heyspex-home.mjs';
24
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
25
+ import { homedir } from 'node:os';
26
+ import { dirname, join } from 'node:path';
27
+
28
+ import { assertNotRealHomeUnderTest, removeAccount, setCurrentAccount } from './credentials.mjs';
29
+
30
+ /** `~/.heyspex/registry.json` for `homeDir`. */
31
+ export function registryFilePath(homeDir = heyspexHome()) {
32
+ return join(homeDir, '.heyspex', 'registry.json');
33
+ }
34
+
35
+ /**
36
+ * Set registry.json's `current_account_id` (or clear it with `null`).
37
+ * @param {{ homeDir?: string, accountId: string | null }} input
38
+ * @returns {{ status: 'updated' | 'unchanged' | 'absent' | 'v1' | 'unreadable', path: string }}
39
+ */
40
+ export function setRegistryCurrentAccount({ homeDir = heyspexHome(), accountId }) {
41
+ const path = registryFilePath(homeDir);
42
+ if (!existsSync(path)) return { status: 'absent', path };
43
+ let json;
44
+ try {
45
+ json = JSON.parse(readFileSync(path, 'utf8'));
46
+ } catch {
47
+ return { status: 'unreadable', path };
48
+ }
49
+ if (!json || typeof json !== 'object' || Array.isArray(json)) return { status: 'unreadable', path };
50
+ if (json.schema_version !== 2) return { status: 'v1', path };
51
+ const next = accountId ?? null;
52
+ if ((json.current_account_id ?? null) === next) return { status: 'unchanged', path };
53
+ json.current_account_id = next;
54
+ assertNotRealHomeUnderTest(path);
55
+ mkdirSync(dirname(path), { recursive: true });
56
+ const tmp = `${path}.tmp`;
57
+ writeFileSync(tmp, `${JSON.stringify(json, null, 2)}\n`, 'utf8');
58
+ renameSync(tmp, path);
59
+ return { status: 'updated', path };
60
+ }
61
+
62
+ /**
63
+ * Make `accountId` the machine's current account in BOTH stores. The account
64
+ * must already hold a token (a switch never pairs). Throws when it does not;
65
+ * the registry is touched only after the credentials half succeeded.
66
+ * @param {{ homeDir?: string, accountId: string }} input
67
+ */
68
+ export function switchCurrentAccount({ homeDir = heyspexHome(), accountId }) {
69
+ const credentialsPath = setCurrentAccount({ homeDir, accountId });
70
+ const registry = setRegistryCurrentAccount({ homeDir, accountId });
71
+ return { accountId, credentialsPath, registry };
72
+ }
73
+
74
+ /**
75
+ * Sign one account out (default: the current one). When the current account is
76
+ * removed, both stores' `current_account_id` are cleared; removing another
77
+ * account leaves the registry alone.
78
+ * @param {{ homeDir?: string, accountId?: string | null }} [input]
79
+ */
80
+ export function signOutAccount({ homeDir = heyspexHome(), accountId = null } = {}) {
81
+ const result = removeAccount({ homeDir, accountId });
82
+ const registry =
83
+ result.removed && result.wasCurrent
84
+ ? setRegistryCurrentAccount({ homeDir, accountId: null })
85
+ : { status: 'unchanged', path: registryFilePath(homeDir) };
86
+ return { ...result, registry };
87
+ }
88
+
89
+ /**
90
+ * After a fresh pairing stored `accountId` as current in the credentials
91
+ * store, carry the registry's pointer along with it.
92
+ * @param {{ homeDir?: string, accountId: string }} input
93
+ */
94
+ export function followCurrentAccountInRegistry({ homeDir = heyspexHome(), accountId }) {
95
+ return setRegistryCurrentAccount({ homeDir, accountId });
96
+ }
@@ -29,11 +29,57 @@
29
29
  import { execFileSync } from 'node:child_process';
30
30
  import { readFileSync, existsSync } from 'node:fs';
31
31
  import { wikilinkTarget } from './blocker-resolver.mjs';
32
+ import { readViaSelector } from './read-path.mjs';
32
33
  import { spexConsoleSettingsCandidates } from './vault-settings.mjs';
33
34
 
34
35
  export const ALIGNMENT_FUSE_DAYS = 7;
35
36
  export const DEFAULT_FOCUS_PIN_EXPIRATION_DAYS = 7;
36
37
 
38
+ /**
39
+ * Marker carried by every result this module returns when a read the DISK tier
40
+ * performs has no equivalent in the `neon` read tier.
41
+ *
42
+ * The rule it exists to enforce: **a cloud-mode gate never fails OPEN
43
+ * silently.** In the `neon` tier there may be no git checkout of the vault at
44
+ * all, so `git log` against the vault root is not "slow" or "empty" — it is
45
+ * meaningless. The honest verdict is "cannot verify in cloud mode", carried in
46
+ * the result itself, NOT a thrown ENOENT and NOT a quiet `skip: true`.
47
+ */
48
+ export const CLOUD_MODE_UNSUPPORTED_READ = 'CLOUD_MODE_UNSUPPORTED_READ';
49
+
50
+ /** Build one clearly-marked "this could not be sourced in cloud mode" record. */
51
+ export function cloudModeUnsupportedRead({ what, why, routeLabel = null, consoleUrl = null }) {
52
+ return {
53
+ unsupported: CLOUD_MODE_UNSUPPORTED_READ,
54
+ what,
55
+ why,
56
+ route_label: routeLabel,
57
+ console_url: consoleUrl ?? null,
58
+ };
59
+ }
60
+
61
+ /** True for any value produced by {@link cloudModeUnsupportedRead}. */
62
+ export function isCloudModeUnsupportedRead(value) {
63
+ return Boolean(value) && value.unsupported === CLOUD_MODE_UNSUPPORTED_READ;
64
+ }
65
+
66
+ /**
67
+ * Why the git-time reads below have no cloud twin, stated once.
68
+ *
69
+ * The console serves no git-history route (audited 2026-09-14 across
70
+ * `spex-console/src/app/api/`): `/api/activity` and `/api/specs/[id]/activity`
71
+ * serve the agent tool-call log, not vault history; `POST /api/snapshot`
72
+ * TRIGGERS a git export drain and reads nothing back; `/api/specs` carries only
73
+ * frontmatter `created:` / `updated:` — the hand-editable fields the commit
74
+ * timestamp was deliberately chosen to be immune to (see the section header
75
+ * above). Substituting them would silently swap a spoof-proof signal for a
76
+ * spoofable one inside a gate, which is worse than reporting the gap.
77
+ */
78
+ const GIT_HISTORY_UNSOURCEABLE_WHY =
79
+ 'the neon read tier may hold no git checkout of the vault, and the console exposes no git-history route — '
80
+ + '/api/specs carries only frontmatter `created:`/`updated:`, the hand-editable fields the commit timestamp is chosen to be immune to, '
81
+ + 'and /api/activity is the agent tool-call log, not vault history';
82
+
37
83
  // ─────────────────────────────────────────────────────────────────────────────
38
84
  // Git-time helpers — SSOT for "when did this spec enter the vault?" The
39
85
  // commit timestamp (`%ct`, seconds-since-epoch in committer's local timezone)
@@ -149,6 +195,100 @@ export function computeAlignmentSignature({ childIds, epicUpdated, worldStateHas
149
195
  return djb2(`${sortedChildren}|${epicUpdated || ''}|${worldStateHash || ''}`);
150
196
  }
151
197
 
198
+ // ─────────────────────────────────────────────────────────────────────────────
199
+ // Tier-aware git-time reads ([[HS-T6GS-MCPC-EPIC]] — every vault read the MCP
200
+ // server makes goes through `readViaSelector`).
201
+ //
202
+ // The tier is RESOLVED ONCE by the handler that owns the request and threaded
203
+ // in as `deps.tier`; nothing in this module calls `resolveReadTier()` itself.
204
+ // `tier` defaults to `'disk'` so every existing caller — and every existing
205
+ // test — keeps today's behavior byte-for-byte: the disk arm of
206
+ // `readViaSelector` is literally `diskRead()`, and `diskRead` here is the
207
+ // untouched synchronous function above.
208
+ //
209
+ // The `neon` arm does NOT call the console: nothing there serves git history
210
+ // (see GIT_HISTORY_UNSOURCEABLE_WHY). It returns a clearly-marked
211
+ // CLOUD_MODE_UNSUPPORTED_READ record instead — kept inside `neonRead` so that
212
+ // the day a `/api/vault/git-history`-shaped route exists, swapping this body
213
+ // for a real fetch also gets the CONSOLE_UNREACHABLE_CLOUD_MODE failure
214
+ // envelope for free.
215
+ // ─────────────────────────────────────────────────────────────────────────────
216
+
217
+ /** Console URL to NAME in cloud-mode diagnostics; never resolved on the disk path. */
218
+ function diagnosticConsoleUrl(tier, deps) {
219
+ return tier === 'disk' ? null : (deps?.consoleUrl ?? null);
220
+ }
221
+
222
+ /** Tier-aware {@link gitFirstCommitTime}. `neon` answers an unsupported-read record. */
223
+ export async function gitFirstCommitTimeViaTier(vaultRoot, path, deps = {}) {
224
+ const { tier = 'disk' } = deps;
225
+ const consoleUrl = diagnosticConsoleUrl(tier, deps);
226
+ return readViaSelector({
227
+ tier,
228
+ diskRead: () => gitFirstCommitTime(vaultRoot, path),
229
+ neonRead: async () =>
230
+ cloudModeUnsupportedRead({
231
+ what: `the first-commit timestamp of \`${path}\` (\`git log --diff-filter=A --follow --format=%ct -- ${path}\`)`,
232
+ why: GIT_HISTORY_UNSOURCEABLE_WHY,
233
+ routeLabel: 'git log --diff-filter=A --follow',
234
+ consoleUrl,
235
+ }),
236
+ routeLabel: 'git log --diff-filter=A --follow',
237
+ consoleUrl,
238
+ });
239
+ }
240
+
241
+ /** Tier-aware {@link gitLastCommitTime}. `neon` answers an unsupported-read record. */
242
+ export async function gitLastCommitTimeViaTier(vaultRoot, path, deps = {}) {
243
+ const { tier = 'disk' } = deps;
244
+ const consoleUrl = diagnosticConsoleUrl(tier, deps);
245
+ return readViaSelector({
246
+ tier,
247
+ diskRead: () => gitLastCommitTime(vaultRoot, path),
248
+ neonRead: async () =>
249
+ cloudModeUnsupportedRead({
250
+ what: `the last-commit timestamp of \`${path}\` (\`git log -1 --format=%ct -- ${path}\`)`,
251
+ why: GIT_HISTORY_UNSOURCEABLE_WHY,
252
+ routeLabel: 'git log -1 --format=%ct',
253
+ consoleUrl,
254
+ }),
255
+ routeLabel: 'git log -1 --format=%ct',
256
+ consoleUrl,
257
+ });
258
+ }
259
+
260
+ /**
261
+ * Tier-aware {@link computeWorldStateDelta}.
262
+ *
263
+ * The `neon` answer keeps the `{ addedPaths, hash }` SHAPE so a caller that
264
+ * only reads those two fields cannot crash — but it is additionally stamped
265
+ * `unsupported: CLOUD_MODE_UNSUPPORTED_READ` and carries the full
266
+ * `unsupported_read` record, so a caller that treats the empty delta as "no
267
+ * new specs" is the one making that (wrong) choice explicitly.
268
+ * {@link canSkipAlignmentViaTier} refuses to.
269
+ */
270
+ export async function computeWorldStateDeltaViaTier(vaultRoot, sinceTs, deps = {}) {
271
+ const { tier = 'disk' } = deps;
272
+ const consoleUrl = diagnosticConsoleUrl(tier, deps);
273
+ return readViaSelector({
274
+ tier,
275
+ diskRead: () => computeWorldStateDelta(vaultRoot, sinceTs),
276
+ neonRead: async () => ({
277
+ addedPaths: [],
278
+ hash: djb2(''),
279
+ unsupported: CLOUD_MODE_UNSUPPORTED_READ,
280
+ unsupported_read: cloudModeUnsupportedRead({
281
+ what: 'the set of `specs/` files first committed since the last alignment review (`git log --since=… --diff-filter=A --name-only -- specs/`)',
282
+ why: GIT_HISTORY_UNSOURCEABLE_WHY,
283
+ routeLabel: 'git log --since --diff-filter=A -- specs/',
284
+ consoleUrl,
285
+ }),
286
+ }),
287
+ routeLabel: 'git log --since --diff-filter=A -- specs/',
288
+ consoleUrl,
289
+ });
290
+ }
291
+
152
292
  // ─────────────────────────────────────────────────────────────────────────────
153
293
  // Settings-driven fuse — read spex-console/settings.json (or .heyspex/ mirror) so
154
294
  // disabling `focusPinExpirationEnabled` in the UI also disables the alignment
@@ -156,6 +296,25 @@ export function computeAlignmentSignature({ childIds, epicUpdated, worldStateHas
156
296
  // drop fuse_expired from the skip-reason ladder.
157
297
  // ─────────────────────────────────────────────────────────────────────────────
158
298
 
299
+ /**
300
+ * LOCAL CACHE — stays on disk in BOTH read tiers, deliberately.
301
+ *
302
+ * Both call sites (`readAlignmentFuseSettings`, `readReadinessFloorSettings`)
303
+ * point this at `spexConsoleSettingsCandidates(vaultRoot)`, i.e.
304
+ * `spex-console/settings.json` and its read-only `.heyspex/` mirrors. That is
305
+ * gitignored machine-local CONFIG, not vault content: it never lives under
306
+ * `specs/`, it is not a document with an id, frontmatter or a wikilink target,
307
+ * and `PUT /api/settings` explicitly refuses to persist it outside local mode
308
+ * (HS-29MO). So there is nothing in Neon to read it from, and nothing about a
309
+ * cloud-reliant session that should change where a local knob is read.
310
+ *
311
+ * This is the same call that `read-path-wiring.test.mjs` AC5 makes for
312
+ * credentials, the scheduler cache and caller-supplied args files: "still read
313
+ * from disk in both reliances." Both call sites therefore keep this function
314
+ * untouched — no tier argument, no console route, no degrade marker; a missing
315
+ * file already means "use the shipped default", which is the correct answer on
316
+ * a machine with no console settings file, cloud-reliant or not.
317
+ */
159
318
  function readJsonSafe(path) {
160
319
  try {
161
320
  if (!existsSync(path)) return null;
@@ -165,6 +324,10 @@ function readJsonSafe(path) {
165
324
  }
166
325
  }
167
326
 
327
+ /**
328
+ * Read tier: DISK in both tiers — machine-local console config, not vault
329
+ * content. See {@link readJsonSafe} for the full call-site conclusion.
330
+ */
168
331
  export function readAlignmentFuseSettings(vaultRoot) {
169
332
  const fallback = { enabled: true, days: DEFAULT_FOCUS_PIN_EXPIRATION_DAYS };
170
333
  for (const p of spexConsoleSettingsCandidates(vaultRoot)) {
@@ -261,6 +424,101 @@ export function canSkipAlignment(epic, vaultRoot, options = {}) {
261
424
  return { skip: true, reason: 'binary_skip_rule', signature: currentSignature };
262
425
  }
263
426
 
427
+ /**
428
+ * Tier-aware {@link canSkipAlignment}.
429
+ *
430
+ * `disk` → `canSkipAlignment(epic, vaultRoot, options)`, byte-identical (the
431
+ * selector's disk arm is literally `diskRead()`). `tier` defaults to `'disk'`,
432
+ * so an existing caller that passes no deps is unchanged.
433
+ *
434
+ * `neon` → the gate FAILS CLOSED and SAYS SO. Two of the rule's four
435
+ * components are git-truth (`older_children` needs each child's first-commit
436
+ * time; `new_specs_since_breakdown` needs the added-files delta) and the third
437
+ * — `signature_drift` — mixes the world-state hash into its signature, so a
438
+ * synthesized empty delta would make it compare a signature against inputs it
439
+ * never actually read. Rather than pass on unverified inputs (fail open) or
440
+ * throw ENOENT from `git` against a directory that does not exist, the verdict
441
+ * is `{ skip: false, reason: 'cloud_mode_unverifiable' }` carrying
442
+ * {@link CLOUD_MODE_UNSUPPORTED_READ} and a per-read record naming exactly
443
+ * what could not be sourced and why.
444
+ *
445
+ * Two reasons survive cloud mode intact and are returned as themselves,
446
+ * because neither reads git or the vault: `no_checkpoint` (the epic's own
447
+ * frontmatter carries no `alignment_reviewed_at`) and `fuse_expired` (that
448
+ * timestamp against a local settings knob — see {@link readJsonSafe}). Both
449
+ * already say `skip: false`, so returning them changes nothing about failing
450
+ * closed; it just gives the more specific reason.
451
+ *
452
+ * A caller that HAS sourced the world-state delta by some other means may pass
453
+ * it as `options.worldStateDelta`; it is then trusted and not listed as
454
+ * unsupported, exactly as on disk.
455
+ */
456
+ export async function canSkipAlignmentViaTier(epic, vaultRoot, options = {}, deps = {}) {
457
+ const { tier = 'disk' } = deps;
458
+ if (tier !== 'neon') {
459
+ return readViaSelector({
460
+ tier,
461
+ diskRead: () => canSkipAlignment(epic, vaultRoot, options),
462
+ routeLabel: 'canSkipAlignment',
463
+ consoleUrl: null,
464
+ });
465
+ }
466
+
467
+ const consoleUrl = deps.consoleUrl ?? null;
468
+ const fm = epic?.frontmatter || {};
469
+ const reviewedAt = fm.alignment_reviewed_at;
470
+ const reviewedMs = reviewedAt ? Date.parse(String(reviewedAt)) : NaN;
471
+ if (!Number.isFinite(reviewedMs)) {
472
+ // Frontmatter-only verdict — as true in cloud mode as on disk.
473
+ return { skip: false, reason: 'no_checkpoint' };
474
+ }
475
+
476
+ const unsupportedReads = [
477
+ cloudModeUnsupportedRead({
478
+ what: 'the first-commit timestamp of the epic and of each child, which the `older_children` check compares',
479
+ why: GIT_HISTORY_UNSOURCEABLE_WHY,
480
+ routeLabel: 'git log --diff-filter=A --follow',
481
+ consoleUrl,
482
+ }),
483
+ ];
484
+
485
+ let worldStateDelta = options.worldStateDelta;
486
+ if (!worldStateDelta) {
487
+ worldStateDelta = await computeWorldStateDeltaViaTier(vaultRoot, reviewedMs, { tier, consoleUrl });
488
+ if (worldStateDelta.unsupported_read) unsupportedReads.push(worldStateDelta.unsupported_read);
489
+ }
490
+
491
+ // Re-run the shared rule with the git-backed inputs NEUTRALIZED rather than
492
+ // re-implementing the ladder: `epicPath`/`childResolver` are nulled so the
493
+ // older_children walk is skipped (it is already a no-op without them), and
494
+ // the delta is whatever we could honestly source.
495
+ const inner = canSkipAlignment(epic, vaultRoot, {
496
+ ...options,
497
+ worldStateDelta,
498
+ epicPath: null,
499
+ childResolver: null,
500
+ });
501
+
502
+ // Reasons that stand on their own in cloud mode: they read no git history.
503
+ if (inner.reason === 'fuse_expired' || inner.reason === 'new_specs_since_breakdown') {
504
+ return { ...inner, cloud_mode_unsupported_reads: unsupportedReads };
505
+ }
506
+
507
+ return {
508
+ skip: false,
509
+ reason: 'cloud_mode_unverifiable',
510
+ unsupported: CLOUD_MODE_UNSUPPORTED_READ,
511
+ cloud_mode_unsupported_reads: unsupportedReads,
512
+ message:
513
+ `${CLOUD_MODE_UNSUPPORTED_READ}: the alignment skip rule cannot be verified in the neon read tier — `
514
+ + `${unsupportedReads.map((u) => u.what).join('; ')}. `
515
+ + 'The gate stays CLOSED (skip: false) rather than passing on inputs it never read.',
516
+ // What the rule WOULD have said from the components that were readable.
517
+ // Reported for transparency only — never as the verdict.
518
+ unverified_inner_verdict: inner,
519
+ };
520
+ }
521
+
264
522
  /** Epic children live in `children:`; milestone children live in `epics:`. */
265
523
  export function childRefsField(grain) {
266
524
  return grain === 'milestone' ? 'epics' : 'children';
@@ -306,6 +564,39 @@ export function extractSection(body, headingRe) {
306
564
  return out.join('\n').trim();
307
565
  }
308
566
 
567
+ /**
568
+ * Does the body carry a level-2 heading whose title matches `headingRe`? Same
569
+ * scan as `extractSection`, but it answers "is the heading THERE" rather than
570
+ * "what is under it" — an empty section and an absent one look identical to
571
+ * `extractSection` (both `''`), and the `## Done when` alias below has to tell
572
+ * them apart. Exported for the alias's own tests.
573
+ */
574
+ export function hasHeading(body, headingRe) {
575
+ if (typeof body !== 'string') return false;
576
+ for (const line of body.split(/\r?\n/)) {
577
+ const h2 = line.match(/^##\s+(.+?)\s*$/);
578
+ if (h2 && !line.startsWith('###') && headingRe.test(h2[1])) return true;
579
+ }
580
+ return false;
581
+ }
582
+
583
+ /** The canonical acceptance section title this parser extracts from. */
584
+ const ACCEPTANCE_HEADING_TITLE = /^acceptance(?:\s+criteria)?$/i;
585
+
586
+ /**
587
+ * Deliberately BROADER than `ACCEPTANCE_HEADING_TITLE`, and used only to decide
588
+ * whether the `## Done when` alias may fire. The vault carries many suffixed
589
+ * Acceptance headings this parser does not extract from (`## Acceptance
590
+ * verification`, `## Acceptance gate — epic close`, `## Acceptance (epic-level)`,
591
+ * …). A doc that has ANY of them has an acceptance section, so the legacy alias
592
+ * must not fire there — otherwise the alias would newly disagree with the
593
+ * console parser, which matches `## Acceptance…` with arbitrary suffixes.
594
+ */
595
+ const ACCEPTANCE_HEADING_TITLE_ANY = /^acceptance/i;
596
+
597
+ /** Legacy back-compat alias for the acceptance section — see HS-VDJH-LBUA below. */
598
+ const DONE_WHEN_HEADING_TITLE_ALIAS = /^done\s+when\b/i;
599
+
309
600
  /** Bullet lines (`-`/`*`, checkbox, or numbered `1.` / `1)`) → trimmed text array. */
310
601
  export function extractBullets(text) {
311
602
  if (typeof text !== 'string') return [];
@@ -332,7 +623,27 @@ export function parseAcceptanceSubsections(body) {
332
623
  // heading variant (~48 vault specs use the latter — and the console parser
333
624
  // already accepts it, so anchoring exactly here broke cross-surface parity and
334
625
  // wrongly routed those specs to the Tier-3 clarification floor). [[HS-TASK-I2Y8]]
335
- const section = extractSection(body, /^acceptance(?:\s+criteria)?$/i);
626
+ let section = extractSection(body, ACCEPTANCE_HEADING_TITLE);
627
+ // HS-VDJH-LBUA — legacy `## Done when` BACK-COMPAT ALIAS (read side only; no
628
+ // vault file is ever rewritten). `## Acceptance criteria` stays canonical, but
629
+ // 138 vault files (measured 2026-09-01: 111 captures, 16 diagnoses, 8 tasks,
630
+ // 2 milestones, 1 research) carry the same section under `## Done when` and no
631
+ // `## Acceptance…` heading at all — the derived execution brief read zero
632
+ // criteria out of every one of them, so a dispatched diagnose could not be
633
+ // graded and the critic returned `uncertain` by construction.
634
+ //
635
+ // Tried ONLY when the document has no `## Acceptance…` heading, so the two
636
+ // vault files carrying BOTH headings still resolve to the canonical one
637
+ // regardless of document order — and a present-but-empty Acceptance section
638
+ // still reads as zero criteria rather than falling through. `\b` after `when`
639
+ // keeps the match tight: `## Done whenever …` is not a criteria section.
640
+ //
641
+ // Mirrors dev-factory/lib/acceptance-criteria.mjs and the console parser
642
+ // (spex-console/src/lib/acceptance-criteria.ts); the three are pinned together
643
+ // by spex-console/src/lib/__tests__/acceptance-criteria-parity.test.ts.
644
+ if (!section && !hasHeading(body, ACCEPTANCE_HEADING_TITLE_ANY)) {
645
+ section = extractSection(body, DONE_WHEN_HEADING_TITLE_ALIAS);
646
+ }
336
647
  if (!section) return { sections: [], hasSubsections: false };
337
648
 
338
649
  const lines = section.split(/\r?\n/);
@@ -1094,6 +1405,9 @@ export const DEFAULT_READINESS_FLOOR = { minAcceptanceBullets: 1, minGoalChars:
1094
1405
  * .heyspex mirror under a `briefReadinessFloor: { minAcceptanceBullets,
1095
1406
  * minGoalChars }` key. Missing / malformed values fall back per-field to
1096
1407
  * DEFAULT_READINESS_FLOOR. Mirrors readAlignmentFuseSettings' lookup order.
1408
+ *
1409
+ * Read tier: DISK in both tiers — this is machine-local config, not vault
1410
+ * content. See {@link readJsonSafe} for the full call-site conclusion.
1097
1411
  */
1098
1412
  export function readReadinessFloorSettings(vaultRoot) {
1099
1413
  for (const p of spexConsoleSettingsCandidates(vaultRoot)) {