heyspex 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (322) hide show
  1. package/README.md +21 -0
  2. package/bin/hs.mjs +931 -98
  3. package/bin/spexcode.mjs +262 -0
  4. package/dev-factory/engine/worktree.mjs +740 -0
  5. package/dev-factory/lib/code-repo-root.mjs +67 -0
  6. package/dev-factory/lib/context-budget.mjs +84 -0
  7. package/dev-factory/lib/epic-detect.mjs +190 -0
  8. package/dev-factory/lib/fleet-queue-file.mjs +846 -0
  9. package/dev-factory/lib/fleet-queue.mjs +426 -0
  10. package/dev-factory/lib/fleet-state.mjs +14 -18
  11. package/dev-factory/lib/model-catalog.mjs +338 -0
  12. package/dev-factory/lib/seat-context.mjs +199 -0
  13. package/dev-factory/lib/spec-io.mjs +93 -0
  14. package/dev-factory/lib/sufficiency-score.mjs +8 -2
  15. package/dev-factory/lib/worktree-allocator.mjs +744 -0
  16. package/dev-factory/model-catalog.json +222 -0
  17. package/dist/cli/hs-check.mjs +1085 -103
  18. package/dist/cli/hs-claim.mjs +936 -63
  19. package/dist/cli/hs-connect.mjs +9786 -1910
  20. package/dist/cli/hs-defer.mjs +5267 -3053
  21. package/dist/cli/hs-dev.mjs +564 -0
  22. package/dist/cli/hs-env.mjs +385 -299
  23. package/dist/cli/hs-index-rebuild.mjs +7129 -0
  24. package/dist/cli/hs-index-reconcile.mjs +7852 -0
  25. package/dist/cli/hs-init.mjs +777 -138
  26. package/dist/cli/hs-install.mjs +6583 -485
  27. package/dist/cli/hs-join.mjs +10137 -2368
  28. package/dist/cli/hs-vault-pull.mjs +4659 -5882
  29. package/dist/cli/hs-vault.mjs +3793 -1537
  30. package/dist/cli/hs-workspace.mjs +2550 -524
  31. package/dist/cli-templates/customer-vault/specs/entity-model.md +594 -601
  32. package/dist/cli-templates/customer-vault/specs/protocols/canonical-frontmatter-templates.md +110 -177
  33. package/dist/cli-templates/customer-vault/specs/protocols/destructive-action-checklist.md +122 -0
  34. package/dist/cli-templates/customer-vault/specs/protocols/memory-protocol.md +13 -15
  35. package/dist/cli-templates/customer-vault/specs/protocols/session-close-protocol.md +91 -0
  36. package/dist/cli-templates/customer-vault/specs/protocols/spec-hierarchy.md +150 -168
  37. package/dist/cli-templates/customer-vault/specs/protocols/spec-summary-authoring.md +83 -0
  38. package/dist/cli-templates/harness-commands/claude/hs-auto.md +36 -0
  39. package/dist/cli-templates/harness-commands/claude/hs-backfill.md +28 -0
  40. package/dist/cli-templates/harness-commands/claude/hs-capture.md +62 -0
  41. package/dist/cli-templates/harness-commands/claude/hs-defer.md +29 -0
  42. package/dist/cli-templates/harness-commands/claude/hs-dev.md +20 -0
  43. package/dist/cli-templates/harness-commands/claude/hs-doctor.md +32 -0
  44. package/dist/cli-templates/harness-commands/claude/hs-ground.md +28 -0
  45. package/dist/cli-templates/harness-commands/claude/hs-handoff.md +33 -0
  46. package/dist/cli-templates/harness-commands/claude/hs-log.md +27 -0
  47. package/dist/cli-templates/harness-commands/claude/hs-next.md +29 -0
  48. package/dist/cli-templates/harness-commands/claude/hs-promote.md +70 -0
  49. package/dist/cli-templates/harness-commands/claude/hs-push.md +35 -0
  50. package/dist/cli-templates/harness-commands/claude/hs-qc.md +27 -0
  51. package/dist/cli-templates/harness-commands/claude/hs-res.md +63 -0
  52. package/dist/cli-templates/harness-commands/claude/hs-resolve.md +23 -0
  53. package/dist/cli-templates/harness-commands/claude/hs-review.md +23 -0
  54. package/dist/cli-templates/harness-commands/claude/hs-view.md +26 -0
  55. package/dist/cli-templates/harness-commands/claude/kill-ports.md +32 -0
  56. package/dist/cli-templates/harness-commands/claude/prototype.md +91 -0
  57. package/dist/cli-templates/harness-commands/cursor/hs-auto.md +40 -0
  58. package/dist/cli-templates/harness-commands/cursor/hs-backfill.md +31 -0
  59. package/dist/cli-templates/harness-commands/cursor/hs-capture.md +65 -0
  60. package/dist/cli-templates/harness-commands/cursor/hs-defer.md +32 -0
  61. package/dist/cli-templates/harness-commands/cursor/hs-dev.md +24 -0
  62. package/dist/cli-templates/harness-commands/cursor/hs-doctor.md +36 -0
  63. package/dist/cli-templates/harness-commands/cursor/hs-ground.md +31 -0
  64. package/dist/cli-templates/harness-commands/cursor/hs-handoff.md +37 -0
  65. package/dist/cli-templates/harness-commands/cursor/hs-log.md +31 -0
  66. package/dist/cli-templates/harness-commands/cursor/hs-next.md +32 -0
  67. package/dist/cli-templates/harness-commands/cursor/hs-promote.md +73 -0
  68. package/dist/cli-templates/harness-commands/cursor/hs-push.md +38 -0
  69. package/dist/cli-templates/harness-commands/cursor/hs-qc.md +30 -0
  70. package/dist/cli-templates/harness-commands/cursor/hs-res.md +67 -0
  71. package/dist/cli-templates/harness-commands/cursor/hs-resolve.md +26 -0
  72. package/dist/cli-templates/harness-commands/cursor/hs-review.md +26 -0
  73. package/dist/cli-templates/harness-commands/cursor/hs-view.md +30 -0
  74. package/dist/cli-templates/harness-commands/cursor/kill-ports.md +35 -0
  75. package/dist/cli-templates/harness-commands/cursor/prototype.md +95 -0
  76. package/docs/mcp-client-config.md +259 -6
  77. package/package.json +231 -124
  78. package/plugins/heyspex/.claude-plugin/plugin.json +12 -0
  79. package/plugins/heyspex/.cursor-plugin/plugin.json +15 -0
  80. package/plugins/heyspex/.mcp.json +12 -0
  81. package/plugins/heyspex/bin/launch-mcp.mjs +95 -0
  82. package/plugins/heyspex/bin/run-hook.mjs +62 -0
  83. package/plugins/heyspex/hooks/cursor-hooks.json +227 -0
  84. package/plugins/heyspex/hooks/hooks.json +361 -0
  85. package/plugins/heyspex/mcp.json +13 -0
  86. package/scripts/check-mcp-freshness.mjs +17 -9
  87. package/scripts/claude-hooks/_env-vault-events.mjs +68 -0
  88. package/scripts/claude-hooks/_guardrail-events.mjs +96 -0
  89. package/scripts/claude-hooks/_guardrail-loader.mjs +494 -0
  90. package/scripts/claude-hooks/_intent-classifier-router.mjs +410 -0
  91. package/scripts/claude-hooks/_intent-classifier-semantic.mjs +351 -0
  92. package/scripts/claude-hooks/_run.mjs +120 -0
  93. package/scripts/claude-hooks/_transcript-text.mjs +60 -0
  94. package/scripts/claude-hooks/_vault-retrieval.mjs +322 -0
  95. package/scripts/claude-hooks/agent-spawn-delegation-gate.mjs +133 -0
  96. package/scripts/claude-hooks/capture-workflow-gate.mjs +11 -0
  97. package/scripts/claude-hooks/check-auto-memory-budget.mjs +396 -0
  98. package/scripts/claude-hooks/check-claude-md-budget.mjs +327 -0
  99. package/scripts/claude-hooks/check-mcp-freshness-hook.mjs +14 -0
  100. package/scripts/claude-hooks/claim-before-code-gate.mjs +11 -0
  101. package/scripts/claude-hooks/claim-liveness-shared.mjs +575 -0
  102. package/scripts/claude-hooks/close-terminal-on-ship.mjs +64 -0
  103. package/scripts/claude-hooks/coo-denylist-gate.mjs +11 -0
  104. package/scripts/claude-hooks/coo-guard-check.mjs +11 -0
  105. package/scripts/claude-hooks/deep-research-cost-gate.mjs +11 -0
  106. package/scripts/claude-hooks/dev-server-task-reminder.mjs +15 -0
  107. package/scripts/claude-hooks/emit-claim-status.mjs +41 -0
  108. package/scripts/claude-hooks/enforce-session-sequence.mjs +11 -0
  109. package/scripts/claude-hooks/enforce-vault-naming.mjs +11 -0
  110. package/scripts/claude-hooks/env-vault-guard.mjs +11 -0
  111. package/scripts/claude-hooks/epic-milestone-phrasing-gate.mjs +11 -0
  112. package/scripts/claude-hooks/git-identity-guard.mjs +11 -0
  113. package/scripts/claude-hooks/git-reflog-audit.mjs +15 -0
  114. package/scripts/claude-hooks/git-stash-gate.mjs +11 -0
  115. package/scripts/claude-hooks/guard-auto-memory-writes.mjs +137 -0
  116. package/scripts/claude-hooks/id-display-format-gate.mjs +11 -0
  117. package/scripts/claude-hooks/inject-communication-style.mjs +66 -0
  118. package/scripts/claude-hooks/inject-coo-seat.mjs +78 -0
  119. package/scripts/claude-hooks/inject-dev-server-awareness.mjs +11 -0
  120. package/scripts/claude-hooks/inject-doctrine.mjs +13 -0
  121. package/scripts/claude-hooks/inject-vault-context.mjs +14 -0
  122. package/scripts/claude-hooks/intent-classifier-trigger.mjs +247 -0
  123. package/scripts/claude-hooks/log-session-activity.mjs +341 -0
  124. package/scripts/claude-hooks/naughty-list-gate.mjs +11 -0
  125. package/scripts/claude-hooks/notify-status-flip.mjs +72 -0
  126. package/scripts/claude-hooks/plan-exit-ingest-gate.mjs +170 -0
  127. package/scripts/claude-hooks/plan-ingest-close-reminder.mjs +66 -0
  128. package/scripts/claude-hooks/plan-review-gate.mjs +11 -0
  129. package/scripts/claude-hooks/preflight-reminder.mjs +262 -0
  130. package/scripts/claude-hooks/protected-action-intent.mjs +221 -0
  131. package/scripts/claude-hooks/research-method-guard.mjs +398 -0
  132. package/scripts/claude-hooks/research-protocol-gate.mjs +11 -0
  133. package/scripts/claude-hooks/session-end.mjs +449 -0
  134. package/scripts/claude-hooks/settings-write-gate.mjs +11 -0
  135. package/scripts/claude-hooks/settlement-check-gate.mjs +39 -0
  136. package/scripts/claude-hooks/status-flip-detect.mjs +17 -0
  137. package/scripts/claude-hooks/terminal-close-shared.mjs +426 -0
  138. package/scripts/claude-hooks/terminal-close-watcher.mjs +122 -0
  139. package/scripts/claude-hooks/wip-checkpoint.mjs +298 -0
  140. package/scripts/cursor-hooks/manifest.mjs +235 -0
  141. package/scripts/dev-server-status.mjs +34 -13
  142. package/scripts/dev-server-stop.mjs +313 -0
  143. package/scripts/heyspex-mode.mjs +268 -0
  144. package/scripts/hs-auto-resolve.mjs +240 -0
  145. package/scripts/hs-auto-source.mjs +398 -0
  146. package/scripts/hs-auto.mjs +134 -0
  147. package/scripts/hs-capture-cli.mjs +326 -0
  148. package/scripts/hs-classifier.mjs +136 -0
  149. package/scripts/hs-companion.mjs +88 -0
  150. package/scripts/hs-gateway.mjs +115 -0
  151. package/scripts/hs-mode.mjs +125 -0
  152. package/scripts/hs-next.mjs +441 -0
  153. package/scripts/hs-settings.mjs +338 -0
  154. package/scripts/hs-view.mjs +333 -0
  155. package/scripts/hs-worktree.mjs +201 -0
  156. package/scripts/install-claude-code-hooks.mjs +270 -0
  157. package/scripts/install-cursor-hooks.mjs +471 -0
  158. package/scripts/lib/agent-env-markers.mjs +38 -0
  159. package/scripts/lib/console-url-resolver.mjs +197 -60
  160. package/scripts/lib/customer-kit.mjs +886 -0
  161. package/scripts/lib/dev-mode.mjs +244 -28
  162. package/scripts/lib/heyspex-home.mjs +72 -0
  163. package/scripts/lib/hs-command.mjs +277 -0
  164. package/scripts/lib/hs-companion-settings.mjs +472 -0
  165. package/scripts/lib/hs-source-commit.mjs +59 -5
  166. package/scripts/lib/hs-source-identity.mjs +526 -0
  167. package/scripts/lib/hs-source-log.mjs +11 -21
  168. package/scripts/lib/hs-source-push.mjs +107 -58
  169. package/scripts/lib/hs-worktree-args.mjs +115 -0
  170. package/scripts/lib/shared-path-helpers.mjs +134 -0
  171. package/scripts/lib/standalone-doctor.mjs +540 -3
  172. package/scripts/lib/subagent-git-lock.mjs +109 -0
  173. package/scripts/lib/template-pin.mjs +34 -0
  174. package/scripts/provenance-lib.mjs +91 -0
  175. package/scripts/stamp-provenance-on-edit.mjs +222 -0
  176. package/scripts/wip-ledger-health.mjs +653 -0
  177. package/spexharness/doctrine/baseline/SPEXCODE-DOCTRINE.baseline.json +29 -0
  178. package/spexharness/src/adapters/audit.mjs +197 -0
  179. package/spexharness/src/adapters/claude-code.mjs +441 -0
  180. package/spexharness/src/chat/tui/ansi.mjs +448 -0
  181. package/spexharness/src/chat/tui/palette.mjs +22 -0
  182. package/spexharness/src/chat/tui/select.mjs +208 -0
  183. package/spexharness/src/config-root.mjs +54 -0
  184. package/spexharness/src/context-budget.mjs +141 -0
  185. package/spexharness/src/dev-server-control.mjs +389 -0
  186. package/spexharness/src/doctrine/behaviour.mjs +371 -0
  187. package/spexharness/src/doctrine/corpus.mjs +733 -0
  188. package/spexharness/src/doctrine/inject.mjs +338 -0
  189. package/spexharness/src/doctrine/merge.mjs +184 -0
  190. package/spexharness/src/doctrine/sources.mjs +252 -0
  191. package/spexharness/src/doctrine/synthesize.mjs +401 -0
  192. package/spexharness/src/doctrine/vault-source.mjs +574 -0
  193. package/spexharness/src/doctrine/writer.mjs +176 -0
  194. package/spexharness/src/events.mjs +84 -0
  195. package/spexharness/src/gates/capture-workflow-gate.mjs +202 -0
  196. package/spexharness/src/gates/claim-before-code-gate.decide.mjs +33 -0
  197. package/spexharness/src/gates/claim-before-code-gate.mjs +129 -0
  198. package/spexharness/src/gates/deep-research-cost-gate.mjs +274 -0
  199. package/spexharness/src/gates/enforce-session-sequence.mjs +895 -0
  200. package/spexharness/src/gates/enforce-vault-naming.mjs +215 -0
  201. package/spexharness/src/gates/epic-milestone-phrasing-gate.mjs +233 -0
  202. package/spexharness/src/gates/git-identity-guard.decide.mjs +436 -0
  203. package/spexharness/src/gates/git-identity-guard.mjs +61 -0
  204. package/spexharness/src/gates/git-reflog-audit.mjs +151 -0
  205. package/spexharness/src/gates/git-stash-gate.decide.mjs +361 -0
  206. package/spexharness/src/gates/git-stash-gate.mjs +159 -0
  207. package/spexharness/src/gates/id-display-format-gate.mjs +190 -0
  208. package/spexharness/src/gates/inject-doctrine-gate.mjs +89 -0
  209. package/spexharness/src/gates/inject-vault-context.mjs +239 -0
  210. package/spexharness/src/gates/naughty-list-gate.mjs +213 -0
  211. package/spexharness/src/gates/plan-review-gate.mjs +233 -0
  212. package/spexharness/src/gates/preflight-reminder-gate.mjs +265 -0
  213. package/spexharness/src/gates/research-protocol-gate.mjs +175 -0
  214. package/spexharness/src/gates/settings-write-gate.decide.mjs +96 -0
  215. package/spexharness/src/gates/settings-write-gate.mjs +104 -0
  216. package/spexharness/src/git-reflog-audit.mjs +294 -0
  217. package/spexharness/src/intent-classifier.mjs +194 -0
  218. package/spexharness/src/intent-directive.mjs +541 -0
  219. package/spexharness/src/profile/learn.mjs +551 -0
  220. package/spexharness/src/profile/proposals.mjs +357 -0
  221. package/spexharness/src/profile/vault-inclusion.mjs +386 -0
  222. package/spexharness/src/redact.mjs +151 -0
  223. package/spexharness/src/rule-table.mjs +819 -0
  224. package/spexharness/src/seat-context.mjs +122 -0
  225. package/spexharness/src/session-bootstrap.mjs +512 -0
  226. package/spexharness/src/session-record.mjs +815 -0
  227. package/spexharness/src/token-inventory.mjs +207 -0
  228. package/vendor/heyspex-mcp/account-switch.mjs +95 -0
  229. package/vendor/heyspex-mcp/alignment-gate.mjs +315 -1
  230. package/vendor/heyspex-mcp/blocker-resolver.mjs +37 -2
  231. package/vendor/heyspex-mcp/breakdown-ladder.mjs +119 -4
  232. package/vendor/heyspex-mcp/breakdown-source.mjs +271 -0
  233. package/vendor/heyspex-mcp/breakdown-writer.mjs +23 -3
  234. package/vendor/heyspex-mcp/capture-buffer-patches.mjs +46 -5
  235. package/vendor/heyspex-mcp/capture-channel.mjs +55 -5
  236. package/vendor/heyspex-mcp/capture-console-client.mjs +80 -1
  237. package/vendor/heyspex-mcp/capture-focus.mjs +1035 -29
  238. package/vendor/heyspex-mcp/capture-outbox.mjs +5 -1
  239. package/vendor/heyspex-mcp/capture-tools.mjs +1219 -0
  240. package/vendor/heyspex-mcp/claim-tools.mjs +1124 -0
  241. package/vendor/heyspex-mcp/cli-verbs.mjs +2076 -0
  242. package/vendor/heyspex-mcp/cli.mjs +201 -15524
  243. package/vendor/heyspex-mcp/cloud-tool-surface.mjs +128 -0
  244. package/vendor/heyspex-mcp/cloud-vault-writes.mjs +412 -0
  245. package/vendor/heyspex-mcp/config-seed.yaml +15 -0
  246. package/vendor/heyspex-mcp/config-settings.mjs +290 -7
  247. package/vendor/heyspex-mcp/console-fetch.mjs +657 -16
  248. package/vendor/heyspex-mcp/console-transport.mjs +166 -0
  249. package/vendor/heyspex-mcp/credentials.mjs +584 -0
  250. package/vendor/heyspex-mcp/defer-spec.mjs +44 -4
  251. package/vendor/heyspex-mcp/deferred-reclaim.mjs +29 -3
  252. package/vendor/heyspex-mcp/design-handlers.mjs +31 -0
  253. package/vendor/heyspex-mcp/design-tools.mjs +4 -1
  254. package/vendor/heyspex-mcp/dev-factory-bridge.mjs +4 -4
  255. package/vendor/heyspex-mcp/dev-mode-core.mjs +45 -0
  256. package/vendor/heyspex-mcp/dev-tools.mjs +124 -0
  257. package/vendor/heyspex-mcp/diagnose-handlers.mjs +54 -0
  258. package/vendor/heyspex-mcp/events-tools.mjs +207 -0
  259. package/vendor/heyspex-mcp/filing-tools.mjs +688 -0
  260. package/vendor/heyspex-mcp/fleet-handlers.mjs +37 -0
  261. package/vendor/heyspex-mcp/fleet-queue-tools.mjs +69 -14
  262. package/vendor/heyspex-mcp/focus-tools.mjs +190 -0
  263. package/vendor/heyspex-mcp/folder-trust-store.mjs +59 -0
  264. package/vendor/heyspex-mcp/http-transport.mjs +300 -0
  265. package/vendor/heyspex-mcp/list-tools.mjs +257 -0
  266. package/vendor/heyspex-mcp/login-handoff.mjs +15 -1
  267. package/vendor/heyspex-mcp/mcp-config.mjs +94 -0
  268. package/vendor/heyspex-mcp/memory-tools.mjs +523 -0
  269. package/vendor/heyspex-mcp/mvp-mode.mjs +53 -6
  270. package/vendor/heyspex-mcp/occupied-keys.mjs +141 -0
  271. package/vendor/heyspex-mcp/on-deck-brief.mjs +3360 -0
  272. package/vendor/heyspex-mcp/on-deck-scheduler.mjs +1708 -0
  273. package/vendor/heyspex-mcp/package-root.mjs +32 -0
  274. package/vendor/heyspex-mcp/peer-presence.mjs +8 -1
  275. package/vendor/heyspex-mcp/plan-ground.mjs +621 -652
  276. package/vendor/heyspex-mcp/plan-push.mjs +906 -21
  277. package/vendor/heyspex-mcp/plan-review.mjs +124 -30
  278. package/vendor/heyspex-mcp/preflight-gate.mjs +0 -0
  279. package/vendor/heyspex-mcp/procedure-handlers.mjs +325 -0
  280. package/vendor/heyspex-mcp/procedure-tools.mjs +540 -351
  281. package/vendor/heyspex-mcp/promote-capture.mjs +386 -69
  282. package/vendor/heyspex-mcp/promote-diagnose.mjs +74 -0
  283. package/vendor/heyspex-mcp/promote-research-to-diagnose.mjs +9 -2
  284. package/vendor/heyspex-mcp/promote-research.mjs +660 -53
  285. package/vendor/heyspex-mcp/promotion-helpers.mjs +251 -0
  286. package/vendor/heyspex-mcp/prompts.mjs +1460 -0
  287. package/vendor/heyspex-mcp/read-path.mjs +29 -9
  288. package/vendor/heyspex-mcp/receipt-shape.mjs +146 -0
  289. package/vendor/heyspex-mcp/recipe-handlers.mjs +117 -0
  290. package/vendor/heyspex-mcp/regen-tasks.mjs +210 -3
  291. package/vendor/heyspex-mcp/register-spec.mjs +266 -54
  292. package/vendor/heyspex-mcp/request-context.mjs +59 -0
  293. package/vendor/heyspex-mcp/research-focus.mjs +207 -4
  294. package/vendor/heyspex-mcp/research-handlers.mjs +623 -0
  295. package/vendor/heyspex-mcp/research-tools.mjs +4 -4
  296. package/vendor/heyspex-mcp/response-budget.mjs +348 -0
  297. package/vendor/heyspex-mcp/review-focus.mjs +25 -1
  298. package/vendor/heyspex-mcp/search-specs.mjs +280 -0
  299. package/vendor/heyspex-mcp/seat-block-ledger.mjs +113 -0
  300. package/vendor/heyspex-mcp/seat-identity.mjs +2 -6
  301. package/vendor/heyspex-mcp/session-owner.mjs +108 -0
  302. package/vendor/heyspex-mcp/session-tools.mjs +152 -0
  303. package/vendor/heyspex-mcp/settings-registry.mjs +232 -0
  304. package/vendor/heyspex-mcp/settings-tools.mjs +85 -12
  305. package/vendor/heyspex-mcp/spec-edit-tools.mjs +446 -0
  306. package/vendor/heyspex-mcp/spec-id-lib.mjs +93 -17
  307. package/vendor/heyspex-mcp/split-research.mjs +301 -56
  308. package/vendor/heyspex-mcp/stdio-rpc.mjs +22 -0
  309. package/vendor/heyspex-mcp/submit-for-review.mjs +132 -6
  310. package/vendor/heyspex-mcp/summary-contract.mjs +105 -0
  311. package/vendor/heyspex-mcp/summary-write-tools.mjs +224 -0
  312. package/vendor/heyspex-mcp/symptom-query.mjs +146 -0
  313. package/vendor/heyspex-mcp/test-helpers.mjs +108 -1
  314. package/vendor/heyspex-mcp/tool-catalog.mjs +2813 -0
  315. package/vendor/heyspex-mcp/tool-dispatch.mjs +639 -0
  316. package/vendor/heyspex-mcp/tool-helpers.mjs +325 -0
  317. package/vendor/heyspex-mcp/validate-linkage.mjs +414 -27
  318. package/vendor/heyspex-mcp/vault-io.mjs +724 -0
  319. package/vendor/heyspex-mcp/vault-less-endpoints.mjs +52 -0
  320. package/vendor/heyspex-mcp/vault-resolve.mjs +366 -68
  321. package/vendor/heyspex-mcp/verify-reproduction.mjs +270 -30
  322. package/vendor/heyspex-mcp/whoami.mjs +91 -0
