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,733 @@
1
+ // spexharness/src/doctrine/corpus.mjs
2
+ //
3
+ // DR1 (epic [[HS-KNHZ-8CMW-EPIC-spexcode-doctrine-v0-the-foundational]], task
4
+ // [[HS-TWTC-8CMW-TASK-dr1-doctrine-corpus-reader-stream-every]]) — the streaming,
5
+ // memory-bounded extractor over every source sources.mjs enumerates. Never
6
+ // `readFileSync`s a whole session file (some run past 100 MB): every JSONL/markdown
7
+ // file is read line-by-line via `node:readline` over a `createReadStream`.
8
+ //
9
+ // One record per signal, with provenance: `{ id, source, project, session, ts, seq,
10
+ // kind, text, context?, confidence? }`. `kind` is one of:
11
+ // - `instruction` — a user turn ≥ 12 words that directs work (imperative/preference
12
+ // language; approximated here as "12+ words, not already a ruling/correction").
13
+ // - `correction` — a user turn that immediately follows an assistant turn and
14
+ // contains a reversal/reprimand marker ("no,", "not that", "wrong", "don't",
15
+ // "stop", "instead", "i said", "why did you"); carries a 240-char `context` of
16
+ // the assistant text it corrects.
17
+ // - `ruling` — contains an explicit-decision marker ("always", "never", "from
18
+ // now on", "owner ruling", "yes -", "no -", "rule:", "decision:"), any length.
19
+ // - `approval`/`refusal` — a permission-mode record (Claude Code) or a tool-confirm
20
+ // answer.
21
+ // - `taste` — one `- <text> Confidence: <n>` line from a Command Code
22
+ // taste.md, `confidence` carried.
23
+ //
24
+ // Every `text`/`context` is redacted via spexharness/src/redact.mjs BEFORE it is
25
+ // written anywhere — this module never even holds an unredacted string past the
26
+ // point of extraction. Assistant text is kept only transiently (as `context` for a
27
+ // following correction), never emitted as its own observation. Pasted
28
+ // screenshots/images and raw tool payloads are skipped (never even reach the
29
+ // classifier — a `tool_result`/`tool_reference`-only content array is dropped whole).
30
+ //
31
+ // OWNER-AUTHORSHIP (2026-09-20): a `tool_result`-block check is not enough — machine text
32
+ // also arrives as an ordinary `type:"text"` block in a `type:"user"` record. Two screens now
33
+ // run before classification: `isOwnerAuthoredClaudeCodeRecord` (record shape) and
34
+ // `machineTextMarker` (text shape). See the "owner-authored vs machine-authored" section.
35
+ //
36
+ // Incremental: a per-session state entry `{ mtime, lines, lastAssistantText,
37
+ // lastRecordType }` lets a re-run (a) skip a session outright when its file's mtime
38
+ // is unchanged, and (b) resume a grown session from the line it left off at, so only
39
+ // the NEW lines are parsed and only the NEW observations are appended/returned.
40
+
41
+ import { closeSync, createReadStream, existsSync, mkdirSync, openSync, readFileSync, writeSync } from 'node:fs';
42
+ import { createInterface } from 'node:readline';
43
+ import { dirname, join } from 'node:path';
44
+
45
+ import { redactText } from '../redact.mjs';
46
+ import { enumerateSources as defaultEnumerateSources, sourceKey } from './sources.mjs';
47
+
48
+ export { sourceKey };
49
+
50
+ /**
51
+ * @typedef {{
52
+ * id: string,
53
+ * source: string,
54
+ * project: string,
55
+ * session: string,
56
+ * ts: string|null,
57
+ * seq: number,
58
+ * kind: 'instruction'|'correction'|'ruling'|'approval'|'refusal'|'taste',
59
+ * text: string,
60
+ * context?: string,
61
+ * confidence?: number,
62
+ * }} DoctrineObservation
63
+ */
64
+
65
+ // ── kind classification ────────────────────────────────────────────────────────────
66
+
67
+ const RULING_MARKERS = [
68
+ /\balways\b/i,
69
+ /\bnever\b/i,
70
+ /\bfrom now on\b/i,
71
+ /\bowner ruling\b/i,
72
+ /\byes\s*-/i,
73
+ /\bno\s*-/i,
74
+ /\brule:/i,
75
+ /\bdecision:/i,
76
+ ];
77
+
78
+ const CORRECTION_MARKERS = [
79
+ /\bno,/i,
80
+ /\bnot that\b/i,
81
+ /\bwrong\b/i,
82
+ /\bdon'?t\b/i,
83
+ /\bstop\b/i,
84
+ /\binstead\b/i,
85
+ /\bi said\b/i,
86
+ /\bwhy did you\b/i,
87
+ ];
88
+
89
+ const MIN_INSTRUCTION_WORDS = 12;
90
+
91
+ function wordCount(text) {
92
+ const trimmed = text.trim();
93
+ if (!trimmed) return 0;
94
+ return trimmed.split(/\s+/).filter(Boolean).length;
95
+ }
96
+
97
+ /**
98
+ * Classify one user-authored turn's text. Precedence: a CORRECTION only fires when
99
+ * the turn immediately follows an assistant turn (`precededByAssistant`) AND carries
100
+ * a reversal/reprimand marker; else a RULING marker (any length) wins; else 12+ words
101
+ * makes it an INSTRUCTION; otherwise the turn carries no signal and is dropped.
102
+ *
103
+ * @param {string} text
104
+ * @param {{ precededByAssistant?: boolean }} [opts]
105
+ * @returns {'correction'|'ruling'|'instruction'|null}
106
+ */
107
+ export function classifyUserText(text, { precededByAssistant = false } = {}) {
108
+ if (typeof text !== 'string' || !text.trim()) return null;
109
+ if (precededByAssistant && CORRECTION_MARKERS.some((re) => re.test(text))) return 'correction';
110
+ if (RULING_MARKERS.some((re) => re.test(text))) return 'ruling';
111
+ if (wordCount(text) >= MIN_INSTRUCTION_WORDS) return 'instruction';
112
+ return null;
113
+ }
114
+
115
+ // ── content-shape helpers (shared by Claude Code + Command Code, same block shape) ──
116
+
117
+ function extractTextFromContent(content) {
118
+ if (typeof content === 'string') return content;
119
+ if (!Array.isArray(content)) return '';
120
+ return content
121
+ .filter((b) => b && b.type === 'text' && typeof b.text === 'string')
122
+ .map((b) => b.text)
123
+ .join('\n');
124
+ }
125
+
126
+ /** True when every block in an array content is a tool result/reference — never the owner's words. */
127
+ function isToolOnlyContent(content) {
128
+ if (!Array.isArray(content) || content.length === 0) return false;
129
+ return content.every((b) => b && (b.type === 'tool_result' || b.type === 'tool_reference'));
130
+ }
131
+
132
+ // ── owner-authored vs machine-authored text ────────────────────────────────────────
133
+ //
134
+ // `isToolOnlyContent` above only catches a literal `tool_result`/`tool_reference` BLOCK.
135
+ // The far larger contamination channel is machine text that arrives as an ordinary
136
+ // `type:"text"` block inside a `type:"user"` record, which sails straight through to the
137
+ // classifier. Measured on this machine 2026-09-20: 862 of 5,328 stored observations (16.2%)
138
+ // were not the owner's words at all — 483 copies of the sidecar title-generator's own system
139
+ // prompt, 376 `<task-notification>` blobs, plus hook-injected context.
140
+ //
141
+ // Two independent screens, because each catches what the other cannot:
142
+ // 1. RECORD SHAPE (`isOwnerAuthoredClaudeCodeRecord`) — Claude Code's own `promptSource`
143
+ // discriminator, the Claude Code counterpart of the `meta.source !== 'user'` check
144
+ // `handleCommandCodeRecord` has always had. Precise, but only present on newer records.
145
+ // 2. TEXT SHAPE (`machineTextMarker`) — a small, documented denylist of the control
146
+ // envelopes and injected preambles the harness and host actually emit. Catches older
147
+ // records that predate `promptSource`, and already-stored observations (see
148
+ // `filterObservations`, the corpus-hygiene path).
149
+ //
150
+ // DELIBERATELY CONSERVATIVE. A genuine owner line dropped here costs one habit citation; a
151
+ // machine line learned here is laundered into a confident-looking committed principle that
152
+ // ships to every clone. When in doubt, drop it.
153
+
154
+ /**
155
+ * `promptSource` values that mean "the owner put this text here". Verified on this machine
156
+ * 2026-09-20 over 5,803 Claude Code user records: `typed` (201), `queued` (11),
157
+ * `suggestion_accepted` (6) — against `system` (171: hook injections and task
158
+ * notifications) and, in the sidecar SDK sessions, `sdk`. `paste`/`slash_command` are
159
+ * listed defensively; they are owner-originated shapes the host may emit.
160
+ */
161
+ export const OWNER_PROMPT_SOURCES = Object.freeze([
162
+ 'typed',
163
+ 'queued',
164
+ 'paste',
165
+ 'pasted',
166
+ 'slash_command',
167
+ 'suggestion_accepted',
168
+ ]);
169
+
170
+ /**
171
+ * True when a Claude Code `type:"user"` record is a turn the OWNER authored, as opposed to
172
+ * one the host or a hook injected into the transcript in user position.
173
+ *
174
+ * A record with NO `promptSource` is allowed through (5,414 of 5,803 on this machine — the
175
+ * field is recent, and most of those are tool-result relays already dropped by
176
+ * `isToolOnlyContent`); those are caught by the text screen instead. A record WITH a
177
+ * `promptSource` must name an owner origin — an UNRECOGNISED value is rejected, which is the
178
+ * conservative direction: a future host adding a new owner-typed source costs us some genuine
179
+ * lines until it is listed here, whereas allowing unknowns re-opens exactly this bug.
180
+ *
181
+ * @param {object} rec
182
+ * @returns {boolean}
183
+ */
184
+ export function isOwnerAuthoredClaudeCodeRecord(rec) {
185
+ if (!rec || typeof rec !== 'object') return false;
186
+ const promptSource = rec.promptSource;
187
+ if (typeof promptSource === 'string' && promptSource && !OWNER_PROMPT_SOURCES.includes(promptSource)) {
188
+ return false;
189
+ }
190
+ // Transcript scaffolding the host writes in user position, never a typed turn.
191
+ if (rec.isMeta === true) return false;
192
+ if (rec.isCompactSummary === true) return false;
193
+ // A sidechain is a subagent's own transcript: its "user" turns are the PARENT AGENT's
194
+ // prompts, not the owner's.
195
+ if (rec.isSidechain === true) return false;
196
+ if (typeof rec.userType === 'string' && rec.userType !== 'external') return false;
197
+ return true;
198
+ }
199
+
200
+ /**
201
+ * Control-envelope tag names. Machine content delivered in user position is wrapped in one of
202
+ * these XML-ish envelopes by the host or by this harness; the owner does not type them.
203
+ * Matched as a tag (`<name`, `<name>`, `</name>`) anywhere in the text — an envelope appended
204
+ * AFTER an owner sentence still means the blob as a whole is not a clean habit citation.
205
+ */
206
+ export const CONTROL_ENVELOPE_TAGS = Object.freeze([
207
+ 'task-notification',
208
+ 'system-reminder',
209
+ 'local-command-stdout',
210
+ 'local-command-stderr',
211
+ 'command-name',
212
+ 'command-message',
213
+ 'command-args',
214
+ 'command-contents',
215
+ 'user-prompt-submit-hook',
216
+ 'session-start-hook',
217
+ 'post-tool-use-hook',
218
+ 'bash-stdout',
219
+ 'bash-stderr',
220
+ 'ide_selection',
221
+ 'ide_opened_file',
222
+ 'ide_diagnostics',
223
+ 'channel',
224
+ ]);
225
+
226
+ function envelopePattern(tag) {
227
+ return new RegExp(`<\\/?${tag.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?=[\\s/>])`, 'i');
228
+ }
229
+
230
+ /**
231
+ * The denylist, one row per marker, each with the shape it anchors on and why it is here.
232
+ * Data-driven on purpose: a single unreadable mega-regex is how the next marker gets added
233
+ * wrong. Anchored on shapes the harness/host EMIT (an envelope tag, a rendered block header,
234
+ * a fixed generator prompt) rather than on incidental wording, so a genuine owner sentence
235
+ * that happens to mention "SpexCode Profile" in prose is not swept up.
236
+ *
237
+ * @type {ReadonlyArray<{ id: string, pattern: RegExp, why: string }>}
238
+ */
239
+ export const MACHINE_TEXT_MARKERS = Object.freeze([
240
+ ...CONTROL_ENVELOPE_TAGS.map((tag) => ({
241
+ id: `envelope:${tag}`,
242
+ pattern: envelopePattern(tag),
243
+ why: `<${tag}> is a host/harness control envelope delivered in user position`,
244
+ })),
245
+ {
246
+ id: 'sticky-title-sidecar',
247
+ // The single largest contaminant measured: 483 copies of the session-title generator's own
248
+ // system prompt, harvested out of the sidecar SDK sessions under a Local\Temp cwd.
249
+ pattern: /You maintain a short, sticky title for an ongoing coding-assistant session/i,
250
+ why: "the session-title generator's system prompt, replayed into its own sidecar transcript",
251
+ },
252
+ {
253
+ id: 'hook-additional-context',
254
+ pattern: /UserPromptSubmit hook additional context:/i,
255
+ why: 'the literal preamble Claude Code prefixes to a UserPromptSubmit hook injection',
256
+ },
257
+ {
258
+ id: 'local-command-caveat',
259
+ pattern: /Caveat: The messages below were generated by the user while running local commands/i,
260
+ why: 'the host preamble wrapping local slash-command output, not a typed turn',
261
+ },
262
+ {
263
+ id: 'spexcode-profile-block',
264
+ // session-bootstrap.mjs's `renderProfileBlock` — the profile we WRITE, injected every user
265
+ // turn. Learning it back is a direct feedback loop. Line-anchored so prose that merely
266
+ // names the profile survives.
267
+ pattern: /^#[ \t]*SpexCode Profile\b/m,
268
+ why: "the injected SpexCode Profile block's own header — re-learning it feeds the profile back into itself",
269
+ },
270
+ {
271
+ id: 'spexcode-doctrine-block',
272
+ pattern: /^#[ \t]*SpexCode Doctrine\b/m,
273
+ why: 'the injected SpexCode Doctrine block header — same feedback loop as the profile block',
274
+ },
275
+ {
276
+ id: 'doctrine-injection',
277
+ // doctrine/inject.mjs's `renderDoctrineBlock` header: `Doctrine (learned <iso>, N principles):`
278
+ pattern: /^Doctrine \(learned .*\bprinciples?\):/m,
279
+ why: "the injected Doctrine block's rendered header line",
280
+ },
281
+ {
282
+ id: 'vault-context-injection',
283
+ pattern: /VAULT CONTEXT \(retrieved/i,
284
+ why: 'the inject-vault-context gate\'s retrieved-references block',
285
+ },
286
+ {
287
+ id: 'heyspex-gate-check',
288
+ pattern: /HEYSPEX GATE CHECK/i,
289
+ why: 'a HeySpex gate-check preamble injected ahead of the turn',
290
+ },
291
+ {
292
+ id: 'dev-factory-seat',
293
+ pattern: /^DEV-FACTORY SEAT:/m,
294
+ why: "dev-factory/lib/seat-context.mjs's seat mandate line, injected at session start",
295
+ },
296
+ ]);
297
+
298
+ /**
299
+ * The id of the first {@link MACHINE_TEXT_MARKERS} row that matches `text`, or `null`.
300
+ * Returning the id (not just a boolean) is what lets the hygiene path report WHY each row
301
+ * was rejected.
302
+ * @param {unknown} text
303
+ * @returns {string|null}
304
+ */
305
+ export function machineTextMarker(text) {
306
+ if (typeof text !== 'string' || !text) return null;
307
+ for (const marker of MACHINE_TEXT_MARKERS) {
308
+ if (marker.pattern.test(text)) return marker.id;
309
+ }
310
+ return null;
311
+ }
312
+
313
+ /** True when `text` carries any machine-authorship marker — see {@link MACHINE_TEXT_MARKERS}. */
314
+ export function isMachineAuthoredText(text) {
315
+ return machineTextMarker(text) !== null;
316
+ }
317
+
318
+ function parseJsonLine(raw) {
319
+ try {
320
+ return JSON.parse(raw);
321
+ } catch {
322
+ return null;
323
+ }
324
+ }
325
+
326
+ // ── per-source-shape record handlers — each returns zero or more candidate
327
+ // observations `{ kind, text, ts?, context?, confidence? }` and may mutate `ctx`
328
+ // (the session's running "what came before" state, carried across incremental runs). ──
329
+
330
+ /**
331
+ * Claude Code JSONL record shapes (verified on this machine 2026-09-11):
332
+ * {"type":"user","message":{"role":"user","content": string | Array<block>}, "timestamp", ...}
333
+ * {"type":"assistant","message":{"content":[{type:"text"|"tool_use"|"thinking"}]}}
334
+ * {"type":"permission-mode","permissionMode": string, "sessionId"}
335
+ * {"type":"mode", ...} — session UI state, not an observation; skipped.
336
+ * Any other `type` (ai-title, summary, environment, ...) is likewise skipped.
337
+ */
338
+ export function handleClaudeCodeRecord(rec, ctx) {
339
+ const results = [];
340
+ if (!rec || typeof rec !== 'object') return results;
341
+
342
+ if (rec.type === 'assistant') {
343
+ const text = extractTextFromContent(rec.message?.content);
344
+ ctx.lastRecordType = 'assistant';
345
+ if (text) ctx.lastAssistantText = text;
346
+ return results;
347
+ }
348
+
349
+ if (rec.type === 'user') {
350
+ const content = rec.message?.content;
351
+ const precededByAssistant = ctx.lastRecordType === 'assistant';
352
+
353
+ // The two authorship screens run BEFORE `ctx.lastRecordType` is touched, so an injected
354
+ // machine record is TRANSPARENT to the correction chain. Otherwise a task notification
355
+ // landing between the assistant's reply and the owner's "No, stop — ..." made that
356
+ // correction look like it followed a user turn, and it was silently dropped: the host
357
+ // interrupting the owner cost us exactly the observations worth the most.
358
+ //
359
+ // Record-shape screen — the Claude Code counterpart of handleCommandCodeRecord's
360
+ // `meta.source !== 'user'` discriminator.
361
+ if (!isOwnerAuthoredClaudeCodeRecord(rec)) return results;
362
+ const text = isToolOnlyContent(content) ? '' : extractTextFromContent(content);
363
+ // Text-shape screen — catches the records that predate `promptSource`.
364
+ if (text && isMachineAuthoredText(text)) return results;
365
+
366
+ ctx.lastRecordType = 'user';
367
+ if (!text) return results;
368
+ const kind = classifyUserText(text, { precededByAssistant });
369
+ if (!kind) return results;
370
+ const obs = { kind, text, ts: rec.timestamp ?? null };
371
+ if (kind === 'correction' && ctx.lastAssistantText) obs.context = ctx.lastAssistantText.slice(0, 240);
372
+ results.push(obs);
373
+ return results;
374
+ }
375
+
376
+ if (rec.type === 'permission-mode') {
377
+ results.push({ kind: 'approval', text: `permission mode: ${rec.permissionMode ?? 'unknown'}`, ts: null });
378
+ return results;
379
+ }
380
+
381
+ return results;
382
+ }
383
+
384
+ /**
385
+ * Command Code JSONL record shapes (verified on this machine 2026-09-11):
386
+ * {"type":"session", ...} — one per file, not an observation.
387
+ * {"type":"message","message":{"role":"user"|"assistant","content":[block...],
388
+ * "meta":{"source":"user"|"tool"|...}}}
389
+ * `meta.source === "user"` is the discriminator for a genuinely owner-typed turn — a
390
+ * `role:"user"` turn whose content is a tool_result relay carries `meta.source ===
391
+ * "tool"` and is never the owner's words.
392
+ */
393
+ export function handleCommandCodeRecord(rec, ctx) {
394
+ const results = [];
395
+ if (!rec || rec.type !== 'message') return results;
396
+ const role = rec.message?.role;
397
+ const content = rec.message?.content;
398
+
399
+ if (role === 'assistant') {
400
+ const text = extractTextFromContent(content);
401
+ ctx.lastRecordType = 'assistant';
402
+ if (text) ctx.lastAssistantText = text;
403
+ return results;
404
+ }
405
+
406
+ if (role === 'user') {
407
+ const precededByAssistant = ctx.lastRecordType === 'assistant';
408
+ ctx.lastRecordType = 'user';
409
+ if (rec.message?.meta?.source !== 'user') return results; // tool-result relay, not the owner
410
+ if (isToolOnlyContent(content)) return results;
411
+ const text = extractTextFromContent(content);
412
+ if (!text) return results;
413
+ if (isMachineAuthoredText(text)) return results;
414
+ const kind = classifyUserText(text, { precededByAssistant });
415
+ if (!kind) return results;
416
+ const obs = { kind, text, ts: rec.timestamp ?? null };
417
+ if (kind === 'correction' && ctx.lastAssistantText) obs.context = ctx.lastAssistantText.slice(0, 240);
418
+ results.push(obs);
419
+ return results;
420
+ }
421
+
422
+ return results;
423
+ }
424
+
425
+ const TASTE_LINE_RE = /^-\s+(.*?)\s+Confidence:\s*([0-9]*\.?[0-9]+)\s*$/;
426
+
427
+ /** One `- <text> Confidence: <n>` line from a Command Code taste.md. */
428
+ export function handleTasteLine(rawLine) {
429
+ const m = TASTE_LINE_RE.exec(rawLine.trim());
430
+ if (!m) return null;
431
+ return { kind: 'taste', text: m[1].trim(), confidence: Number(m[2]), ts: null };
432
+ }
433
+
434
+ /**
435
+ * spexharness's own session-end export (session-record.mjs / session-end.mjs). Its
436
+ * current shape (`{ sessionId, exportedAt, events, toolCalls }`) carries no user-
437
+ * authored text at all — only gate decisions and tool-call name/input/output — so
438
+ * there is nothing to classify from it TODAY. This handler is forward-compatible: a
439
+ * future sink that adds a string `ask` field (mirroring session-record.mjs's
440
+ * `createSessionRecord({ ask })`) is picked up automatically, classified exactly like
441
+ * a Claude Code user turn, with no prior-assistant context available in this file.
442
+ */
443
+ export function handleSpexcodeRecord(rec) {
444
+ if (!rec || typeof rec.ask !== 'string') return [];
445
+ if (isMachineAuthoredText(rec.ask)) return [];
446
+ const kind = classifyUserText(rec.ask, { precededByAssistant: false });
447
+ if (!kind) return [];
448
+ return [{ kind, text: rec.ask, ts: rec.exportedAt ?? null }];
449
+ }
450
+
451
+ // ── streaming line reader ────────────────────────────────────────────────────────
452
+
453
+ /** Yield `{ n, line }` for every line of `filePath` past line number `skip` (1-indexed). */
454
+ async function* streamNewLines(filePath, skip) {
455
+ const rl = createInterface({ input: createReadStream(filePath, { encoding: 'utf8' }), crlfDelay: Infinity });
456
+ let n = 0;
457
+ try {
458
+ for await (const line of rl) {
459
+ n++;
460
+ if (n <= skip) continue;
461
+ yield { n, line };
462
+ }
463
+ } finally {
464
+ rl.close();
465
+ }
466
+ }
467
+
468
+ function dispatchRecord(src, rec, rawLine, ctx) {
469
+ if (src.source === 'claude-code') return handleClaudeCodeRecord(rec, ctx);
470
+ if (src.source === 'command-code') return handleCommandCodeRecord(rec, ctx);
471
+ if (src.source === 'spexcode') return handleSpexcodeRecord(rec);
472
+ return [];
473
+ }
474
+
475
+ /**
476
+ * Extract new observations from ONE source (one session file), resuming from its
477
+ * prior state. Returns the per-session outcome; observations are pushed to `sink` as
478
+ * they are produced (never buffered whole here) so a caller can stream them straight
479
+ * to disk.
480
+ */
481
+ async function extractFromSource(src, priorState, redact, sink) {
482
+ const key = sourceKey(src);
483
+ const prior = priorState[key] ?? { mtime: null, lines: 0, lastAssistantText: null, lastRecordType: null };
484
+
485
+ if (prior.mtime === src.mtime) {
486
+ return { key, newState: prior, added: 0, skipped: true };
487
+ }
488
+
489
+ const ctx = { lastAssistantText: prior.lastAssistantText ?? null, lastRecordType: prior.lastRecordType ?? null };
490
+ let added = 0;
491
+ let lastLine = prior.lines;
492
+
493
+ if (src.source === 'taste') {
494
+ for await (const { n, line } of streamNewLines(src.path, prior.lines)) {
495
+ lastLine = n;
496
+ const trimmed = line.trim();
497
+ if (!trimmed) continue;
498
+ const cand = handleTasteLine(line);
499
+ if (!cand) continue;
500
+ emit(src, n, cand, redact, sink);
501
+ added++;
502
+ }
503
+ } else {
504
+ for await (const { n, line } of streamNewLines(src.path, prior.lines)) {
505
+ lastLine = n;
506
+ const trimmed = line.trim();
507
+ if (!trimmed) continue;
508
+ const rec = parseJsonLine(trimmed);
509
+ if (!rec) continue;
510
+ const candidates = dispatchRecord(src, rec, trimmed, ctx);
511
+ for (const cand of candidates) {
512
+ emit(src, n, cand, redact, sink);
513
+ added++;
514
+ }
515
+ }
516
+ }
517
+
518
+ return {
519
+ key,
520
+ newState: {
521
+ mtime: src.mtime,
522
+ lines: Math.max(lastLine, prior.lines),
523
+ lastAssistantText: ctx.lastAssistantText,
524
+ lastRecordType: ctx.lastRecordType,
525
+ },
526
+ added,
527
+ skipped: false,
528
+ };
529
+ }
530
+
531
+ function emit(src, seq, cand, redact, sink) {
532
+ const obs = {
533
+ id: `${src.source}:${src.project}:${src.session}:${seq}`,
534
+ source: src.source,
535
+ project: src.project,
536
+ session: src.session,
537
+ ts: cand.ts ?? null,
538
+ seq,
539
+ kind: cand.kind,
540
+ text: redact(cand.text),
541
+ };
542
+ if (cand.context) obs.context = redact(cand.context);
543
+ if (typeof cand.confidence === 'number') obs.confidence = cand.confidence;
544
+ sink(obs);
545
+ }
546
+
547
+ // ── top-level entrypoint ────────────────────────────────────────────────────────
548
+
549
+ /**
550
+ * Stream every source and extract observations, resuming from `state`.
551
+ *
552
+ * @param {{
553
+ * sources?: Array<{source:string,project:string,session:string,path:string,mtime:number}>,
554
+ * state?: object,
555
+ * redact?: (text: string) => string,
556
+ * since?: string|null,
557
+ * project?: string|null,
558
+ * onObservation?: ((obs: object) => void)|null,
559
+ * onSourceDone?: ((result: { source: string, project: string, session: string, added: number, skipped: boolean }) => void)|null,
560
+ * }} [opts]
561
+ * @returns {Promise<{ observations: DoctrineObservation[], newState: object, countsBySource: object, countsByKind: object, sessionsProcessed: number, sessionsSkipped: number }>}
562
+ */
563
+ export async function extractObservations({
564
+ sources = defaultEnumerateSources(),
565
+ state = {},
566
+ redact = redactText,
567
+ since = null,
568
+ project = null,
569
+ onObservation = null,
570
+ onSourceDone = null,
571
+ } = {}) {
572
+ const sinceMs = since ? Date.parse(since) : NaN;
573
+ const filtered = sources.filter((s) => {
574
+ if (project && s.project !== project) return false;
575
+ if (Number.isFinite(sinceMs) && s.mtime < sinceMs) return false;
576
+ return true;
577
+ });
578
+
579
+ const observations = [];
580
+ const sink = (obs) => {
581
+ observations.push(obs);
582
+ if (onObservation) onObservation(obs);
583
+ };
584
+
585
+ const newState = { ...state };
586
+ const countsBySource = {};
587
+ const countsByKind = {};
588
+ let sessionsProcessed = 0;
589
+ let sessionsSkipped = 0;
590
+
591
+ for (const src of filtered) {
592
+ const result = await extractFromSource(src, state, redact, sink);
593
+ newState[result.key] = result.newState;
594
+ if (result.skipped) sessionsSkipped++;
595
+ else sessionsProcessed++;
596
+ countsBySource[src.source] = (countsBySource[src.source] ?? 0) + result.added;
597
+ if (onSourceDone) {
598
+ onSourceDone({ source: src.source, project: src.project, session: src.session, added: result.added, skipped: result.skipped });
599
+ }
600
+ }
601
+
602
+ for (const obs of observations) {
603
+ countsByKind[obs.kind] = (countsByKind[obs.kind] ?? 0) + 1;
604
+ }
605
+
606
+ return { observations, newState, countsBySource, countsByKind, sessionsProcessed, sessionsSkipped };
607
+ }
608
+
609
+ // ── incremental state + observation-file persistence ────────────────────────────
610
+
611
+ export function defaultDoctrineDir(repoRoot) {
612
+ return join(repoRoot, '.heyspex', 'doctrine');
613
+ }
614
+
615
+ export function defaultStatePath(repoRoot) {
616
+ return join(defaultDoctrineDir(repoRoot), 'state.json');
617
+ }
618
+
619
+ export function defaultObservationsPath(repoRoot) {
620
+ return join(defaultDoctrineDir(repoRoot), 'observations.jsonl');
621
+ }
622
+
623
+ /** Read the incremental state file; missing/corrupt → `{}` (a fresh full read). */
624
+ export function loadDoctrineState(path) {
625
+ if (!existsSync(path)) return {};
626
+ try {
627
+ const parsed = JSON.parse(readFileSync(path, 'utf8'));
628
+ return parsed && typeof parsed === 'object' ? parsed : {};
629
+ } catch {
630
+ return {};
631
+ }
632
+ }
633
+
634
+ /** Persist the incremental state file. */
635
+ export function saveDoctrineState(path, state) {
636
+ mkdirSync(dirname(path), { recursive: true });
637
+ writeFileSync_(path, `${JSON.stringify(state, null, 2)}\n`);
638
+ }
639
+
640
+ // writeFileSync isn't imported above (this module prefers streaming appends for the
641
+ // observation log); a tiny local wrapper keeps the state-file write a plain one-shot
642
+ // without adding a second import line for a single call site.
643
+ function writeFileSync_(path, contents) {
644
+ const fd = openSync(path, 'w');
645
+ try {
646
+ writeSync(fd, contents);
647
+ } finally {
648
+ closeSync(fd);
649
+ }
650
+ }
651
+
652
+ /** Append observations to the observation JSONL log — line-atomic (open "a" + one writeSync + close). */
653
+ export function appendObservations(path, observations) {
654
+ if (!observations || observations.length === 0) return;
655
+ mkdirSync(dirname(path), { recursive: true });
656
+ const fd = openSync(path, 'a');
657
+ try {
658
+ for (const obs of observations) writeSync(fd, `${JSON.stringify(obs)}\n`);
659
+ } finally {
660
+ closeSync(fd);
661
+ }
662
+ }
663
+
664
+ /**
665
+ * CORPUS HYGIENE — re-apply the machine-text screen to observations ALREADY stored in
666
+ * `observations.jsonl`, so a corpus harvested by an older build can be cleaned without
667
+ * re-reading every session file (and without discarding rows whose sessions have since been
668
+ * deleted). Pure: reads nothing, writes nothing — the caller decides what to do with `kept`.
669
+ *
670
+ * The record-shape screen cannot run here (a stored observation no longer carries its source
671
+ * record's `promptSource`), so this is the text screen plus, optionally, the project scope.
672
+ *
673
+ * `claudeCodeProjects` mirrors sources.mjs exactly: it scopes ONLY `source: 'claude-code'`
674
+ * rows, because that is the only source the cross-project harvest applies to. Command Code,
675
+ * taste and spexcode rows are left alone — their `project` is derived differently (taste and
676
+ * spexcode carry `basename(repoRoot)`, not the Claude Code directory slug), so scoping them
677
+ * by the same list would silently delete this repo's own taste profile.
678
+ *
679
+ * @param {Array<object>} observations
680
+ * @param {{ claudeCodeProjects?: string[]|null }} [opts]
681
+ * @returns {{
682
+ * kept: Array<object>,
683
+ * rejected: Array<{ observation: object, reason: string }>,
684
+ * rejectedByReason: Record<string, number>,
685
+ * }}
686
+ */
687
+ export function filterObservations(observations, { claudeCodeProjects = null } = {}) {
688
+ const allow = Array.isArray(claudeCodeProjects) ? new Set(claudeCodeProjects) : null;
689
+ const kept = [];
690
+ const rejected = [];
691
+ const rejectedByReason = {};
692
+ const reject = (observation, reason) => {
693
+ rejected.push({ observation, reason });
694
+ rejectedByReason[reason] = (rejectedByReason[reason] ?? 0) + 1;
695
+ };
696
+
697
+ for (const obs of observations ?? []) {
698
+ if (!obs || typeof obs !== 'object') continue;
699
+ if (allow && obs.source === 'claude-code' && !allow.has(obs.project)) {
700
+ reject(obs, 'out-of-scope-project');
701
+ continue;
702
+ }
703
+ const marker = machineTextMarker(obs.text) ?? machineTextMarker(obs.context);
704
+ if (marker) {
705
+ reject(obs, marker);
706
+ continue;
707
+ }
708
+ kept.push(obs);
709
+ }
710
+
711
+ return { kept, rejected, rejectedByReason };
712
+ }
713
+
714
+ /**
715
+ * Read all observations from the JSONL log, oldest first. Missing file → []. Corrupt lines are skipped.
716
+ * @param {string} path
717
+ * @returns {DoctrineObservation[]}
718
+ */
719
+ export function readObservations(path) {
720
+ if (!existsSync(path)) return [];
721
+ const text = readFileSync(path, 'utf8');
722
+ const out = [];
723
+ for (const line of text.split('\n')) {
724
+ const trimmed = line.trim();
725
+ if (!trimmed) continue;
726
+ try {
727
+ out.push(JSON.parse(trimmed));
728
+ } catch {
729
+ // corrupt/partial line — skip, don't throw
730
+ }
731
+ }
732
+ return out;
733
+ }