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,886 @@
1
+ // customer-kit.mjs — what `hs install` ships into a CUSTOMER repo, in one place.
2
+ //
3
+ // Owner decision 2026-10-02 (follow-through of
4
+ // [[HS-EN4F-F4V2-DIAG-a-repo-set-up-with-hs-install-gets-no]]): every customer
5
+ // repo gets the full HeySpex agent kit — the lifecycle hooks and the /hs-*
6
+ // commands. The platform's own `.claude/settings.json` registers every hook as
7
+ // `node "$CLAUDE_PROJECT_DIR/scripts/claude-hooks/_run.mjs" <hook>` and its
8
+ // commands run `node dev-factory/...` / `node bin/hs.mjs ...` repo-relative;
9
+ // both only resolve inside heyspex-app, so in a customer repo they failed with
10
+ // MODULE_NOT_FOUND — a non-blocking error, i.e. every gate silently off.
11
+ //
12
+ // PORTABLE PATHS ([[HS-0AF8-WW9R-TASK-portable-hook-and-command-paths-in]],
13
+ // epic [[HS-O4Y6-WW9R-EPIC-customer-kit-packaging-hooks-in-the-npm]]). The
14
+ // first kit (2026-10-02) addressed the shim by the platform checkout's ABSOLUTE
15
+ // path, which a customer then committed — on any other machine every hook was
16
+ // a dead command (invariant 8). Nothing written into a customer repo names
17
+ // this machine any more:
18
+ // - hooks run `node "$CLAUDE_PROJECT_DIR/.claude/heyspex/run-hook.mjs" <hook>`
19
+ // — a small committed LAUNCHER ({@link renderCustomerLauncher}) that finds
20
+ // the `heyspex` package from the repo (node_modules/heyspex first, a
21
+ // machine-local env override second, the global npm root last) and hands
22
+ // off in-process to the package's shim;
23
+ // - commands call `hs <verb>` or `node node_modules/heyspex/<file>`;
24
+ // - `.mcp.json` / `.cursor/mcp.json` point at `node_modules/heyspex` through
25
+ // the client's own project-dir variable (install.ts).
26
+ // The platform checkout is its own kit and keeps its `$CLAUDE_PROJECT_DIR/
27
+ // scripts/...` form ({@link samePlatformPath}). A legacy absolute row is
28
+ // recognized ({@link ourHookTail}) and REPAIRED in place by the next `hs
29
+ // install` / `hs connect [--fix-commands]`.
30
+ //
31
+ // This module is the reviewable table plus the pure helpers every consumer
32
+ // shares:
33
+ // - spex-console/src/lib/cli/install.ts (`hs install` writes the kit)
34
+ // - spex-console/src/lib/cli/connect-checks.ts / connect.ts (drift check +
35
+ // `hs connect --fix-commands` ship the same rendered commands)
36
+ // - scripts/install-cursor-hooks.mjs (Cursor hooks, same scope table)
37
+ // - scripts/heyspex-doctor.mjs (`customer-kit` check)
38
+ // - plugins/heyspex/hooks/hooks.json (generated: `--write-plugin-hooks`)
39
+ //
40
+ // SCOPE RULE. A hook is `customer` when it acts on the session's project
41
+ // (CLAUDE_PROJECT_DIR / the active vault / the MCP server's state) and makes
42
+ // sense in any repo HeySpex is installed in. It is `platform` when its subject
43
+ // is heyspex-app's own development machinery (the spex-console dev server and
44
+ // Neon, dev-factory seats and the COO window, the platform's env vault, the
45
+ // vendored MCP build) or when it still resolves its state from the platform
46
+ // checkout's own location instead of the project. A new hook in
47
+ // `.claude/settings.json` without a row here fails customer-kit.test.mjs, so
48
+ // the decision is always made explicitly, never by omission.
49
+
50
+ import { createHash } from 'node:crypto';
51
+ import fs from 'node:fs';
52
+ import path from 'node:path';
53
+ import { fileURLToPath, pathToFileURL } from 'node:url';
54
+
55
+ /** @typedef {'customer' | 'platform'} KitScope */
56
+
57
+ /**
58
+ * One scope decision per hook name (the token after `_run.mjs`).
59
+ * @type {Readonly<Record<string, { scope: KitScope, why: string }>>}
60
+ */
61
+ export const HOOK_SCOPES = Object.freeze({
62
+ // ── customer: vault / workflow / safety gates ─────────────────────────────
63
+ 'wip-checkpoint': { scope: 'customer', why: 'Previous-session WIP handoff, read from the active vault.' },
64
+ 'inject-doctrine': {
65
+ scope: 'customer',
66
+ why:
67
+ 'Owner ruling 2026-10-03 (HS-A2PO): ships. A customer session gets the baseline edition of SpexCode Doctrine ' +
68
+ '(CUSTOMER_HOOK_DATA), the foundation its own learned layer is built on; never the platform\'s learned file.',
69
+ },
70
+ 'check-auto-memory-budget': { scope: 'customer', why: 'Auto-memory index budget for the session project.' },
71
+ 'check-claude-md-budget': { scope: 'customer', why: 'Budget check on the project\'s always-loaded agent contract.' },
72
+ 'git-reflog-audit': { scope: 'customer', why: 'Session-close git audit backstop (stash/reset/rebase witness).' },
73
+ 'preflight-reminder': { scope: 'customer', why: 'Names the required procedure before filing/destructive turns.' },
74
+ 'intent-classifier-trigger': { scope: 'customer', why: 'Routes capture/research/settings/defer intents.' },
75
+ 'inject-communication-style': { scope: 'customer', why: 'Plain-language communication rules each turn.' },
76
+ 'inject-vault-context': { scope: 'customer', why: 'Lexical vault retrieval for the prompt.' },
77
+ 'git-identity-guard': { scope: 'customer', why: 'Agents never write git identity (owner ruling 2026-09-19).' },
78
+ 'settings-write-gate': { scope: 'customer', why: 'Approval before the vault\'s .heyspex/config.yaml changes.' },
79
+ 'emit-claim-status': { scope: 'customer', why: 'Claim liveness heartbeat to the console.' },
80
+ 'enforce-vault-naming': { scope: 'customer', why: 'Naming V3 filename guard (HS-RY2N).' },
81
+ 'guard-auto-memory-writes': { scope: 'customer', why: 'No new topic files in the auto-memory directory.' },
82
+ 'plan-review-gate': { scope: 'customer', why: 'Plan review + QC markers before ExitPlanMode.' },
83
+ 'plan-exit-ingest-gate': { scope: 'customer', why: 'Plan ingest on ExitPlanMode; arms claim-before-code.' },
84
+ 'claim-before-code-gate': { scope: 'customer', why: 'First code edit after plan exit needs a claim.' },
85
+ 'capture-workflow-gate': { scope: 'customer', why: 'No implementation spec straight out of an open capture.' },
86
+ 'git-stash-gate': { scope: 'customer', why: 'Always ask before a mutating git stash.' },
87
+ 'research-method-guard': { scope: 'customer', why: 'Research method guard (HS-EN4F).' },
88
+ 'deep-research-cost-gate': { scope: 'customer', why: 'Cost confirmation before a paid deep-research launch.' },
89
+ 'research-protocol-gate': { scope: 'customer', why: 'WebSearch/WebFetch refused until research preflight ran (HS-K0ZP, HS-EN4F).' },
90
+ 'naughty-list-gate': { scope: 'customer', why: 'Re-drive a final message that uses banned jargon.' },
91
+ 'id-display-format-gate': { scope: 'customer', why: 'Naming V3 display-id format in replies.' },
92
+ 'epic-milestone-phrasing-gate': { scope: 'customer', why: 'Epic/milestone phrasing rules in replies.' },
93
+ 'enforce-session-sequence': { scope: 'customer', why: 'Re-drive a turn that shipped without record_wip.' },
94
+ 'notify-status-flip': { scope: 'customer', why: 'Sound on an in_review status flip.' },
95
+ 'close-terminal-on-ship': { scope: 'customer', why: 'Close the launch terminal once the spec ships.' },
96
+ 'scripts/stamp-provenance-on-edit.mjs': { scope: 'customer', why: 'Stamp assisted_by provenance on direct spec edits (active vault).' },
97
+ 'session-end': { scope: 'customer', why: 'Release the claim and export session events.' },
98
+
99
+ // ── platform: heyspex-app's own development machinery ─────────────────────
100
+ 'inject-dev-server-awareness': { scope: 'platform', why: 'About the spex-console dev server in heyspex-app.' },
101
+ 'inject-coo-seat': { scope: 'platform', why: 'dev-factory COO seat mandate.' },
102
+ 'coo-guard-check': { scope: 'platform', why: 'dev-factory breakdown-delegation guard.' },
103
+ 'coo-denylist-gate': { scope: 'platform', why: 'dev-factory COO window deny list.' },
104
+ 'agent-spawn-delegation-gate': { scope: 'platform', why: 'dev-factory seat routing for subagents.' },
105
+ 'check-mcp-freshness-hook': { scope: 'platform', why: 'Freshness of the vendored MCP build — a platform-dev concern.' },
106
+ 'dev-server-task-reminder': { scope: 'platform', why: 'spex-console dev server + Neon DB safety.' },
107
+ 'env-vault-guard': { scope: 'platform', why: 'Guards heyspex-app\'s encrypted env vault paths.' },
108
+ 'log-session-activity': { scope: 'platform', why: 'Resolves .heyspex state from the platform checkout\'s own location, not the project — would log customer sessions into heyspex-app.' },
109
+ });
110
+
111
+ /**
112
+ * Data files (not static imports, so verify-mcp-package's hookTargets closure walk cannot
113
+ * see them) a customer-scoped hook reads at run time from the installed package. Each must
114
+ * be listed in package.json `files` and exist on disk; customer-kit.test.mjs asserts both.
115
+ * The inject-doctrine entry is the ONLY doctrine content that ships: the learned
116
+ * spexharness/doctrine/SPEXCODE-DOCTRINE.json is distilled from the owner's own sessions and
117
+ * must never match a `files` entry (same test).
118
+ * @type {Readonly<Record<string, readonly string[]>>}
119
+ */
120
+ export const CUSTOMER_HOOK_DATA = Object.freeze({
121
+ 'inject-doctrine': Object.freeze(['spexharness/doctrine/baseline/SPEXCODE-DOCTRINE.baseline.json']),
122
+ });
123
+
124
+ /**
125
+ * Every Claude Code hook registration, mirroring the platform's
126
+ * `.claude/settings.json` row for row (customer-kit.test.mjs asserts parity).
127
+ * `matcher` is the platform's own matcher; MCP tool names are widened to the
128
+ * plugin-scoped spelling at render time ({@link widenMcpMatcher}).
129
+ * @type {ReadonlyArray<{ event: string, matcher?: string, hook: string, args?: string[], timeout: number }>}
130
+ */
131
+ export const CLAUDE_HOOK_ROWS = Object.freeze([
132
+ { event: 'SessionStart', hook: 'wip-checkpoint', timeout: 5 },
133
+ { event: 'SessionStart', hook: 'inject-dev-server-awareness', timeout: 5 },
134
+ { event: 'SessionStart', hook: 'inject-coo-seat', timeout: 5 },
135
+ { event: 'SessionStart', hook: 'inject-doctrine', timeout: 5 },
136
+ { event: 'SessionStart', hook: 'coo-guard-check', timeout: 5 },
137
+ { event: 'SessionStart', hook: 'check-auto-memory-budget', timeout: 5 },
138
+ { event: 'SessionStart', hook: 'check-claude-md-budget', timeout: 5 },
139
+ { event: 'SessionStart', hook: 'check-mcp-freshness-hook', timeout: 5 },
140
+ { event: 'SessionStart', hook: 'git-reflog-audit', timeout: 5 },
141
+ { event: 'PreCompact', hook: 'wip-checkpoint', timeout: 5 },
142
+ { event: 'UserPromptSubmit', hook: 'preflight-reminder', timeout: 5 },
143
+ { event: 'UserPromptSubmit', hook: 'intent-classifier-trigger', timeout: 5 },
144
+ { event: 'UserPromptSubmit', hook: 'inject-communication-style', timeout: 5 },
145
+ { event: 'UserPromptSubmit', hook: 'inject-vault-context', timeout: 10 },
146
+ { event: 'PreToolUse', matcher: 'Write|Edit|MultiEdit|NotebookEdit|Bash|PowerShell', hook: 'env-vault-guard', timeout: 5 },
147
+ { event: 'PreToolUse', matcher: 'Bash|PowerShell', hook: 'git-identity-guard', timeout: 5 },
148
+ { event: 'PreToolUse', matcher: 'Write|Edit|NotebookEdit|Bash|PowerShell|mcp__heyspex__heyspex_settings_set', hook: 'settings-write-gate', timeout: 5 },
149
+ { event: 'PreToolUse', matcher: '.*', hook: 'emit-claim-status', args: ['--event=PreToolUse'], timeout: 5 },
150
+ { event: 'PreToolUse', matcher: '.*', hook: 'coo-denylist-gate', timeout: 5 },
151
+ { event: 'PreToolUse', matcher: 'Agent|Task', hook: 'agent-spawn-delegation-gate', timeout: 5 },
152
+ { event: 'PreToolUse', matcher: 'mcp__heyspex__heyspex_claim_spec', hook: 'coo-guard-check', timeout: 5 },
153
+ { event: 'PreToolUse', matcher: 'Write|NotebookEdit|Bash|PowerShell', hook: 'enforce-vault-naming', timeout: 5 },
154
+ { event: 'PreToolUse', matcher: 'Write|NotebookEdit|Bash|PowerShell', hook: 'guard-auto-memory-writes', timeout: 5 },
155
+ { event: 'PreToolUse', matcher: 'Edit|MultiEdit|Write|Bash|PowerShell', hook: 'preflight-reminder', timeout: 5 },
156
+ { event: 'PreToolUse', matcher: 'ExitPlanMode', hook: 'plan-review-gate', timeout: 8 },
157
+ { event: 'PreToolUse', matcher: 'ExitPlanMode', hook: 'plan-exit-ingest-gate', timeout: 5 },
158
+ { event: 'PreToolUse', matcher: 'Edit|MultiEdit|Write', hook: 'claim-before-code-gate', timeout: 5 },
159
+ { event: 'PreToolUse', matcher: 'mcp__heyspex__heyspex_file_spec', hook: 'capture-workflow-gate', timeout: 8 },
160
+ { event: 'PreToolUse', matcher: 'Bash|PowerShell|mcp__neon__prepare_database_migration|mcp__neon__complete_database_migration|mcp__neon__reset_from_parent|mcp__neon__delete_branch|mcp__Neon__prepare_database_migration|mcp__Neon__complete_database_migration|mcp__Neon__reset_from_parent|mcp__Neon__delete_branch', hook: 'dev-server-task-reminder', timeout: 10 },
161
+ { event: 'PreToolUse', matcher: 'Bash|PowerShell', hook: 'git-stash-gate', timeout: 10 },
162
+ { event: 'PreToolUse', matcher: 'Skill|Workflow', hook: 'research-method-guard', timeout: 5 },
163
+ { event: 'PreToolUse', matcher: 'Workflow|Skill', hook: 'deep-research-cost-gate', timeout: 5 },
164
+ { event: 'PreToolUse', matcher: 'WebSearch|WebFetch|mcp__heyspex__heyspex_save_research|mcp__plugin_heyspex_heyspex__heyspex_save_research', hook: 'research-protocol-gate', timeout: 5 },
165
+ { event: 'PreToolUse', matcher: '.*', hook: 'log-session-activity', timeout: 5 },
166
+ { event: 'Stop', hook: 'emit-claim-status', args: ['--event=Stop'], timeout: 5 },
167
+ { event: 'Stop', hook: 'naughty-list-gate', timeout: 5 },
168
+ { event: 'Stop', hook: 'id-display-format-gate', timeout: 5 },
169
+ { event: 'Stop', hook: 'epic-milestone-phrasing-gate', timeout: 5 },
170
+ { event: 'Stop', hook: 'git-reflog-audit', timeout: 5 },
171
+ { event: 'Stop', hook: 'enforce-session-sequence', timeout: 5 },
172
+ { event: 'PostToolUse', matcher: '.*', hook: 'emit-claim-status', args: ['--event=PostToolUse'], timeout: 5 },
173
+ { event: 'PostToolUse', matcher: 'Edit|Write|MultiEdit', hook: 'notify-status-flip', timeout: 5 },
174
+ { event: 'PostToolUse', matcher: 'Edit|Write|MultiEdit', hook: 'close-terminal-on-ship', timeout: 5 },
175
+ { event: 'PostToolUse', matcher: 'Edit|Write|MultiEdit', hook: 'scripts/stamp-provenance-on-edit.mjs', timeout: 5 },
176
+ { event: 'PostToolUse', matcher: '.*', hook: 'log-session-activity', timeout: 5 },
177
+ { event: 'SessionEnd', hook: 'session-end', timeout: 5 },
178
+ { event: 'SessionEnd', hook: 'close-terminal-on-ship', timeout: 5 },
179
+ ]);
180
+
181
+ /**
182
+ * One scope decision per harness command file (`.claude/commands/<name>.md`,
183
+ * mirrored into `.cursor/commands/`). Only `customer` files ship.
184
+ * @type {Readonly<Record<string, { scope: KitScope, why: string }>>}
185
+ */
186
+ export const COMMAND_SCOPES = Object.freeze({
187
+ 'hs-backfill.md': { scope: 'customer', why: 'Backfill completed work into the vault.' },
188
+ 'hs-capture.md': { scope: 'customer', why: 'Capture intake.' },
189
+ 'hs-defer.md': { scope: 'customer', why: 'Split + defer remaining work.' },
190
+ 'hs-doctor.md': { scope: 'customer', why: 'Install-surface repair when heyspex_* tools are missing.' },
191
+ 'hs-ground.md': { scope: 'customer', why: 'Pre-plan grounding report.' },
192
+ 'hs-handoff.md': { scope: 'customer', why: 'Short-code lane handoff.' },
193
+ 'hs-next.md': { scope: 'customer', why: 'On-deck prompt + claim handshake.' },
194
+ 'hs-promote.md': { scope: 'customer', why: 'Capture → research → milestone promotion.' },
195
+ 'hs-push.md': { scope: 'customer', why: 'Push an epic plan into the vault.' },
196
+ 'hs-qc.md': { scope: 'customer', why: 'Post-plan QC gate.' },
197
+ 'hs-res.md': { scope: 'customer', why: 'Research method entrypoint (SpexCrawl/DeepSeek).' },
198
+ 'hs-resolve.md': { scope: 'customer', why: 'Resolution lane.' },
199
+ 'hs-review.md': { scope: 'customer', why: 'Review queue.' },
200
+ 'hs-view.md': { scope: 'customer', why: 'Open a console surface.' },
201
+ 'hs-auto.md': { scope: 'platform', why: 'Routes work into dev-factory (platform build system).' },
202
+ 'hs-dev.md': { scope: 'platform', why: 'Admin/dev-only HeySpex dev/prod mode switch.' },
203
+ 'hs-log.md': { scope: 'platform', why: 'Admin/dev-only dev-server log watcher.' },
204
+ 'kill-ports.md': { scope: 'platform', why: 'Kills local dev-server ports.' },
205
+ 'prototype.md': { scope: 'platform', why: 'Console design-preview prototypes inside spex-console.' },
206
+ });
207
+
208
+ /** @param {string} hook */
209
+ export function hookScope(hook) {
210
+ return HOOK_SCOPES[hook]?.scope ?? null;
211
+ }
212
+
213
+ /** @param {string} file basename, e.g. `hs-res.md` */
214
+ export function isCustomerCommand(file) {
215
+ return COMMAND_SCOPES[file]?.scope === 'customer';
216
+ }
217
+
218
+ /** Forward-slash form of an absolute path (node accepts it on Windows; no shell eats it). */
219
+ export function toPosixPath(p) {
220
+ return String(p).replace(/\\/g, '/').replace(/\/+$/, '');
221
+ }
222
+
223
+ /** Case-insensitive on Windows, separator-agnostic path equality. */
224
+ export function samePlatformPath(a, b) {
225
+ if (!a || !b) return false;
226
+ const norm = (p) => {
227
+ const s = toPosixPath(path.resolve(p));
228
+ return process.platform === 'win32' ? s.toLowerCase() : s;
229
+ };
230
+ return norm(a) === norm(b);
231
+ }
232
+
233
+ // ---------------------------------------------------------------------------
234
+ // Claude Code hooks
235
+ // ---------------------------------------------------------------------------
236
+
237
+ const MCP_PROJECT_PREFIX = 'mcp__heyspex__';
238
+ const MCP_PLUGIN_PREFIX = 'mcp__plugin_heyspex_heyspex__';
239
+
240
+ /**
241
+ * A project-scoped MCP tool name in a matcher also needs its plugin-scoped
242
+ * spelling, so the gate holds under either wiring (.mcp.json or the plugin).
243
+ * @param {string | undefined} matcher
244
+ */
245
+ export function widenMcpMatcher(matcher) {
246
+ if (!matcher) return matcher;
247
+ const parts = matcher.split('|');
248
+ const out = [];
249
+ for (const part of parts) {
250
+ if (!out.includes(part)) out.push(part);
251
+ if (part.startsWith(MCP_PROJECT_PREFIX)) {
252
+ const plugin = MCP_PLUGIN_PREFIX + part.slice(MCP_PROJECT_PREFIX.length);
253
+ if (!parts.includes(plugin) && !out.includes(plugin)) out.push(plugin);
254
+ }
255
+ }
256
+ return out.join('|');
257
+ }
258
+
259
+ /** The hook rows a customer repo gets. */
260
+ export function customerHookRows() {
261
+ return CLAUDE_HOOK_ROWS.filter((r) => hookScope(r.hook) === 'customer');
262
+ }
263
+
264
+ /** `<hook> [args...]` — the identity of a registration within one event. */
265
+ export function hookTail(row) {
266
+ return [row.hook, ...(row.args ?? [])].join(' ');
267
+ }
268
+
269
+ // ---------------------------------------------------------------------------
270
+ // The committed launcher a customer repo carries
271
+ // ---------------------------------------------------------------------------
272
+
273
+ /** The Claude Code hook shim, relative to the `heyspex` package root. */
274
+ export const CLAUDE_HOOK_SHIM = 'scripts/claude-hooks/_run.mjs';
275
+ /** The Cursor hook shim, relative to the `heyspex` package root. */
276
+ export const CURSOR_HOOK_SHIM = 'scripts/cursor-hooks/_run.mjs';
277
+
278
+ /**
279
+ * Where each harness's launcher lives inside a customer repo, and the
280
+ * project-dir variable that harness expands in a hook command.
281
+ * @type {Readonly<Record<'claude' | 'cursor', { rel: readonly string[], projectDirEnv: string, shim: string, registry: string }>>}
282
+ */
283
+ export const CUSTOMER_LAUNCHERS = Object.freeze({
284
+ claude: Object.freeze({
285
+ rel: Object.freeze(['.claude', 'heyspex', 'run-hook.mjs']),
286
+ projectDirEnv: 'CLAUDE_PROJECT_DIR',
287
+ shim: CLAUDE_HOOK_SHIM,
288
+ registry: '.claude/settings.json',
289
+ }),
290
+ cursor: Object.freeze({
291
+ rel: Object.freeze(['.cursor', 'heyspex', 'run-hook.mjs']),
292
+ projectDirEnv: 'CURSOR_PROJECT_DIR',
293
+ shim: CURSOR_HOOK_SHIM,
294
+ registry: '.cursor/hooks.json',
295
+ }),
296
+ });
297
+
298
+ /**
299
+ * Runner prefix for a customer repo: the committed launcher, addressed from the
300
+ * harness's project-dir variable — never this machine's path. The platform
301
+ * root is accepted (and ignored) so every caller keeps its signature; the
302
+ * launcher file itself is what {@link renderCustomerLauncher} renders.
303
+ * @param {string} [_platformRoot]
304
+ * @param {'claude' | 'cursor'} [surface]
305
+ */
306
+ export function customerRunnerPrefix(_platformRoot, surface = 'claude') {
307
+ const l = CUSTOMER_LAUNCHERS[surface];
308
+ return `node "$${l.projectDirEnv}/${l.rel.join('/')}"`;
309
+ }
310
+
311
+ /** Runner prefix inside the plugin: its own launcher, which finds the platform. */
312
+ export const PLUGIN_RUNNER_PREFIX = 'node "${CLAUDE_PLUGIN_ROOT}/bin/run-hook.mjs"';
313
+
314
+ // The launcher is kept as ONE template string (not a sibling file) so the
315
+ // bundled CLI (`dist/cli/*.mjs`, built from install.ts) carries it by
316
+ // construction — a file read relative to this module would miss once bundled.
317
+ // `__TOKENS__` are substituted per surface; no backticks inside, on purpose.
318
+ const LAUNCHER_TEMPLATE = [
319
+ '#!/usr/bin/env node',
320
+ '// HeySpex hook launcher -- GENERATED by hs install / hs connect. Commit it with this repo.',
321
+ '//',
322
+ '// Every HeySpex hook registered in __REGISTRY__ runs',
323
+ '// node "$__ENV__/__LAUNCHER__" <hook> [args]',
324
+ '// This file carries NO machine-specific path. It finds the heyspex package this',
325
+ '// repo depends on (node_modules/heyspex) and hands off in-process to the',
326
+ '// package\'s hook shim (__SHIM__), so the same committed command works on every',
327
+ '// machine the repo is cloned to.',
328
+ '//',
329
+ '// Resolution order (the first root that carries the shim wins):',
330
+ '// 1. HEYSPEX_PLATFORM_ROOT explicit machine-local override',
331
+ '// 2. HEYSPEX_MCP_ENTRY -> its package root machine-local override',
332
+ '// 3. node_modules/heyspex, walking up from this file (the repo-local dependency)',
333
+ '// 4. node_modules/heyspex up from $__ENV__, $CLAUDE_PROJECT_DIR, $CURSOR_PROJECT_DIR, cwd',
334
+ '// 5. <npm root -g>/heyspex a global install, last resort',
335
+ '// Nothing found -> one stderr line and exit 0 (fail-open, like the shim itself).',
336
+ "import { spawnSync } from 'node:child_process';",
337
+ "import { existsSync } from 'node:fs';",
338
+ "import { dirname, resolve } from 'node:path';",
339
+ "import { fileURLToPath, pathToFileURL } from 'node:url';",
340
+ '',
341
+ "const SHIM = '__SHIM__'.split('/');",
342
+ 'const here = dirname(fileURLToPath(import.meta.url));',
343
+ 'const env = process.env;',
344
+ 'const text = (v) => (typeof v === \'string\' && v.trim() ? v.trim() : null);',
345
+ 'const hasShim = (root) => Boolean(root) && existsSync(resolve(root, ...SHIM));',
346
+ '',
347
+ 'function packageUpFrom(start) {',
348
+ ' let dir = resolve(start);',
349
+ ' for (;;) {',
350
+ " const candidate = resolve(dir, 'node_modules', 'heyspex');",
351
+ ' if (hasShim(candidate)) return candidate;',
352
+ ' const parent = dirname(dir);',
353
+ ' if (parent === dir) return null;',
354
+ ' dir = parent;',
355
+ ' }',
356
+ '}',
357
+ '',
358
+ 'function globalPackage() {',
359
+ ' try {',
360
+ ' // One command string through the shell: npm is a .cmd on Windows, and a',
361
+ ' // shell + args array is what node deprecates (DEP0190).',
362
+ " const res = spawnSync('npm root -g', { encoding: 'utf8', timeout: 5000, shell: true });",
363
+ " const root = String(res.stdout ?? '').trim();",
364
+ " return root ? resolve(root, 'heyspex') : null;",
365
+ ' } catch {',
366
+ ' return null;',
367
+ ' }',
368
+ '}',
369
+ '',
370
+ 'const mcpEntry = text(env.HEYSPEX_MCP_ENTRY);',
371
+ 'const starts = [...new Set([here, text(env.__ENV__), text(env.CLAUDE_PROJECT_DIR), text(env.CURSOR_PROJECT_DIR), process.cwd()].filter(Boolean))];',
372
+ 'let root =',
373
+ " [text(env.HEYSPEX_PLATFORM_ROOT), mcpEntry && resolve(mcpEntry, '..', '..', '..')].find(hasShim) ??",
374
+ ' starts.map(packageUpFrom).find(Boolean) ??',
375
+ ' null;',
376
+ 'if (!root) {',
377
+ ' const global = globalPackage();',
378
+ ' if (hasShim(global)) root = global;',
379
+ '}',
380
+ '',
381
+ 'if (!root) {',
382
+ ' process.stderr.write(',
383
+ " '[heyspex] hook ' + (process.argv[2] ?? '?') + ' skipped: no heyspex package found ' +",
384
+ " '(run \"npm install heyspex\" in this repo, or set HEYSPEX_PLATFORM_ROOT). Looked for __SHIM__ under ' +",
385
+ " 'HEYSPEX_PLATFORM_ROOT, HEYSPEX_MCP_ENTRY, node_modules/heyspex up from ' + starts.join(', ') + ', and npm root -g.\\n',",
386
+ ' );',
387
+ ' process.exit(0);',
388
+ '}',
389
+ '',
390
+ '// argv is already [node, <this file>, <hook>, ...args] -- the shape the shim',
391
+ '// reads; the shim path replaces this file so a shim guarded by "am I main?"',
392
+ '// (the Cursor one) runs exactly as if Cursor had invoked it directly.',
393
+ 'const shim = resolve(root, ...SHIM);',
394
+ 'process.argv[1] = shim;',
395
+ 'await import(pathToFileURL(shim).href);',
396
+ '',
397
+ ].join('\n');
398
+
399
+ /**
400
+ * The launcher file `hs install` / `hs connect` write into a customer repo
401
+ * (`.claude/heyspex/run-hook.mjs`; the Cursor copy at
402
+ * `.cursor/heyspex/run-hook.mjs`). Pure: a string, no machine path in it.
403
+ * @param {'claude' | 'cursor'} [surface]
404
+ */
405
+ export function renderCustomerLauncher(surface = 'claude') {
406
+ const l = CUSTOMER_LAUNCHERS[surface];
407
+ return LAUNCHER_TEMPLATE.replace(/__(REGISTRY|ENV|LAUNCHER|SHIM)__/g, (_m, token) =>
408
+ token === 'REGISTRY' ? l.registry : token === 'ENV' ? l.projectDirEnv : token === 'LAUNCHER' ? l.rel.join('/') : l.shim,
409
+ );
410
+ }
411
+
412
+ /**
413
+ * Whether the launcher on disk needs (re)writing. EOL-insensitive: git's
414
+ * autocrlf may check the committed copy out with CRLF.
415
+ * @param {string | null | undefined} existing
416
+ * @param {'claude' | 'cursor'} [surface]
417
+ * @returns {{ write: boolean, reason: 'missing' | 'unchanged' | 'update', text: string }}
418
+ */
419
+ export function planCustomerLauncherWrite(existing, surface = 'claude') {
420
+ const text = renderCustomerLauncher(surface);
421
+ if (existing === null || existing === undefined) return { write: true, reason: 'missing', text };
422
+ if (String(existing).replace(/\r\n/g, '\n') === text) return { write: false, reason: 'unchanged', text };
423
+ return { write: true, reason: 'update', text };
424
+ }
425
+
426
+ /**
427
+ * A HeySpex hook command that still names a machine path: a quoted script
428
+ * that starts with a drive letter or a root slash rather than a `$VAR`. The
429
+ * legacy 2026-10-02 kit form; what `hs doctor` reports as a stale path and
430
+ * what the next install/connect repairs.
431
+ * @param {unknown} command
432
+ */
433
+ export function isAbsoluteHookCommand(command) {
434
+ if (typeof command !== 'string' || ourHookTail(command) === null) return false;
435
+ return /^\s*node\s+"(?:[A-Za-z]:)?[\\/](?!\$)/.test(command) || /^\s*node\s+(?:[A-Za-z]:)?[\\/]\S*(?:_run|run-hook)\.mjs/.test(command);
436
+ }
437
+
438
+ /**
439
+ * `event: <hook> [args]` for every HeySpex row in a parsed settings object
440
+ * whose command is still an absolute machine path.
441
+ * @param {Record<string, unknown>} settings
442
+ * @returns {string[]}
443
+ */
444
+ export function legacyAbsoluteHookRows(settings) {
445
+ const out = [];
446
+ const hooks = settings?.hooks && typeof settings.hooks === 'object' ? settings.hooks : {};
447
+ for (const [event, groups] of Object.entries(hooks)) {
448
+ if (!Array.isArray(groups)) continue;
449
+ for (const group of groups) {
450
+ for (const h of Array.isArray(group?.hooks) ? group.hooks : []) {
451
+ if (isAbsoluteHookCommand(h?.command)) out.push(`${event}: ${ourHookTail(h.command)}`);
452
+ }
453
+ }
454
+ }
455
+ return out;
456
+ }
457
+
458
+ /**
459
+ * Desired registrations for a runner prefix.
460
+ * @param {string} runnerPrefix
461
+ * @returns {{ event: string, matcher?: string, command: string, timeout: number, tail: string }[]}
462
+ */
463
+ export function renderCustomerHooks(runnerPrefix) {
464
+ return customerHookRows().map((row) => ({
465
+ event: row.event,
466
+ ...(row.matcher ? { matcher: widenMcpMatcher(row.matcher) } : {}),
467
+ command: `${runnerPrefix} ${hookTail(row)}`,
468
+ timeout: row.timeout,
469
+ tail: hookTail(row),
470
+ }));
471
+ }
472
+
473
+ /**
474
+ * Ours = any command routed through a HeySpex runner: the platform shim
475
+ * (`scripts/claude-hooks/_run.mjs`, by `$CLAUDE_PROJECT_DIR` or a legacy
476
+ * absolute path), the plugin launcher (`bin/run-hook.mjs`) or the committed
477
+ * customer launcher (`.claude/heyspex/run-hook.mjs`).
478
+ */
479
+ const OUR_RUNNER_RE = /(?:scripts\/claude-hooks\/_run\.mjs|(?:bin|heyspex)\/run-hook\.mjs)"?\s+(.+)$/;
480
+
481
+ /** The `<hook> [args]` tail of one of our commands, or null for a foreign one. */
482
+ export function ourHookTail(command) {
483
+ if (typeof command !== 'string') return null;
484
+ const m = command.replace(/\\/g, '/').match(OUR_RUNNER_RE);
485
+ return m ? m[1].trim() : null;
486
+ }
487
+
488
+ /**
489
+ * Merge the customer hook kit into a parsed `.claude/settings.json` object, in
490
+ * place. Merge-not-clobber: other keys, other events and foreign hooks
491
+ * survive untouched. An existing HeySpex entry (any `_run.mjs`/`run-hook.mjs`
492
+ * command: an older platform path, a copied `$CLAUDE_PROJECT_DIR` form) is
493
+ * REPAIRED in place when it matches a kit row, and removed when it does not
494
+ * (a platform-only hook, a duplicate, a retired hook) — never duplicated.
495
+ * Idempotent: a second call reports no change.
496
+ *
497
+ * @param {Record<string, unknown>} settings
498
+ * @param {string} runnerPrefix e.g. {@link customerRunnerPrefix}(platformRoot)
499
+ * @returns {{ changed: boolean, added: string[], repaired: string[], removed: string[], total: number }}
500
+ */
501
+ export function mergeCustomerKitHooks(settings, runnerPrefix) {
502
+ const desired = renderCustomerHooks(runnerPrefix);
503
+ const hooks =
504
+ settings.hooks && typeof settings.hooks === 'object' && !Array.isArray(settings.hooks)
505
+ ? /** @type {Record<string, unknown>} */ (settings.hooks)
506
+ : {};
507
+ const claimed = new Set();
508
+ const added = [];
509
+ const repaired = [];
510
+ const removed = [];
511
+ const key = (event, tail) => `${event}::${tail}`;
512
+ const desiredByKey = new Map(desired.map((d) => [key(d.event, d.tail), d]));
513
+
514
+ for (const [event, groups] of Object.entries(hooks)) {
515
+ if (!Array.isArray(groups)) continue;
516
+ const keptGroups = [];
517
+ for (const group of groups) {
518
+ if (!group || typeof group !== 'object' || !Array.isArray(group.hooks)) {
519
+ keptGroups.push(group);
520
+ continue;
521
+ }
522
+ const before = group.hooks.length;
523
+ const keptHooks = [];
524
+ for (const h of group.hooks) {
525
+ const tail = ourHookTail(h?.command);
526
+ if (tail === null) {
527
+ keptHooks.push(h);
528
+ continue;
529
+ }
530
+ const k = key(event, tail);
531
+ const want = desiredByKey.get(k);
532
+ const matcherOk = want && (group.matcher ?? '') === (want.matcher ?? '');
533
+ if (!want || claimed.has(k) || !matcherOk) {
534
+ removed.push(`${event}: ${tail}`);
535
+ continue;
536
+ }
537
+ claimed.add(k);
538
+ if (h.command !== want.command || h.timeout !== want.timeout || h.type !== 'command') {
539
+ h.type = 'command';
540
+ h.command = want.command;
541
+ h.timeout = want.timeout;
542
+ repaired.push(`${event}: ${tail}`);
543
+ }
544
+ keptHooks.push(h);
545
+ }
546
+ group.hooks = keptHooks;
547
+ // Drop a group only when THIS merge emptied it.
548
+ if (keptHooks.length > 0 || before === 0) keptGroups.push(group);
549
+ }
550
+ hooks[event] = keptGroups;
551
+ }
552
+
553
+ for (const d of desired) {
554
+ const k = key(d.event, d.tail);
555
+ if (claimed.has(k)) continue;
556
+ const list = Array.isArray(hooks[d.event]) ? /** @type {unknown[]} */ (hooks[d.event]) : [];
557
+ list.push({
558
+ ...(d.matcher ? { matcher: d.matcher } : {}),
559
+ hooks: [{ type: 'command', command: d.command, timeout: d.timeout }],
560
+ });
561
+ hooks[d.event] = list;
562
+ added.push(`${d.event}: ${d.tail}`);
563
+ }
564
+
565
+ const changed = added.length + repaired.length + removed.length > 0;
566
+ if (changed) settings.hooks = hooks;
567
+ return { changed, added, repaired, removed, total: desired.length };
568
+ }
569
+
570
+ /** The plugin's hooks.json document, generated from the same rows. */
571
+ export function renderPluginHooksDocument() {
572
+ const hooks = {};
573
+ for (const d of renderCustomerHooks(PLUGIN_RUNNER_PREFIX)) {
574
+ (hooks[d.event] ??= []).push({
575
+ ...(d.matcher ? { matcher: d.matcher } : {}),
576
+ hooks: [{ type: 'command', command: d.command, timeout: d.timeout }],
577
+ });
578
+ }
579
+ return {
580
+ _comment:
581
+ 'GENERATED from scripts/lib/customer-kit.mjs (customer-scoped rows only) — do not edit by hand; ' +
582
+ 'run `node scripts/lib/customer-kit.mjs --write-plugin-hooks`. Every command goes through ' +
583
+ 'bin/run-hook.mjs, which finds the hook runtime (HEYSPEX_PLATFORM_ROOT / HEYSPEX_MCP_ENTRY when set ' +
584
+ 'in the machine\'s own env, then the plugin\'s own location, node_modules/heyspex, ' +
585
+ '$CLAUDE_PROJECT_DIR) — never $CLAUDE_PROJECT_DIR/scripts, which only exists in heyspex-app. ' +
586
+ 'WARNING: a repo whose .claude/settings.json already carries the hs install kit hooks runs every ' +
587
+ 'hook twice if this plugin is also enabled — use one or the other.',
588
+ hooks,
589
+ };
590
+ }
591
+
592
+ export function renderPluginHooksText() {
593
+ return `${JSON.stringify(renderPluginHooksDocument(), null, 2)}\n`;
594
+ }
595
+
596
+ // ---------------------------------------------------------------------------
597
+ // Harness commands (.claude/commands, .cursor/commands)
598
+ // ---------------------------------------------------------------------------
599
+
600
+ /**
601
+ * The two harness-command trees `scripts/sync-skills.mjs` generates. Each
602
+ * names the INSTALLED tree (what a target repo carries) and, in priority
603
+ * order, where its CANONICAL content is read from: the packaged mirror
604
+ * (`dist/cli-templates/harness-commands/**`, a published install) then the
605
+ * platform checkout's own tree (a dev checkout before the first build).
606
+ * Re-exported by connect-checks.ts — one definition.
607
+ */
608
+ export const HARNESS_COMMAND_SURFACES = Object.freeze([
609
+ {
610
+ installedDir: Object.freeze(['.claude', 'commands']),
611
+ packagedDir: Object.freeze(['dist', 'cli-templates', 'harness-commands', 'claude']),
612
+ devSourceDir: Object.freeze(['.claude', 'commands']),
613
+ },
614
+ {
615
+ installedDir: Object.freeze(['.cursor', 'commands']),
616
+ packagedDir: Object.freeze(['dist', 'cli-templates', 'harness-commands', 'cursor']),
617
+ devSourceDir: Object.freeze(['.cursor', 'commands']),
618
+ },
619
+ ]);
620
+
621
+ /**
622
+ * Directory holding the canonical content for one surface, or null.
623
+ * @param {string} platformRoot
624
+ * @param {(typeof HARNESS_COMMAND_SURFACES)[number]} surface
625
+ * @param {{ joinPath: (...s: string[]) => string, listDir: (dir: string) => string[] }} deps
626
+ * @returns {string | null}
627
+ */
628
+ export function resolveCanonicalCommandDir(platformRoot, surface, deps) {
629
+ const hasMd = (dir) => deps.listDir(dir).some((f) => f.endsWith('.md'));
630
+ const packaged = deps.joinPath(platformRoot, ...surface.packagedDir);
631
+ if (hasMd(packaged)) return packaged;
632
+ const devSource = deps.joinPath(platformRoot, ...surface.devSourceDir);
633
+ if (hasMd(devSource)) return devSource;
634
+ return null;
635
+ }
636
+
637
+ /** Launcher aliases: npm scripts that have an `hs` verb. */
638
+ const NPM_ALIASES = Object.freeze({ doctor: 'doctor', setup: 'doctor --fix' });
639
+ const REPO_DIRS = 'bin|scripts|dev-factory|vendor|spexharness';
640
+
641
+ /**
642
+ * Platform scripts the shipped commands invoke directly that have an `hs`
643
+ * verb (`bin/hs.mjs` SPAWN_TARGETS) — the verb is what a customer runs.
644
+ */
645
+ const SCRIPT_VERBS = Object.freeze({
646
+ 'scripts/heyspex-doctor.mjs': 'doctor',
647
+ 'scripts/hs-view.mjs': 'view',
648
+ 'scripts/hs-capture-cli.mjs': 'capture',
649
+ 'scripts/hs-next.mjs': 'next',
650
+ 'scripts/hs-mode.mjs': 'mode',
651
+ 'scripts/hs-settings.mjs': 'settings',
652
+ 'scripts/hs-companion.mjs': 'companion',
653
+ 'scripts/wip-ledger-health.mjs': 'wip',
654
+ 'scripts/dev-server-status.mjs': 'server status',
655
+ 'scripts/dev-server-stop.mjs': 'server stop',
656
+ });
657
+
658
+ /** Where the `heyspex` package sits in a customer repo, repo-relative. */
659
+ export const PORTABLE_PACKAGE_DIR = 'node_modules/heyspex';
660
+
661
+ /**
662
+ * Rewrite repo-relative platform invocations to PORTABLE ones — forms that
663
+ * resolve from the customer repo on any machine, never this machine's path:
664
+ * `node bin/hs.mjs <args>` → `hs <args>`
665
+ * `node scripts/heyspex-doctor.mjs`→ `hs doctor` (SCRIPT_VERBS)
666
+ * `node dev-factory/x.mjs` → `node node_modules/heyspex/dev-factory/x.mjs`
667
+ * `npm run doctor|setup` → `hs doctor [--fix]`
668
+ * `npm run <script>` → `npm --prefix node_modules/heyspex run <script>`
669
+ * Prose mentions of a path (no `node ` in front) are left alone. The platform
670
+ * root is accepted (and ignored) so every caller keeps its signature.
671
+ * @param {string} content
672
+ * @param {string} [_platformRoot]
673
+ */
674
+ export function rewritePlatformPaths(content, _platformRoot) {
675
+ return String(content)
676
+ .replace(/\bnode bin\/hs\.mjs\b/g, 'hs')
677
+ .replace(new RegExp(`\\bnode (${REPO_DIRS})/([\\w./-]+\\.m?js)\\b`, 'g'), (_m, dir, rest) => {
678
+ const rel = `${dir}/${rest}`;
679
+ return SCRIPT_VERBS[rel] ? `hs ${SCRIPT_VERBS[rel]}` : `node ${PORTABLE_PACKAGE_DIR}/${rel}`;
680
+ })
681
+ .replace(/\bnpm run ([\w:.-]+)/g, (_m, script) =>
682
+ NPM_ALIASES[script] ? `hs ${NPM_ALIASES[script]}` : `npm --prefix ${PORTABLE_PACKAGE_DIR} run ${script}`,
683
+ );
684
+ }
685
+
686
+ const MARKER_RE = /\n<!-- heyspex-kit: managed by hs install \(sha256:([0-9a-f]{16})\)[^\n]*-->\n?$/;
687
+
688
+ function shortHash(text) {
689
+ return createHash('sha256').update(text, 'utf8').digest('hex').slice(0, 16);
690
+ }
691
+
692
+ /**
693
+ * The customer copy of a canonical command: platform paths made portable,
694
+ * then a trailing marker carrying the hash of the body. The marker is how a
695
+ * re-run tells "ours, untouched, safe to update" from "the customer edited
696
+ * it, leave it alone".
697
+ */
698
+ export function renderKitCommand(content, platformRoot) {
699
+ const body = `${rewritePlatformPaths(content, platformRoot).replace(/\s+$/, '')}\n`;
700
+ return (
701
+ `${body}\n<!-- heyspex-kit: managed by hs install (sha256:${shortHash(body)}). ` +
702
+ 'Edit freely: once edited, hs install leaves this file alone. -->\n'
703
+ );
704
+ }
705
+
706
+ /**
707
+ * @param {string} content
708
+ * @returns {'managed' | 'customized' | 'unmanaged'}
709
+ */
710
+ export function kitFileState(content) {
711
+ const m = String(content).match(MARKER_RE);
712
+ if (!m) return 'unmanaged';
713
+ const body = String(content).slice(0, m.index).replace(/\n$/, '');
714
+ return shortHash(`${body}\n`) === m[1] ? 'managed' : 'customized';
715
+ }
716
+
717
+ /**
718
+ * Whether to write one kit file. `raw` is the canonical (unrendered) content:
719
+ * an existing file equal to it is an old verbatim copy (`hs connect
720
+ * --fix-commands` before this change) and is safe to replace.
721
+ * @returns {{ write: boolean, reason: 'missing' | 'unchanged' | 'update' | 'legacy-copy' | 'customized' | 'forced' }}
722
+ */
723
+ export function planKitFileWrite({ existing, rendered, raw, force }) {
724
+ if (existing === null || existing === undefined) return { write: true, reason: 'missing' };
725
+ if (existing === rendered) return { write: false, reason: 'unchanged' };
726
+ if (kitFileState(existing) === 'managed') return { write: true, reason: 'update' };
727
+ if (existing === raw) return { write: true, reason: 'legacy-copy' };
728
+ return force ? { write: true, reason: 'forced' } : { write: false, reason: 'customized' };
729
+ }
730
+
731
+ /**
732
+ * Every canonical harness command file, as `(installedDir, file, content,
733
+ * raw)`. With `targetRoot` set to a repo other than the platform, the list is
734
+ * filtered to customer-scoped commands and `content` is the rendered customer
735
+ * copy; otherwise `content === raw` (the platform's own trees).
736
+ * @param {string} platformRoot
737
+ * @param {{ joinPath: (...s: string[]) => string, readFile: (p: string) => string | null, listDir: (dir: string) => string[] }} deps
738
+ * @param {{ targetRoot?: string, isPlatformTarget?: boolean }} [opts]
739
+ * @returns {{ installedDir: readonly string[], file: string, content: string, raw: string }[]}
740
+ */
741
+ export function listKitCommandFiles(platformRoot, deps, opts = {}) {
742
+ const customer =
743
+ opts.targetRoot !== undefined &&
744
+ !(opts.isPlatformTarget ?? samePlatformPath(opts.targetRoot, platformRoot));
745
+ const out = [];
746
+ for (const surface of HARNESS_COMMAND_SURFACES) {
747
+ const dir = resolveCanonicalCommandDir(platformRoot, surface, deps);
748
+ if (!dir) continue;
749
+ const files = deps
750
+ .listDir(dir)
751
+ .filter((f) => f.endsWith('.md'))
752
+ .sort();
753
+ for (const file of files) {
754
+ if (customer && !isCustomerCommand(file)) continue;
755
+ const raw = deps.readFile(deps.joinPath(dir, file));
756
+ if (raw === null) continue;
757
+ out.push({
758
+ installedDir: surface.installedDir,
759
+ file,
760
+ raw,
761
+ content: customer ? renderKitCommand(raw, platformRoot) : raw,
762
+ });
763
+ }
764
+ }
765
+ return out;
766
+ }
767
+
768
+ // ---------------------------------------------------------------------------
769
+ // Audit (hs doctor)
770
+ // ---------------------------------------------------------------------------
771
+
772
+ const fsDeps = {
773
+ joinPath: (...s) => path.join(...s),
774
+ readFile: (p) => {
775
+ try {
776
+ return fs.readFileSync(p, 'utf8');
777
+ } catch {
778
+ return null;
779
+ }
780
+ },
781
+ listDir: (d) => {
782
+ try {
783
+ return fs.readdirSync(d);
784
+ } catch {
785
+ return [];
786
+ }
787
+ },
788
+ };
789
+
790
+ /**
791
+ * What a customer repo is missing or carries stale, without writing anything.
792
+ * @param {{ targetRoot: string, platformRoot: string, deps?: typeof fsDeps }} opts
793
+ */
794
+ export function auditCustomerKit({ targetRoot, platformRoot, deps = fsDeps }) {
795
+ const report = {
796
+ settingsError: null,
797
+ /** `stalePaths`: rows still on the legacy absolute machine path (a subset of `repaired`). */
798
+ hooks: { added: [], repaired: [], removed: [], stalePaths: [], total: 0 },
799
+ /** The committed launcher the hook rows point at. */
800
+ launcher: { path: [...CUSTOMER_LAUNCHERS.claude.rel].join('/'), state: 'missing' },
801
+ commands: { missing: [], stale: [], customized: [] },
802
+ };
803
+ const settingsPath = deps.joinPath(targetRoot, '.claude', 'settings.json');
804
+ const raw = deps.readFile(settingsPath);
805
+ let settings = {};
806
+ if (raw !== null) {
807
+ try {
808
+ const parsed = JSON.parse(raw);
809
+ settings = parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
810
+ } catch (err) {
811
+ report.settingsError = `${settingsPath}: ${err instanceof Error ? err.message : String(err)}`;
812
+ }
813
+ }
814
+ if (!report.settingsError) {
815
+ const stalePaths = legacyAbsoluteHookRows(settings);
816
+ const r = mergeCustomerKitHooks(structuredClone(settings), customerRunnerPrefix(platformRoot));
817
+ report.hooks = { added: r.added, repaired: r.repaired, removed: r.removed, stalePaths, total: r.total };
818
+ }
819
+ const launcherPlan = planCustomerLauncherWrite(
820
+ deps.readFile(deps.joinPath(targetRoot, ...CUSTOMER_LAUNCHERS.claude.rel)),
821
+ );
822
+ report.launcher.state = launcherPlan.reason === 'update' ? 'stale' : launcherPlan.reason === 'missing' ? 'missing' : 'current';
823
+ for (const f of listKitCommandFiles(platformRoot, deps, { targetRoot, isPlatformTarget: false })) {
824
+ if (f.installedDir[0] !== '.claude') continue; // .cursor is the cursor-hooks/connect checks' business
825
+ const rel = [...f.installedDir, f.file].join('/');
826
+ const existing = deps.readFile(deps.joinPath(targetRoot, ...f.installedDir, f.file));
827
+ const plan = planKitFileWrite({ existing, rendered: f.content, raw: f.raw, force: false });
828
+ if (plan.reason === 'missing') report.commands.missing.push(rel);
829
+ else if (plan.reason === 'update' || plan.reason === 'legacy-copy') report.commands.stale.push(rel);
830
+ else if (plan.reason === 'customized') report.commands.customized.push(rel);
831
+ }
832
+ return report;
833
+ }
834
+
835
+ // ---------------------------------------------------------------------------
836
+ // CLI: regenerate plugins/heyspex/hooks/hooks.json
837
+ // ---------------------------------------------------------------------------
838
+
839
+ /**
840
+ * "Was THIS module the script node was started with?" — bundle-safe.
841
+ *
842
+ * The plain comparison every script in this repo used (`import.meta.url ===
843
+ * pathToFileURL(argv[1])`) is wrong inside the esbuild CLI bundle
844
+ * (`dist/cli/*.mjs`, scripts/build-cli-bundle.mjs): there `import.meta.url`
845
+ * is the BUNDLE file's url for every module in it, and the bundle IS argv[1],
846
+ * so the test reads true for all of them at once and this file's CLI entry
847
+ * hijacked `hs connect --check` (it printed the usage line and no report).
848
+ * Requiring the module's own basename in the url rules the bundle out: a
849
+ * bundle is named after its entry (`hs-connect.mjs`), never after a library.
850
+ * Pure (every input injectable) so the three shapes are unit-tested.
851
+ *
852
+ * @param {string} importMetaUrl The module's `import.meta.url`.
853
+ * @param {string} selfBasename The module's own file name, e.g. `customer-kit.mjs`.
854
+ * @param {string | undefined} [argv1] `process.argv[1]` (default: the real one).
855
+ */
856
+ export function invokedDirectly(importMetaUrl, selfBasename, argv1 = process.argv[1]) {
857
+ try {
858
+ if (!argv1) return false;
859
+ const self = fileURLToPath(importMetaUrl);
860
+ if (path.basename(self) !== selfBasename) return false;
861
+ return pathToFileURL(path.resolve(argv1)).href === pathToFileURL(self).href;
862
+ } catch {
863
+ return false;
864
+ }
865
+ }
866
+
867
+ const SELF = fileURLToPath(import.meta.url);
868
+ if (invokedDirectly(import.meta.url, 'customer-kit.mjs')) {
869
+ const platformRoot = path.resolve(path.dirname(SELF), '..', '..');
870
+ const target = path.join(platformRoot, 'plugins', 'heyspex', 'hooks', 'hooks.json');
871
+ const want = renderPluginHooksText();
872
+ // EOL-insensitive: git's autocrlf may check the file out with CRLF.
873
+ const same = (fsDeps.readFile(target) ?? '').replace(/\r\n/g, '\n') === want;
874
+ if (process.argv.includes('--write-plugin-hooks')) {
875
+ if (!same) fs.writeFileSync(target, want, 'utf8');
876
+ console.log(`customer-kit: ${same ? 'unchanged' : 'wrote'} ${path.relative(platformRoot, target)}`);
877
+ } else if (process.argv.includes('--check-plugin-hooks')) {
878
+ if (!same) {
879
+ console.error('customer-kit: plugins/heyspex/hooks/hooks.json is stale — run `node scripts/lib/customer-kit.mjs --write-plugin-hooks`');
880
+ process.exit(1);
881
+ }
882
+ console.log('customer-kit: plugins/heyspex/hooks/hooks.json up to date');
883
+ } else {
884
+ console.log('usage: node scripts/lib/customer-kit.mjs --write-plugin-hooks | --check-plugin-hooks');
885
+ }
886
+ }