@@ -0,0 +1,122 @@
1
+ // spexharness/src/seat-context.mjs
2
+ //
3
+ // The harness's seat-context loader: a seat's procedures, skills and memories, for any
4
+ // front (a chat window, an overnight seat run, the Claude Code plugin shell) — not just
5
+ // the Claude Code "SessionStart injection" text dev-factory/lib/seat-context.mjs produces.
6
+ //
7
+ // Built ON dev-factory/lib/seat-context.mjs (its buildSeatContext + repoRoot are the single
8
+ // source of truth for the "DEV-FACTORY SEAT: <title> — <mandate>" line and for reading
9
+ // dev-factory/seats.json — see that file's header for why three consumers already depend
10
+ // on it staying byte-identical), NOT reimplemented here. This module wraps that seat line
11
+ // in a front-agnostic, structured shape and layers on the vault-sourced context — the
12
+ // procedures (specs/protocols/*.md), skills (.claude/skills/*), and memories
13
+ // (specs/memory/**/*.md) a seat has available — none of which dev-factory/lib/seat-context.mjs
14
+ // carries, since its only job is the Claude-Code-shaped injection string.
15
+ //
16
+ // Every listing here is best-effort: a missing directory (e.g. a stripped-down worktree
17
+ // with no specs/ junction, or a front with no .claude/skills) yields an empty array, never
18
+ // a throw — the same fail-soft posture buildSeatContext itself uses for a missing/corrupt
19
+ // seats.json.
20
+
21
+ import { readFileSync, readdirSync } from 'node:fs';
22
+ import path from 'node:path';
23
+ import { buildSeatContext, repoRoot } from '../../dev-factory/lib/seat-context.mjs';
24
+
25
+ export { repoRoot };
26
+
27
+ /** List `.md` basenames (no extension) directly under `dir`, sorted. [] if `dir` doesn't exist. */
28
+ function listMarkdownSlugs(dir) {
29
+ try {
30
+ return readdirSync(dir)
31
+ .filter((f) => f.endsWith('.md'))
32
+ .map((f) => f.slice(0, -3))
33
+ .sort();
34
+ } catch {
35
+ return [];
36
+ }
37
+ }
38
+
39
+ /** List every `.md` slug under `dir`, one level of subdirectories deep (specs/memory/{lessons,decisions,...}/*.md). [] if `dir` doesn't exist. */
40
+ function listMarkdownSlugsRecursiveOneLevel(dir) {
41
+ let entries;
42
+ try {
43
+ entries = readdirSync(dir, { withFileTypes: true });
44
+ } catch {
45
+ return [];
46
+ }
47
+ const slugs = [];
48
+ for (const entry of entries) {
49
+ const full = path.join(dir, entry.name);
50
+ if (entry.isDirectory()) {
51
+ slugs.push(...listMarkdownSlugs(full).map((slug) => `${entry.name}/${slug}`));
52
+ } else if (entry.isFile() && entry.name.endsWith('.md')) {
53
+ slugs.push(entry.name.slice(0, -3));
54
+ }
55
+ }
56
+ return slugs.sort();
57
+ }
58
+
59
+ /** List skill directory/file names under a skills root (each entry is a skill id). [] if the root doesn't exist. */
60
+ function listSkillIds(dir) {
61
+ let entries;
62
+ try {
63
+ entries = readdirSync(dir, { withFileTypes: true });
64
+ } catch {
65
+ return [];
66
+ }
67
+ return entries
68
+ .filter((e) => e.isDirectory() || e.name.endsWith('.md'))
69
+ .map((e) => (e.isDirectory() ? e.name : e.name.slice(0, -3)))
70
+ .sort();
71
+ }
72
+
73
+ /**
74
+ * Load one seat's full context: the seat line (built on dev-factory/lib/seat-context.mjs),
75
+ * plus the procedures, skills and memories available to it — front-agnostic, so a chat
76
+ * front, a headless seat, or a Claude Code hook can all consume the same shape and format
77
+ * it however that front needs (text injection, a system-prompt block, a tool result…).
78
+ *
79
+ * @param {{ root?: string, seatId?: string|null, vaultRoot?: string|null }} [opts]
80
+ * `root` — repo root (defaults to repoRoot()): where dev-factory/seats.json and
81
+ * .claude/skills live. `vaultRoot` — the SPEC vault root, where specs/protocols and
82
+ * specs/memory live; defaults to `root`, but the platform checkout and its vault are
83
+ * separate directories for the owner (HS_VAULT_ROOT names the vault — see hs-chat.ts's
84
+ * own REPO_ROOT note), so a front that knows its vault passes it here or every
85
+ * vault-sourced piece silently reads as empty. `seatId` — when supplied and
86
+ * dev-factory/seats.json has a matching seat, that seat's own record is returned under
87
+ * `seat`; omit to get the same information buildSeatContext already exposes (the combined
88
+ * `seatLine` for every active seat) without a single-seat lookup.
89
+ * @returns {{
90
+ * seatLine: string|null,
91
+ * seat: object|null,
92
+ * procedures: string[],
93
+ * skills: string[],
94
+ * memories: string[],
95
+ * }}
96
+ */
97
+ export function loadSeatContext({ root = repoRoot(), seatId = null, vaultRoot = null } = {}) {
98
+ const seatLine = buildSeatContext(root);
99
+ const specsRoot = typeof vaultRoot === 'string' && vaultRoot.length > 0 ? vaultRoot : root;
100
+
101
+ let seat = null;
102
+ if (seatId) {
103
+ try {
104
+ // Reuse the exact seats.json dev-factory/lib/seat-context.mjs already reads — no
105
+ // second parse-and-guess of the registry shape here.
106
+ const seatsPath = path.join(root, 'dev-factory', 'seats.json');
107
+ const registry = JSON.parse(readFileSync(seatsPath, 'utf8'));
108
+ const seats = Array.isArray(registry?.seats) ? registry.seats : [];
109
+ seat = seats.find((s) => s && s.id === seatId) ?? null;
110
+ } catch {
111
+ seat = null;
112
+ }
113
+ }
114
+
115
+ return {
116
+ seatLine,
117
+ seat,
118
+ procedures: listMarkdownSlugs(path.join(specsRoot, 'specs', 'protocols')),
119
+ skills: listSkillIds(path.join(root, '.claude', 'skills')),
120
+ memories: listMarkdownSlugsRecursiveOneLevel(path.join(specsRoot, 'specs', 'memory')),
121
+ };
122
+ }
@@ -0,0 +1,512 @@
1
+ // spexharness/src/session-bootstrap.mjs
2
+ //
3
+ // The harness's ONE session bootstrap: the single in-process path that loads everything a
4
+ // seat needs to start — the seat context, the WIP block, the memories and the preflight
5
+ // procedures — exactly once per session, and hands them back as one structured block.
6
+ //
7
+ // Why this module exists (epic HS-Y646, task HS-QEXH): today the seat block arrives three
8
+ // ways (the Claude Code SessionStart hook, spexharness/src/callers/headless.mjs, and each
9
+ // front's own ad-hoc read), so a DeepSeek seat and a local-model seat neither start the
10
+ // same way nor start once. This module is the one path: a front — `hs chat`, an overnight
11
+ // headless seat, a DeepSeek seat entry point, a 64k local-model seat entry point — calls
12
+ // startSession()/bootstrapSession() and gets the same six pieces in the same shape. No
13
+ // front loads a piece itself, and nothing here reloads a piece a second time.
14
+ //
15
+ // Loading is NOT reinvented here. The seat context comes from
16
+ // spexharness/src/seat-context.mjs's loadSeatContext (itself built directly on
17
+ // dev-factory/lib/seat-context.mjs's buildSeatContext/repoRoot — see that module's header
18
+ // for why that stays the single source of truth), and the three vault-sourced pieces are
19
+ // derived from THAT already-loaded context rather than re-walking specs/ per piece. That is
20
+ // the whole point of the "exactly once" criterion: one seats.json parse, one specs/ walk.
21
+ //
22
+ // Every loader is injectable (`loaders`), which is how a front that has a live lane swaps
23
+ // the fail-soft on-disk defaults for the real thing — most importantly the WIP block, whose
24
+ // system of record is the Neon lane, not a file. The defaults keep an offline/stripped-down
25
+ // worktree (no specs/ junction, no lane file) starting successfully with empty pieces rather
26
+ // than throwing, the same fail-soft posture loadSeatContext and decide.mjs already take.
27
+ //
28
+ // I/O: only the default loaders touch disk (via loadSeatContext and best-effort reads).
29
+ // Injecting all six loaders makes this module pure.
30
+
31
+ import { existsSync, readFileSync } from 'node:fs';
32
+ import { randomUUID } from 'node:crypto';
33
+ import path from 'node:path';
34
+ import { loadSeatContext, repoRoot } from './seat-context.mjs';
35
+ import { loadDoctrine, renderDoctrineBlock, DOCTRINE_MAX_TOKENS, defaultDoctrinePath } from './doctrine/inject.mjs';
36
+ import { defaultProfilePath, defaultProfileStatePath, loadProfileState, renderProfileDigest, PROFILE_MAX_TOKENS } from './profile/learn.mjs';
37
+ import { estimateTokens } from './token-inventory.mjs';
38
+
39
+ export { repoRoot };
40
+
41
+ /** The intent a seat preflights for when neither the caller nor the seat row names one. */
42
+ export const DEFAULT_INTENT = 'execute_spec';
43
+
44
+ /** The six pieces a session bootstrap loads, in load order. Used for the load-count ledger.
45
+ * HS-X3S1 (DR3) added `doctrine`: the SpexCode Doctrine block (../doctrine/inject.mjs), so
46
+ * every seat's bootstrap — not just the chat front's system message — carries it. HS-3H6E
47
+ * (P4) added `profile`, LAST: the SpexCode Profile block (../profile/learn.mjs), layered
48
+ * AFTER `doctrine` — enforced SpexDoctrine, then learned SpexCode Doctrine, then
49
+ * per-developer SpexCode Profile, per the three-layers decision memory. */
50
+ export const BOOTSTRAP_PIECES = Object.freeze(['seatContext', 'wip', 'memories', 'procedures', 'doctrine', 'profile']);
51
+
52
+ /**
53
+ * Preflight intent → the protocol slugs that intent binds, mirroring the intents
54
+ * `heyspex_preflight` accepts. Only slugs that actually exist under specs/protocols for the
55
+ * given root are returned (a stripped-down worktree yields fewer, never a throw), and an
56
+ * unknown intent yields none rather than guessing.
57
+ * @type {Readonly<Record<string, ReadonlyArray<string>>>}
58
+ */
59
+ export const DEFAULT_INTENT_PROCEDURES = Object.freeze({
60
+ execute_spec: Object.freeze(['pre-execution-alignment-gate', 'session-close-protocol']),
61
+ claim_spec: Object.freeze(['pre-execution-alignment-gate']),
62
+ file_milestone: Object.freeze(['plan-ingest-agent-workflow', 'roadmap-protocol', 'spec-summary-authoring']),
63
+ file_epic: Object.freeze(['plan-ingest-agent-workflow', 'roadmap-protocol', 'spec-summary-authoring']),
64
+ file_task: Object.freeze(['spec-summary-authoring', 'checks-manifest-authoring']),
65
+ memory_creation: Object.freeze(['memory-protocol']),
66
+ close_session: Object.freeze(['session-close-protocol']),
67
+ destructive_action: Object.freeze(['destructive-action-checklist']),
68
+ protocol_edit: Object.freeze(['rule-placement-protocol']),
69
+ server_impacting_action: Object.freeze(['dev-server-lifecycle-and-db-safety']),
70
+ restructure_content: Object.freeze(['internal-linking-protocol']),
71
+ });
72
+
73
+ /** Read a JSON file, or return null for missing/unreadable/corrupt — never throws. */
74
+ function readJsonSoft(filePath) {
75
+ try {
76
+ return JSON.parse(readFileSync(filePath, 'utf8'));
77
+ } catch {
78
+ return null;
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Pull `tags:` out of a markdown file's YAML frontmatter — both the block form
84
+ * (`tags:\n - ui\n - console`) and the inline form (`tags: [ui, console]`).
85
+ * Best-effort and allocation-light: this runs once per memory file at session start, so it
86
+ * stays a frontmatter scan rather than a YAML parse. Unreadable file → [].
87
+ *
88
+ * @param {string} filePath
89
+ * @returns {string[]}
90
+ */
91
+ function readFrontmatterTags(filePath) {
92
+ let text;
93
+ try {
94
+ text = readFileSync(filePath, 'utf8');
95
+ } catch {
96
+ return [];
97
+ }
98
+ if (!text.startsWith('---')) return [];
99
+ const end = text.indexOf('\n---', 3);
100
+ const frontmatter = end === -1 ? text : text.slice(0, end);
101
+ const lines = frontmatter.split('\n');
102
+ const tags = [];
103
+ for (let i = 0; i < lines.length; i += 1) {
104
+ const match = /^tags:\s*(.*)$/.exec(lines[i]);
105
+ if (!match) continue;
106
+ const inline = match[1].trim();
107
+ if (inline.startsWith('[')) {
108
+ for (const raw of inline.replace(/^\[|\]$/g, '').split(',')) {
109
+ const tag = raw.trim().replace(/^['"]|['"]$/g, '');
110
+ if (tag) tags.push(tag);
111
+ }
112
+ } else if (inline) {
113
+ tags.push(inline.replace(/^['"]|['"]$/g, ''));
114
+ }
115
+ for (let j = i + 1; j < lines.length; j += 1) {
116
+ const item = /^\s*-\s*(.+?)\s*$/.exec(lines[j]);
117
+ if (!item) break;
118
+ tags.push(item[1].replace(/^['"]|['"]$/g, ''));
119
+ }
120
+ break;
121
+ }
122
+ return tags;
123
+ }
124
+
125
+ /**
126
+ * The tags a seat recalls memories by: the seat row's own `tags` when it has them, else the
127
+ * identity it does have (its id and group) — so a seat with no curated tag list still
128
+ * recalls something targeted instead of everything.
129
+ *
130
+ * @param {object|null} seat a dev-factory/seats.json seat record.
131
+ * @returns {string[]}
132
+ */
133
+ export function seatMemoryTags(seat) {
134
+ if (Array.isArray(seat?.tags) && seat.tags.length > 0) {
135
+ return seat.tags.filter((t) => typeof t === 'string' && t.length > 0);
136
+ }
137
+ return [seat?.id, seat?.group].filter((t) => typeof t === 'string' && t.length > 0);
138
+ }
139
+
140
+ /** The intent a session preflights for: the caller's, else the seat row's, else the default. */
141
+ function resolveIntent(explicitIntent, seat) {
142
+ return explicitIntent ?? seat?.defaultIntent ?? DEFAULT_INTENT;
143
+ }
144
+
145
+ /* ------------------------------------------------------------------ default loaders --- */
146
+
147
+ /**
148
+ * Default seat-context loader: spexharness/src/seat-context.mjs's loadSeatContext, which is
149
+ * itself the shared dev-factory builder. Not reimplemented, and called exactly once.
150
+ */
151
+ function defaultSeatContextLoader({ root, seatId, vaultRoot }) {
152
+ return loadSeatContext({ root, seatId, vaultRoot });
153
+ }
154
+
155
+ /**
156
+ * Default WIP-block loader. The system of record for a WIP block is the Neon lane, which a
157
+ * front with a live connection injects as `loaders.wip`; this default is the offline
158
+ * fallback — a best-effort read of a JSON lane file, keyed by seat when the file is a map of
159
+ * seats. Missing/corrupt/absent seat → null, never a throw, so a seat with no prior WIP
160
+ * starts clean instead of failing to start.
161
+ */
162
+ function defaultWipLoader({ root, seatId, wipLanePath }) {
163
+ const lanePath = wipLanePath ?? path.join(root, '.heyspex', 'session-wip.json');
164
+ const lane = readJsonSoft(lanePath);
165
+ if (!lane || typeof lane !== 'object') return null;
166
+ if (seatId && Object.prototype.hasOwnProperty.call(lane, seatId)) return lane[seatId] ?? null;
167
+ if (Object.prototype.hasOwnProperty.call(lane, 'wip')) return lane.wip ?? null;
168
+ return lane;
169
+ }
170
+
171
+ /**
172
+ * Default memories loader — derived from the ALREADY-LOADED seat context, so this does not
173
+ * re-walk specs/memory. Returns `{ slug, tags }` for each memory whose frontmatter tags
174
+ * intersect the seat's tags; a seat that resolves no tags (no seat row at all) recalls the
175
+ * full list unfiltered, matching loadSeatContext's own untargeted view.
176
+ *
177
+ * A seat whose tags match NO memory at all (the common shape today: seats.json rows carry
178
+ * no `tags`, so the fallback identity tags — the seat id and group — match nothing in a
179
+ * vault whose memories are tagged by domain) also falls back to the full list rather than
180
+ * reporting an empty recall: the memories exist and are the seat's to read, the seat merely
181
+ * has no curated subset yet. `targeted: false` on each entry says which case produced it.
182
+ */
183
+ function defaultMemoriesLoader({ root, vaultRoot, seatContext }) {
184
+ const slugs = Array.isArray(seatContext?.memories) ? seatContext.memories : [];
185
+ const wanted = seatMemoryTags(seatContext?.seat);
186
+ const specsRoot = typeof vaultRoot === 'string' && vaultRoot.length > 0 ? vaultRoot : root;
187
+ const memoryRoot = path.join(specsRoot, 'specs', 'memory');
188
+ const all = slugs.map((slug) => ({ slug, tags: readFrontmatterTags(path.join(memoryRoot, `${slug}.md`)) }));
189
+ if (wanted.length === 0) return all;
190
+ const wantedSet = new Set(wanted);
191
+ const targeted = all.filter((m) => m.tags.some((tag) => wantedSet.has(tag)));
192
+ if (targeted.length > 0) return targeted.map((m) => ({ ...m, targeted: true }));
193
+ return all.map((m) => ({ ...m, targeted: false }));
194
+ }
195
+
196
+ /**
197
+ * Default preflight-procedure loader — also derived from the already-loaded seat context.
198
+ * Returns the slugs {@link DEFAULT_INTENT_PROCEDURES} binds to the session's intent, kept
199
+ * only when specs/protocols actually carries them. Unknown intent → [].
200
+ */
201
+ function defaultProceduresLoader({ intent, seatContext, intentProcedures }) {
202
+ const table = intentProcedures ?? DEFAULT_INTENT_PROCEDURES;
203
+ const wanted = table[intent] ?? [];
204
+ const available = new Set(Array.isArray(seatContext?.procedures) ? seatContext.procedures : []);
205
+ return wanted.filter((slug) => available.has(slug));
206
+ }
207
+
208
+ /**
209
+ * Default Doctrine loader (HS-X3S1, DR3): reads `<root>/spexharness/doctrine/
210
+ * SPEXCODE-DOCTRINE.json` (../doctrine/inject.mjs's `loadDoctrine`, cached, tolerant of
211
+ * absence) and renders it within its own documented budget (`renderDoctrineBlock`,
212
+ * DOCTRINE_MAX_TOKENS) — never the full unbounded document. A repo with no Doctrine yet
213
+ * still starts: the render degrades to the absence notice rather than throwing.
214
+ */
215
+ function defaultDoctrineLoader({ root }) {
216
+ const doctrine = loadDoctrine({ path: defaultDoctrinePath(root) });
217
+ return renderDoctrineBlock(doctrine, { maxTokens: DOCTRINE_MAX_TOKENS });
218
+ }
219
+
220
+ /**
221
+ * The profile is "ready" to condition a turn only once BOTH are true: the rendered file
222
+ * exists on disk, AND P2's incremental-state mark (`.heyspex/profile-state.json`'s
223
+ * `lastLearnedAt`, ../profile/learn.mjs's `loadProfileState`) says a learning pass actually
224
+ * completed. `buildProfile` (learn.mjs) and `accept` (proposals.mjs, P6) both write the
225
+ * profile file BEFORE saving state, so checking only file-existence would read a mid-write
226
+ * (or stale, pre-first-run) file as done; requiring the state mark too closes that race.
227
+ *
228
+ * Exported (P8, HS-NRCK — plugin parity): the Claude Code plugin's own injection hook
229
+ * (scripts/claude-hooks/inject-vault-context.mjs, via its gate
230
+ * spexharness/src/gates/inject-vault-context.mjs) is a thin shell over THIS function and
231
+ * {@link defaultProfileLoader} below — it never re-implements the readiness check or the
232
+ * rendering, so a plugin session and a SpexCode seat's own bootstrap see byte-identical
233
+ * profile content.
234
+ *
235
+ * @param {{ root: string }} args
236
+ * @returns {boolean}
237
+ */
238
+ export function isProfileReady({ root }) {
239
+ if (!existsSync(defaultProfilePath(root))) return false;
240
+ const state = loadProfileState(defaultProfileStatePath(root));
241
+ return typeof state.lastLearnedAt === 'string' && state.lastLearnedAt.length > 0;
242
+ }
243
+
244
+ /**
245
+ * Render the profile file's markdown into the same shape {@link defaultDoctrineLoader}
246
+ * renders Doctrine into — `{ text, tokens, chars, learnedAt }` — measured through
247
+ * ../token-inventory.mjs's `estimateTokens` (the same chars/4 estimate every other piece's
248
+ * size comes from), so a caller that records this block through
249
+ * ../context-budget.mjs's `recordContextInjection` (P2's `registerProfileContextUsage`
250
+ * call shape) gets a real token count, not a guess.
251
+ *
252
+ * @param {string} markdown
253
+ * @param {{ learnedAt?: string|null, maxTokens?: number }} [opts] - `maxTokens` defaults to
254
+ * ../profile/learn.mjs's {@link PROFILE_MAX_TOKENS}, mirroring how the doctrine loader
255
+ * above passes DOCTRINE_MAX_TOKENS to `renderDoctrineBlock`.
256
+ * @returns {{ text: string, tokens: number, chars: number, learnedAt: string|null, truncated: boolean }}
257
+ */
258
+ export function renderProfileBlock(markdown, { learnedAt = null, maxTokens = PROFILE_MAX_TOKENS } = {}) {
259
+ const full = String(markdown ?? '').replace(/\n+$/, '');
260
+ const budget = Number.isFinite(maxTokens) && maxTokens > 0 ? maxTokens : Infinity;
261
+
262
+ // Tail cut on WHOLE lines, the same convention renderDoctrineBlock uses: the first line
263
+ // that would overflow, and every line after it, are dropped together, so the block never
264
+ // ends mid-citation and a cheap later line never jumps the queue past a dropped one.
265
+ //
266
+ // This is the load-bearing containment: it bounds what reaches a seat's context REGARDLESS
267
+ // of what is on disk. The profile file on this machine had grown to 4.2MB (~1.06M tokens)
268
+ // and was injected whole, on EVERY user prompt, which is what pushed a 1M-window model
269
+ // past its limit before the developer's own message was even appended.
270
+ const lines = full.split('\n');
271
+ const kept = [];
272
+ let usedTokens = 0;
273
+ let truncated = false;
274
+ for (const line of lines) {
275
+ const lineTokens = estimateTokens(`${line}\n`);
276
+ if (usedTokens + lineTokens > budget) {
277
+ truncated = true;
278
+ break;
279
+ }
280
+ usedTokens += lineTokens;
281
+ kept.push(line);
282
+ }
283
+ if (truncated) kept.push('', `_(profile truncated to its ${budget}-token injection budget)_`);
284
+
285
+ const text = kept.join('\n').replace(/\n+$/, '');
286
+ return { text, tokens: estimateTokens(text), chars: text.length, learnedAt, truncated };
287
+ }
288
+
289
+ /**
290
+ * Default Profile loader (HS-3H6E, P4 — Conditioning, epic HS-HEBV scope item 4): once
291
+ * {@link isProfileReady} is true, reads `.heyspex/spexcode-profile.md`
292
+ * (../profile/learn.mjs's `defaultProfilePath`) — written only by `buildProfile`
293
+ * (learn.mjs, direct fold) or `accept` (proposals.mjs, P6's curated accept/decline gate),
294
+ * so every line already on disk is an accepted, non-pending entry — and renders it.
295
+ *
296
+ * Returns `null`, never a placeholder block, until the learner has completed for this
297
+ * machine: a turn whose bootstrap resolves before then must carry NO profile section at
298
+ * all (absent, not an empty one) — unlike {@link defaultDoctrineLoader}, which always
299
+ * renders something (an absence notice) because Doctrine is expected to exist from the
300
+ * start of a vault's life, while a fresh machine's Profile legitimately has not learned
301
+ * anything yet.
302
+ */
303
+ export function defaultProfileLoader({ root }) {
304
+ if (!isProfileReady({ root })) return null;
305
+ let markdown;
306
+ try {
307
+ markdown = readFileSync(defaultProfilePath(root), 'utf8');
308
+ } catch {
309
+ return null;
310
+ }
311
+ if (!markdown || !markdown.trim()) return null;
312
+ const state = loadProfileState(defaultProfileStatePath(root));
313
+
314
+ // Prefer the structured digest (../profile/learn.mjs's `renderProfileDigest`): it budgets
315
+ // FAIRLY across sections, so "Recurring corrections" and "Tools approved or refused" still
316
+ // reach the seat instead of being starved by a long first section. The markdown file itself
317
+ // stays the complete browsable record and is deliberately NOT what gets injected.
318
+ //
319
+ // The markdown path below remains as the fallback for a state file that is missing or
320
+ // carries no sections (an older build, a hand-written profile) — and `renderProfileBlock`
321
+ // clamps that path too, so neither route can put an unbounded file into a context window.
322
+ const hasSections = Object.values(state?.sections ?? {}).some((s) => Array.isArray(s) && s.length > 0);
323
+ if (hasSections) {
324
+ const digest = renderProfileDigest(state.sections, { maxTokens: PROFILE_MAX_TOKENS, estimate: estimateTokens });
325
+ // On this path the digest is the authority on the budget — it already selected entries to
326
+ // fit. The outer clamp stays on as a BACKSTOP (a bug in the digest still cannot put an
327
+ // unbounded block into context) but is given headroom so it does not fight the digest's
328
+ // own accounting: the two round differently (the clamp sums a ceil per line, the digest
329
+ // sums a ceil per entry), and a clamp set to the same number trims the digest's closing
330
+ // footer every time purely on rounding drift.
331
+ return renderProfileBlock(digest.text, { learnedAt: state.lastLearnedAt, maxTokens: PROFILE_MAX_TOKENS * 2 });
332
+ }
333
+ return renderProfileBlock(markdown, { learnedAt: state.lastLearnedAt });
334
+ }
335
+
336
+ const DEFAULT_LOADERS = Object.freeze({
337
+ seatContext: defaultSeatContextLoader,
338
+ wip: defaultWipLoader,
339
+ memories: defaultMemoriesLoader,
340
+ procedures: defaultProceduresLoader,
341
+ doctrine: defaultDoctrineLoader,
342
+ profile: defaultProfileLoader,
343
+ });
344
+
345
+ /* ---------------------------------------------------------------------- the bootstrap --- */
346
+
347
+ /**
348
+ * @typedef {object} SessionBootstrapBlock
349
+ * @property {string} sessionId this session's id (generated when the caller has none).
350
+ * @property {string|null} seatId the seat this session runs as.
351
+ * @property {string|null} front the entry point that started it ('deepseek', 'local-model', 'chat', …) — recorded, never branched on.
352
+ * @property {string} intent the preflight intent the procedures were loaded for.
353
+ * @property {string} startedAt ISO timestamp of the bootstrap.
354
+ * @property {object} seatContext loadSeatContext()'s result: seatLine, seat, procedures, skills, memories.
355
+ * @property {unknown} wip the WIP block for this seat (null when there is none).
356
+ * @property {Array<{slug: string, tags: string[]}>} memories memories recalled by seat tag.
357
+ * @property {string[]} procedures preflight procedure slugs bound to `intent`.
358
+ * @property {{ text: string, tokens: number, chars: number, principleCount: number,
359
+ * includedCount: number, learnedAt: string|null, truncated: boolean }} doctrine
360
+ * the rendered SpexCode Doctrine block (HS-X3S1/DR3, ../doctrine/inject.mjs's
361
+ * `renderDoctrineBlock`); absence renders as a notice, never a throw.
362
+ * @property {{ text: string, tokens: number, chars: number, learnedAt: string|null }|null} profile
363
+ * the rendered SpexCode Profile block (HS-3H6E/P4, ../profile/learn.mjs), injected AFTER
364
+ * `doctrine` — enforced SpexDoctrine, then learned SpexCode Doctrine, then per-developer
365
+ * SpexCode Profile. `null` — ABSENT, not an empty placeholder — until the learner has
366
+ * completed for this machine; a turn whose bootstrap resolves before then never carries a
367
+ * profile section at all.
368
+ * @property {Readonly<Record<string, number>>} loadCounts how many times each piece was loaded — 1 each, per HS-QEXH AC1.
369
+ * @property {Array<{piece: string, error: string}>} errors loaders that failed (the piece degrades to empty; a session still starts).
370
+ */
371
+
372
+ /**
373
+ * Create a session bootstrap handle. The handle memoizes: `load()` runs the six loaders on
374
+ * the first call and returns the SAME block (and the same load counts) on every later call,
375
+ * which is what makes "exactly once per session" true even when several parts of a front ask
376
+ * for the session block independently.
377
+ *
378
+ * @param {{
379
+ * root?: string,
380
+ * vaultRoot?: string|null,
381
+ * seatId?: string|null,
382
+ * sessionId?: string|null,
383
+ * front?: string|null,
384
+ * intent?: string|null,
385
+ * wipLanePath?: string|null,
386
+ * intentProcedures?: Record<string, ReadonlyArray<string>>|null,
387
+ * loaders?: Partial<{ seatContext: Function, wip: Function, memories: Function, procedures: Function, doctrine: Function, profile: Function }>,
388
+ * }} [options]
389
+ * `vaultRoot` — the SPEC vault root (specs/protocols, specs/memory) when it is not the
390
+ * repo root; forwarded to every loader and to loadSeatContext. Omitted → `root`.
391
+ * @returns {{
392
+ * sessionId: string,
393
+ * seatId: string|null,
394
+ * front: string|null,
395
+ * load: () => Promise<SessionBootstrapBlock>,
396
+ * loaded: () => SessionBootstrapBlock|null,
397
+ * loadCounts: () => Readonly<Record<string, number>>,
398
+ * }}
399
+ */
400
+ export function createSessionBootstrap(options = {}) {
401
+ const {
402
+ root = repoRoot(),
403
+ vaultRoot = null,
404
+ seatId = null,
405
+ sessionId = null,
406
+ front = null,
407
+ intent = null,
408
+ wipLanePath = null,
409
+ intentProcedures = null,
410
+ loaders = {},
411
+ } = options;
412
+
413
+ const resolvedSessionId = sessionId ?? randomUUID();
414
+ const loaderSet = { ...DEFAULT_LOADERS, ...loaders };
415
+ const counts = Object.fromEntries(BOOTSTRAP_PIECES.map((piece) => [piece, 0]));
416
+ const errors = [];
417
+
418
+ /** @type {SessionBootstrapBlock|null} */
419
+ let block = null;
420
+ /** @type {Promise<SessionBootstrapBlock>|null} */
421
+ let inFlight = null;
422
+
423
+ /** Run one loader, counting the call and folding a throw into a recorded error + fallback. */
424
+ async function runLoader(piece, args, fallback) {
425
+ counts[piece] += 1;
426
+ try {
427
+ return await loaderSet[piece](args);
428
+ } catch (err) {
429
+ errors.push({ piece, error: err instanceof Error ? err.message : String(err) });
430
+ return fallback;
431
+ }
432
+ }
433
+
434
+ async function loadOnce() {
435
+ const base = { root, vaultRoot, seatId, sessionId: resolvedSessionId, front, wipLanePath, intentProcedures };
436
+
437
+ // Seat context first and once — the other three are derived from it rather than
438
+ // re-reading seats.json / specs/ per piece. This is the "arrives three ways" fix.
439
+ const seatContext = (await runLoader('seatContext', base, null)) ?? {
440
+ seatLine: null,
441
+ seat: null,
442
+ procedures: [],
443
+ skills: [],
444
+ memories: [],
445
+ };
446
+ const resolvedIntent = resolveIntent(intent, seatContext.seat);
447
+ const withContext = { ...base, seatContext, intent: resolvedIntent };
448
+
449
+ const [wip, memories, procedures, doctrine, profile] = await Promise.all([
450
+ runLoader('wip', withContext, null),
451
+ runLoader('memories', withContext, []),
452
+ runLoader('procedures', withContext, []),
453
+ // A failed doctrine loader degrades to the SAME absence-notice shape a missing file
454
+ // renders to (renderDoctrineBlock(null)) — never `null` itself, so every consumer can
455
+ // read `block.doctrine.text` unconditionally, exactly like the other three pieces.
456
+ runLoader('doctrine', withContext, renderDoctrineBlock(null)),
457
+ // A failed (or not-yet-ready) profile loader degrades to `null` — ABSENCE, not a
458
+ // placeholder block, matching {@link defaultProfileLoader}'s own not-ready return.
459
+ runLoader('profile', withContext, null),
460
+ ]);
461
+
462
+ block = Object.freeze({
463
+ sessionId: resolvedSessionId,
464
+ seatId,
465
+ front,
466
+ intent: resolvedIntent,
467
+ startedAt: new Date().toISOString(),
468
+ seatContext,
469
+ wip: wip ?? null,
470
+ memories: memories ?? [],
471
+ procedures: procedures ?? [],
472
+ doctrine: doctrine ?? renderDoctrineBlock(null),
473
+ profile: profile ?? null,
474
+ loadCounts: Object.freeze({ ...counts }),
475
+ errors: Object.freeze(errors.slice()),
476
+ });
477
+ return block;
478
+ }
479
+
480
+ return {
481
+ sessionId: resolvedSessionId,
482
+ seatId,
483
+ front,
484
+ /** Load (or return the already-loaded) session block. Concurrent callers share one load. */
485
+ load() {
486
+ if (!inFlight) inFlight = loadOnce();
487
+ return inFlight;
488
+ },
489
+ /** The block if it has already loaded, else null — for a front that must not trigger a load. */
490
+ loaded() {
491
+ return block;
492
+ },
493
+ /** Live per-piece load-count ledger — every entry is 1 once a session has started. */
494
+ loadCounts() {
495
+ return Object.freeze({ ...counts });
496
+ },
497
+ };
498
+ }
499
+
500
+ /**
501
+ * One-shot form: bootstrap a session and return its block. This is what a seat entry point
502
+ * calls — a DeepSeek seat, a 64k local-model seat, `hs chat`, or a headless overnight run all
503
+ * call this same function with a different `front`/`seatId` and nothing else different.
504
+ *
505
+ * @param {Parameters<typeof createSessionBootstrap>[0]} [options]
506
+ * @returns {Promise<SessionBootstrapBlock>}
507
+ */
508
+ export function bootstrapSession(options = {}) {
509
+ return createSessionBootstrap(options).load();
510
+ }
511
+
512
+ export default { createSessionBootstrap, bootstrapSession };