heyspex 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (352) hide show
  1. package/.claude-plugin/marketplace.json +14 -0
  2. package/README.md +21 -0
  3. package/bin/hs.mjs +1089 -104
  4. package/bin/spexcode.mjs +262 -0
  5. package/dev-factory/engine/worktree.mjs +740 -0
  6. package/dev-factory/lib/code-repo-root.mjs +67 -0
  7. package/dev-factory/lib/context-budget.mjs +84 -0
  8. package/dev-factory/lib/epic-detect.mjs +190 -0
  9. package/dev-factory/lib/fleet-queue-file.mjs +846 -0
  10. package/dev-factory/lib/fleet-queue.mjs +426 -0
  11. package/dev-factory/lib/fleet-state.mjs +14 -18
  12. package/dev-factory/lib/model-catalog.mjs +338 -0
  13. package/dev-factory/lib/seat-context.mjs +199 -0
  14. package/dev-factory/lib/spec-io.mjs +93 -0
  15. package/dev-factory/lib/sufficiency-score.mjs +8 -2
  16. package/dev-factory/lib/worktree-allocator.mjs +744 -0
  17. package/dev-factory/model-catalog.json +222 -0
  18. package/dist/cli/hs-check.mjs +1127 -103
  19. package/dist/cli/hs-claim.mjs +981 -63
  20. package/dist/cli/hs-connect.mjs +10059 -1729
  21. package/dist/cli/hs-defer.mjs +5507 -3072
  22. package/dist/cli/hs-dev.mjs +576 -0
  23. package/dist/cli/hs-env.mjs +416 -302
  24. package/dist/cli/hs-index-rebuild.mjs +7334 -0
  25. package/dist/cli/hs-index-reconcile.mjs +8138 -0
  26. package/dist/cli/hs-init.mjs +2840 -149
  27. package/dist/cli/hs-install.mjs +7214 -594
  28. package/dist/cli/hs-join.mjs +10640 -2421
  29. package/dist/cli/hs-vault-pull.mjs +8157 -6370
  30. package/dist/cli/hs-vault-push.mjs +6433 -0
  31. package/dist/cli/hs-vault.mjs +4029 -1555
  32. package/dist/cli/hs-workspace.mjs +2702 -538
  33. package/dist/cli-templates/customer-vault/specs/entity-model.md +594 -601
  34. package/dist/cli-templates/customer-vault/specs/protocols/canonical-frontmatter-templates.md +110 -177
  35. package/dist/cli-templates/customer-vault/specs/protocols/destructive-action-checklist.md +122 -0
  36. package/dist/cli-templates/customer-vault/specs/protocols/memory-protocol.md +13 -15
  37. package/dist/cli-templates/customer-vault/specs/protocols/session-close-protocol.md +91 -0
  38. package/dist/cli-templates/customer-vault/specs/protocols/spec-hierarchy.md +150 -168
  39. package/dist/cli-templates/customer-vault/specs/protocols/spec-summary-authoring.md +83 -0
  40. package/dist/cli-templates/harness-commands/claude/hs-auto.md +36 -0
  41. package/dist/cli-templates/harness-commands/claude/hs-backfill.md +28 -0
  42. package/dist/cli-templates/harness-commands/claude/hs-capture.md +62 -0
  43. package/dist/cli-templates/harness-commands/claude/hs-defer.md +29 -0
  44. package/dist/cli-templates/harness-commands/claude/hs-dev.md +20 -0
  45. package/dist/cli-templates/harness-commands/claude/hs-doctor.md +32 -0
  46. package/dist/cli-templates/harness-commands/claude/hs-ground.md +30 -0
  47. package/dist/cli-templates/harness-commands/claude/hs-handoff.md +33 -0
  48. package/dist/cli-templates/harness-commands/claude/hs-log.md +27 -0
  49. package/dist/cli-templates/harness-commands/claude/hs-next.md +29 -0
  50. package/dist/cli-templates/harness-commands/claude/hs-promote.md +70 -0
  51. package/dist/cli-templates/harness-commands/claude/hs-push.md +35 -0
  52. package/dist/cli-templates/harness-commands/claude/hs-qc.md +27 -0
  53. package/dist/cli-templates/harness-commands/claude/hs-res.md +63 -0
  54. package/dist/cli-templates/harness-commands/claude/hs-resolve.md +23 -0
  55. package/dist/cli-templates/harness-commands/claude/hs-review.md +23 -0
  56. package/dist/cli-templates/harness-commands/claude/hs-view.md +28 -0
  57. package/dist/cli-templates/harness-commands/claude/kill-ports.md +32 -0
  58. package/dist/cli-templates/harness-commands/claude/prototype.md +91 -0
  59. package/dist/cli-templates/harness-commands/cursor/hs-auto.md +40 -0
  60. package/dist/cli-templates/harness-commands/cursor/hs-backfill.md +31 -0
  61. package/dist/cli-templates/harness-commands/cursor/hs-capture.md +65 -0
  62. package/dist/cli-templates/harness-commands/cursor/hs-defer.md +32 -0
  63. package/dist/cli-templates/harness-commands/cursor/hs-dev.md +24 -0
  64. package/dist/cli-templates/harness-commands/cursor/hs-doctor.md +36 -0
  65. package/dist/cli-templates/harness-commands/cursor/hs-ground.md +33 -0
  66. package/dist/cli-templates/harness-commands/cursor/hs-handoff.md +37 -0
  67. package/dist/cli-templates/harness-commands/cursor/hs-log.md +31 -0
  68. package/dist/cli-templates/harness-commands/cursor/hs-next.md +32 -0
  69. package/dist/cli-templates/harness-commands/cursor/hs-promote.md +73 -0
  70. package/dist/cli-templates/harness-commands/cursor/hs-push.md +38 -0
  71. package/dist/cli-templates/harness-commands/cursor/hs-qc.md +30 -0
  72. package/dist/cli-templates/harness-commands/cursor/hs-res.md +67 -0
  73. package/dist/cli-templates/harness-commands/cursor/hs-resolve.md +26 -0
  74. package/dist/cli-templates/harness-commands/cursor/hs-review.md +26 -0
  75. package/dist/cli-templates/harness-commands/cursor/hs-view.md +32 -0
  76. package/dist/cli-templates/harness-commands/cursor/kill-ports.md +35 -0
  77. package/dist/cli-templates/harness-commands/cursor/prototype.md +95 -0
  78. package/docs/copilot-plugin-install.md +162 -0
  79. package/docs/driving-heyspex-from-copilot.md +165 -0
  80. package/docs/mcp-client-config.md +259 -6
  81. package/package.json +249 -124
  82. package/plugins/heyspex/.claude-plugin/plugin.json +12 -0
  83. package/plugins/heyspex/.cursor-plugin/plugin.json +15 -0
  84. package/plugins/heyspex/.mcp.json +12 -0
  85. package/plugins/heyspex/README.md +309 -0
  86. package/plugins/heyspex/bin/launch-mcp.mjs +95 -0
  87. package/plugins/heyspex/bin/run-hook.mjs +80 -0
  88. package/plugins/heyspex/hooks/cursor-hooks.json +308 -0
  89. package/plugins/heyspex/hooks/hooks.json +418 -0
  90. package/plugins/heyspex/mcp.json +13 -0
  91. package/scripts/check-mcp-freshness.mjs +17 -9
  92. package/scripts/claude-hooks/_env-vault-events.mjs +68 -0
  93. package/scripts/claude-hooks/_guardrail-events.mjs +96 -0
  94. package/scripts/claude-hooks/_guardrail-loader.mjs +494 -0
  95. package/scripts/claude-hooks/_intent-classifier-router.mjs +456 -0
  96. package/scripts/claude-hooks/_intent-classifier-semantic.mjs +356 -0
  97. package/scripts/claude-hooks/_run.mjs +413 -0
  98. package/scripts/claude-hooks/_transcript-text.mjs +60 -0
  99. package/scripts/claude-hooks/_vault-retrieval.mjs +322 -0
  100. package/scripts/claude-hooks/agent-spawn-delegation-gate.mjs +133 -0
  101. package/scripts/claude-hooks/capture-execution-gate.mjs +51 -0
  102. package/scripts/claude-hooks/capture-workflow-gate.mjs +11 -0
  103. package/scripts/claude-hooks/check-auto-memory-budget.mjs +396 -0
  104. package/scripts/claude-hooks/check-claude-md-budget.mjs +327 -0
  105. package/scripts/claude-hooks/check-mcp-freshness-hook.mjs +14 -0
  106. package/scripts/claude-hooks/claim-before-code-gate.mjs +11 -0
  107. package/scripts/claude-hooks/claim-liveness-shared.mjs +577 -0
  108. package/scripts/claude-hooks/close-terminal-on-ship.mjs +64 -0
  109. package/scripts/claude-hooks/coo-denylist-gate.mjs +11 -0
  110. package/scripts/claude-hooks/coo-guard-check.mjs +11 -0
  111. package/scripts/claude-hooks/deep-research-cost-gate.mjs +11 -0
  112. package/scripts/claude-hooks/dev-server-task-reminder.mjs +15 -0
  113. package/scripts/claude-hooks/diagnose-capture-reminder.mjs +11 -0
  114. package/scripts/claude-hooks/directive-obligation-gate.mjs +11 -0
  115. package/scripts/claude-hooks/emit-claim-status.mjs +41 -0
  116. package/scripts/claude-hooks/enforce-session-sequence.mjs +11 -0
  117. package/scripts/claude-hooks/enforce-vault-naming.mjs +11 -0
  118. package/scripts/claude-hooks/env-vault-guard.mjs +11 -0
  119. package/scripts/claude-hooks/epic-milestone-phrasing-gate.mjs +11 -0
  120. package/scripts/claude-hooks/git-identity-guard.mjs +11 -0
  121. package/scripts/claude-hooks/git-reflog-audit.mjs +15 -0
  122. package/scripts/claude-hooks/git-stash-gate.mjs +11 -0
  123. package/scripts/claude-hooks/guard-auto-memory-writes.mjs +137 -0
  124. package/scripts/claude-hooks/id-display-format-gate.mjs +11 -0
  125. package/scripts/claude-hooks/inject-communication-style.mjs +86 -0
  126. package/scripts/claude-hooks/inject-coo-seat.mjs +78 -0
  127. package/scripts/claude-hooks/inject-dev-server-awareness.mjs +11 -0
  128. package/scripts/claude-hooks/inject-doctrine.mjs +13 -0
  129. package/scripts/claude-hooks/inject-vault-context.mjs +14 -0
  130. package/scripts/claude-hooks/intent-classifier-trigger.mjs +279 -0
  131. package/scripts/claude-hooks/log-session-activity.mjs +341 -0
  132. package/scripts/claude-hooks/naughty-list-gate.mjs +11 -0
  133. package/scripts/claude-hooks/notify-status-flip.mjs +72 -0
  134. package/scripts/claude-hooks/plan-exit-ingest-gate.mjs +170 -0
  135. package/scripts/claude-hooks/plan-ingest-close-reminder.mjs +66 -0
  136. package/scripts/claude-hooks/plan-review-gate.mjs +11 -0
  137. package/scripts/claude-hooks/preflight-reminder.mjs +262 -0
  138. package/scripts/claude-hooks/protected-action-intent.mjs +221 -0
  139. package/scripts/claude-hooks/research-method-guard.mjs +398 -0
  140. package/scripts/claude-hooks/research-protocol-gate.mjs +11 -0
  141. package/scripts/claude-hooks/session-end.mjs +451 -0
  142. package/scripts/claude-hooks/settings-write-gate.mjs +11 -0
  143. package/scripts/claude-hooks/settlement-check-gate.mjs +39 -0
  144. package/scripts/claude-hooks/status-flip-detect.mjs +17 -0
  145. package/scripts/claude-hooks/terminal-close-shared.mjs +426 -0
  146. package/scripts/claude-hooks/terminal-close-watcher.mjs +122 -0
  147. package/scripts/claude-hooks/vault-mirror-catchup.mjs +11 -0
  148. package/scripts/claude-hooks/vault-write-tier-guard.mjs +11 -0
  149. package/scripts/claude-hooks/wip-checkpoint.mjs +302 -0
  150. package/scripts/cursor-hooks/manifest.mjs +281 -0
  151. package/scripts/dev-server-status.mjs +34 -13
  152. package/scripts/dev-server-stop.mjs +313 -0
  153. package/scripts/heyspex-mode.mjs +288 -0
  154. package/scripts/hs-auto-resolve.mjs +240 -0
  155. package/scripts/hs-auto-source.mjs +398 -0
  156. package/scripts/hs-auto.mjs +134 -0
  157. package/scripts/hs-capture-cli.mjs +326 -0
  158. package/scripts/hs-classifier.mjs +136 -0
  159. package/scripts/hs-companion.mjs +88 -0
  160. package/scripts/hs-gateway.mjs +115 -0
  161. package/scripts/hs-mode.mjs +125 -0
  162. package/scripts/hs-next.mjs +441 -0
  163. package/scripts/hs-settings.mjs +338 -0
  164. package/scripts/hs-view.mjs +496 -0
  165. package/scripts/hs-worktree.mjs +201 -0
  166. package/scripts/install-claude-code-hooks.mjs +272 -0
  167. package/scripts/install-cursor-hooks.mjs +471 -0
  168. package/scripts/lib/agent-env-markers.mjs +38 -0
  169. package/scripts/lib/console-url-resolver.mjs +199 -61
  170. package/scripts/lib/customer-kit.mjs +1203 -0
  171. package/scripts/lib/dev-mode.mjs +249 -32
  172. package/scripts/lib/directive-obligations.mjs +349 -0
  173. package/scripts/lib/heyspex-home.mjs +72 -0
  174. package/scripts/lib/hook-dispositions.mjs +210 -0
  175. package/scripts/lib/hook-vault-header.mjs +30 -0
  176. package/scripts/lib/hs-command.mjs +277 -0
  177. package/scripts/lib/hs-companion-settings.mjs +472 -0
  178. package/scripts/lib/hs-source-branch.mjs +196 -0
  179. package/scripts/lib/hs-source-commit.mjs +59 -5
  180. package/scripts/lib/hs-source-identity.mjs +526 -0
  181. package/scripts/lib/hs-source-log.mjs +13 -22
  182. package/scripts/lib/hs-source-preflight.mjs +18 -4
  183. package/scripts/lib/hs-source-push.mjs +386 -307
  184. package/scripts/lib/hs-worktree-args.mjs +115 -0
  185. package/scripts/lib/shared-path-helpers.mjs +134 -0
  186. package/scripts/lib/standalone-doctor.mjs +540 -3
  187. package/scripts/lib/subagent-git-lock.mjs +109 -0
  188. package/scripts/lib/template-pin.mjs +34 -0
  189. package/scripts/lib/window-placement.mjs +109 -0
  190. package/scripts/provenance-lib.mjs +91 -0
  191. package/scripts/stamp-provenance-on-edit.mjs +222 -0
  192. package/scripts/wip-ledger-health.mjs +653 -0
  193. package/spexharness/doctrine/baseline/SPEXCODE-DOCTRINE.baseline.json +29 -0
  194. package/spexharness/src/adapters/audit.mjs +197 -0
  195. package/spexharness/src/adapters/claude-code.mjs +449 -0
  196. package/spexharness/src/adapters/copilot.mjs +236 -0
  197. package/spexharness/src/adapters/detect-harness.mjs +34 -0
  198. package/spexharness/src/chat/tui/ansi.mjs +448 -0
  199. package/spexharness/src/chat/tui/palette.mjs +22 -0
  200. package/spexharness/src/chat/tui/select.mjs +208 -0
  201. package/spexharness/src/config-root.mjs +54 -0
  202. package/spexharness/src/context-budget.mjs +141 -0
  203. package/spexharness/src/dev-server-control.mjs +389 -0
  204. package/spexharness/src/doctrine/behaviour.mjs +371 -0
  205. package/spexharness/src/doctrine/corpus.mjs +733 -0
  206. package/spexharness/src/doctrine/inject.mjs +338 -0
  207. package/spexharness/src/doctrine/merge.mjs +184 -0
  208. package/spexharness/src/doctrine/sources.mjs +252 -0
  209. package/spexharness/src/doctrine/synthesize.mjs +401 -0
  210. package/spexharness/src/doctrine/vault-source.mjs +574 -0
  211. package/spexharness/src/doctrine/writer.mjs +176 -0
  212. package/spexharness/src/events.mjs +84 -0
  213. package/spexharness/src/gates/_shell-write-targets.mjs +77 -0
  214. package/spexharness/src/gates/capture-workflow-gate.mjs +202 -0
  215. package/spexharness/src/gates/claim-before-code-gate.decide.mjs +33 -0
  216. package/spexharness/src/gates/claim-before-code-gate.mjs +129 -0
  217. package/spexharness/src/gates/deep-research-cost-gate.mjs +275 -0
  218. package/spexharness/src/gates/diagnose-capture-reminder.mjs +112 -0
  219. package/spexharness/src/gates/directive-obligation-gate.decide.mjs +132 -0
  220. package/spexharness/src/gates/directive-obligation-gate.mjs +183 -0
  221. package/spexharness/src/gates/enforce-session-sequence.mjs +895 -0
  222. package/spexharness/src/gates/enforce-vault-naming.mjs +215 -0
  223. package/spexharness/src/gates/epic-milestone-phrasing-gate.mjs +233 -0
  224. package/spexharness/src/gates/git-identity-guard.decide.mjs +436 -0
  225. package/spexharness/src/gates/git-identity-guard.mjs +61 -0
  226. package/spexharness/src/gates/git-reflog-audit.mjs +151 -0
  227. package/spexharness/src/gates/git-stash-gate.decide.mjs +361 -0
  228. package/spexharness/src/gates/git-stash-gate.mjs +159 -0
  229. package/spexharness/src/gates/id-display-format-gate.mjs +190 -0
  230. package/spexharness/src/gates/inject-doctrine-gate.mjs +89 -0
  231. package/spexharness/src/gates/inject-vault-context.mjs +239 -0
  232. package/spexharness/src/gates/naughty-list-gate.mjs +213 -0
  233. package/spexharness/src/gates/plan-review-gate.mjs +233 -0
  234. package/spexharness/src/gates/preflight-reminder-gate.mjs +265 -0
  235. package/spexharness/src/gates/research-protocol-gate.mjs +175 -0
  236. package/spexharness/src/gates/settings-write-gate.decide.mjs +96 -0
  237. package/spexharness/src/gates/settings-write-gate.mjs +104 -0
  238. package/spexharness/src/gates/vault-mirror-catchup.mjs +124 -0
  239. package/spexharness/src/gates/vault-write-tier-guard.decide.mjs +125 -0
  240. package/spexharness/src/gates/vault-write-tier-guard.mjs +42 -0
  241. package/spexharness/src/git-reflog-audit.mjs +294 -0
  242. package/spexharness/src/intent-classifier.mjs +196 -0
  243. package/spexharness/src/intent-directive.mjs +626 -0
  244. package/spexharness/src/profile/learn.mjs +551 -0
  245. package/spexharness/src/profile/proposals.mjs +357 -0
  246. package/spexharness/src/profile/vault-inclusion.mjs +386 -0
  247. package/spexharness/src/redact.mjs +151 -0
  248. package/spexharness/src/rule-table.mjs +849 -0
  249. package/spexharness/src/seat-context.mjs +122 -0
  250. package/spexharness/src/session-bootstrap.mjs +512 -0
  251. package/spexharness/src/session-record.mjs +815 -0
  252. package/spexharness/src/token-inventory.mjs +207 -0
  253. package/vendor/heyspex-mcp/account-switch.mjs +96 -0
  254. package/vendor/heyspex-mcp/alignment-gate.mjs +315 -1
  255. package/vendor/heyspex-mcp/blocker-resolver.mjs +37 -2
  256. package/vendor/heyspex-mcp/breakdown-ladder.mjs +119 -4
  257. package/vendor/heyspex-mcp/breakdown-source.mjs +271 -0
  258. package/vendor/heyspex-mcp/breakdown-writer.mjs +23 -3
  259. package/vendor/heyspex-mcp/capture-buffer-patches.mjs +77 -8
  260. package/vendor/heyspex-mcp/capture-channel.mjs +55 -5
  261. package/vendor/heyspex-mcp/capture-console-client.mjs +117 -3
  262. package/vendor/heyspex-mcp/capture-focus.mjs +1061 -31
  263. package/vendor/heyspex-mcp/capture-outbox.mjs +15 -5
  264. package/vendor/heyspex-mcp/capture-sources.mjs +195 -0
  265. package/vendor/heyspex-mcp/capture-tools.mjs +1264 -0
  266. package/vendor/heyspex-mcp/claim-tools.mjs +1147 -0
  267. package/vendor/heyspex-mcp/cli-verbs.mjs +2074 -0
  268. package/vendor/heyspex-mcp/cli.mjs +207 -15524
  269. package/vendor/heyspex-mcp/close-branch-report.mjs +102 -0
  270. package/vendor/heyspex-mcp/cloud-tool-surface.mjs +128 -0
  271. package/vendor/heyspex-mcp/cloud-vault-writes.mjs +602 -0
  272. package/vendor/heyspex-mcp/config-seed.yaml +15 -0
  273. package/vendor/heyspex-mcp/config-settings.mjs +290 -7
  274. package/vendor/heyspex-mcp/console-fetch.mjs +822 -17
  275. package/vendor/heyspex-mcp/console-transport.mjs +172 -0
  276. package/vendor/heyspex-mcp/credentials.mjs +585 -0
  277. package/vendor/heyspex-mcp/defer-spec.mjs +44 -4
  278. package/vendor/heyspex-mcp/deferred-reclaim.mjs +29 -3
  279. package/vendor/heyspex-mcp/design-handlers.mjs +31 -0
  280. package/vendor/heyspex-mcp/design-tools.mjs +4 -1
  281. package/vendor/heyspex-mcp/dev-factory-bridge.mjs +4 -4
  282. package/vendor/heyspex-mcp/dev-mode-core.mjs +45 -0
  283. package/vendor/heyspex-mcp/dev-tools.mjs +131 -0
  284. package/vendor/heyspex-mcp/diagnose-handlers.mjs +54 -0
  285. package/vendor/heyspex-mcp/events-tools.mjs +207 -0
  286. package/vendor/heyspex-mcp/filing-tools.mjs +723 -0
  287. package/vendor/heyspex-mcp/fleet-handlers.mjs +37 -0
  288. package/vendor/heyspex-mcp/fleet-queue-tools.mjs +71 -15
  289. package/vendor/heyspex-mcp/focus-tools.mjs +190 -0
  290. package/vendor/heyspex-mcp/folder-trust-store.mjs +60 -0
  291. package/vendor/heyspex-mcp/http-transport.mjs +302 -0
  292. package/vendor/heyspex-mcp/list-tools.mjs +257 -0
  293. package/vendor/heyspex-mcp/login-handoff.mjs +15 -1
  294. package/vendor/heyspex-mcp/mcp-config.mjs +107 -0
  295. package/vendor/heyspex-mcp/memory-tools.mjs +523 -0
  296. package/vendor/heyspex-mcp/mvp-mode.mjs +53 -6
  297. package/vendor/heyspex-mcp/obligation-backstop.mjs +103 -0
  298. package/vendor/heyspex-mcp/occupied-keys.mjs +142 -0
  299. package/vendor/heyspex-mcp/on-deck-brief.mjs +3377 -0
  300. package/vendor/heyspex-mcp/on-deck-scheduler.mjs +1708 -0
  301. package/vendor/heyspex-mcp/package-root.mjs +32 -0
  302. package/vendor/heyspex-mcp/peer-presence.mjs +8 -1
  303. package/vendor/heyspex-mcp/plan-ground.mjs +621 -652
  304. package/vendor/heyspex-mcp/plan-push.mjs +908 -22
  305. package/vendor/heyspex-mcp/plan-review.mjs +124 -30
  306. package/vendor/heyspex-mcp/preflight-gate.mjs +0 -0
  307. package/vendor/heyspex-mcp/procedure-handlers.mjs +327 -0
  308. package/vendor/heyspex-mcp/procedure-tools.mjs +1181 -970
  309. package/vendor/heyspex-mcp/promote-capture.mjs +388 -69
  310. package/vendor/heyspex-mcp/promote-diagnose.mjs +74 -0
  311. package/vendor/heyspex-mcp/promote-research-to-diagnose.mjs +9 -2
  312. package/vendor/heyspex-mcp/promote-research.mjs +666 -53
  313. package/vendor/heyspex-mcp/promotion-helpers.mjs +251 -0
  314. package/vendor/heyspex-mcp/prompts.mjs +1460 -0
  315. package/vendor/heyspex-mcp/read-path.mjs +191 -15
  316. package/vendor/heyspex-mcp/receipt-shape.mjs +146 -0
  317. package/vendor/heyspex-mcp/recipe-handlers.mjs +117 -0
  318. package/vendor/heyspex-mcp/regen-tasks.mjs +210 -3
  319. package/vendor/heyspex-mcp/register-spec.mjs +266 -54
  320. package/vendor/heyspex-mcp/request-context.mjs +83 -0
  321. package/vendor/heyspex-mcp/research-focus.mjs +207 -4
  322. package/vendor/heyspex-mcp/research-handlers.mjs +625 -0
  323. package/vendor/heyspex-mcp/research-tools.mjs +4 -4
  324. package/vendor/heyspex-mcp/response-budget.mjs +348 -0
  325. package/vendor/heyspex-mcp/review-focus.mjs +25 -1
  326. package/vendor/heyspex-mcp/roadmap-default.mjs +70 -0
  327. package/vendor/heyspex-mcp/search-specs.mjs +280 -0
  328. package/vendor/heyspex-mcp/seat-block-ledger.mjs +113 -0
  329. package/vendor/heyspex-mcp/seat-identity.mjs +2 -6
  330. package/vendor/heyspex-mcp/session-owner.mjs +109 -0
  331. package/vendor/heyspex-mcp/session-tools.mjs +162 -0
  332. package/vendor/heyspex-mcp/settings-registry.mjs +232 -0
  333. package/vendor/heyspex-mcp/settings-tools.mjs +85 -12
  334. package/vendor/heyspex-mcp/spec-edit-tools.mjs +453 -0
  335. package/vendor/heyspex-mcp/spec-id-lib.mjs +93 -17
  336. package/vendor/heyspex-mcp/split-research.mjs +304 -56
  337. package/vendor/heyspex-mcp/stdio-rpc.mjs +22 -0
  338. package/vendor/heyspex-mcp/submit-for-review.mjs +150 -7
  339. package/vendor/heyspex-mcp/summary-contract.mjs +105 -0
  340. package/vendor/heyspex-mcp/summary-write-tools.mjs +228 -0
  341. package/vendor/heyspex-mcp/symptom-query.mjs +146 -0
  342. package/vendor/heyspex-mcp/test-helpers.mjs +123 -1
  343. package/vendor/heyspex-mcp/tool-catalog.mjs +2819 -0
  344. package/vendor/heyspex-mcp/tool-dispatch.mjs +657 -0
  345. package/vendor/heyspex-mcp/tool-helpers.mjs +325 -0
  346. package/vendor/heyspex-mcp/validate-linkage.mjs +414 -27
  347. package/vendor/heyspex-mcp/vault-io.mjs +720 -0
  348. package/vendor/heyspex-mcp/vault-less-endpoints.mjs +52 -0
  349. package/vendor/heyspex-mcp/vault-resolve.mjs +554 -68
  350. package/vendor/heyspex-mcp/verify-reproduction.mjs +270 -30
  351. package/vendor/heyspex-mcp/whoami.mjs +92 -0
  352. package/vendor/heyspex-mcp/write-outbox.mjs +512 -0
