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,551 @@
1
+ // spexharness/src/profile/learn.mjs
2
+ //
3
+ // P1 (epic [[HS-HEBV-8CMW-EPIC-spexcode-profile-v0-learn-the-developer]], task
4
+ // [[HS-3N98-8CMW-TASK-p1-spexcode-profile-learner-core]]) + P2 (task
5
+ // [[HS-6CE7-8CMW-TASK-p2-profile-learner-incremental-learned]]) — the SpexCode
6
+ // Profile learner core. Distils this machine's already-extracted Doctrine
7
+ // observation stream into a human-readable, cited profile file
8
+ // (`.heyspex/spexcode-profile.md`, machine-local, NEVER written to the vault
9
+ // under `specs/`).
10
+ //
11
+ // v0 is heuristic only — no model call (epic's own scope boundary; the trained
12
+ // classifier lives in [[HS-PYW7-PSHL-MIL-per-vault-ml-training-infrastructure]]
13
+ // and is explicitly out of scope here).
14
+ //
15
+ // Reuse, never duplicate:
16
+ // - spexharness/src/doctrine/corpus.mjs's `readObservations` is the ONLY way
17
+ // this module reads the observation stream — it never re-parses a raw
18
+ // session file, never re-implements JSONL streaming, and never re-derives
19
+ // a citation. DR1 already redacted every `text`/`context` before it ever
20
+ // reached `.heyspex/doctrine/observations.jsonl`.
21
+ // - spexharness/src/context-budget.mjs's `recordContextInjection` is the
22
+ // one context-injection measurement path in the harness; this module only
23
+ // WIRES the profile block's size through it (see
24
+ // `registerProfileContextUsage`) — the actual injection into a seat's
25
+ // context is a later task (epic scope item 4).
26
+ //
27
+ // Kinds this module classifies into profile sections: `instruction`,
28
+ // `correction`, `ruling`, `approval`, `refusal`, `taste` — the six kinds DR1's
29
+ // corpus.mjs produces. `behaviour` (DR-behaviour) and `vault-rule` (DR5,
30
+ // concurrent with this task) are deliberately SKIPPED: those are
31
+ // session-shape/vault-sourced signals, not the per-developer habit
32
+ // observations this v0 profile distils. A vault decision/lesson's automatic
33
+ // inclusion in the profile is a separate task (epic scope item 8, P7) — this
34
+ // module does not read the vault.
35
+ //
36
+ // Incremental (P2): `.heyspex/profile-state.json` (`loadProfileState` /
37
+ // `saveProfileState`) carries a cursor (`lastObservationSeq` — a count of
38
+ // observations already folded into the profile, not a per-session `seq`
39
+ // field; the observation log is append-only so a simple position cursor is a
40
+ // correct, and much simpler, "or equivalent" to a true monotonic sequence)
41
+ // plus the already-classified section entries themselves, so a re-run only
42
+ // classifies NEW observations and the previously-written section entries are
43
+ // carried through unchanged — the rendered `.md` file's pre-existing
44
+ // observation lines come out byte-identical, because they are re-rendered
45
+ // from the exact same stored entries in the exact same order, never
46
+ // re-derived from a fresh markdown parse.
47
+ //
48
+ // Cold-run cap (P2): `maxObservations` bounds how many NEW observations a
49
+ // single `buildProfile` call folds in; anything beyond the cap is left for
50
+ // the next call (the cursor only advances past what was actually processed),
51
+ // and the return value reports how many were deferred.
52
+
53
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
54
+ import { dirname, join } from 'node:path';
55
+
56
+ import { readObservations } from '../doctrine/corpus.mjs';
57
+ import { recordContextInjection } from '../context-budget.mjs';
58
+
59
+ /** Observation kinds this v0 developer-profile deliberately does not learn from. */
60
+ const SKIPPED_KINDS = new Set(['behaviour', 'vault-rule']);
61
+
62
+ /** Cold-run cap: default max NEW observations a single `buildProfile` call folds in. */
63
+ export const DEFAULT_MAX_OBSERVATIONS = 500;
64
+
65
+ // ── size caps (HS — profile context blowout) ────────────────────────────────────────
66
+ //
67
+ // Three caps, because the profile blew the context window three different ways at once and
68
+ // each one is independently sufficient to do it again:
69
+ //
70
+ // 1. MAX_ENTRY_CHARS — ONE observation's text. DR1's corpus happily extracts a whole
71
+ // pasted document as a single `instruction`/`taste` observation; the two largest
72
+ // entries on this machine were 244KB and 242KB EACH. An entry is a one-line habit
73
+ // citation, never a document, so it is clamped (and whitespace-collapsed — see
74
+ // `clampEntryText`, embedded newlines also broke the rendered markdown structure).
75
+ // 2. MAX_ENTRIES_PER_SECTION — total retained entries. `maxObservations` only bounds how
76
+ // many NEW observations ONE run folds in; sections themselves were append-forever, so
77
+ // every run permanently added up to 500 more lines. Most recent entries win.
78
+ // 3. PROFILE_MAX_TOKENS — the injection budget, enforced at render (session-bootstrap.mjs's
79
+ // `renderProfileBlock`), mirroring DOCTRINE_MAX_TOKENS. This is the load-bearing one:
80
+ // it bounds what reaches a seat's context REGARDLESS of what is on disk, so a profile
81
+ // file that grew huge under an older build can never blow a context window again.
82
+
83
+ /** Max characters ONE observation's text keeps as a profile entry. */
84
+ export const MAX_ENTRY_CHARS = 400;
85
+
86
+ /** Max entries retained per section; the most recent are kept. */
87
+ export const MAX_ENTRIES_PER_SECTION = 200;
88
+
89
+ /**
90
+ * Injection budget for the rendered profile block, in tokens — the profile's counterpart to
91
+ * ../doctrine/inject.mjs's `DOCTRINE_MAX_TOKENS` (800). Enforced by session-bootstrap.mjs's
92
+ * `renderProfileBlock`, which imports it from here the same way that module imports
93
+ * `DOCTRINE_MAX_TOKENS` from doctrine/inject.mjs.
94
+ */
95
+ export const PROFILE_MAX_TOKENS = 1200;
96
+
97
+ /**
98
+ * Clamp ONE observation's text to a single, bounded profile line.
99
+ *
100
+ * Collapses whitespace FIRST: `citationLine` renders `- <text> _(citation)_` as one markdown
101
+ * list item, so a text carrying its own newlines (a pasted document, a stack trace) did not
102
+ * merely take up room — its embedded `## ...`/``` lines were re-parsed as real markdown
103
+ * structure, which is why the rendered profile grew headings like "Error Message" and
104
+ * "Code Frame" that no section in SECTION_ORDER ever defines.
105
+ *
106
+ * @param {string} text
107
+ * @param {{ maxChars?: number }} [opts]
108
+ * @returns {string}
109
+ */
110
+ export function clampEntryText(text, { maxChars = MAX_ENTRY_CHARS } = {}) {
111
+ const collapsed = String(text ?? '').replace(/\s+/g, ' ').trim();
112
+ if (collapsed.length <= maxChars) return collapsed;
113
+ return `${collapsed.slice(0, Math.max(1, maxChars - 1)).trimEnd()}…`;
114
+ }
115
+
116
+ /** Keep a section within {@link MAX_ENTRIES_PER_SECTION}, preferring the most recent entries. */
117
+ function trimSection(entries) {
118
+ return entries.length > MAX_ENTRIES_PER_SECTION ? entries.slice(-MAX_ENTRIES_PER_SECTION) : entries;
119
+ }
120
+
121
+ /**
122
+ * Fixed section order — every rendered profile uses this order; empty sections are omitted
123
+ * (both `renderProfileMarkdown` and `renderProfileDigest` skip a section with no entries, so
124
+ * `unclassified` is invisible on a profile that has none).
125
+ *
126
+ * `unclassified` exists because "Preferred languages and idioms" used to be the FALLBACK for
127
+ * everything that was not an approval/refusal/correction and carried no test/commit keyword.
128
+ * A plain bug report was therefore served to a seat under the heading "Preferred languages and
129
+ * idioms", i.e. as a language preference the owner never expressed. Nothing is dropped — an
130
+ * observation that fits no section is filed here, honestly labelled, instead of being
131
+ * mislabelled.
132
+ */
133
+ export const SECTION_ORDER = Object.freeze([
134
+ { key: 'languages', heading: 'Preferred languages and idioms' },
135
+ { key: 'tests', heading: 'Test style' },
136
+ { key: 'commits', heading: 'Commit and message conventions' },
137
+ { key: 'tools', heading: 'Tools approved or refused' },
138
+ { key: 'corrections', heading: 'Recurring corrections' },
139
+ { key: 'unclassified', heading: 'Unclassified observations' },
140
+ ]);
141
+
142
+ const SECTION_KEYS = SECTION_ORDER.map((s) => s.key);
143
+
144
+ // ── keyword matching ──────────────────────────────────────────────────────────────
145
+ //
146
+ // WORD BOUNDARIES, not `String.includes`. A bare substring test made `test` match `latest`
147
+ // and `fastest`, and `spec` match `specific` and `respect` — so "ship the latest build"
148
+ // was filed under "Test style". Each list is compiled once into one alternation with
149
+ // boundaries on both sides.
150
+
151
+ const TEST_KEYWORDS = ['test', 'tests', 'testing', 'spec', 'specs', 'jest', 'vitest', 'mocha', 'assert', 'assertion', 'assertions', 'coverage', 'tdd'];
152
+ const COMMIT_KEYWORDS = ['commit', 'commits', 'committed', 'pull request', 'pull requests', 'pr', 'prs', 'changelog', 'conventional commit'];
153
+ /**
154
+ * Positive evidence that an observation really is about a language/idiom preference. Before
155
+ * this list `languages` was a fallback, so it collected everything; now it has to be EARNED,
156
+ * and what does not earn it lands in `unclassified`.
157
+ */
158
+ const LANGUAGE_KEYWORDS = [
159
+ 'typescript', 'javascript', 'esm', 'cjs', 'mjs', 'commonjs', 'python', 'rust', 'golang', 'bash', 'powershell', 'sql', 'css', 'html', 'jsx', 'tsx', 'react', 'node', 'npm',
160
+ 'idiom', 'idioms', 'syntax', 'lint', 'linting', 'eslint', 'prettier', 'formatting', 'indentation', 'naming convention', 'naming conventions',
161
+ 'arrow function', 'arrow functions', 'async', 'await', 'strict mode', 'typed', 'typing', 'interface', 'interfaces', 'generics', 'import', 'imports', 'module', 'modules',
162
+ ];
163
+
164
+ function keywordMatcher(keywords) {
165
+ const alternation = keywords
166
+ .slice()
167
+ .sort((a, b) => b.length - a.length)
168
+ .map((k) => k.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
169
+ .join('|');
170
+ return new RegExp(`\\b(?:${alternation})\\b`, 'i');
171
+ }
172
+
173
+ const TEST_RE = keywordMatcher(TEST_KEYWORDS);
174
+ const COMMIT_RE = keywordMatcher(COMMIT_KEYWORDS);
175
+ const LANGUAGE_RE = keywordMatcher(LANGUAGE_KEYWORDS);
176
+
177
+ // ── paths ─────────────────────────────────────────────────────────────────────────
178
+
179
+ /** Mirrors DR1's `defaultObservationsPath` pattern — the profile file's canonical location. */
180
+ export function defaultProfilePath(repoRoot) {
181
+ return join(repoRoot, '.heyspex', 'spexcode-profile.md');
182
+ }
183
+
184
+ /** The incremental-mark file's canonical location — separate from DR1's own `.heyspex/doctrine/state.json`. */
185
+ export function defaultProfileStatePath(repoRoot) {
186
+ return join(repoRoot, '.heyspex', 'profile-state.json');
187
+ }
188
+
189
+ // ── section classification (heuristic, no model call) ───────────────────────────────
190
+
191
+ /**
192
+ * Classify one observation into a profile section key. `approval`/`refusal` observations
193
+ * feed "tools approved or refused"; `correction` observations feed "recurring corrections"
194
+ * (each correction DR1 extracted already represents a correction event); everything else is
195
+ * classified by WORD-BOUNDARY keyword matching over `text`.
196
+ *
197
+ * There is no fallback section. An observation matching no keyword list is `unclassified` —
198
+ * see {@link SECTION_ORDER}. It is still kept and still cited; it is simply not asserted to
199
+ * be a language preference, a test-style rule or a commit convention when nothing in it says
200
+ * so.
201
+ *
202
+ * @param {{ kind?: string, text?: string }} obs
203
+ * @returns {'languages'|'tests'|'commits'|'tools'|'corrections'|'unclassified'}
204
+ */
205
+ export function classifySection(obs) {
206
+ if (obs.kind === 'approval' || obs.kind === 'refusal') return 'tools';
207
+ if (obs.kind === 'correction') return 'corrections';
208
+ const text = String(obs.text ?? '');
209
+ if (TEST_RE.test(text)) return 'tests';
210
+ if (COMMIT_RE.test(text)) return 'commits';
211
+ if (LANGUAGE_RE.test(text)) return 'languages';
212
+ return 'unclassified';
213
+ }
214
+
215
+ // ── dedupe (the Profile path's own defect) ────────────────────────────────────────
216
+ //
217
+ // doctrine/merge.mjs dedupes principles; the Profile path never did, so identical text was
218
+ // appended once per occurrence — `permission mode: bypassPermissions` alone accounted for
219
+ // 3,996 of the 5,328 observations measured on this machine, and every one of them became its
220
+ // own profile line, evicting genuine habits under MAX_ENTRIES_PER_SECTION.
221
+ //
222
+ // Applied in `cloneSections`, so it protects the STORED `profile-state.json` as well as the
223
+ // rendered digest — a state file written by an older build is collapsed the first time it is
224
+ // read, exactly like the size caps above.
225
+
226
+ /**
227
+ * Normalized dedupe key: casing-, punctuation- and whitespace-insensitive. Shared with
228
+ * proposals.mjs's near-duplicate detection, which imports it from here — one fingerprint
229
+ * definition, so "already in the profile" and "already proposed" can never disagree.
230
+ * @param {unknown} text
231
+ * @returns {string}
232
+ */
233
+ export function normalizeFingerprint(text) {
234
+ return String(text ?? '')
235
+ .toLowerCase()
236
+ .replace(/[^\p{L}\p{N}\s]/gu, ' ')
237
+ .replace(/\s+/g, ' ')
238
+ .trim();
239
+ }
240
+
241
+ /**
242
+ * Collapse entries sharing a {@link normalizeFingerprint}, keeping the MOST RECENT occurrence
243
+ * (and therefore its citation) at the most-recent position — so a survivor is never evicted
244
+ * by {@link trimSection} on account of where its first duplicate happened to sit.
245
+ * @param {Array<{ text?: string }>} entries
246
+ * @returns {Array<object>}
247
+ */
248
+ export function dedupeSection(entries) {
249
+ const byFingerprint = new Map();
250
+ for (const entry of entries) {
251
+ const fingerprint = normalizeFingerprint(entry?.text);
252
+ if (byFingerprint.has(fingerprint)) byFingerprint.delete(fingerprint);
253
+ byFingerprint.set(fingerprint, entry);
254
+ }
255
+ return [...byFingerprint.values()];
256
+ }
257
+
258
+ // ── section-entry state (the structured, append-friendly store) ─────────────────────
259
+
260
+ function emptySections() {
261
+ const out = {};
262
+ for (const key of SECTION_KEYS) out[key] = [];
263
+ return out;
264
+ }
265
+
266
+ function cloneSections(sections) {
267
+ const out = emptySections();
268
+ if (!sections || typeof sections !== 'object') return out;
269
+ for (const key of SECTION_KEYS) {
270
+ // Entries are clamped and trimmed HERE as well as at fold time, so a state file written
271
+ // by an older, uncapped build is normalized the first time it is read instead of
272
+ // re-rendering its oversized entries forever. This preserves the incremental
273
+ // "re-renders byte-identical" contract in the only way that stays true going forward:
274
+ // every stored entry is already within the caps, so re-rendering it is a no-op.
275
+ const entries = Array.isArray(sections[key]) ? sections[key] : [];
276
+ out[key] = trimSection(dedupeSection(entries.map((e) => ({ ...e, text: clampEntryText(e?.text) }))));
277
+ }
278
+ return out;
279
+ }
280
+
281
+ function emptyProfileState() {
282
+ return { lastObservationSeq: 0, lastLearnedAt: null, sections: emptySections() };
283
+ }
284
+
285
+ // ── incremental state persistence ────────────────────────────────────────────────
286
+
287
+ /** Read the profile incremental-state file; missing/corrupt → a fresh empty state. */
288
+ export function loadProfileState(path) {
289
+ if (!existsSync(path)) return emptyProfileState();
290
+ try {
291
+ const parsed = JSON.parse(readFileSync(path, 'utf8'));
292
+ if (!parsed || typeof parsed !== 'object') return emptyProfileState();
293
+ return {
294
+ lastObservationSeq: Number.isFinite(parsed.lastObservationSeq) ? parsed.lastObservationSeq : 0,
295
+ lastLearnedAt: typeof parsed.lastLearnedAt === 'string' ? parsed.lastLearnedAt : null,
296
+ sections: cloneSections(parsed.sections),
297
+ };
298
+ } catch {
299
+ return emptyProfileState();
300
+ }
301
+ }
302
+
303
+ /** Persist the profile incremental-state file. */
304
+ export function saveProfileState(path, state) {
305
+ mkdirSync(dirname(path), { recursive: true });
306
+ writeFileSync(path, `${JSON.stringify(state, null, 2)}\n`);
307
+ }
308
+
309
+ // ── rendering ─────────────────────────────────────────────────────────────────────
310
+
311
+ /**
312
+ * One profile line for a section entry — carries its session citation
313
+ * (`source`, `project`, `session`, `ts`) exactly as DR1's corpus.mjs recorded it on the
314
+ * observation. Never re-derived or guessed: every field is read straight off the
315
+ * `DoctrineObservation` this entry was built from.
316
+ *
317
+ * @param {{ text: string, source: string, project: string, session: string, ts: string|null }} entry
318
+ * @returns {string}
319
+ */
320
+ export function citationLine(entry) {
321
+ return `- ${entry.text} _(source: ${entry.source}, project: ${entry.project}, session: ${entry.session}, ts: ${entry.ts ?? 'null'})_`;
322
+ }
323
+
324
+ /**
325
+ * Render the full profile markdown from a `sections` store (the same shape `buildProfile`
326
+ * carries on its returned `state.sections`). One heading per non-empty section, in
327
+ * {@link SECTION_ORDER}; a section with no entries is omitted entirely rather than printed
328
+ * with a "nothing yet" placeholder. Pure — no I/O.
329
+ *
330
+ * @param {Record<string, Array<{ text: string, source: string, project: string, session: string, ts: string|null }>>} sections
331
+ * @returns {string}
332
+ */
333
+ export function renderProfileMarkdown(sections) {
334
+ const lines = [
335
+ '# SpexCode Profile',
336
+ '',
337
+ 'Machine-local, heuristic v0 — never written to the vault. Every line cites the session it came from.',
338
+ '',
339
+ ];
340
+ for (const { key, heading } of SECTION_ORDER) {
341
+ const entries = sections?.[key] ?? [];
342
+ if (entries.length === 0) continue;
343
+ lines.push(`## ${heading}`, '');
344
+ for (const entry of entries) lines.push(citationLine(entry));
345
+ lines.push('');
346
+ }
347
+ return `${lines.join('\n').replace(/\n+$/, '')}\n`;
348
+ }
349
+
350
+ /**
351
+ * Render the INJECTED profile digest — what actually rides a seat's context — from the same
352
+ * structured `sections` store {@link renderProfileMarkdown} renders the full file from.
353
+ *
354
+ * Distinct from `renderProfileMarkdown` on purpose: that one renders the complete, browsable
355
+ * record to `.heyspex/spexcode-profile.md`, and is allowed to be long. This one is the
356
+ * bounded excerpt, and it allocates the budget FAIRLY ACROSS SECTIONS rather than filling it
357
+ * from the top. A plain top-down cut of the rendered file spends the entire budget inside
358
+ * "Preferred languages and idioms" (the first and largest section) and silently starves
359
+ * "Recurring corrections" and "Tools approved or refused" — the two sections whose whole
360
+ * point is to change how a seat behaves.
361
+ *
362
+ * Selection within a section is most-recent-first, since a developer's latest correction
363
+ * describes them better than their oldest one. Round-robin across sections then takes one
364
+ * entry from each in turn, so a section is never crowded out by a wordier neighbour.
365
+ *
366
+ * @param {Record<string, Array<{ text: string, source: string, project: string, session: string, ts: string|null }>>} sections
367
+ * @param {{ maxTokens?: number, estimate?: (text: string) => number }} [opts]
368
+ * @returns {{ text: string, includedCount: number, totalCount: number, truncated: boolean }}
369
+ */
370
+ export function renderProfileDigest(sections, { maxTokens = PROFILE_MAX_TOKENS, estimate } = {}) {
371
+ const sizeOf = typeof estimate === 'function' ? estimate : (t) => Math.ceil(String(t).length / 4);
372
+ const budget = Number.isFinite(maxTokens) && maxTokens > 0 ? maxTokens : Infinity;
373
+
374
+ const header = '# SpexCode Profile';
375
+ const subtitle = 'Machine-local, heuristic v0 — never written to the vault. Every line cites the session it came from.';
376
+
377
+ // Most-recent-first queues, one per section, plus the running total for the footer.
378
+ const queues = new Map();
379
+ let totalCount = 0;
380
+ for (const { key } of SECTION_ORDER) {
381
+ const entries = Array.isArray(sections?.[key]) ? [...sections[key]].reverse() : [];
382
+ totalCount += entries.length;
383
+ if (entries.length > 0) queues.set(key, entries);
384
+ }
385
+
386
+ // Reserve the footer's own cost up front, so the digest always comes in AT OR UNDER
387
+ // `budget` INCLUDING its footer. Without the reservation the footer is the one line the
388
+ // outer clamp in session-bootstrap.mjs's `renderProfileBlock` trims off — replacing the
389
+ // informative "N of M entries shown" note with its own blunter one.
390
+ const footerReserve = totalCount > 0 ? sizeOf(`_(${totalCount} of ${totalCount} profile entries shown — bounded to a ${budget}-token injection budget.)_`) : 0;
391
+ let used = sizeOf(header) + sizeOf(subtitle) + footerReserve;
392
+ const picked = new Map(); // key -> lines, in the order they were selected
393
+ let includedCount = 0;
394
+ let truncated = false;
395
+
396
+ // Round-robin: one entry per section per pass, until the budget is spent or nothing is left.
397
+ while (queues.size > 0) {
398
+ let progressed = false;
399
+ for (const { key, heading } of SECTION_ORDER) {
400
+ const queue = queues.get(key);
401
+ if (!queue || queue.length === 0) continue;
402
+ const line = citationLine(queue[0]);
403
+ const needsHeading = !picked.has(key);
404
+ const cost = sizeOf(line) + (needsHeading ? sizeOf(`## ${heading}`) : 0);
405
+ if (used + cost > budget) {
406
+ truncated = true;
407
+ queues.delete(key);
408
+ continue;
409
+ }
410
+ queue.shift();
411
+ used += cost;
412
+ if (needsHeading) picked.set(key, []);
413
+ picked.get(key).push(line);
414
+ includedCount++;
415
+ progressed = true;
416
+ if (queue.length === 0) queues.delete(key);
417
+ }
418
+ if (!progressed) break;
419
+ }
420
+
421
+ const lines = [header, '', subtitle, ''];
422
+ for (const { key, heading } of SECTION_ORDER) {
423
+ const got = picked.get(key);
424
+ if (!got || got.length === 0) continue;
425
+ lines.push(`## ${heading}`, '', ...got, '');
426
+ }
427
+ if (truncated) {
428
+ lines.push(`_(${includedCount} of ${totalCount} profile entries shown — bounded to a ${budget}-token injection budget.)_`);
429
+ }
430
+
431
+ return { text: lines.join('\n').replace(/\n+$/, ''), includedCount, totalCount, truncated };
432
+ }
433
+
434
+ // ── context-budget registration (P2 scope item 3 — measurement only, no injection here) ──
435
+
436
+ /**
437
+ * Register the profile block's size against the harness's single context-injection
438
+ * accounting path ({@link recordContextInjection}), the same pattern DR3 uses for the
439
+ * Doctrine block. This function only measures and records — actually injecting the
440
+ * profile into a seat's context is a later task (epic scope item 4, "Conditioning").
441
+ *
442
+ * @param {ReturnType<import('../session-record.mjs').createSessionRecord>} record
443
+ * @param {string} markdown
444
+ * @param {{ source?: string }} [opts]
445
+ * @returns {import('../context-budget.mjs').ContextInjectionMeasurement}
446
+ */
447
+ export function registerProfileContextUsage(record, markdown, { source = 'spexcode-profile' } = {}) {
448
+ return recordContextInjection(record, { source, text: markdown });
449
+ }
450
+
451
+ // ── top-level entrypoint ────────────────────────────────────────────────────────
452
+
453
+ /**
454
+ * Build (or incrementally extend) the developer's SpexCode Profile from this machine's
455
+ * Doctrine observation stream, and write it to `outPath`.
456
+ *
457
+ * State resolution order: an explicit `state` object wins; else, when `statePath` is
458
+ * given, it is loaded from disk; else a fresh empty state (a first/cold run). When
459
+ * `statePath` is given, the resulting state is also persisted back to it — a caller that
460
+ * wants a purely in-memory two-call test (no disk state file) passes `state` explicitly
461
+ * instead and threads the returned `state` into the next call itself.
462
+ *
463
+ * @param {{
464
+ * observationsPath: string,
465
+ * outPath: string,
466
+ * statePath?: string|null,
467
+ * state?: { lastObservationSeq: number, lastLearnedAt: string|null, sections: object }|null,
468
+ * maxObservations?: number,
469
+ * now?: () => string,
470
+ * }} opts
471
+ * @returns {{
472
+ * profilePath: string,
473
+ * markdown: string,
474
+ * state: object,
475
+ * observationsProcessed: number,
476
+ * observationsDeferred: number,
477
+ * }}
478
+ */
479
+ export function buildProfile({
480
+ observationsPath,
481
+ outPath,
482
+ statePath = null,
483
+ state = null,
484
+ maxObservations = DEFAULT_MAX_OBSERVATIONS,
485
+ now = () => new Date().toISOString(),
486
+ } = {}) {
487
+ if (!observationsPath) throw new Error('buildProfile: observationsPath is required');
488
+ if (!outPath) throw new Error('buildProfile: outPath is required');
489
+
490
+ const priorState = state ?? (statePath ? loadProfileState(statePath) : null) ?? emptyProfileState();
491
+ const sections = cloneSections(priorState.sections);
492
+ const startIndex = Number.isFinite(priorState.lastObservationSeq) ? priorState.lastObservationSeq : 0;
493
+ const cap = Number.isFinite(maxObservations) && maxObservations > 0 ? maxObservations : Infinity;
494
+
495
+ const allObservations = readObservations(observationsPath);
496
+
497
+ let processed = 0;
498
+ let index = startIndex;
499
+ for (; index < allObservations.length; index++) {
500
+ if (processed >= cap) break;
501
+ const obs = allObservations[index];
502
+ if (!obs || SKIPPED_KINDS.has(obs.kind)) continue;
503
+ const key = classifySection(obs);
504
+ sections[key].push({
505
+ text: clampEntryText(obs.text),
506
+ source: obs.source,
507
+ project: obs.project,
508
+ session: obs.session,
509
+ ts: obs.ts ?? null,
510
+ });
511
+ sections[key] = trimSection(dedupeSection(sections[key]));
512
+ processed++;
513
+ }
514
+
515
+ const observationsDeferred = Math.max(0, allObservations.length - index);
516
+ const newState = { lastObservationSeq: index, lastLearnedAt: now(), sections };
517
+ const markdown = renderProfileMarkdown(sections);
518
+
519
+ mkdirSync(dirname(outPath), { recursive: true });
520
+ writeFileSync(outPath, markdown);
521
+ if (statePath) saveProfileState(statePath, newState);
522
+
523
+ return {
524
+ profilePath: outPath,
525
+ markdown,
526
+ state: newState,
527
+ observationsProcessed: processed,
528
+ observationsDeferred,
529
+ };
530
+ }
531
+
532
+ export default {
533
+ DEFAULT_MAX_OBSERVATIONS,
534
+ MAX_ENTRY_CHARS,
535
+ MAX_ENTRIES_PER_SECTION,
536
+ PROFILE_MAX_TOKENS,
537
+ clampEntryText,
538
+ SECTION_ORDER,
539
+ classifySection,
540
+ normalizeFingerprint,
541
+ dedupeSection,
542
+ loadProfileState,
543
+ saveProfileState,
544
+ citationLine,
545
+ renderProfileMarkdown,
546
+ renderProfileDigest,
547
+ registerProfileContextUsage,
548
+ buildProfile,
549
+ defaultProfilePath,
550
+ defaultProfileStatePath,
551
+ };