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,338 @@
1
+ // spexharness/src/doctrine/inject.mjs
2
+ //
3
+ // DR3 (epic [[HS-KNHZ-8CMW-EPIC-spexcode-doctrine-v0-the-foundational]], task
4
+ // [[HS-X3S1-8CMW-TASK-dr3-doctrine-at-runtime-injected-into]]) — Doctrine AT RUNTIME. DR2
5
+ // (writer.mjs/merge.mjs/synthesize.mjs) produces the committed artifacts
6
+ // (spexharness/doctrine/SPEXCODE-DOCTRINE.json/.md); this module is the ONE seam every
7
+ // runtime surface (a SpexCode seat's bootstrap, the chat front's system message, the Claude
8
+ // Code plugin's SessionStart hook, the `/doctrine` built-in) reads that document through —
9
+ // none of them re-parses the JSON or re-implements the confidence-ordered, budget-capped
10
+ // rendering a second time.
11
+ //
12
+ // loadDoctrine() is CACHED (keyed by resolved path) and TOLERANT OF ABSENCE: a repo with no
13
+ // SPEXCODE-DOCTRINE.json yet (DR5's concurrent learner re-run notwithstanding — this reads
14
+ // whatever is on disk at call time, never bakes a snapshot into a test) returns `null` rather
15
+ // than throwing, and every caller downstream (renderDoctrineBlock) turns that into an
16
+ // explicit absence notice rather than silently injecting nothing. invalidateDoctrineCache()
17
+ // is the other half of that cache: `/doctrine refresh` calls it once `hs doctrine learn`
18
+ // finishes, so the NEXT read picks up the freshly written file instead of the stale cached
19
+ // one.
20
+ //
21
+ // renderDoctrineBlock() is measured against its OWN documented budget (DOCTRINE_MAX_TOKENS),
22
+ // deliberately separate from system-message.mjs's SYSTEM_MESSAGE_BYTE_CAP (2000 bytes,
23
+ // sent on every turn) — the Doctrine block rides the bootstrap injection (once per session,
24
+ // not per turn), so it gets real room; the system message only ever carries a one-line
25
+ // pointer to it (system-message.mjs's own `doctrine` option), never the body.
26
+ //
27
+ // recordDoctrineInjection() is the bridge to ../context-budget.mjs's ONE measurement path
28
+ // (recordContextInjection) — same pattern ../chat/system-message.mjs and
29
+ // ../gates/inject-vault-context.mjs already use, under `source: 'doctrine'`.
30
+ //
31
+ // layerProfileOverDoctrine() is Scope 5's pure helper: the SpexCode Profile layer (a later
32
+ // epic) sits ON TOP of Doctrine without silently overriding it — a profile entry with an
33
+ // APPROVED `supersedes` renders as a refinement; one without keeps the Doctrine line and is
34
+ // flagged, exactly the layering rule the doctrine writer's own DOCTRINE_HEADER documents
35
+ // (writer.mjs: "a per-developer layer on top, which may refine a principle here but may not
36
+ // silently contradict one").
37
+ //
38
+ // Pure aside from loadDoctrine's one fs.readFileSync — every other export here does no I/O.
39
+
40
+ import { readFileSync } from 'node:fs';
41
+ import path from 'node:path';
42
+ import { fileURLToPath } from 'node:url';
43
+
44
+ import { estimateTokens } from '../token-inventory.mjs';
45
+ import { recordContextInjection } from '../context-budget.mjs';
46
+ import { CATEGORY_ORDER, CATEGORY_LABELS } from './writer.mjs';
47
+
48
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
49
+ const REPO_ROOT = path.resolve(HERE, '..', '..', '..');
50
+
51
+ /** Default location of the committed Doctrine artifact, relative to a repo root. */
52
+ export function defaultDoctrinePath(repoRoot = REPO_ROOT) {
53
+ return path.join(repoRoot, 'spexharness', 'doctrine', 'SPEXCODE-DOCTRINE.json');
54
+ }
55
+
56
+ // ── Audience: platform checkout vs customer repo (HS-A2PO, owner ruling 2026-10-03) ──────────
57
+ //
58
+ // "yes, inject-doctrine ships to customers. Baseline foundational doctrine on which user's
59
+ // doctrine will be built ontop." The full learned file (SPEXCODE-DOCTRINE.json, distilled from
60
+ // the owner's own sessions and full of platform rules) is NOT in the npm package; what ships is
61
+ // the curated BASELINE edition below. A session whose project is the package's own checkout
62
+ // (heyspex-app) keeps reading the learned file; every other project reads ONLY the baseline
63
+ // from the installed package — never a file under the customer's project, never the owner's
64
+ // learned set — and gets nothing at all (a clean no-op) when the baseline is absent.
65
+
66
+ /** The package root this module was loaded from (heyspex-app, or node_modules/heyspex). */
67
+ export const PACKAGE_ROOT = REPO_ROOT;
68
+
69
+ /** The shipped baseline edition, repo-relative (also listed in package.json `files`). */
70
+ export const BASELINE_DOCTRINE_REL = 'spexharness/doctrine/baseline/SPEXCODE-DOCTRINE.baseline.json';
71
+
72
+ /** Absolute path of the baseline edition inside a package root. */
73
+ export function baselineDoctrinePath(packageRoot = PACKAGE_ROOT) {
74
+ return path.join(packageRoot, ...BASELINE_DOCTRINE_REL.split('/'));
75
+ }
76
+
77
+ function samePath(a, b) {
78
+ const norm = (p) => {
79
+ const s = path.resolve(p).replace(/\\/g, '/').replace(/\/+$/, '');
80
+ return process.platform === 'win32' ? s.toLowerCase() : s;
81
+ };
82
+ return norm(a) === norm(b);
83
+ }
84
+
85
+ /**
86
+ * Which Doctrine a session in `projectRoot` reads. Pure (no fs).
87
+ * - project IS the package checkout → `platform`: the learned file under the project.
88
+ * - anything else → `customer`: the package's baseline edition only.
89
+ *
90
+ * @param {{ projectRoot: string, packageRoot?: string }} opts
91
+ * @returns {{ audience: 'platform'|'customer', path: string }}
92
+ */
93
+ export function resolveDoctrineSource({ projectRoot, packageRoot = PACKAGE_ROOT }) {
94
+ if (projectRoot && samePath(projectRoot, packageRoot)) {
95
+ return { audience: 'platform', path: defaultDoctrinePath(projectRoot) };
96
+ }
97
+ return { audience: 'customer', path: baselineDoctrinePath(packageRoot) };
98
+ }
99
+
100
+ /**
101
+ * The bootstrap-injection budget this module's own render function is held to — separate
102
+ * from, and much larger than, system-message.mjs's per-turn SYSTEM_MESSAGE_BYTE_CAP (see
103
+ * this file's own header). Chosen to comfortably hold every category's top principles
104
+ * without needing to be raised for the doctrine set this epic ships with; a caller with a
105
+ * tighter/looser seat budget passes its own `maxTokens` to {@link renderDoctrineBlock}.
106
+ */
107
+ export const DOCTRINE_MAX_TOKENS = 800;
108
+
109
+ /** Shown (and injected) when no Doctrine document is readable at the resolved path. */
110
+ export const DOCTRINE_ABSENT_NOTICE =
111
+ 'Doctrine: not yet learned — spexharness/doctrine/SPEXCODE-DOCTRINE.json is missing or empty. ' +
112
+ 'Run `hs doctrine learn` (or `/doctrine refresh`) to generate it.';
113
+
114
+ /** loadDoctrine()'s cache: resolved path -> parsed doctrine (or `null` for absent/corrupt). */
115
+ const cache = new Map();
116
+
117
+ /**
118
+ * Load the Doctrine JSON document, cached by resolved path. Tolerant of absence: a missing
119
+ * file, an unreadable one, or one that fails to parse as JSON all resolve to `null` rather
120
+ * than throwing — a runtime surface with no Doctrine yet must still start.
121
+ *
122
+ * @param {{ path?: string }} [opts] - `path` overrides the default
123
+ * `<repoRoot>/spexharness/doctrine/SPEXCODE-DOCTRINE.json` (tests point this at a fixture).
124
+ * @returns {object|null} the parsed document (writer.mjs's `buildDoctrineJson` shape), or null.
125
+ */
126
+ export function loadDoctrine({ path: doctrinePath } = {}) {
127
+ const resolved = typeof doctrinePath === 'string' && doctrinePath ? doctrinePath : defaultDoctrinePath();
128
+ if (cache.has(resolved)) return cache.get(resolved);
129
+ let doctrine = null;
130
+ try {
131
+ doctrine = JSON.parse(readFileSync(resolved, 'utf8'));
132
+ } catch {
133
+ doctrine = null;
134
+ }
135
+ cache.set(resolved, doctrine);
136
+ return doctrine;
137
+ }
138
+
139
+ /**
140
+ * Invalidate loadDoctrine()'s cache — the whole thing, or just one resolved path. `/doctrine
141
+ * refresh` (builtins.mjs) calls this once `hs doctrine learn` finishes writing a fresh file,
142
+ * so the NEXT loadDoctrine() call re-reads instead of returning the stale cached document.
143
+ *
144
+ * @param {string} [doctrinePath] - when given, only this path's cache entry is dropped.
145
+ */
146
+ export function invalidateDoctrineCache(doctrinePath) {
147
+ if (typeof doctrinePath === 'string' && doctrinePath) cache.delete(doctrinePath);
148
+ else cache.clear();
149
+ }
150
+
151
+ /** One principle rendered as a single line: `- [do] <text>` / `- [avoid] <text>`. */
152
+ function principleLine(p) {
153
+ const stance = p?.stance === 'avoid' ? 'avoid' : 'do';
154
+ return `- [${stance}] ${p?.text ?? ''}`;
155
+ }
156
+
157
+ /**
158
+ * Render the Doctrine document into the block every runtime surface injects: a header line
159
+ * (`learnedAt` + principle count — AC3's "/doctrine prints the document header + principle
160
+ * count + learnedAt" reads exactly this line), then the TOP principles by confidence,
161
+ * grouped by category (writer.mjs's own CATEGORY_ORDER/CATEGORY_LABELS — the same section
162
+ * order the committed Markdown document uses), one line each, kept within `maxTokens`.
163
+ *
164
+ * Absence (`doctrine` is null/has no principles) renders {@link DOCTRINE_ABSENT_NOTICE}
165
+ * instead — AC1's "absent (with a notice) when the file is missing".
166
+ *
167
+ * Budget: categories (and the principles inside them) are added in confidence order; the
168
+ * FIRST principle that would push the rendered text over `maxTokens` and everything ranked
169
+ * below it are dropped (a true tail cut, matching token-inventory.mjs's own budgeting
170
+ * convention) — `truncated: true` says so. A category with zero principles that fit is
171
+ * omitted entirely rather than rendered as an empty header.
172
+ *
173
+ * @param {object|null} doctrine - loadDoctrine()'s result.
174
+ * @param {{ maxTokens?: number }} [opts] - defaults to {@link DOCTRINE_MAX_TOKENS}.
175
+ * @returns {{ text: string, tokens: number, chars: number, principleCount: number,
176
+ * includedCount: number, learnedAt: string|null, truncated: boolean }}
177
+ */
178
+ export function renderDoctrineBlock(doctrine, { maxTokens = DOCTRINE_MAX_TOKENS } = {}) {
179
+ const principles = Array.isArray(doctrine?.principles) ? doctrine.principles : [];
180
+ if (principles.length === 0) {
181
+ const text = DOCTRINE_ABSENT_NOTICE;
182
+ return { text, tokens: estimateTokens(text), chars: text.length, principleCount: 0, includedCount: 0, learnedAt: null, truncated: false };
183
+ }
184
+
185
+ const principleCount = principles.length;
186
+ const learnedAt = typeof doctrine?.learnedAt === 'string' ? doctrine.learnedAt : null;
187
+ const plural = `${principleCount} principle${principleCount === 1 ? '' : 's'}`;
188
+ const edition = typeof doctrine?.edition === 'string' && doctrine.edition ? doctrine.edition : null;
189
+ const header = edition
190
+ ? `Doctrine (${edition} edition, ${plural}):`
191
+ : `Doctrine (learned ${learnedAt ?? 'unknown'}, ${plural}):`;
192
+
193
+ // Selection is GLOBAL confidence order (AC1's "top principles by confidence within the
194
+ // budget") — a true tail cut, same convention token-inventory.mjs's own budgeting uses:
195
+ // the first principle that would overflow, and every lower-confidence one after it, are
196
+ // dropped together, rather than letting a cheaper low-confidence line jump the queue.
197
+ const sorted = [...principles].sort((a, b) => (Number(b?.confidence) || 0) - (Number(a?.confidence) || 0));
198
+
199
+ let usedTokens = estimateTokens(header);
200
+ let truncated = false;
201
+ const includedCats = new Set();
202
+ const included = []; // { cat, line } in confidence order — display groups these by category.
203
+
204
+ for (const p of sorted) {
205
+ const cat = typeof p?.category === 'string' && p.category ? p.category : 'product-taste';
206
+ const line = principleLine(p);
207
+ const lineTokens = estimateTokens(line);
208
+ const needsCatHeader = !includedCats.has(cat);
209
+ const catHeaderTokens = needsCatHeader ? estimateTokens(`${CATEGORY_LABELS[cat] ?? cat}:`) : 0;
210
+ if (usedTokens + catHeaderTokens + lineTokens > maxTokens) {
211
+ truncated = true;
212
+ break;
213
+ }
214
+ usedTokens += catHeaderTokens + lineTokens;
215
+ includedCats.add(cat);
216
+ included.push({ cat, line });
217
+ }
218
+
219
+ // Display grouping: the SELECTED principles, sectioned in writer.mjs's own canonical
220
+ // CATEGORY_ORDER (matching the committed Markdown document's section order), each section
221
+ // keeping the confidence order it was selected in.
222
+ const byCategory = new Map();
223
+ for (const item of included) {
224
+ if (!byCategory.has(item.cat)) byCategory.set(item.cat, []);
225
+ byCategory.get(item.cat).push(item.line);
226
+ }
227
+ const orderedCats = [
228
+ ...CATEGORY_ORDER.filter((c) => byCategory.has(c)),
229
+ ...[...byCategory.keys()].filter((c) => !CATEGORY_ORDER.includes(c)),
230
+ ];
231
+
232
+ const lines = [header];
233
+ for (const cat of orderedCats) {
234
+ lines.push(`${CATEGORY_LABELS[cat] ?? cat}:`, ...byCategory.get(cat));
235
+ }
236
+
237
+ const text = lines.join('\n');
238
+ return {
239
+ text,
240
+ tokens: estimateTokens(text),
241
+ chars: text.length,
242
+ principleCount,
243
+ includedCount: included.length,
244
+ learnedAt,
245
+ truncated,
246
+ };
247
+ }
248
+
249
+ /**
250
+ * Record a rendered Doctrine block onto a session record through ../context-budget.mjs's ONE
251
+ * measurement path — `source: 'doctrine'`, same convention ../chat/system-message.mjs uses
252
+ * for its own piece. A no-op (returns null) when no `record` is supplied, exactly like
253
+ * buildSystemMessage's own optional `sessionRecord` — a caller with no session record yet
254
+ * still gets the block back from {@link renderDoctrineBlock} without erroring here.
255
+ *
256
+ * @param {ReturnType<import('../session-record.mjs').createSessionRecord>|null} record
257
+ * @param {{ text?: string, tokens?: number }|null} block - renderDoctrineBlock()'s result.
258
+ * @returns {import('../context-budget.mjs').ContextInjectionMeasurement|null}
259
+ */
260
+ export function recordDoctrineInjection(record, block) {
261
+ if (!record || !block) return null;
262
+ return recordContextInjection(record, { source: 'doctrine', name: 'doctrine', text: block.text, tokens: block.tokens });
263
+ }
264
+
265
+ /**
266
+ * Scope 5's layering rule, as a pure helper: fold a Profile's entries over the Doctrine
267
+ * principles they target, WITHOUT the Profile module existing yet (it ships in a later
268
+ * epic — HS-HEBV per the task's own depends_on). Consumed today only by this module's own
269
+ * tests (fixtures stand in for real profile entries); `/profile` (the later epic) is the
270
+ * eventual real caller.
271
+ *
272
+ * A profile entry targets one Doctrine principle by exact `targetText` match against that
273
+ * principle's `text` (Doctrine principles carry no stable id today — matching on the
274
+ * authored text is the same identity DR2's own merge.mjs dedup uses). An entry with no
275
+ * matching principle is ignored (Profile-only additions are out of this task's scope).
276
+ *
277
+ * - entry.supersedes?.approved === true → the layered line is the PROFILE's own text,
278
+ * `source: 'profile-refines'`, carrying `refines` (the original Doctrine text) — "refines"
279
+ * the Scope 5 language.
280
+ * - anything else (no entry, entry present but not approved) → the Doctrine line is kept
281
+ * verbatim, `source: 'doctrine'`; a present-but-unapproved entry additionally sets
282
+ * `flagged: true` and `contradictedBy` — the `/profile` flag Scope 5 asks for.
283
+ *
284
+ * @param {object|null} doctrine - loadDoctrine()'s result.
285
+ * @param {Array<{ targetText: string, text?: string, stance?: 'do'|'avoid',
286
+ * supersedes?: { approved?: boolean } }>} [profileEntries]
287
+ * @returns {Array<{ category: string, stance: string, text: string,
288
+ * source: 'doctrine'|'profile-refines', flagged: boolean, refines?: string, contradictedBy?: string }>}
289
+ */
290
+ export function layerProfileOverDoctrine(doctrine, profileEntries = []) {
291
+ const principles = Array.isArray(doctrine?.principles) ? doctrine.principles : [];
292
+ const entries = Array.isArray(profileEntries) ? profileEntries : [];
293
+ const byTarget = new Map();
294
+ for (const entry of entries) {
295
+ if (entry && typeof entry.targetText === 'string' && entry.targetText) byTarget.set(entry.targetText, entry);
296
+ }
297
+
298
+ return principles.map((p) => {
299
+ const entry = byTarget.get(p?.text);
300
+ if (!entry) {
301
+ return { category: p?.category ?? 'product-taste', stance: p?.stance ?? 'do', text: p?.text ?? '', source: 'doctrine', flagged: false };
302
+ }
303
+ const approved = entry?.supersedes?.approved === true;
304
+ if (approved) {
305
+ return {
306
+ category: p?.category ?? 'product-taste',
307
+ stance: entry.stance ?? p?.stance ?? 'do',
308
+ text: typeof entry.text === 'string' && entry.text ? entry.text : p?.text ?? '',
309
+ source: 'profile-refines',
310
+ refines: p?.text ?? '',
311
+ flagged: false,
312
+ };
313
+ }
314
+ return {
315
+ category: p?.category ?? 'product-taste',
316
+ stance: p?.stance ?? 'do',
317
+ text: p?.text ?? '',
318
+ source: 'doctrine',
319
+ flagged: true,
320
+ contradictedBy: typeof entry.text === 'string' ? entry.text : null,
321
+ };
322
+ });
323
+ }
324
+
325
+ export default {
326
+ defaultDoctrinePath,
327
+ PACKAGE_ROOT,
328
+ BASELINE_DOCTRINE_REL,
329
+ baselineDoctrinePath,
330
+ resolveDoctrineSource,
331
+ DOCTRINE_MAX_TOKENS,
332
+ DOCTRINE_ABSENT_NOTICE,
333
+ loadDoctrine,
334
+ invalidateDoctrineCache,
335
+ renderDoctrineBlock,
336
+ recordDoctrineInjection,
337
+ layerProfileOverDoctrine,
338
+ };
@@ -0,0 +1,184 @@
1
+ // spexharness/src/doctrine/merge.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]]) — pure merge of candidate
5
+ // principles (from synthesize.mjs's seat batches, plus the Command Code taste lines
6
+ // seeded in as principles) into the final set a Doctrine document is written from.
7
+ //
8
+ // DR5 ([[HS-7Z9E-8CMW-TASK-dr5-vault-knowledge-as-a-doctrine]]) adds a second seeded
9
+ // source: vault-source.mjs's curated vault rules (owner rulings, lessons, protocol
10
+ // enforcement, accepted ADRs, north star/operating model/principles-index, the
11
+ // naughty list, this repo's CLAUDE.md). Vault rules are seeded FIRST, ahead of the
12
+ // seat's synthesized candidates, so a matching session-derived principle dedupes INTO
13
+ // the vault rule (the vault rule's text stays canonical) and a contradicting one is
14
+ // headlined by the vault rule — the owner's written word wins over inferred behaviour.
15
+ //
16
+ // Pure: no I/O, no randomness, no clock reads except a caller-supplied `now` (unused
17
+ // today, reserved so a future "later ruling wins" tie-break can be date-aware without
18
+ // changing this module's shape).
19
+ //
20
+ // What this does, in order:
21
+ // 1. Dedupe near-identical principles (same category + stance, normalized-text
22
+ // similarity ≥ DEDUP_SIMILARITY_THRESHOLD) — union their evidence ids and raise
23
+ // confidence with each independent piece of evidence. The FIRST-seen candidate's
24
+ // text is kept as canonical; later matches only contribute evidence/confidence.
25
+ // 2. Detect a contradiction: same category, OPPOSITE stance, similarity above the
26
+ // same threshold (i.e. clearly the same topic). Neither side is dropped — both
27
+ // stay in `principles`, and the pair is recorded under `conflicts`. The headline
28
+ // is the vault-sourced side when either side is vault-sourced; otherwise the
29
+ // later-seen one (later ruling wins for the headline; both cited either way).
30
+ // 3. Seed the Command Code taste lines as principles at their own stated confidence,
31
+ // `evidence: ['taste:<...>']` (the observation id corpus.mjs already gives a
32
+ // taste line, which is itself prefixed `taste:` — see {@link seedTasteAsPrinciples}).
33
+ // 4. Seed vault-source.mjs's `vault-rule` observations as principles at their own
34
+ // `seedConfidence`, `evidence: [obs.id]` (already shaped `vault:<spec id>...`),
35
+ // category from {@link import('./vault-source.mjs').classifyVaultCategory} — see
36
+ // {@link seedVaultRulesAsPrinciples}.
37
+
38
+ import { classifyVaultCategory } from './vault-source.mjs';
39
+
40
+ /**
41
+ * @typedef {{
42
+ * text: string,
43
+ * category: string,
44
+ * confidence: number,
45
+ * evidence: string[],
46
+ * stance: 'do'|'avoid',
47
+ * supersedes?: string[],
48
+ * }} DoctrinePrinciple
49
+ */
50
+
51
+ const DEFAULT_CATEGORY = 'product-taste';
52
+
53
+ /** Lowercase, strip punctuation, collapse whitespace — the text dedup/contradiction compares on. */
54
+ export function normalizeForDedup(text) {
55
+ return String(text ?? '')
56
+ .toLowerCase()
57
+ .replace(/[^a-z0-9\s]/g, ' ')
58
+ .replace(/\s+/g, ' ')
59
+ .trim();
60
+ }
61
+
62
+ /** Token-overlap (Jaccard) similarity of two principle texts, 0..1. Pure, no dependencies. */
63
+ export function textSimilarity(a, b) {
64
+ const ta = new Set(normalizeForDedup(a).split(' ').filter(Boolean));
65
+ const tb = new Set(normalizeForDedup(b).split(' ').filter(Boolean));
66
+ if (ta.size === 0 || tb.size === 0) return 0;
67
+ let intersection = 0;
68
+ for (const t of ta) if (tb.has(t)) intersection++;
69
+ const union = ta.size + tb.size - intersection;
70
+ return union === 0 ? 0 : intersection / union;
71
+ }
72
+
73
+ /** Similarity at/above this is "the same topic" for both dedup and contradiction detection. */
74
+ export const DEDUP_SIMILARITY_THRESHOLD = 0.5;
75
+
76
+ /**
77
+ * Seed the Command Code taste lines (corpus.mjs observations with `kind === 'taste'`)
78
+ * as candidate principles at their own stated confidence. `evidence` is the taste
79
+ * observation's own id (already shaped `taste:<project>:<session>:<seq>`), which both
80
+ * satisfies "cites an id that exists" and reads as `taste:<n>` per the task brief.
81
+ *
82
+ * @param {Array<{ id: string, text: string, confidence?: number }>} tasteObservations
83
+ * @returns {DoctrinePrinciple[]}
84
+ */
85
+ export function seedTasteAsPrinciples(tasteObservations) {
86
+ return (tasteObservations ?? []).map((obs) => ({
87
+ text: obs.text,
88
+ category: DEFAULT_CATEGORY,
89
+ confidence: typeof obs.confidence === 'number' ? obs.confidence : 0.5,
90
+ evidence: [obs.id],
91
+ stance: 'do',
92
+ }));
93
+ }
94
+
95
+ /**
96
+ * Seed vault-source.mjs's `vault-rule` observations (owner decisions, lessons,
97
+ * protocol enforcement, accepted ADRs, north star/operating model/principles-index,
98
+ * the naughty list, CLAUDE.md) as candidate principles at their own `seedConfidence`.
99
+ * `evidence` is the observation's own id (already shaped `vault:<spec id>...`).
100
+ * `category` comes from {@link classifyVaultCategory} — never the seat (vault
101
+ * observations are never sent to it, see synthesize.mjs). Every seeded principle
102
+ * carries `vaultSourced: true` so {@link mergePrinciples} can headline it over a
103
+ * contradicting session-derived principle regardless of processing order.
104
+ *
105
+ * @param {Array<{ id: string, set: string, text: string, stance: 'do'|'avoid', seedConfidence: number }>} vaultObservations
106
+ * @returns {DoctrinePrinciple[]}
107
+ */
108
+ export function seedVaultRulesAsPrinciples(vaultObservations) {
109
+ return (vaultObservations ?? [])
110
+ .filter((obs) => obs && obs.source === 'vault' && obs.kind === 'vault-rule')
111
+ .map((obs) => ({
112
+ text: obs.text,
113
+ category: classifyVaultCategory(obs),
114
+ confidence: typeof obs.seedConfidence === 'number' ? obs.seedConfidence : 0.5,
115
+ evidence: [obs.id],
116
+ stance: obs.stance === 'avoid' ? 'avoid' : 'do',
117
+ vaultSourced: true,
118
+ }));
119
+ }
120
+
121
+ /** Independent evidence nudges confidence up, asymptotic to 0.99 — never overridden downward by a merge. */
122
+ function raiseConfidence(baseConfidence, candidateConfidence) {
123
+ const bumped = baseConfidence + (1 - baseConfidence) * 0.15;
124
+ return Math.min(0.99, Math.max(bumped, candidateConfidence ?? baseConfidence));
125
+ }
126
+
127
+ function dedupeEvidence(evidence) {
128
+ return Array.from(new Set(evidence.filter((id) => typeof id === 'string' && id.length > 0)));
129
+ }
130
+
131
+ /**
132
+ * Merge candidate principles (freshly synthesized + any `existing` from a prior run,
133
+ * oldest first) into the final principle set, deduping, raising confidence, and
134
+ * recording contradictions.
135
+ *
136
+ * @param {DoctrinePrinciple[]} candidates
137
+ * @param {{ existing?: DoctrinePrinciple[] }} [opts] `existing` — principles from a
138
+ * prior incremental run, merged in FIRST (oldest), so a fresh candidate can still be
139
+ * detected as a contradiction of (and become the new headline over) one already on
140
+ * file.
141
+ * @returns {{ principles: DoctrinePrinciple[], conflicts: Array<{ category: string, a: string, b: string, headline: string }> }}
142
+ */
143
+ export function mergePrinciples(candidates, { existing = [] } = {}) {
144
+ const ordered = [...existing, ...candidates];
145
+ const merged = [];
146
+ const conflicts = [];
147
+
148
+ for (const cand of ordered) {
149
+ if (!cand || typeof cand.text !== 'string' || !cand.text.trim()) continue;
150
+
151
+ const dupe = merged.find(
152
+ (m) => m.category === cand.category && m.stance === cand.stance && textSimilarity(m.text, cand.text) >= DEDUP_SIMILARITY_THRESHOLD,
153
+ );
154
+ if (dupe) {
155
+ dupe.evidence = dedupeEvidence([...dupe.evidence, ...(cand.evidence ?? [])]);
156
+ dupe.confidence = raiseConfidence(dupe.confidence, cand.confidence);
157
+ if (cand.vaultSourced) dupe.vaultSourced = true; // the vault-sourced flag propagates forward once true
158
+ continue;
159
+ }
160
+
161
+ const opposite = merged.find(
162
+ (m) => m.category === cand.category && m.stance !== cand.stance && textSimilarity(m.text, cand.text) >= DEDUP_SIMILARITY_THRESHOLD,
163
+ );
164
+ if (opposite) {
165
+ // The owner's written word wins over inferred behaviour: when either side of a
166
+ // contradiction is vault-sourced, IT is the headline regardless of which was
167
+ // seen first; otherwise the later-seen candidate is the headline as before.
168
+ const headline = opposite.vaultSourced && !cand.vaultSourced ? opposite.text : cand.text;
169
+ conflicts.push({ category: cand.category, a: opposite.text, b: cand.text, headline });
170
+ }
171
+
172
+ merged.push({
173
+ text: cand.text,
174
+ category: cand.category ?? DEFAULT_CATEGORY,
175
+ confidence: typeof cand.confidence === 'number' ? cand.confidence : 0.5,
176
+ evidence: dedupeEvidence(cand.evidence ?? []),
177
+ stance: cand.stance === 'avoid' ? 'avoid' : 'do',
178
+ ...(Array.isArray(cand.supersedes) && cand.supersedes.length ? { supersedes: cand.supersedes.slice() } : {}),
179
+ ...(cand.vaultSourced ? { vaultSourced: true } : {}),
180
+ });
181
+ }
182
+
183
+ return { principles: merged, conflicts };
184
+ }