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,252 @@
1
+ // spexharness/src/doctrine/sources.mjs
2
+ //
3
+ // DR1 (epic [[HS-KNHZ-8CMW-EPIC-spexcode-doctrine-v0-the-foundational]], task
4
+ // [[HS-TWTC-8CMW-TASK-dr1-doctrine-corpus-reader-stream-every]]) — enumerates every
5
+ // source the Doctrine corpus reader draws from, verified on this machine 2026-09-11:
6
+ //
7
+ // - Claude Code sessions: ~/.claude/projects/<project>/*.jsonl — SCOPED TO THIS REPO'S
8
+ // project directory by default since 2026-09-20 (see `claudeCodeProjectSlug`); other
9
+ // projects only on the explicit `allClaudeProjects` / `claudeProjects` opt-in.
10
+ // - Command Code sessions: ~/.commandcode/projects/<project>/<id>.jsonl — the
11
+ // sibling `<id>.checkpoints.jsonl` is a DIFFERENT, denser log of the same
12
+ // session (checkpoint snapshots) and is deliberately excluded: reading it too
13
+ // would double-count every Command Code session.
14
+ // - The Command Code taste profile(s): <repo>/.commandcode/taste/taste/taste.md
15
+ // (this repo, and the sibling spexvr checkout named in the task brief).
16
+ // - This harness's own session-end export: .heyspex/session-end-events.jsonl
17
+ // (written by scripts/claude-hooks/session-end.mjs's createLocalEventsExporter,
18
+ // itself a thin wrapper over session-record.mjs's appendSessionRecord).
19
+ //
20
+ // Pure enumeration: fs.readdirSync + fs.statSync only, never a file read — corpus.mjs
21
+ // does the streaming content read. Every root is injectable so a test never touches
22
+ // the real machine's ~/.claude or ~/.commandcode directories.
23
+
24
+ import { readdirSync, statSync } from 'node:fs';
25
+ import { homedir } from 'node:os';
26
+ import { basename, join } from 'node:path';
27
+
28
+ /** Default Claude Code projects root. */
29
+ export function defaultClaudeProjectsDir() {
30
+ return join(homedir(), '.claude', 'projects');
31
+ }
32
+
33
+ /**
34
+ * The Claude Code project-directory name for a working directory. Claude Code names each
35
+ * subdirectory of `~/.claude/projects/` after the session's cwd with every non-alphanumeric
36
+ * character replaced by `-` (verified on this machine 2026-09-20: `C:\dev-projects\heyspex-app`
37
+ * → `C--dev-projects-heyspex-app`).
38
+ *
39
+ * This exists because the corpus reader is SCOPED TO THIS REPO by default. Harvesting every
40
+ * project under `~/.claude/projects/` mixed five other checkouts — and a `C:\Users\...\Temp`
41
+ * scratch directory used by a sidecar SDK prompt — into a Doctrine document that is COMMITTED
42
+ * TO GIT, so one machine's unrelated work shipped to every clone. Only 33% of the 5,328-row
43
+ * corpus measured on 2026-09-20 came from this repo.
44
+ *
45
+ * @param {string} repoRoot
46
+ * @returns {string}
47
+ */
48
+ export function claudeCodeProjectSlug(repoRoot) {
49
+ return String(repoRoot ?? '').replace(/[^A-Za-z0-9]/g, '-');
50
+ }
51
+
52
+ /** Default Command Code projects root. */
53
+ export function defaultCommandCodeProjectsDir() {
54
+ return join(homedir(), '.commandcode', 'projects');
55
+ }
56
+
57
+ /**
58
+ * Default taste.md candidates — this repo's own compiled profile plus the sibling
59
+ * spexvr checkout named in the task brief. A path that does not exist is silently
60
+ * skipped by {@link enumerateTasteSources}, so listing a candidate that is absent on
61
+ * a given machine is harmless.
62
+ */
63
+ export function defaultTasteFiles(repoRoot) {
64
+ return [
65
+ join(repoRoot, '.commandcode', 'taste', 'taste', 'taste.md'),
66
+ join(repoRoot, '..', 'spexvr', '.commandcode', 'taste', 'taste', 'taste.md'),
67
+ ];
68
+ }
69
+
70
+ /**
71
+ * Default harness session-end export path — see scripts/claude-hooks/session-end.mjs
72
+ * `sessionEventsExportPath`. This module does not import that script (it lives in
73
+ * scripts/, this module in spexharness/) so the default is re-derived here; the two
74
+ * must stay in step (both are `<root>/.heyspex/session-end-events.jsonl`).
75
+ */
76
+ export function defaultSpexcodeEventsPath(repoRoot) {
77
+ return join(repoRoot, '.heyspex', 'session-end-events.jsonl');
78
+ }
79
+
80
+ function safeReaddir(dir) {
81
+ try {
82
+ return readdirSync(dir, { withFileTypes: true });
83
+ } catch {
84
+ return [];
85
+ }
86
+ }
87
+
88
+ function safeStat(path) {
89
+ try {
90
+ return statSync(path);
91
+ } catch {
92
+ return null;
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Enumerate every Claude Code session file under `dir` (one subdirectory per project).
98
+ *
99
+ * `projects` restricts the walk to the named project directories. Omitted (or `null`) it
100
+ * walks every subdirectory — the historical behaviour, kept so a caller that has already
101
+ * decided which roots to read (and every existing test) is unaffected. {@link enumerateSources}
102
+ * is where the SCOPED-TO-THIS-REPO default lives.
103
+ *
104
+ * @param {string} dir
105
+ * @param {{ projects?: string[]|null }} [opts]
106
+ * @returns {Array<{ source: 'claude-code', project: string, session: string, path: string, mtime: number }>}
107
+ */
108
+ export function enumerateClaudeCodeSources(dir, { projects = null } = {}) {
109
+ const out = [];
110
+ const allow = Array.isArray(projects) ? new Set(projects) : null;
111
+ for (const projectEnt of safeReaddir(dir)) {
112
+ if (!projectEnt.isDirectory()) continue;
113
+ const project = projectEnt.name;
114
+ if (allow && !allow.has(project)) continue;
115
+ const projectDir = join(dir, project);
116
+ for (const fileEnt of safeReaddir(projectDir)) {
117
+ if (!fileEnt.isFile() || !fileEnt.name.endsWith('.jsonl')) continue;
118
+ const filePath = join(projectDir, fileEnt.name);
119
+ const stat = safeStat(filePath);
120
+ if (!stat) continue;
121
+ out.push({
122
+ source: 'claude-code',
123
+ project,
124
+ session: fileEnt.name.replace(/\.jsonl$/, ''),
125
+ path: filePath,
126
+ mtime: stat.mtimeMs,
127
+ });
128
+ }
129
+ }
130
+ return out;
131
+ }
132
+
133
+ /**
134
+ * Enumerate every Command Code session file under `dir`, excluding the
135
+ * `.checkpoints.jsonl` sidecar (see module header).
136
+ * @param {string} dir
137
+ * @returns {Array<{ source: 'command-code', project: string, session: string, path: string, mtime: number }>}
138
+ */
139
+ export function enumerateCommandCodeSources(dir) {
140
+ const out = [];
141
+ for (const projectEnt of safeReaddir(dir)) {
142
+ if (!projectEnt.isDirectory()) continue;
143
+ const project = projectEnt.name;
144
+ const projectDir = join(dir, project);
145
+ for (const fileEnt of safeReaddir(projectDir)) {
146
+ if (!fileEnt.isFile()) continue;
147
+ if (!fileEnt.name.endsWith('.jsonl') || fileEnt.name.endsWith('.checkpoints.jsonl')) continue;
148
+ const filePath = join(projectDir, fileEnt.name);
149
+ const stat = safeStat(filePath);
150
+ if (!stat) continue;
151
+ out.push({
152
+ source: 'command-code',
153
+ project,
154
+ session: fileEnt.name.replace(/\.jsonl$/, ''),
155
+ path: filePath,
156
+ mtime: stat.mtimeMs,
157
+ });
158
+ }
159
+ }
160
+ return out;
161
+ }
162
+
163
+ /**
164
+ * Enumerate taste-profile sources — each existing file in `files` yields one source.
165
+ * `project` is read off the path itself (the directory just above `.commandcode`),
166
+ * falling back to `'unknown'` for a path shaped unlike the documented layout.
167
+ * @param {string[]} files
168
+ * @returns {Array<{ source: 'taste', project: string, session: 'taste', path: string, mtime: number }>}
169
+ */
170
+ export function enumerateTasteSources(files) {
171
+ const out = [];
172
+ for (const filePath of files) {
173
+ const stat = safeStat(filePath);
174
+ if (!stat || !stat.isFile()) continue;
175
+ // .../<repo>/.commandcode/taste/taste/taste.md — the repo name sits one segment
176
+ // above `.commandcode`.
177
+ const parts = filePath.split(/[\\/]/);
178
+ const idx = parts.lastIndexOf('.commandcode');
179
+ const project = idx > 0 ? parts[idx - 1] : 'unknown';
180
+ out.push({ source: 'taste', project, session: 'taste', path: filePath, mtime: stat.mtimeMs });
181
+ }
182
+ return out;
183
+ }
184
+
185
+ /**
186
+ * Enumerate the harness's own session-end export, when present. Zero entries until a
187
+ * SessionEnd hook has actually appended to it on this machine — that file carries no
188
+ * `ask` text today (only events + tool-call log; see corpus.mjs's handler for the
189
+ * documented, forward-compatible handling of that shape).
190
+ * @param {string} filePath
191
+ * @param {{ project?: string }} [opts]
192
+ * @returns {Array<{ source: 'spexcode', project: string, session: string, path: string, mtime: number }>}
193
+ */
194
+ export function enumerateSpexcodeSources(filePath, { project = 'heyspex-app' } = {}) {
195
+ const stat = safeStat(filePath);
196
+ if (!stat || !stat.isFile()) return [];
197
+ return [
198
+ {
199
+ source: 'spexcode',
200
+ project,
201
+ session: basename(filePath).replace(/\.jsonl$/, ''),
202
+ path: filePath,
203
+ mtime: stat.mtimeMs,
204
+ },
205
+ ];
206
+ }
207
+
208
+ /**
209
+ * Enumerate every source the Doctrine corpus reader draws from. Every root is
210
+ * injectable (tests pass fixture dirs); omitted, each defaults to the real machine
211
+ * path / repo-relative path documented above.
212
+ *
213
+ * ── Claude Code scope (2026-09-20) ──────────────────────────────────────────────
214
+ * Claude Code sessions default to THIS REPO'S project directory only
215
+ * ({@link claudeCodeProjectSlug}). Other projects are opt-in and never implicit:
216
+ * - `allClaudeProjects: true` — every project directory under the root (the old behaviour).
217
+ * - `claudeProjects: ['C--dev-projects-spexvr', ...]` — an explicit list.
218
+ * `allClaudeProjects` wins over `claudeProjects` when both are given.
219
+ *
220
+ * @param {{
221
+ * claudeProjectsDir?: string,
222
+ * claudeProjects?: string[]|null,
223
+ * allClaudeProjects?: boolean,
224
+ * commandCodeProjectsDir?: string,
225
+ * tasteFiles?: string[],
226
+ * spexcodeEventsPath?: string,
227
+ * repoRoot?: string,
228
+ * }} [opts]
229
+ * @returns {Array<{ source: string, project: string, session: string, path: string, mtime: number }>}
230
+ */
231
+ export function enumerateSources(opts = {}) {
232
+ const repoRoot = opts.repoRoot ?? process.cwd();
233
+ const claudeProjectsDir = opts.claudeProjectsDir ?? defaultClaudeProjectsDir();
234
+ const commandCodeProjectsDir = opts.commandCodeProjectsDir ?? defaultCommandCodeProjectsDir();
235
+ const tasteFiles = opts.tasteFiles ?? defaultTasteFiles(repoRoot);
236
+ const spexcodeEventsPath = opts.spexcodeEventsPath ?? defaultSpexcodeEventsPath(repoRoot);
237
+ const claudeProjects = opts.allClaudeProjects
238
+ ? null
239
+ : (opts.claudeProjects ?? [claudeCodeProjectSlug(repoRoot)]);
240
+
241
+ return [
242
+ ...enumerateClaudeCodeSources(claudeProjectsDir, { projects: claudeProjects }),
243
+ ...enumerateCommandCodeSources(commandCodeProjectsDir),
244
+ ...enumerateTasteSources(tasteFiles),
245
+ ...enumerateSpexcodeSources(spexcodeEventsPath, { project: basename(repoRoot) }),
246
+ ];
247
+ }
248
+
249
+ /** Stable per-session key used by corpus.mjs's incremental state. */
250
+ export function sourceKey(src) {
251
+ return `${src.source}:${src.project}:${src.session}`;
252
+ }
@@ -0,0 +1,401 @@
1
+ // spexharness/src/doctrine/synthesize.mjs
2
+ //
3
+ // DR2 (epic [[HS-KNHZ-8CMW-EPIC-spexcode-doctrine-v0-the-foundational]], task
4
+ // [[HS-YMPY-8CMW-TASK-dr2-doctrine-synthesis-a-routed]]) — batches the observation
5
+ // stream corpus.mjs produced and asks the `research_synthesis` seat (DeepSeek V4
6
+ // Flash, per dev-factory/routing-table.json `purposes.research_synthesis` /
7
+ // `lockedPrimaries.research_synthesis`) for candidate Doctrine principles.
8
+ //
9
+ // Routing (owner ruling [[owner-ruling-a-routed-model-seat]]): every model call goes
10
+ // through spexharness/src/loop.mjs `runToolLoop` — NEVER a bare `callModel` — even
11
+ // though this seat needs no tools; that keeps the harness's gates, redaction and
12
+ // error-recycling on the call path. No tools are offered (`tools` is omitted), so
13
+ // `runToolLoop`'s `withTools` is false and the loop resolves on the model's own first
14
+ // reply, gated by `hooks.onFinal` for the STRICT-JSON contract:
15
+ // { "principles": [ { "text", "category", "confidence" (0-1), "evidence": [id,...], "stance" } ] }
16
+ // A reply that is not valid JSON, or that cites an evidence id NOT in the batch it was
17
+ // given, is REJECTED and the seat is asked to retry — once (`maxFinalRetries: 1`,
18
+ // runToolLoop's own Stop-hook-shaped refusal/retry path).
19
+ //
20
+ // Cost is recorded through the existing cost meter (spexharness/src/chat/cost-meter.mjs)
21
+ // when a caller supplies one, one row per model call (one per batch, or two on a retry) —
22
+ // the SAME ledger `dev-factory/cost-tracker.mjs` and the chat margin already read.
23
+ //
24
+ // DR5 ([[HS-7Z9E-8CMW-TASK-dr5-vault-knowledge-as-a-doctrine]]): vault-source.mjs's
25
+ // `source: 'vault'` observations are ALREADY rules (curated by the owner) and are
26
+ // NEVER sent to this seat — {@link batchObservations}/{@link synthesizeDoctrine} drop
27
+ // them before batching, as a hard invariant independent of what a caller passes in.
28
+ // They are instead seeded straight into principles by merge.mjs's
29
+ // `seedVaultRulesAsPrinciples`. This module's only awareness of them is contextual: a
30
+ // caller may pass `vaultContextLines` (see {@link buildVaultRuleContextLines}) — a
31
+ // short "here is what's already established" list folded into the system prompt so a
32
+ // session-derived principle is phrased consistently with the vault and not restated
33
+ // as if it were new.
34
+
35
+ import { PROVIDERS, callModel } from '../../../dev-factory/cloud-clients.mjs';
36
+ import { DEEPSEEK } from '../../../dev-factory/lib/model-catalog.mjs';
37
+ import { runToolLoop } from '../loop.mjs';
38
+
39
+ /**
40
+ * @typedef {{
41
+ * text: string,
42
+ * category: string,
43
+ * confidence: number,
44
+ * evidence: string[],
45
+ * stance: 'do'|'avoid',
46
+ * supersedes?: string[],
47
+ * }} DoctrinePrincipleCandidate
48
+ */
49
+
50
+ /** The `research_synthesis` seat's locked primary (dev-factory/routing-table.json). */
51
+ export const SYNTHESIS_MODEL = DEEPSEEK.FLASH;
52
+
53
+ /** Categories a candidate principle may be filed under (epic scope). */
54
+ export const CATEGORIES = [
55
+ 'delegation',
56
+ 'verification',
57
+ 'communication',
58
+ 'naming',
59
+ 'safety-permissions',
60
+ 'research-grounding',
61
+ 'session-hygiene',
62
+ 'product-taste',
63
+ ];
64
+
65
+ /**
66
+ * The ESCAPE VALVE. Without it the seat had to file every observation under one of
67
+ * {@link CATEGORIES} — there was no way to say "this yields no principle" — so off-topic input
68
+ * (a bug report, a paste, a machine blob) was laundered into a confident-looking principle
69
+ * rather than declined. A principle carrying this sentinel category is parsed successfully
70
+ * and then DROPPED: it never reaches merge.mjs, the writer, or the committed document.
71
+ *
72
+ * It is deliberately NOT a member of `CATEGORIES`, so nothing downstream has to learn about
73
+ * it and `parseSynthesisReply` still rejects a genuinely unknown category.
74
+ */
75
+ export const NO_PRINCIPLE_CATEGORY = 'none';
76
+
77
+ // Approximation stated in the task brief: observations ≈ 4 chars/token.
78
+ const CHARS_PER_TOKEN = 4;
79
+ const TARGET_BATCH_TOKENS = 40_000;
80
+
81
+ /** The row shape a batch sends the seat — the ONLY fields it ever sees per observation. */
82
+ function batchRow(obs) {
83
+ return { id: obs.id, kind: obs.kind, project: obs.project, date: obs.ts, text: obs.text };
84
+ }
85
+
86
+ function approxChars(obs) {
87
+ return JSON.stringify(batchRow(obs)).length;
88
+ }
89
+
90
+ /**
91
+ * Estimate the token count of a set of observations (≈ 4 chars/token, per the batch
92
+ * row shape actually sent to the seat) — call BEFORE synthesizing so the real-run cost
93
+ * can be printed ahead of any dispatch.
94
+ * @param {Array<object>} observations
95
+ * @returns {number}
96
+ */
97
+ export function estimateTokens(observations) {
98
+ const eligible = (observations ?? []).filter((o) => o?.source !== 'vault');
99
+ const chars = eligible.reduce((sum, obs) => sum + approxChars(obs), 0);
100
+ return Math.ceil(chars / CHARS_PER_TOKEN);
101
+ }
102
+
103
+ /**
104
+ * Estimate the $ cost of synthesizing `observations` at `model`'s DeepSeek pricing —
105
+ * input tokens from the batch content, output tokens estimated generously (principles
106
+ * are short JSON; ~15% of input is a safe ceiling for a well-bounded batch).
107
+ * @param {Array<object>} observations
108
+ * @param {string} [model]
109
+ * @returns {{ estimatedInputTokens: number, estimatedOutputTokens: number, estimatedCostUSD: number }}
110
+ */
111
+ export function estimateSynthesisCostUSD(observations, model = SYNTHESIS_MODEL) {
112
+ const p = PROVIDERS[model];
113
+ const estimatedInputTokens = estimateTokens(observations);
114
+ const estimatedOutputTokens = Math.ceil(estimatedInputTokens * 0.15);
115
+ const estimatedCostUSD = p
116
+ ? +(((estimatedInputTokens * p.priceIn) + (estimatedOutputTokens * p.priceOut)) / 1e6).toFixed(6)
117
+ : 0;
118
+ return { estimatedInputTokens, estimatedOutputTokens, estimatedCostUSD };
119
+ }
120
+
121
+ /**
122
+ * Batch observations into ~40k-token groups: grouped by project, oldest-first within
123
+ * a project ("newest-last" per the task brief), a batch closed once adding the next
124
+ * observation would exceed `targetTokens`.
125
+ *
126
+ * Project order defaults to alphabetical (deterministic). A caller running against a
127
+ * spend cap that cannot fit the whole corpus may pass `projectOrder` — an explicit
128
+ * priority list (e.g. `['heyspex-app', ...otherProjectsNewestFirst]`) — so the
129
+ * highest-priority projects land in the EARLIEST batches, and a `maxBatches` cutoff
130
+ * then naturally keeps the highest-priority material and drops the rest.
131
+ *
132
+ * @param {Array<object>} observations
133
+ * @param {{ targetTokens?: number, projectOrder?: string[] }} [opts]
134
+ * @returns {Array<Array<object>>}
135
+ */
136
+ export function batchObservations(observations, { targetTokens = TARGET_BATCH_TOKENS, projectOrder = null } = {}) {
137
+ const targetChars = targetTokens * CHARS_PER_TOKEN;
138
+ const eligible = (observations ?? []).filter((o) => o?.source !== 'vault');
139
+ const byProject = new Map();
140
+ for (const obs of eligible) {
141
+ const list = byProject.get(obs.project) ?? [];
142
+ list.push(obs);
143
+ byProject.set(obs.project, list);
144
+ }
145
+
146
+ const priorityIndex = new Map((projectOrder ?? []).map((p, i) => [p, i]));
147
+ const projects = [...byProject.keys()].sort((a, b) => {
148
+ const ia = priorityIndex.has(a) ? priorityIndex.get(a) : Number.POSITIVE_INFINITY;
149
+ const ib = priorityIndex.has(b) ? priorityIndex.get(b) : Number.POSITIVE_INFINITY;
150
+ if (ia !== ib) return ia - ib;
151
+ return a.localeCompare(b);
152
+ });
153
+
154
+ const ordered = [];
155
+ for (const project of projects) {
156
+ const list = byProject.get(project);
157
+ list.sort((a, b) => String(a.ts ?? '').localeCompare(String(b.ts ?? '')) || (a.seq ?? 0) - (b.seq ?? 0));
158
+ ordered.push(...list);
159
+ }
160
+
161
+ const batches = [];
162
+ let current = [];
163
+ let currentChars = 0;
164
+ for (const obs of ordered) {
165
+ const chars = approxChars(obs);
166
+ if (current.length && currentChars + chars > targetChars) {
167
+ batches.push(current);
168
+ current = [];
169
+ currentChars = 0;
170
+ }
171
+ current.push(obs);
172
+ currentChars += chars;
173
+ }
174
+ if (current.length) batches.push(current);
175
+ return batches;
176
+ }
177
+
178
+ /**
179
+ * The top vault-rule principle texts per category, for prompt context — see the module
180
+ * header. Accepts merge.mjs's `seedVaultRulesAsPrinciples` output shape
181
+ * (`{ text, category, confidence }`); sorts each category's candidates by confidence
182
+ * descending and keeps the top `perCategory`. Pure, deterministic.
183
+ *
184
+ * @param {Array<{ text: string, category: string, confidence: number }>} vaultPrinciples
185
+ * @param {{ perCategory?: number }} [opts]
186
+ * @returns {string[]} lines shaped `[category] text`
187
+ */
188
+ export function buildVaultRuleContextLines(vaultPrinciples, { perCategory = 3 } = {}) {
189
+ const byCategory = new Map();
190
+ for (const p of vaultPrinciples ?? []) {
191
+ if (!p || typeof p.text !== 'string' || !p.text.trim()) continue;
192
+ const list = byCategory.get(p.category) ?? [];
193
+ list.push(p);
194
+ byCategory.set(p.category, list);
195
+ }
196
+ const lines = [];
197
+ for (const [category, list] of byCategory) {
198
+ const top = [...list].sort((a, b) => (b.confidence ?? 0) - (a.confidence ?? 0)).slice(0, perCategory);
199
+ for (const p of top) lines.push(`[${category}] ${p.text}`);
200
+ }
201
+ return lines;
202
+ }
203
+
204
+ /** The system prompt every synthesis call sends — states the Doctrine's purpose and the hard rules. */
205
+ export function buildSynthesisSystemPrompt({ vaultContextLines = [] } = {}) {
206
+ const base = [
207
+ 'You distill SpexCode Doctrine: the base operating principles every developer profile is built on,',
208
+ 'learned from how the owner directs coding agents across many real sessions.',
209
+ '',
210
+ 'You will receive one batch of observations, each shaped { id, kind, project, date, text }.',
211
+ 'Most observations (kind instruction/correction/ruling/approval/refusal/taste) are the owner\'s own',
212
+ 'words. Some observations have kind "behaviour" — a compact, aggregated summary of how a real',
213
+ 'session actually ran (tools called, guardrail refusals/blocks, claims and specs advanced), built',
214
+ 'from HeySpex\'s own logs, not something the owner said. Treat "behaviour" observations as lower-',
215
+ 'weight supporting evidence for verification/safety-permissions/session-hygiene principles — useful',
216
+ 'to corroborate a pattern, never sufficient on their own to justify a high-confidence principle.',
217
+ 'Reply with STRICT JSON ONLY — no prose, no markdown code fences — matching exactly this shape:',
218
+ '{ "principles": [ { "text": string, "category": one of ' +
219
+ JSON.stringify(CATEGORIES) +
220
+ ', "confidence": number between 0 and 1, "evidence": [observation id, ...], "stance": "do" | "avoid" } ] }',
221
+ '',
222
+ 'Rules:',
223
+ '- Prefer a rule that recurs across multiple sessions/observations over a one-off remark.',
224
+ '- Never quote more than 12 words verbatim from any single observation.',
225
+ '- Every id in "evidence" MUST be one of the ids given in THIS batch — never invent an id, and never cite one not present here.',
226
+ '- Never invent a principle or evidence unsupported by this batch.',
227
+ '- NOT EVERY OBSERVATION YIELDS A PRINCIPLE. An observation that is off-topic, is a one-off',
228
+ ' bug report, is pasted material, or is not the owner expressing a working preference must',
229
+ ' NOT be forced into a category. Decline it: either leave it out entirely, or emit an entry',
230
+ ' with "category": ' + JSON.stringify(NO_PRINCIPLE_CATEGORY) + ' citing it in "evidence". A declined entry is discarded.',
231
+ '- Returning { "principles": [] } for a batch that yields nothing is a CORRECT answer, not a failure.',
232
+ ];
233
+
234
+ if (vaultContextLines.length) {
235
+ base.push(
236
+ '',
237
+ "These rules are ALREADY established from the owner's own curated vault (decisions, protocols, north star, CLAUDE.md) —",
238
+ 'phrase any new principle consistently with these, and do NOT re-emit one of them as if it were a new principle:',
239
+ ...vaultContextLines.map((l) => `- ${l}`),
240
+ );
241
+ }
242
+
243
+ return base.join('\n');
244
+ }
245
+
246
+ function buildBatchUserMessage(batch) {
247
+ const rows = batch.map(batchRow);
248
+ return `Observations batch (${rows.length} items):\n${JSON.stringify(rows)}\n\nReturn ONLY the strict JSON object described in the system prompt — no other text.`;
249
+ }
250
+
251
+ function stripCodeFence(text) {
252
+ const trimmed = String(text ?? '').trim();
253
+ const fence = /^```(?:json)?\s*([\s\S]*?)\s*```$/i.exec(trimmed);
254
+ return fence ? fence[1].trim() : trimmed;
255
+ }
256
+
257
+ /**
258
+ * Validate + parse one seat reply against the strict-JSON contract for `batch`. Never
259
+ * throws — returns `{ error }` for anything not conforming, so the caller can decide
260
+ * to reject-and-retry.
261
+ *
262
+ * An entry whose category is {@link NO_PRINCIPLE_CATEGORY} is the seat DECLINING the
263
+ * observations it cites (see the constant's note). It is validated only as far as its
264
+ * evidence ids — no text, confidence or stance is demanded for something being thrown away —
265
+ * and is returned separately on `declined`, never on `principles`.
266
+ *
267
+ * @param {string} text
268
+ * @param {string[]} validIds
269
+ * @returns {{ principles: DoctrinePrincipleCandidate[], declined: string[] } | { error: string }}
270
+ */
271
+ export function parseSynthesisReply(text, validIds) {
272
+ let parsed;
273
+ try {
274
+ parsed = JSON.parse(stripCodeFence(text));
275
+ } catch {
276
+ return { error: 'reply is not valid JSON' };
277
+ }
278
+ if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.principles)) {
279
+ return { error: 'reply is missing a "principles" array' };
280
+ }
281
+ const idSet = new Set(validIds);
282
+ const principles = [];
283
+ const declined = [];
284
+ for (const p of parsed.principles) {
285
+ if (!p || typeof p !== 'object') return { error: 'a principle entry is not an object' };
286
+ if (p.category === NO_PRINCIPLE_CATEGORY) {
287
+ for (const id of Array.isArray(p.evidence) ? p.evidence : []) {
288
+ if (!idSet.has(id)) return { error: `principle cites an evidence id not in this batch: "${id}"` };
289
+ declined.push(id);
290
+ }
291
+ continue;
292
+ }
293
+ if (typeof p.text !== 'string' || !p.text.trim()) return { error: 'a principle is missing "text"' };
294
+ if (!CATEGORIES.includes(p.category)) return { error: `principle has an unknown category "${p.category}"` };
295
+ if (typeof p.confidence !== 'number' || Number.isNaN(p.confidence) || p.confidence < 0 || p.confidence > 1) {
296
+ return { error: 'a principle has a "confidence" outside 0..1' };
297
+ }
298
+ if (!Array.isArray(p.evidence) || p.evidence.length === 0) return { error: 'a principle is missing "evidence"' };
299
+ for (const id of p.evidence) {
300
+ if (!idSet.has(id)) return { error: `principle cites an evidence id not in this batch: "${id}"` };
301
+ }
302
+ if (p.stance !== 'do' && p.stance !== 'avoid') return { error: 'a principle has a "stance" other than do/avoid' };
303
+ principles.push(p);
304
+ }
305
+ return { principles, declined };
306
+ }
307
+
308
+ /**
309
+ * Synthesize ONE batch through the routed seat. Always goes through `runToolLoop` —
310
+ * never a bare `callModelFn` — per [[owner-ruling-a-routed-model-seat]]; no tools are
311
+ * offered. A reply that fails {@link parseSynthesisReply} is rejected via
312
+ * `hooks.onFinal` and retried once (`maxFinalRetries`).
313
+ *
314
+ * @param {Array<object>} batch
315
+ * @param {{ model?: string, callModelFn?: Function, maxFinalRetries?: number, costMeter?: { recordTurn: Function } | null, vaultContextLines?: string[] }} [opts]
316
+ * @returns {Promise<{ principles: DoctrinePrincipleCandidate[], costUSD: number, error: string|null }>}
317
+ */
318
+ export async function synthesizeBatch(batch, { model = SYNTHESIS_MODEL, callModelFn = callModel, maxFinalRetries = 1, costMeter = null, vaultContextLines = [] } = {}) {
319
+ const validIds = batch.map((o) => o.id);
320
+ const system = buildSynthesisSystemPrompt({ vaultContextLines });
321
+ const messages = [{ role: 'user', content: buildBatchUserMessage(batch) }];
322
+
323
+ let lastParsed = null;
324
+ const result = await runToolLoop({
325
+ callModelFn,
326
+ model,
327
+ messages,
328
+ execute: async () => ({ content: JSON.stringify({ error: 'the doctrine synthesis seat has no tools available' }) }),
329
+ maxToolTurns: 1,
330
+ callOpts: { system, maxTokens: 8192, temp: 0 },
331
+ maxFinalRetries,
332
+ onTurn: costMeter
333
+ ? ({ res }) => {
334
+ if (res && !res.error) costMeter.recordTurn({ model: res.model ?? model, usage: res.usage, costUSD: res.costUSD, seconds: res.seconds });
335
+ }
336
+ : null,
337
+ hooks: {
338
+ onFinal: async (text) => {
339
+ const parsed = parseSynthesisReply(text, validIds);
340
+ lastParsed = parsed;
341
+ if (parsed.error) {
342
+ return {
343
+ accept: false,
344
+ feedback: `Your reply was rejected: ${parsed.error}. Reply again with ONLY the strict JSON object described in the system prompt, citing only ids present in this batch.`,
345
+ };
346
+ }
347
+ return { accept: true };
348
+ },
349
+ },
350
+ });
351
+
352
+ if (!result.ok || !lastParsed || lastParsed.error) {
353
+ return { principles: [], declined: [], costUSD: result.costUSD ?? 0, error: lastParsed?.error ?? result.error ?? 'synthesis failed with no reply' };
354
+ }
355
+ return { principles: lastParsed.principles, declined: lastParsed.declined ?? [], costUSD: result.costUSD ?? 0, error: null };
356
+ }
357
+
358
+ /**
359
+ * Synthesize the whole observation set: batch it, run each batch (up to `maxBatches`)
360
+ * through {@link synthesizeBatch}, and total the cost. Batches beyond `maxBatches` are
361
+ * left unrun (`batchesSkipped`) rather than silently dropped from the count — a caller
362
+ * prioritizing which observations to include should slice `observations` BEFORE
363
+ * calling this, so the skipped batches are the ones that mattered least.
364
+ *
365
+ * @param {Array<object>} observations
366
+ * @param {{ model?: string, callModelFn?: Function, maxBatches?: number, costMeter?: object|null, onBatch?: Function|null, projectOrder?: string[], vaultContextLines?: string[] }} [opts]
367
+ * @returns {Promise<{ principles: DoctrinePrincipleCandidate[], batchesRun: number, batchesTotal: number, batchesSkipped: number, costUSD: number, results: Array<{ batchIndex: number, size: number, principlesReturned: number, error: string|null, costUSD: number }> }>}
368
+ */
369
+ export async function synthesizeDoctrine(observations, { model = SYNTHESIS_MODEL, callModelFn = callModel, maxBatches = Infinity, costMeter = null, onBatch = null, projectOrder = null, vaultContextLines = [] } = {}) {
370
+ // Vault observations are never sent to the seat (see module header) — batchObservations
371
+ // already drops them, this filter is belt-and-suspenders for anything that inspects
372
+ // `observations` directly before batching.
373
+ const batches = batchObservations(observations, { projectOrder });
374
+ const limit = Number.isFinite(maxBatches) ? Math.max(0, maxBatches) : batches.length;
375
+ const toRun = batches.slice(0, limit);
376
+
377
+ const allPrinciples = [];
378
+ const allDeclined = [];
379
+ const results = [];
380
+ let costUSD = 0;
381
+
382
+ for (let i = 0; i < toRun.length; i++) {
383
+ const batch = toRun[i];
384
+ const r = await synthesizeBatch(batch, { model, callModelFn, costMeter, vaultContextLines });
385
+ costUSD += r.costUSD;
386
+ results.push({ batchIndex: i, size: batch.length, principlesReturned: r.principles.length, declinedCount: (r.declined ?? []).length, error: r.error, costUSD: r.costUSD });
387
+ allPrinciples.push(...r.principles);
388
+ allDeclined.push(...(r.declined ?? []));
389
+ if (onBatch) onBatch({ index: i, of: toRun.length, batch, result: r });
390
+ }
391
+
392
+ return {
393
+ principles: allPrinciples,
394
+ declined: allDeclined,
395
+ batchesRun: toRun.length,
396
+ batchesTotal: batches.length,
397
+ batchesSkipped: batches.length - toRun.length,
398
+ costUSD,
399
+ results,
400
+ };
401
+ }