@@ -0,0 +1,124 @@
1
+ // spexharness/src/gates/vault-mirror-catchup.mjs
2
+ //
3
+ // SessionStart catch-up for the cloud -> local vault mirror (diagnose HS-XJFQ, acceptance criterion 3:
4
+ // "a Companion or MCP session start against a clone that is behind the cloud closes the gap before the
5
+ // first tool call"). In cloud write mode the console is the system of record and the disk clone is a
6
+ // mirror; this gate starts `hs pull --apply` in the BACKGROUND so the hook returns immediately.
7
+ //
8
+ // Fail-open and silent on every skip: local reliance, no vault root / no heyspex.yaml, kill switch
9
+ // HEYSPEX_VAULT_MIRROR_CATCHUP_OFF=1, or a catch-up that already ran in the last 10 minutes (stamp file).
10
+ // Never denies. Emits one line of additionalContext when it spawns.
11
+ //
12
+ // ctx (all optional, for tests): env, vaultRoot, writeMode, now, exists(p), statMtimeMs(p), mkdirp(p),
13
+ // writeFile(p, s), openLog(p) -> fd, spawnDetached(cmd, args, opts), hsCommand() -> { command, args, shell }.
14
+
15
+ import fs from 'node:fs';
16
+ import path from 'node:path';
17
+ import { spawn } from 'node:child_process';
18
+
19
+ import { defaultConfigRoot } from '../config-root.mjs';
20
+ import { resolveDevMode } from '../../../scripts/lib/dev-mode.mjs';
21
+ import { describeHsCommand } from '../../../scripts/lib/hs-command.mjs';
22
+
23
+ export const id = 'vault-mirror-catchup';
24
+ export const event = 'session_start';
25
+ export const severity = 'info';
26
+
27
+ export const KILL_ENV = 'HEYSPEX_VAULT_MIRROR_CATCHUP_OFF';
28
+ export const FRESH_WINDOW_MS = 10 * 60 * 1000;
29
+ export const LOG_REL = '.heyspex/vault-mirror-catchup.log';
30
+ export const STAMP_REL = '.heyspex/vault-mirror-catchup.stamp';
31
+
32
+ /** Resolve how to launch `hs pull --apply`: the PATH shim (shell on Windows) or `node <dispatcher>`. */
33
+ export function defaultHsLaunch() {
34
+ const d = describeHsCommand();
35
+ if (d.onPath) return { command: 'hs', args: ['pull', '--apply'], shell: process.platform === 'win32' };
36
+ if (d.dispatcherPath) return { command: process.execPath, args: [d.dispatcherPath, 'pull', '--apply'], shell: false };
37
+ return { command: 'hs', args: ['pull', '--apply'], shell: process.platform === 'win32' };
38
+ }
39
+
40
+ /**
41
+ * Pure-ish decision: returns { spawn: false } or { spawn: true, vaultRoot, command, args, ... }.
42
+ * All I/O goes through injected deps.
43
+ */
44
+ export function decideCatchup(deps = {}) {
45
+ const env = deps.env ?? process.env;
46
+ if (String(env[KILL_ENV] ?? '') === '1') return { spawn: false, reason: 'kill-switch' };
47
+ let writeMode = deps.writeMode;
48
+ if (!writeMode) {
49
+ try {
50
+ writeMode = resolveDevMode({ env }).vaultWriteMode;
51
+ } catch {
52
+ return { spawn: false, reason: 'dev-mode-unresolved' };
53
+ }
54
+ }
55
+ if (writeMode !== 'cloud') return { spawn: false, reason: 'local-reliance' };
56
+ let vaultRoot = deps.vaultRoot;
57
+ if (vaultRoot === undefined) {
58
+ try {
59
+ vaultRoot = defaultConfigRoot();
60
+ } catch {
61
+ vaultRoot = null;
62
+ }
63
+ }
64
+ if (!vaultRoot) return { spawn: false, reason: 'no-vault-root' };
65
+ const exists = deps.exists ?? fs.existsSync;
66
+ if (!exists(path.join(vaultRoot, 'heyspex.yaml'))) return { spawn: false, reason: 'no-heyspex-yaml' };
67
+
68
+ const now = (deps.now ?? Date.now)();
69
+ const stampPath = path.join(vaultRoot, STAMP_REL);
70
+ const mtime = (deps.statMtimeMs ?? ((p) => {
71
+ try {
72
+ return fs.statSync(p).mtimeMs;
73
+ } catch {
74
+ return null;
75
+ }
76
+ }))(stampPath);
77
+ if (typeof mtime === 'number' && now - mtime >= 0 && now - mtime < FRESH_WINDOW_MS) {
78
+ return { spawn: false, reason: 'stamp-fresh' };
79
+ }
80
+ const launch = (deps.hsCommand ?? defaultHsLaunch)();
81
+ return { spawn: true, vaultRoot, stampPath, logPath: path.join(vaultRoot, LOG_REL), ...launch };
82
+ }
83
+
84
+ /** Perform the detached spawn for a { spawn: true } decision. Throws on failure (caller fails open). */
85
+ export function startCatchup(d, deps = {}) {
86
+ const mkdirp = deps.mkdirp ?? ((p) => fs.mkdirSync(p, { recursive: true }));
87
+ const writeFile = deps.writeFile ?? ((p, s) => fs.writeFileSync(p, s));
88
+ const openLog = deps.openLog ?? ((p) => fs.openSync(p, 'a'));
89
+ const spawnDetached = deps.spawnDetached ?? ((cmd, args, opts) => spawn(cmd, args, opts));
90
+ const env = deps.env ?? process.env;
91
+ mkdirp(path.dirname(d.logPath));
92
+ writeFile(d.stampPath, new Date((deps.now ?? Date.now)()).toISOString() + '\n');
93
+ const fd = openLog(d.logPath);
94
+ const child = spawnDetached(d.command, d.args, {
95
+ cwd: d.vaultRoot,
96
+ env: { ...env, HS_VAULT_ROOT: d.vaultRoot },
97
+ detached: true,
98
+ stdio: ['ignore', fd, fd],
99
+ windowsHide: true,
100
+ shell: d.shell === true,
101
+ });
102
+ child?.on?.('error', () => {});
103
+ child?.unref?.();
104
+ return child;
105
+ }
106
+
107
+ export function run(evt, ctx = {}) {
108
+ try {
109
+ const d = decideCatchup(ctx);
110
+ if (!d.spawn) return { decision: 'allow' };
111
+ startCatchup(d, ctx);
112
+ return {
113
+ decision: 'inject',
114
+ inject: {
115
+ additionalContext: `vault mirror catch-up: \`hs pull --apply\` started in the background for ${d.vaultRoot} (log: ${LOG_REL})`,
116
+ severity: 'info',
117
+ },
118
+ };
119
+ } catch {
120
+ return { decision: 'allow' };
121
+ }
122
+ }
123
+
124
+ export default { id, event, severity, run };
@@ -0,0 +1,125 @@
1
+ // spexharness/src/gates/vault-write-tier-guard.decide.mjs
2
+ //
3
+ // Pure decision for the vault-write-tier-guard PreToolUse gate (HS-FCNH).
4
+ //
5
+ // Problem: on a machine in prod / cloud reliance (`hs dev: prod`) the console
6
+ // reads Neon, and the vault clone on disk is a MIRROR the console never reads
7
+ // back. An agent that hand-edits `<vault>/specs/**/*.md` because a console
8
+ // prompt said so writes into a place nobody reads. This gate denies those disk
9
+ // writes in cloud write mode and names the cloud-routed tool to use instead.
10
+ //
11
+ // No process.exit / stdout here. `fs.existsSync` is the only I/O and is
12
+ // injectable (`deps.exists`).
13
+ //
14
+ // call = { toolName, toolInput }
15
+ // deps = { vaultRoot, writeMode, env, cwd?, exists?, platform? }
16
+
17
+ import fs from 'node:fs';
18
+ import path from 'node:path';
19
+
20
+ import { extractShellTargets } from './_shell-write-targets.mjs';
21
+
22
+ export const KILL_ENV = 'HEYSPEX_VAULT_WRITE_TIER_GUARD_OFF';
23
+
24
+ const FILE_TOOLS = new Set(['write', 'edit', 'multiedit', 'notebookedit']);
25
+ const SHELL_TOOLS = new Set(['bash', 'powershell']);
26
+
27
+ function norm(p, platform) {
28
+ const r = String(p);
29
+ return platform === 'win32' ? r.toLowerCase() : r;
30
+ }
31
+
32
+ /** True when `p` resolves to `<vaultRoot>/specs/**\/*.md` outside `.trash/`. */
33
+ export function isGuardedVaultPath(p, { vaultRoot, cwd, platform = process.platform }) {
34
+ if (!p || typeof p !== 'string') return false;
35
+ let abs;
36
+ try {
37
+ abs = path.resolve(cwd ?? process.cwd(), p);
38
+ } catch {
39
+ return false;
40
+ }
41
+ const root = norm(path.resolve(vaultRoot), platform);
42
+ const target = norm(abs, platform);
43
+ const rel = path.relative(root, target);
44
+ if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return false;
45
+ const segs = rel.split(/[\\/]+/);
46
+ if (segs[0] !== 'specs' || segs.length < 2) return false;
47
+ if (segs.includes('.trash')) return false;
48
+ return path.extname(target) === '.md';
49
+ }
50
+
51
+ function editTexts(toolInput) {
52
+ const out = [];
53
+ for (const k of ['new_string', 'content', 'new_source', 'command']) {
54
+ if (typeof toolInput?.[k] === 'string') out.push(toolInput[k]);
55
+ }
56
+ if (Array.isArray(toolInput?.edits)) {
57
+ for (const e of toolInput.edits) if (typeof e?.new_string === 'string') out.push(e.new_string);
58
+ }
59
+ return out;
60
+ }
61
+
62
+ function pickTool(toolInput) {
63
+ return editTexts(toolInput).some((t) => /^summary:/m.test(t))
64
+ ? 'heyspex_author_summary'
65
+ : 'heyspex_edit_file';
66
+ }
67
+
68
+ const ROWS = [
69
+ ['summary: field', 'heyspex_author_summary { spec_id, summary } (load via heyspex_load_tools)', 'heyspex_author_summary'],
70
+ ['body / other frontmatter', 'heyspex_edit_file { file_path, content } (cloud-routed in this mode)', 'heyspex_edit_file'],
71
+ ['status', 'heyspex_submit_for_review / heyspex_claim_spec — never hand-edit status', null],
72
+ ['new spec', 'heyspex_file_spec', null],
73
+ ];
74
+
75
+ export function denyReason(target, tool) {
76
+ const rows = ROWS.map(([label, use, t]) => {
77
+ const mark = t === tool ? '=> ' : ' ';
78
+ return `${mark}${label.padEnd(24)} → ${use}`;
79
+ }).join('\n');
80
+ return (
81
+ `[vault-write-tier-guard] This machine writes the vault to the CLOUD (hs dev: prod). ` +
82
+ `${target} is a disk mirror the console never reads back (HS-FCNH).\n${rows}\n` +
83
+ `Break-glass for a human: ${KILL_ENV}=1.`
84
+ );
85
+ }
86
+
87
+ /**
88
+ * @param {{ toolName: string, toolInput: Record<string, unknown> }} call
89
+ * @param {{ vaultRoot?: string|null, writeMode?: string, env?: Record<string,string|undefined>,
90
+ * cwd?: string, exists?: (p: string) => boolean, platform?: string }} deps
91
+ * @returns {{ allow: boolean, reason?: string, tool?: string }}
92
+ */
93
+ export function decide(call, deps = {}) {
94
+ const env = deps.env ?? process.env;
95
+ if (deps.writeMode !== 'cloud') return { allow: true };
96
+ if (String(env[KILL_ENV] ?? '') === '1') return { allow: true };
97
+ const vaultRoot = deps.vaultRoot;
98
+ if (!vaultRoot || typeof vaultRoot !== 'string') return { allow: true };
99
+ const exists = deps.exists ?? ((p) => fs.existsSync(p));
100
+ if (!exists(path.join(vaultRoot, 'heyspex.yaml'))) return { allow: true };
101
+
102
+ const platform = deps.platform ?? process.platform;
103
+ const ctx = { vaultRoot, cwd: deps.cwd, platform };
104
+ const tool = String(call?.toolName ?? '').toLowerCase();
105
+ const input = call?.toolInput ?? {};
106
+
107
+ if (FILE_TOOLS.has(tool)) {
108
+ const fp = input.file_path ?? input.notebook_path ?? input.filePath;
109
+ if (isGuardedVaultPath(fp, ctx)) {
110
+ return { allow: false, tool: pickTool(input), reason: denyReason(String(fp), pickTool(input)) };
111
+ }
112
+ return { allow: true };
113
+ }
114
+
115
+ if (SHELL_TOOLS.has(tool)) {
116
+ const command = String(input.command ?? '');
117
+ if (!command) return { allow: true };
118
+ for (const t of extractShellTargets(command)) {
119
+ if (isGuardedVaultPath(t, ctx)) {
120
+ return { allow: false, tool: pickTool(input), reason: denyReason(t, pickTool(input)) };
121
+ }
122
+ }
123
+ }
124
+ return { allow: true };
125
+ }
@@ -0,0 +1,42 @@
1
+ // spexharness/src/gates/vault-write-tier-guard.mjs
2
+ //
3
+ // Engine gate for HS-FCNH: in cloud write mode (`hs dev: prod`), deny agent
4
+ // disk writes to `<vault>/specs/**/*.md` — the disk copy is a mirror the console
5
+ // never reads back. Decision logic lives in ./vault-write-tier-guard.decide.mjs.
6
+ //
7
+ // Kill switch (human break-glass): HEYSPEX_VAULT_WRITE_TIER_GUARD_OFF=1.
8
+ // Deliberately NOT in LEGACY_EXIT2_BANNER: it emits the modern
9
+ // permissionDecision: deny JSON via the adapter.
10
+ //
11
+ // `ctx` (all optional): vaultRoot, writeMode, env, cwd, exists.
12
+
13
+ import { defaultConfigRoot } from '../config-root.mjs';
14
+ import { resolveDevMode } from '../../../scripts/lib/dev-mode.mjs';
15
+ import { decide as legacyDecide, KILL_ENV } from './vault-write-tier-guard.decide.mjs';
16
+
17
+ export const id = 'vault-write-tier-guard';
18
+ export const event = 'before_tool';
19
+ export const severity = 'block';
20
+
21
+ export function run(evt, ctx = {}) {
22
+ const env = ctx.env ?? process.env;
23
+ if (String(env[KILL_ENV] ?? '') === '1') return { decision: 'allow' };
24
+ let writeMode = ctx.writeMode;
25
+ if (!writeMode) {
26
+ try {
27
+ writeMode = resolveDevMode({ env }).vaultWriteMode;
28
+ } catch {
29
+ return { decision: 'allow' };
30
+ }
31
+ }
32
+ if (writeMode !== 'cloud') return { decision: 'allow' };
33
+ const vaultRoot = ctx.vaultRoot ?? defaultConfigRoot();
34
+ const verdict = legacyDecide(
35
+ { toolName: evt?.toolName, toolInput: evt?.toolInput ?? {} },
36
+ { vaultRoot, writeMode, env, cwd: ctx.cwd ?? process.env.CLAUDE_PROJECT_DIR ?? process.cwd(), exists: ctx.exists },
37
+ );
38
+ if (verdict.allow) return { decision: 'allow' };
39
+ return { decision: 'deny', reason: verdict.reason };
40
+ }
41
+
42
+ export default { id, event, severity, run };
@@ -0,0 +1,294 @@
1
+ // spexharness/src/git-reflog-audit.mjs
2
+ //
3
+ // The SHARED session-close git audit — moved here verbatim from
4
+ // scripts/claude-hooks/git-reflog-audit.mjs (task HS-610I-P3TJ, epic
5
+ // HS-GG01-P3TJ) as wave 6 of [[HS-AZ61-NYPR]] turned that hook into a thin
6
+ // shim over ./gates/git-reflog-audit.mjs. There is exactly ONE copy of the
7
+ // reflog parsing in the tree: this one. The gate imports it; the Claude Code
8
+ // shim reaches it through the gate; scripts/claude-hooks/git-reflog-audit.test.mjs
9
+ // (the original regression suite, unchanged apart from its import path) drives
10
+ // it directly.
11
+ //
12
+ // WHY the rule exists (kept from the original header): every other gate in this
13
+ // family (git-stash-gate, protected-action-intent) works by INTERCEPTING a
14
+ // destructive command as it is issued — bypassed, disabled, or routed around a
15
+ // gap in coverage, the action leaves no independent trace. This is a WITNESS,
16
+ // not a gate: it snapshots git state at session start and diffs it at session
17
+ // close, so a stash or a reset/checkout/rebase that slipped past every other
18
+ // control still shows up in .heyspex/guardrail-history.jsonl — a record
19
+ // independent of the acting agent's honesty.
20
+ //
21
+ // SCOPE: warn only. Never blocks, never denies.
22
+ //
23
+ // Snapshot (session start): writes `{ stash_count, reflog_head_entry,
24
+ // written_at }` to a SESSION-SCOPED file — the filename carries the session id
25
+ // so concurrent sessions on the same working tree never clobber each other's
26
+ // baseline — at `.heyspex/git-reflog-audit/<sessionId>.json`.
27
+ //
28
+ // Diff (stop / session end):
29
+ // - stash: any INCREASE in `git stash list`'s entry count since the snapshot
30
+ // names the new top entries (a push always lands at stash@{0}).
31
+ // - reflog: any HEAD reflog entry newer than the snapshot's recorded head
32
+ // entry whose action is reset/checkout/rebase is named. Plain commits newer
33
+ // than the snapshot are NOT flagged — that's normal work. Matching strips
34
+ // the `HEAD@{N}:` index label before comparing (that label shifts every time
35
+ // a new entry lands, even when the underlying hash+message didn't change) —
36
+ // see [[never-chain-cd-with-destructive-git-a]] for why a naive hash-only
37
+ // compare is not enough: `git stash push` logs its own internal
38
+ // `reset: moving to HEAD` entry against the SAME hash as the just-committed
39
+ // HEAD, so hash equality alone can't tell old from new.
40
+ // A clean session (no stash growth, no reset/checkout/rebase reflog entries)
41
+ // appends nothing.
42
+ //
43
+ // TEST ISOLATION: every git call here takes an explicit repoRootPath and shells
44
+ // out as `git -C <repoRootPath> ...` — never a bare `git` relying on cwd, and
45
+ // never a `cd X && git ...` chain.
46
+ //
47
+ // FAIL-SAFE: every exported function degrades to an inert value (null / [] /
48
+ // false) on any git or filesystem failure rather than throwing — this must
49
+ // never break a session start or a session close on its own bug.
50
+
51
+ import fs from 'node:fs';
52
+ import path from 'node:path';
53
+ import { spawnSync } from 'node:child_process';
54
+ import { fileURLToPath } from 'node:url';
55
+
56
+ import { appendGuardrailEvent } from '../../scripts/claude-hooks/_guardrail-events.mjs';
57
+
58
+ /** The `rule` id every row this module appends is stamped with. */
59
+ export const RULE_ID = 'git-reflog-audit';
60
+
61
+ /** spexharness/src/ → repo root is two levels up. */
62
+ const DEFAULT_REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
63
+
64
+ /** Env-first repo-root resolution, matching every pre-cutover hook's own helper. */
65
+ export function repoRoot(env = process.env) {
66
+ const fromEnv = env.CLAUDE_PROJECT_DIR?.trim();
67
+ if (fromEnv) return fromEnv.replace(/[\\/]+$/, '');
68
+ return DEFAULT_REPO_ROOT;
69
+ }
70
+
71
+ function git(repoRootPath, args) {
72
+ try {
73
+ const res = spawnSync('git', ['-C', repoRootPath, ...args], { encoding: 'utf8', timeout: 5000 });
74
+ if (res.status !== 0) return null;
75
+ return typeof res.stdout === 'string' ? res.stdout : '';
76
+ } catch {
77
+ return null;
78
+ }
79
+ }
80
+
81
+ /**
82
+ * `git stash list`, newest first (`stash@{0}` is the top). [] on any failure
83
+ * (not a repo, git unavailable, etc.) — never throws.
84
+ */
85
+ export function readStashList(repoRootPath) {
86
+ const out = git(repoRootPath, ['stash', 'list']);
87
+ if (out === null) return [];
88
+ return out.split(/\r?\n/).filter(Boolean);
89
+ }
90
+
91
+ /**
92
+ * `git reflog -n <limit>`, newest first. [] on any failure (including a
93
+ * brand-new repo with no commits yet, where HEAD has no reflog at all).
94
+ */
95
+ export function readReflogLines(repoRootPath, limit = 50) {
96
+ const out = git(repoRootPath, ['reflog', '-n', String(limit)]);
97
+ if (out === null) return [];
98
+ return out.split(/\r?\n/).filter(Boolean);
99
+ }
100
+
101
+ /**
102
+ * Strip the `HEAD@{N}:` index label from a reflog line before comparing two
103
+ * lines captured at different times — the label shifts by one every time a new
104
+ * entry is pushed, even when the underlying hash+message is unchanged, so
105
+ * comparing raw lines would never match a genuinely-unchanged entry.
106
+ */
107
+ export function normalizeReflogLine(line) {
108
+ return typeof line === 'string' ? line.replace(/HEAD@\{\d+\}:/, '').trim() : null;
109
+ }
110
+
111
+ /**
112
+ * The reflog "action" word for a line — the text right after `HEAD@{N}: ` and
113
+ * before the next space/colon/paren (`reset: moving to ...` → `reset`;
114
+ * `rebase (finish): ...` → `rebase`). null when the line doesn't have the
115
+ * expected shape — never throws.
116
+ */
117
+ export function reflogAction(line) {
118
+ if (typeof line !== 'string') return null;
119
+ const m = /HEAD@\{\d+\}:\s*([a-zA-Z-]+)/.exec(line);
120
+ return m ? m[1].toLowerCase() : null;
121
+ }
122
+
123
+ const FLAGGED_REFLOG_ACTIONS = new Set(['reset', 'checkout', 'rebase']);
124
+
125
+ /**
126
+ * Snapshot the git state this audit cares about. Never throws — a repo with no
127
+ * commits yet (empty reflog) or no stash entries yields zeros/nulls.
128
+ */
129
+ export function captureGitState(repoRootPath) {
130
+ const stashLines = readStashList(repoRootPath);
131
+ const reflogLines = readReflogLines(repoRootPath, 1);
132
+ return {
133
+ stash_count: stashLines.length,
134
+ reflog_head_entry: reflogLines[0] ?? null,
135
+ };
136
+ }
137
+
138
+ function sanitizeSessionId(sessionId) {
139
+ const raw =
140
+ typeof sessionId === 'string' && sessionId.trim() ? sessionId.trim() : 'unknown-session';
141
+ return raw.replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 120);
142
+ }
143
+
144
+ /** Default session-scoped snapshot path — `.heyspex/git-reflog-audit/<sessionId>.json`. */
145
+ export function snapshotPathFor(repoRootPath, sessionId, snapshotDir = 'git-reflog-audit') {
146
+ return path.join(repoRootPath, '.heyspex', snapshotDir, `${sanitizeSessionId(sessionId)}.json`);
147
+ }
148
+
149
+ /**
150
+ * Session-start half: write the snapshot file. Returns the written snapshot
151
+ * object, or null on any failure (fail-safe — a write failure here must never
152
+ * break session start).
153
+ */
154
+ export function writeSnapshot({ repoRootPath = repoRoot(), sessionId, snapshotPath } = {}) {
155
+ try {
156
+ const state = captureGitState(repoRootPath);
157
+ const snapshot = { ...state, written_at: new Date().toISOString() };
158
+ const filePath = snapshotPath || snapshotPathFor(repoRootPath, sessionId);
159
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
160
+ fs.writeFileSync(filePath, JSON.stringify(snapshot));
161
+ return snapshot;
162
+ } catch {
163
+ return null;
164
+ }
165
+ }
166
+
167
+ /** Read a previously-written snapshot. null on any failure (missing/corrupt file). */
168
+ export function readSnapshot(snapshotPath) {
169
+ try {
170
+ const raw = fs.readFileSync(snapshotPath, 'utf8');
171
+ const parsed = JSON.parse(raw);
172
+ return parsed && typeof parsed === 'object' ? parsed : null;
173
+ } catch {
174
+ return null;
175
+ }
176
+ }
177
+
178
+ /**
179
+ * Diff current git state against a snapshot. Returns
180
+ * { newStashEntries: string[], flaggedReflogEntries: string[] }
181
+ * — both empty on a clean session.
182
+ */
183
+ export function diffAgainstSnapshot(repoRootPath, snapshot) {
184
+ const result = { newStashEntries: [], flaggedReflogEntries: [] };
185
+ if (!snapshot || typeof snapshot !== 'object') return result;
186
+
187
+ // --- stash growth -------------------------------------------------------
188
+ const currentStash = readStashList(repoRootPath);
189
+ const priorCount = Number.isFinite(snapshot.stash_count) ? snapshot.stash_count : 0;
190
+ const grown = currentStash.length - priorCount;
191
+ if (grown > 0) {
192
+ result.newStashEntries = currentStash.slice(0, grown);
193
+ }
194
+
195
+ // --- reflog: reset/checkout/rebase entries newer than the snapshot ------
196
+ const priorEntry =
197
+ typeof snapshot.reflog_head_entry === 'string' ? snapshot.reflog_head_entry : null;
198
+ const currentReflog = readReflogLines(repoRootPath, 100);
199
+ if (currentReflog.length && currentReflog[0] !== priorEntry) {
200
+ const priorNormalized = normalizeReflogLine(priorEntry);
201
+ const idx =
202
+ priorNormalized === null
203
+ ? -1
204
+ : currentReflog.findIndex((line) => normalizeReflogLine(line) === priorNormalized);
205
+ // idx === -1 means no baseline (fresh repo) or the old entry fell outside
206
+ // the fetch window — bounded fallback: everything fetched is "new".
207
+ const newLines = idx === -1 ? currentReflog : currentReflog.slice(0, idx);
208
+ result.flaggedReflogEntries = newLines.filter((line) =>
209
+ FLAGGED_REFLOG_ACTIONS.has(reflogAction(line)),
210
+ );
211
+ }
212
+
213
+ return result;
214
+ }
215
+
216
+ /**
217
+ * The human-readable half of a flagged diff — `new stash: … ; reflog: …`, the
218
+ * exact `matchContext` string the guardrail-history row carries. Pure.
219
+ *
220
+ * @param {{ newStashEntries?: string[], flaggedReflogEntries?: string[] }} diff
221
+ * @returns {string} '' when nothing was flagged.
222
+ */
223
+ export function describeDiff(diff) {
224
+ const parts = [];
225
+ if (diff?.newStashEntries?.length) parts.push(`new stash: ${diff.newStashEntries.join(' | ')}`);
226
+ if (diff?.flaggedReflogEntries?.length) {
227
+ parts.push(`reflog: ${diff.flaggedReflogEntries.join(' | ')}`);
228
+ }
229
+ return parts.join(' ; ');
230
+ }
231
+
232
+ /** True when a diff has anything worth warning about. Pure. */
233
+ export function isFlagged(diff) {
234
+ return Boolean(diff?.newStashEntries?.length || diff?.flaggedReflogEntries?.length);
235
+ }
236
+
237
+ /**
238
+ * Stop / session-end half: read the snapshot, diff, and — only if something was
239
+ * flagged — append ONE warn row to guardrail-history.jsonl naming it. Returns
240
+ * the diff result (so a caller/test can assert on it), or null when there was
241
+ * no snapshot to compare against (nothing to do, no false-flag risk).
242
+ *
243
+ * @param {{ repoRootPath?: string, sessionId?: string, snapshotPath?: string,
244
+ * append?: (row: object, repoRoot: string) => unknown }} [opts]
245
+ * `append` — override the guardrail-history writer (tests, and any front that
246
+ * wants the decision without the file write).
247
+ */
248
+ export function runSessionEnd({
249
+ repoRootPath = repoRoot(),
250
+ sessionId,
251
+ snapshotPath,
252
+ append = appendGuardrailEvent,
253
+ } = {}) {
254
+ const filePath = snapshotPath || snapshotPathFor(repoRootPath, sessionId);
255
+ const snapshot = readSnapshot(filePath);
256
+ if (!snapshot) return null;
257
+
258
+ const diff = diffAgainstSnapshot(repoRootPath, snapshot);
259
+ if (!isFlagged(diff)) return diff;
260
+
261
+ try {
262
+ append(
263
+ {
264
+ rule: RULE_ID,
265
+ surface: 'session-close',
266
+ severity: 'warn',
267
+ decision: 'warn',
268
+ matchContext: describeDiff(diff),
269
+ },
270
+ repoRootPath,
271
+ );
272
+ } catch {
273
+ // observability must never break the session close
274
+ }
275
+
276
+ return diff;
277
+ }
278
+
279
+ export default {
280
+ RULE_ID,
281
+ captureGitState,
282
+ describeDiff,
283
+ diffAgainstSnapshot,
284
+ isFlagged,
285
+ normalizeReflogLine,
286
+ readReflogLines,
287
+ readSnapshot,
288
+ readStashList,
289
+ reflogAction,
290
+ repoRoot,
291
+ runSessionEnd,
292
+ snapshotPathFor,
293
+ writeSnapshot,
294
+ };