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,1203 @@
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 os from 'node:os';
54
+ import { fileURLToPath, pathToFileURL } from 'node:url';
55
+
56
+ import { dispositionFor } from './hook-dispositions.mjs';
57
+
58
+ /** @typedef {'customer' | 'platform'} KitScope */
59
+
60
+ /**
61
+ * One scope decision per hook name (the token after `_run.mjs`).
62
+ * @type {Readonly<Record<string, { scope: KitScope, why: string }>>}
63
+ */
64
+ export const HOOK_SCOPES = Object.freeze({
65
+ // ── customer: vault / workflow / safety gates ─────────────────────────────
66
+ 'wip-checkpoint': { scope: 'customer', why: 'Previous-session WIP handoff, read from the active vault.' },
67
+ 'inject-doctrine': {
68
+ scope: 'customer',
69
+ why:
70
+ 'Owner ruling 2026-10-03 (HS-A2PO): ships. A customer session gets the baseline edition of SpexCode Doctrine ' +
71
+ '(CUSTOMER_HOOK_DATA), the foundation its own learned layer is built on; never the platform\'s learned file.',
72
+ },
73
+ 'check-auto-memory-budget': { scope: 'customer', why: 'Auto-memory index budget for the session project.' },
74
+ 'check-claude-md-budget': { scope: 'customer', why: 'Budget check on the project\'s always-loaded agent contract.' },
75
+ 'git-reflog-audit': { scope: 'customer', why: 'Session-close git audit backstop (stash/reset/rebase witness).' },
76
+ 'preflight-reminder': { scope: 'customer', why: 'Names the required procedure before filing/destructive turns.' },
77
+ 'intent-classifier-trigger': { scope: 'customer', why: 'Routes capture/research/settings/defer intents.' },
78
+ 'inject-communication-style': { scope: 'customer', why: 'Plain-language communication rules each turn.' },
79
+ 'inject-vault-context': { scope: 'customer', why: 'Lexical vault retrieval for the prompt.' },
80
+ 'git-identity-guard': { scope: 'customer', why: 'Agents never write git identity (owner ruling 2026-09-19).' },
81
+ 'vault-write-tier-guard': { scope: 'customer', why: 'Cloud write mode: disk edits to vault specs are a mirror the console never reads back (HS-FCNH).' },
82
+ 'settings-write-gate': { scope: 'customer', why: 'Approval before the vault\'s .heyspex/config.yaml changes.' },
83
+ 'emit-claim-status': { scope: 'customer', why: 'Claim liveness heartbeat to the console.' },
84
+ 'enforce-vault-naming': { scope: 'customer', why: 'Naming V3 filename guard (HS-RY2N).' },
85
+ 'guard-auto-memory-writes': { scope: 'customer', why: 'No new topic files in the auto-memory directory.' },
86
+ 'plan-review-gate': { scope: 'customer', why: 'Plan review + QC markers before ExitPlanMode.' },
87
+ 'plan-exit-ingest-gate': { scope: 'customer', why: 'Plan ingest on ExitPlanMode; arms claim-before-code.' },
88
+ 'claim-before-code-gate': { scope: 'customer', why: 'First code edit after plan exit needs a claim.' },
89
+ 'directive-obligation-gate': { scope: 'customer', why: 'A capture_on_idea directive leaves a durable obligation; an unmet one denies sub-agent spawns, product-code writes and builds, and re-drives Stop once. Also claim-before-build (HS-20SW).' },
90
+ 'capture-workflow-gate': { scope: 'customer', why: 'No implementation spec straight out of an open capture.' },
91
+ 'vault-mirror-catchup': { scope: 'customer', why: 'Cloud write mode: a SessionStart detached `hs pull --apply` closes a stale disk mirror before the first tool call (HS-XJFQ).' },
92
+ 'diagnose-capture-reminder': { scope: 'customer', why: 'Warn when a diagnose is filed with no capture holding the owner\'s words (HS-IK2D).' },
93
+ 'git-stash-gate': { scope: 'customer', why: 'Always ask before a mutating git stash.' },
94
+ 'research-method-guard': { scope: 'customer', why: 'Research method guard (HS-EN4F).' },
95
+ 'deep-research-cost-gate': { scope: 'customer', why: 'Cost confirmation before a paid deep-research launch.' },
96
+ 'research-protocol-gate': { scope: 'customer', why: 'WebSearch/WebFetch refused until research preflight ran (HS-K0ZP, HS-EN4F).' },
97
+ 'naughty-list-gate': { scope: 'customer', why: 'Re-drive a final message that uses banned jargon.' },
98
+ 'id-display-format-gate': { scope: 'customer', why: 'Naming V3 display-id format in replies.' },
99
+ 'epic-milestone-phrasing-gate': { scope: 'customer', why: 'Epic/milestone phrasing rules in replies.' },
100
+ 'enforce-session-sequence': { scope: 'customer', why: 'Re-drive a turn that shipped without record_wip.' },
101
+ 'notify-status-flip': { scope: 'customer', why: 'Sound on an in_review status flip.' },
102
+ 'close-terminal-on-ship': { scope: 'customer', why: 'Close the launch terminal once the spec ships.' },
103
+ 'scripts/stamp-provenance-on-edit.mjs': { scope: 'customer', why: 'Stamp assisted_by provenance on direct spec edits (active vault).' },
104
+ 'session-end': { scope: 'customer', why: 'Release the claim and export session events.' },
105
+
106
+ // ── platform: heyspex-app's own development machinery ─────────────────────
107
+ 'inject-dev-server-awareness': { scope: 'platform', why: 'About the spex-console dev server in heyspex-app.' },
108
+ 'inject-coo-seat': { scope: 'platform', why: 'dev-factory COO seat mandate.' },
109
+ 'coo-guard-check': { scope: 'platform', why: 'dev-factory breakdown-delegation guard.' },
110
+ 'coo-denylist-gate': { scope: 'platform', why: 'dev-factory COO window deny list.' },
111
+ 'agent-spawn-delegation-gate': { scope: 'platform', why: 'dev-factory seat routing for subagents.' },
112
+ 'capture-execution-gate': { scope: 'platform', why: 'Blocks dev-factory research/paid-model/routing surfaces past an ungrounded capture (HS-KNJ8).' },
113
+ 'check-mcp-freshness-hook': { scope: 'platform', why: 'Freshness of the vendored MCP build — a platform-dev concern.' },
114
+ 'dev-server-task-reminder': { scope: 'platform', why: 'spex-console dev server + Neon DB safety.' },
115
+ 'env-vault-guard': { scope: 'platform', why: 'Guards heyspex-app\'s encrypted env vault paths.' },
116
+ '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.' },
117
+ });
118
+
119
+ /**
120
+ * Data files (not static imports, so verify-mcp-package's hookTargets closure walk cannot
121
+ * see them) a customer-scoped hook reads at run time from the installed package. Each must
122
+ * be listed in package.json `files` and exist on disk; customer-kit.test.mjs asserts both.
123
+ * The inject-doctrine entry is the ONLY doctrine content that ships: the learned
124
+ * spexharness/doctrine/SPEXCODE-DOCTRINE.json is distilled from the owner's own sessions and
125
+ * must never match a `files` entry (same test).
126
+ * @type {Readonly<Record<string, readonly string[]>>}
127
+ */
128
+ export const CUSTOMER_HOOK_DATA = Object.freeze({
129
+ 'inject-doctrine': Object.freeze(['spexharness/doctrine/baseline/SPEXCODE-DOCTRINE.baseline.json']),
130
+ });
131
+
132
+ /**
133
+ * Every Claude Code hook registration, mirroring the platform's
134
+ * `.claude/settings.json` row for row (customer-kit.test.mjs asserts parity).
135
+ * `matcher` is the platform's own matcher; MCP tool names are widened to the
136
+ * plugin-scoped spelling at render time ({@link widenMcpMatcher}).
137
+ * @type {ReadonlyArray<{ event: string, matcher?: string, hook: string, args?: string[], timeout: number }>}
138
+ */
139
+ export const CLAUDE_HOOK_ROWS = Object.freeze([
140
+ { event: 'SessionStart', hook: 'wip-checkpoint', timeout: 5 },
141
+ { event: 'SessionStart', hook: 'inject-dev-server-awareness', timeout: 5 },
142
+ { event: 'SessionStart', hook: 'inject-coo-seat', timeout: 5 },
143
+ { event: 'SessionStart', hook: 'inject-doctrine', timeout: 5 },
144
+ { event: 'SessionStart', hook: 'coo-guard-check', timeout: 5 },
145
+ { event: 'SessionStart', hook: 'check-auto-memory-budget', timeout: 5 },
146
+ { event: 'SessionStart', hook: 'check-claude-md-budget', timeout: 5 },
147
+ { event: 'SessionStart', hook: 'check-mcp-freshness-hook', timeout: 5 },
148
+ { event: 'SessionStart', hook: 'git-reflog-audit', timeout: 5 },
149
+ { event: 'SessionStart', hook: 'vault-mirror-catchup', timeout: 5 },
150
+ { event: 'PreCompact', hook: 'wip-checkpoint', timeout: 5 },
151
+ { event: 'UserPromptSubmit', hook: 'preflight-reminder', timeout: 5 },
152
+ { event: 'UserPromptSubmit', hook: 'intent-classifier-trigger', timeout: 5 },
153
+ { event: 'UserPromptSubmit', hook: 'inject-communication-style', timeout: 5 },
154
+ { event: 'UserPromptSubmit', hook: 'inject-vault-context', timeout: 10 },
155
+ { event: 'PreToolUse', matcher: 'Write|Edit|MultiEdit|NotebookEdit|Bash|PowerShell', hook: 'env-vault-guard', timeout: 5 },
156
+ { event: 'PreToolUse', matcher: 'Bash|PowerShell', hook: 'git-identity-guard', timeout: 5 },
157
+ { event: 'PreToolUse', matcher: 'Write|Edit|MultiEdit|NotebookEdit|Bash|PowerShell', hook: 'vault-write-tier-guard', timeout: 5 },
158
+ { event: 'PreToolUse', matcher: 'Write|Edit|NotebookEdit|Bash|PowerShell|mcp__heyspex__heyspex_settings_set', hook: 'settings-write-gate', timeout: 5 },
159
+ { event: 'PreToolUse', matcher: '.*', hook: 'emit-claim-status', args: ['--event=PreToolUse'], timeout: 5 },
160
+ { event: 'PreToolUse', matcher: '.*', hook: 'coo-denylist-gate', timeout: 5 },
161
+ { event: 'PreToolUse', matcher: 'Agent|Task', hook: 'agent-spawn-delegation-gate', timeout: 5 },
162
+ { event: 'PreToolUse', matcher: 'Bash|PowerShell|Agent|Task', hook: 'capture-execution-gate', timeout: 5 },
163
+ { event: 'PreToolUse', matcher: 'Agent|Task|Edit|Write|MultiEdit|NotebookEdit|Bash|PowerShell', hook: 'directive-obligation-gate', timeout: 5 },
164
+ { event: 'PreToolUse', matcher: 'mcp__heyspex__heyspex_claim_spec', hook: 'coo-guard-check', timeout: 5 },
165
+ { event: 'PreToolUse', matcher: 'Write|NotebookEdit|Bash|PowerShell', hook: 'enforce-vault-naming', timeout: 5 },
166
+ { event: 'PreToolUse', matcher: 'Write|NotebookEdit|Bash|PowerShell', hook: 'guard-auto-memory-writes', timeout: 5 },
167
+ { event: 'PreToolUse', matcher: 'Edit|MultiEdit|Write|Bash|PowerShell', hook: 'preflight-reminder', timeout: 5 },
168
+ { event: 'PreToolUse', matcher: 'ExitPlanMode', hook: 'plan-review-gate', timeout: 8 },
169
+ { event: 'PreToolUse', matcher: 'ExitPlanMode', hook: 'plan-exit-ingest-gate', timeout: 5 },
170
+ { event: 'PreToolUse', matcher: 'Edit|MultiEdit|Write', hook: 'claim-before-code-gate', timeout: 5 },
171
+ { event: 'PreToolUse', matcher: 'mcp__heyspex__heyspex_file_spec', hook: 'capture-workflow-gate', timeout: 8 },
172
+ { event: 'PreToolUse', matcher: 'mcp__heyspex__heyspex_file_spec', hook: 'diagnose-capture-reminder', timeout: 5 },
173
+ { 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 },
174
+ { event: 'PreToolUse', matcher: 'Bash|PowerShell', hook: 'git-stash-gate', timeout: 10 },
175
+ { event: 'PreToolUse', matcher: 'Skill|Workflow', hook: 'research-method-guard', timeout: 5 },
176
+ { event: 'PreToolUse', matcher: 'Workflow|Skill', hook: 'deep-research-cost-gate', timeout: 5 },
177
+ { event: 'PreToolUse', matcher: 'WebSearch|WebFetch|mcp__heyspex__heyspex_save_research|mcp__plugin_heyspex_heyspex__heyspex_save_research', hook: 'research-protocol-gate', timeout: 5 },
178
+ { event: 'PreToolUse', matcher: '.*', hook: 'log-session-activity', timeout: 5 },
179
+ { event: 'Stop', hook: 'emit-claim-status', args: ['--event=Stop'], timeout: 5 },
180
+ { event: 'Stop', hook: 'naughty-list-gate', timeout: 5 },
181
+ { event: 'Stop', hook: 'id-display-format-gate', timeout: 5 },
182
+ { event: 'Stop', hook: 'epic-milestone-phrasing-gate', timeout: 5 },
183
+ { event: 'Stop', hook: 'git-reflog-audit', timeout: 5 },
184
+ { event: 'Stop', hook: 'enforce-session-sequence', timeout: 5 },
185
+ { event: 'Stop', hook: 'directive-obligation-gate', timeout: 5 },
186
+ { event: 'PostToolUse', matcher: '.*', hook: 'emit-claim-status', args: ['--event=PostToolUse'], timeout: 5 },
187
+ { event: 'PostToolUse', matcher: 'Skill|mcp__heyspex__heyspex_update_capture|mcp__heyspex__heyspex_promote_capture.*|mcp__heyspex__heyspex_file_spec|mcp__heyspex__heyspex_claim_spec|mcp__heyspex__heyspex_release_spec|mcp__heyspex__heyspex_defer_spec', hook: 'directive-obligation-gate', timeout: 5 },
188
+ { event: 'PostToolUse', matcher: 'Edit|Write|MultiEdit', hook: 'notify-status-flip', timeout: 5 },
189
+ { event: 'PostToolUse', matcher: 'Edit|Write|MultiEdit', hook: 'close-terminal-on-ship', timeout: 5 },
190
+ { event: 'PostToolUse', matcher: 'Edit|Write|MultiEdit', hook: 'scripts/stamp-provenance-on-edit.mjs', timeout: 5 },
191
+ { event: 'PostToolUse', matcher: '.*', hook: 'log-session-activity', timeout: 5 },
192
+ { event: 'SessionEnd', hook: 'session-end', timeout: 5 },
193
+ { event: 'SessionEnd', hook: 'close-terminal-on-ship', timeout: 5 },
194
+ ]);
195
+
196
+ /**
197
+ * One scope decision per harness command file (`.claude/commands/<name>.md`,
198
+ * mirrored into `.cursor/commands/`). Only `customer` files ship.
199
+ * @type {Readonly<Record<string, { scope: KitScope, why: string }>>}
200
+ */
201
+ export const COMMAND_SCOPES = Object.freeze({
202
+ 'hs-backfill.md': { scope: 'customer', why: 'Backfill completed work into the vault.' },
203
+ 'hs-capture.md': { scope: 'customer', why: 'Capture intake.' },
204
+ 'hs-defer.md': { scope: 'customer', why: 'Split + defer remaining work.' },
205
+ 'hs-doctor.md': { scope: 'customer', why: 'Install-surface repair when heyspex_* tools are missing.' },
206
+ 'hs-ground.md': { scope: 'customer', why: 'Pre-plan grounding report.' },
207
+ 'hs-handoff.md': { scope: 'customer', why: 'Short-code lane handoff.' },
208
+ 'hs-next.md': { scope: 'customer', why: 'On-deck prompt + claim handshake.' },
209
+ 'hs-promote.md': { scope: 'customer', why: 'Capture → research → milestone promotion.' },
210
+ 'hs-push.md': { scope: 'customer', why: 'Push an epic plan into the vault.' },
211
+ 'hs-qc.md': { scope: 'customer', why: 'Post-plan QC gate.' },
212
+ 'hs-res.md': { scope: 'customer', why: 'Research method entrypoint (SpexCrawl/DeepSeek).' },
213
+ 'hs-resolve.md': { scope: 'customer', why: 'Resolution lane.' },
214
+ 'hs-review.md': { scope: 'customer', why: 'Review queue.' },
215
+ 'hs-view.md': { scope: 'customer', why: 'Open a console surface.' },
216
+ 'hs-auto.md': { scope: 'platform', why: 'Routes work into dev-factory (platform build system).' },
217
+ 'hs-dev.md': { scope: 'platform', why: 'Admin/dev-only HeySpex dev/prod mode switch.' },
218
+ 'hs-log.md': { scope: 'platform', why: 'Admin/dev-only dev-server log watcher.' },
219
+ 'kill-ports.md': { scope: 'platform', why: 'Kills local dev-server ports.' },
220
+ 'prototype.md': { scope: 'platform', why: 'Console design-preview prototypes inside spex-console.' },
221
+ });
222
+
223
+ /** @param {string} hook */
224
+ export function hookScope(hook) {
225
+ return HOOK_SCOPES[hook]?.scope ?? null;
226
+ }
227
+
228
+ /** @param {string} file basename, e.g. `hs-res.md` */
229
+ export function isCustomerCommand(file) {
230
+ return COMMAND_SCOPES[file]?.scope === 'customer';
231
+ }
232
+
233
+ /** Forward-slash form of an absolute path (node accepts it on Windows; no shell eats it). */
234
+ export function toPosixPath(p) {
235
+ return String(p).replace(/\\/g, '/').replace(/\/+$/, '');
236
+ }
237
+
238
+ /** Case-insensitive on Windows, separator-agnostic path equality. */
239
+ export function samePlatformPath(a, b) {
240
+ if (!a || !b) return false;
241
+ const norm = (p) => {
242
+ const s = toPosixPath(path.resolve(p));
243
+ return process.platform === 'win32' ? s.toLowerCase() : s;
244
+ };
245
+ return norm(a) === norm(b);
246
+ }
247
+
248
+ // ---------------------------------------------------------------------------
249
+ // Claude Code hooks
250
+ // ---------------------------------------------------------------------------
251
+
252
+ const MCP_PROJECT_PREFIX = 'mcp__heyspex__';
253
+ const MCP_PLUGIN_PREFIX = 'mcp__plugin_heyspex_heyspex__';
254
+
255
+ /**
256
+ * A project-scoped MCP tool name in a matcher also needs its plugin-scoped
257
+ * spelling, so the gate holds under either wiring (.mcp.json or the plugin).
258
+ * @param {string | undefined} matcher
259
+ */
260
+ export function widenMcpMatcher(matcher) {
261
+ if (!matcher) return matcher;
262
+ const parts = matcher.split('|');
263
+ const out = [];
264
+ for (const part of parts) {
265
+ if (!out.includes(part)) out.push(part);
266
+ if (part.startsWith(MCP_PROJECT_PREFIX)) {
267
+ const plugin = MCP_PLUGIN_PREFIX + part.slice(MCP_PROJECT_PREFIX.length);
268
+ if (!parts.includes(plugin) && !out.includes(plugin)) out.push(plugin);
269
+ }
270
+ }
271
+ return out.join('|');
272
+ }
273
+
274
+ /** The hook rows a customer repo gets. */
275
+ export function customerHookRows() {
276
+ return CLAUDE_HOOK_ROWS.filter((r) => hookScope(r.hook) === 'customer');
277
+ }
278
+
279
+ /** `<hook> [args...]` — the identity of a registration within one event. */
280
+ export function hookTail(row) {
281
+ return [row.hook, ...(row.args ?? [])].join(' ');
282
+ }
283
+
284
+ // ---------------------------------------------------------------------------
285
+ // The committed launcher a customer repo carries
286
+ // ---------------------------------------------------------------------------
287
+
288
+ /** The Claude Code hook shim, relative to the `heyspex` package root. */
289
+ export const CLAUDE_HOOK_SHIM = 'scripts/claude-hooks/_run.mjs';
290
+ /** The Cursor hook shim, relative to the `heyspex` package root. */
291
+ export const CURSOR_HOOK_SHIM = 'scripts/cursor-hooks/_run.mjs';
292
+
293
+ /**
294
+ * Where each harness's launcher lives inside a customer repo, and the
295
+ * project-dir variable that harness expands in a hook command.
296
+ * @type {Readonly<Record<'claude' | 'cursor', { rel: readonly string[], projectDirEnv: string, shim: string, registry: string }>>}
297
+ */
298
+ export const CUSTOMER_LAUNCHERS = Object.freeze({
299
+ claude: Object.freeze({
300
+ rel: Object.freeze(['.claude', 'heyspex', 'run-hook.mjs']),
301
+ projectDirEnv: 'CLAUDE_PROJECT_DIR',
302
+ shim: CLAUDE_HOOK_SHIM,
303
+ registry: '.claude/settings.json',
304
+ }),
305
+ cursor: Object.freeze({
306
+ rel: Object.freeze(['.cursor', 'heyspex', 'run-hook.mjs']),
307
+ projectDirEnv: 'CURSOR_PROJECT_DIR',
308
+ shim: CURSOR_HOOK_SHIM,
309
+ registry: '.cursor/hooks.json',
310
+ }),
311
+ });
312
+
313
+ /**
314
+ * Runner prefix for a customer repo: the committed launcher, addressed from the
315
+ * harness's project-dir variable — never this machine's path. The platform
316
+ * root is accepted (and ignored) so every caller keeps its signature; the
317
+ * launcher file itself is what {@link renderCustomerLauncher} renders.
318
+ * @param {string} [_platformRoot]
319
+ * @param {'claude' | 'cursor'} [surface]
320
+ */
321
+ export function customerRunnerPrefix(_platformRoot, surface = 'claude') {
322
+ const l = CUSTOMER_LAUNCHERS[surface];
323
+ // Claude rows are self-locating (Copilot CLI loads .claude/settings.json and does not document CLAUDE_PROJECT_DIR).
324
+ if (surface === 'claude') return CUSTOMER_RUNNER_PREFIX;
325
+ return `node "$${l.projectDirEnv}/${l.rel.join('/')}"`;
326
+ }
327
+
328
+ /** Runner prefix inside the plugin: its own launcher, which finds the platform. */
329
+ const BOOTSTRAP_WALK =
330
+ "var d=process.cwd(),r=e.CLAUDE_PROJECT_DIR;if(!r||!f.existsSync(p.join(r,...n))){r='';for(;;){if(f.existsSync(p.join(d,...n))){r=d;break}var q=p.dirname(d);if(q===d)break;d=q}}";
331
+
332
+ /**
333
+ * SELF-LOCATING BOOTSTRAP ([[HS-20SW-DRSM-DIAG-heyspex-capture-on-idea-and-workflow]], Copilot compatibility).
334
+ *
335
+ * GitHub Copilot CLI runs the hooks in .claude/settings.json too, but it is not documented to set
336
+ * CLAUDE_PROJECT_DIR / CLAUDE_PLUGIN_ROOT, and a PowerShell-run command expands an unset $VAR to
337
+ * nothing - so \`node "$CLAUDE_PROJECT_DIR/scripts/claude-hooks/_run.mjs"\` became \`node /scripts/...\`,
338
+ * MODULE_NOT_FOUND, exit 1, and Copilot turns ANY non-zero exit into a DENY (on a \`.*\` matcher: every tool
339
+ * call). A registered command must therefore (a) work with no env var at all, under bash AND PowerShell, and
340
+ * (b) FAIL OPEN when it cannot find the runtime. It is an inline \`node -e\` script, single-quoted JS only
341
+ * (no double quote, backslash, dollar or backtick), so it survives JSON and every shell unchanged:
342
+ * root = CLAUDE_PROJECT_DIR if it holds the shim, else walk up from cwd looking for scripts/claude-hooks/_run.mjs;
343
+ * not found -> exit 0; found -> import the shim in-process with the same argv and stdin (the shim owns both).
344
+ * The plugin variant first tries <CLAUDE_PLUGIN_ROOT|PLUGIN_ROOT>/bin/run-hook.mjs. The trailing //comment is
345
+ * deliberate: it keeps the recognisable ...scripts/claude-hooks/_run.mjs" <hook> tail every drift check,
346
+ * ourHookTail and the doctor already parse.
347
+ * @param {{ plugin?: boolean }} [opts]
348
+ */
349
+ export function buildBootstrap({ plugin = false } = {}) {
350
+ const walk = BOOTSTRAP_WALK;
351
+ const body = plugin
352
+ ? "var f=require('fs'),p=require('path'),u=require('url'),e=process.env,n=['scripts','claude-hooks','_run.mjs'],s='',m=e.CLAUDE_PLUGIN_ROOT||e.COPILOT_PLUGIN_ROOT||e.PLUGIN_ROOT;" +
353
+ "if(m&&f.existsSync(p.join(m,'bin','run-hook.mjs')))s=p.join(m,'bin','run-hook.mjs');" +
354
+ "if(!s){" + walk + "if(r){e.CLAUDE_PROJECT_DIR=r;s=p.join(r,...n)}}"
355
+ : "var f=require('fs'),p=require('path'),u=require('url'),e=process.env,n=['scripts','claude-hooks','_run.mjs'],s='';" +
356
+ walk + "if(r){e.CLAUDE_PROJECT_DIR=r;s=p.join(r,...n)}";
357
+ return (
358
+ body +
359
+ "if(!s)process.exit(0);process.argv=[process.argv[0],s].concat(process.argv.slice(1));" +
360
+ "import(u.pathToFileURL(s).href).catch(function(){process.exit(0)});" +
361
+ (plugin ? '//bin/run-hook.mjs' : '//scripts/claude-hooks/_run.mjs')
362
+ );
363
+ }
364
+
365
+ /** Platform checkout runner (.claude/settings.json): self-locating, env-independent, fail-open. */
366
+ export const PLATFORM_RUNNER_PREFIX = 'node -e "' + buildBootstrap() + '"';
367
+
368
+ /** Runner prefix inside the plugin: self-locating too (plugin root env, then cwd walk-up), fail-open. */
369
+ export const PLUGIN_RUNNER_PREFIX = 'node -e "' + buildBootstrap({ plugin: true }) + '"';
370
+
371
+ /**
372
+ * Customer-repo bootstrap (same defect, same fix as {@link buildBootstrap}): the committed launcher
373
+ * `.claude/heyspex/run-hook.mjs` is resolved from CLAUDE_PROJECT_DIR if it holds the launcher, else by walking up
374
+ * from cwd, else exit 0 (fail open). Sets CLAUDE_PROJECT_DIR to the found root so the shim reads
375
+ * <project>/.heyspex/mode.json (`hs mode off`). Single-quoted JS only; survives JSON, bash and PowerShell.
376
+ */
377
+ export function buildCustomerBootstrap() {
378
+ const rel = CUSTOMER_LAUNCHERS.claude.rel.map((s) => "'" + s + "'").join(',');
379
+ return (
380
+ "var f=require('fs'),p=require('path'),u=require('url'),e=process.env,n=[" + rel + "],s='';" +
381
+ BOOTSTRAP_WALK + "if(r){e.CLAUDE_PROJECT_DIR=r;s=p.join(r,...n)}" +
382
+ "if(!s)process.exit(0);process.argv=[process.argv[0],s].concat(process.argv.slice(1));" +
383
+ "import(u.pathToFileURL(s).href).catch(function(){process.exit(0)});" +
384
+ '//' + CUSTOMER_LAUNCHERS.claude.rel.join('/')
385
+ );
386
+ }
387
+
388
+ /** Customer repo runner (.claude/settings.json): self-locating, env-independent, fail-open. */
389
+ export const CUSTOMER_RUNNER_PREFIX = 'node -e "' + buildCustomerBootstrap() + '"';
390
+
391
+ // The launcher is kept as ONE template string (not a sibling file) so the
392
+ // bundled CLI (`dist/cli/*.mjs`, built from install.ts) carries it by
393
+ // construction — a file read relative to this module would miss once bundled.
394
+ // `__TOKENS__` are substituted per surface; no backticks inside, on purpose.
395
+ const LAUNCHER_TEMPLATE = [
396
+ '#!/usr/bin/env node',
397
+ '// HeySpex hook launcher -- GENERATED by hs install / hs connect. Commit it with this repo.',
398
+ '//',
399
+ '// Every HeySpex hook registered in __REGISTRY__ runs',
400
+ '// node "$__ENV__/__LAUNCHER__" <hook> [args]',
401
+ '// This file carries NO machine-specific path. It finds the heyspex package this',
402
+ '// repo depends on (node_modules/heyspex) and hands off in-process to the',
403
+ '// package\'s hook shim (__SHIM__), so the same committed command works on every',
404
+ '// machine the repo is cloned to.',
405
+ '//',
406
+ '// Resolution order (the first root that carries the shim wins):',
407
+ '// 1. HEYSPEX_PLATFORM_ROOT explicit machine-local override',
408
+ '// 2. HEYSPEX_MCP_ENTRY -> its package root machine-local override',
409
+ '// 3. node_modules/heyspex, walking up from this file (the repo-local dependency)',
410
+ '// 4. node_modules/heyspex up from $__ENV__, $CLAUDE_PROJECT_DIR, $CURSOR_PROJECT_DIR, cwd',
411
+ '// 5. <npm root -g>/heyspex a global install, last resort',
412
+ '// Nothing found -> one stderr line and exit 0 (fail-open, like the shim itself).',
413
+ "import { spawnSync } from 'node:child_process';",
414
+ "import { existsSync } from 'node:fs';",
415
+ "import { dirname, resolve } from 'node:path';",
416
+ "import { fileURLToPath, pathToFileURL } from 'node:url';",
417
+ '',
418
+ "const SHIM = '__SHIM__'.split('/');",
419
+ 'const here = dirname(fileURLToPath(import.meta.url));',
420
+ 'const env = process.env;',
421
+ 'const text = (v) => (typeof v === \'string\' && v.trim() ? v.trim() : null);',
422
+ 'const hasShim = (root) => Boolean(root) && existsSync(resolve(root, ...SHIM));',
423
+ '',
424
+ 'function packageUpFrom(start) {',
425
+ ' let dir = resolve(start);',
426
+ ' for (;;) {',
427
+ " const candidate = resolve(dir, 'node_modules', 'heyspex');",
428
+ ' if (hasShim(candidate)) return candidate;',
429
+ ' const parent = dirname(dir);',
430
+ ' if (parent === dir) return null;',
431
+ ' dir = parent;',
432
+ ' }',
433
+ '}',
434
+ '',
435
+ 'function globalPackage() {',
436
+ ' try {',
437
+ ' // One command string through the shell: npm is a .cmd on Windows, and a',
438
+ ' // shell + args array is what node deprecates (DEP0190).',
439
+ " const res = spawnSync('npm root -g', { encoding: 'utf8', timeout: 5000, shell: true });",
440
+ " const root = String(res.stdout ?? '').trim();",
441
+ " return root ? resolve(root, 'heyspex') : null;",
442
+ ' } catch {',
443
+ ' return null;',
444
+ ' }',
445
+ '}',
446
+ '',
447
+ 'const mcpEntry = text(env.HEYSPEX_MCP_ENTRY);',
448
+ 'const starts = [...new Set([here, text(env.__ENV__), text(env.CLAUDE_PROJECT_DIR), text(env.CURSOR_PROJECT_DIR), process.cwd()].filter(Boolean))];',
449
+ 'let root =',
450
+ " [text(env.HEYSPEX_PLATFORM_ROOT), mcpEntry && resolve(mcpEntry, '..', '..', '..')].find(hasShim) ??",
451
+ ' starts.map(packageUpFrom).find(Boolean) ??',
452
+ ' null;',
453
+ 'if (!root) {',
454
+ ' const global = globalPackage();',
455
+ ' if (hasShim(global)) root = global;',
456
+ '}',
457
+ '',
458
+ 'if (!root) {',
459
+ ' process.stderr.write(',
460
+ " '[heyspex] hook ' + (process.argv[2] ?? '?') + ' skipped: no heyspex package found ' +",
461
+ " '(run \"npm install heyspex\" in this repo, or set HEYSPEX_PLATFORM_ROOT). Looked for __SHIM__ under ' +",
462
+ " 'HEYSPEX_PLATFORM_ROOT, HEYSPEX_MCP_ENTRY, node_modules/heyspex up from ' + starts.join(', ') + ', and npm root -g.\\n',",
463
+ ' );',
464
+ ' process.exit(0);',
465
+ '}',
466
+ '',
467
+ '// argv is already [node, <this file>, <hook>, ...args] -- the shape the shim',
468
+ '// reads; the shim path replaces this file so a shim guarded by "am I main?"',
469
+ '// (the Cursor one) runs exactly as if Cursor had invoked it directly.',
470
+ 'const shim = resolve(root, ...SHIM);',
471
+ 'process.argv[1] = shim;',
472
+ 'try {',
473
+ ' await import(pathToFileURL(shim).href);',
474
+ '} catch (err) {',
475
+ " process.stderr.write('[heyspex] hook ' + (process.argv[2] ?? '?') + ' skipped: ' + (err && err.message ? err.message : err) + '\\n');",
476
+ ' process.exit(0);',
477
+ '}',
478
+ '',
479
+ ].join('\n');
480
+
481
+ /**
482
+ * The launcher file `hs install` / `hs connect` write into a customer repo
483
+ * (`.claude/heyspex/run-hook.mjs`; the Cursor copy at
484
+ * `.cursor/heyspex/run-hook.mjs`). Pure: a string, no machine path in it.
485
+ * @param {'claude' | 'cursor'} [surface]
486
+ */
487
+ export function renderCustomerLauncher(surface = 'claude') {
488
+ const l = CUSTOMER_LAUNCHERS[surface];
489
+ return LAUNCHER_TEMPLATE.replace(/__(REGISTRY|ENV|LAUNCHER|SHIM)__/g, (_m, token) =>
490
+ token === 'REGISTRY' ? l.registry : token === 'ENV' ? l.projectDirEnv : token === 'LAUNCHER' ? l.rel.join('/') : l.shim,
491
+ );
492
+ }
493
+
494
+ /**
495
+ * Whether the launcher on disk needs (re)writing. EOL-insensitive: git's
496
+ * autocrlf may check the committed copy out with CRLF.
497
+ * @param {string | null | undefined} existing
498
+ * @param {'claude' | 'cursor'} [surface]
499
+ * @returns {{ write: boolean, reason: 'missing' | 'unchanged' | 'update', text: string }}
500
+ */
501
+ export function planCustomerLauncherWrite(existing, surface = 'claude') {
502
+ const text = renderCustomerLauncher(surface);
503
+ if (existing === null || existing === undefined) return { write: true, reason: 'missing', text };
504
+ if (String(existing).replace(/\r\n/g, '\n') === text) return { write: false, reason: 'unchanged', text };
505
+ return { write: true, reason: 'update', text };
506
+ }
507
+
508
+ /**
509
+ * A HeySpex hook command that still names a machine path: a quoted script
510
+ * that starts with a drive letter or a root slash rather than a `$VAR`. The
511
+ * legacy 2026-10-02 kit form; what `hs doctor` reports as a stale path and
512
+ * what the next install/connect repairs.
513
+ * @param {unknown} command
514
+ */
515
+ export function isAbsoluteHookCommand(command) {
516
+ if (typeof command !== 'string' || ourHookTail(command) === null) return false;
517
+ return /^\s*node\s+"(?:[A-Za-z]:)?[\\/](?!\$)/.test(command) || /^\s*node\s+(?:[A-Za-z]:)?[\\/]\S*(?:_run|run-hook)\.mjs/.test(command);
518
+ }
519
+
520
+ /**
521
+ * `event: <hook> [args]` for every HeySpex row in a parsed settings object
522
+ * whose command is still an absolute machine path.
523
+ * @param {Record<string, unknown>} settings
524
+ * @returns {string[]}
525
+ */
526
+ export function legacyAbsoluteHookRows(settings) {
527
+ const out = [];
528
+ const hooks = settings?.hooks && typeof settings.hooks === 'object' ? settings.hooks : {};
529
+ for (const [event, groups] of Object.entries(hooks)) {
530
+ if (!Array.isArray(groups)) continue;
531
+ for (const group of groups) {
532
+ for (const h of Array.isArray(group?.hooks) ? group.hooks : []) {
533
+ if (isAbsoluteHookCommand(h?.command)) out.push(`${event}: ${ourHookTail(h.command)}`);
534
+ }
535
+ }
536
+ }
537
+ return out;
538
+ }
539
+
540
+ /**
541
+ * Desired registrations for a runner prefix.
542
+ * @param {string} runnerPrefix
543
+ * @returns {{ event: string, matcher?: string, command: string, timeout: number, tail: string }[]}
544
+ */
545
+ export function renderCustomerHooks(runnerPrefix) {
546
+ return customerHookRows().map((row) => ({
547
+ event: row.event,
548
+ ...(row.matcher ? { matcher: widenMcpMatcher(row.matcher) } : {}),
549
+ command: `${runnerPrefix} ${hookTail(row)}`,
550
+ timeout: row.timeout,
551
+ tail: hookTail(row),
552
+ }));
553
+ }
554
+
555
+ /**
556
+ * Ours = any command routed through a HeySpex runner: the platform shim
557
+ * (`scripts/claude-hooks/_run.mjs`, by `$CLAUDE_PROJECT_DIR` or a legacy
558
+ * absolute path), the plugin launcher (`bin/run-hook.mjs`) or the committed
559
+ * customer launcher (`.claude/heyspex/run-hook.mjs`).
560
+ */
561
+ const OUR_RUNNER_RE = /(?:scripts\/claude-hooks\/_run\.mjs|(?:bin|heyspex)\/run-hook\.mjs)"?\s+(.+)$/;
562
+
563
+ /** The `<hook> [args]` tail of one of our commands, or null for a foreign one. */
564
+ export function ourHookTail(command) {
565
+ if (typeof command !== 'string') return null;
566
+ const m = command.replace(/\\/g, '/').match(OUR_RUNNER_RE);
567
+ return m ? m[1].trim() : null;
568
+ }
569
+
570
+ /**
571
+ * Merge the customer hook kit into a parsed `.claude/settings.json` object, in
572
+ * place. Merge-not-clobber: other keys, other events and foreign hooks
573
+ * survive untouched. An existing HeySpex entry (any `_run.mjs`/`run-hook.mjs`
574
+ * command: an older platform path, a copied `$CLAUDE_PROJECT_DIR` form) is
575
+ * REPAIRED in place when it matches a kit row, and removed when it does not
576
+ * (a platform-only hook, a duplicate, a retired hook) — never duplicated.
577
+ * Idempotent: a second call reports no change.
578
+ *
579
+ * @param {Record<string, unknown>} settings
580
+ * @param {string} runnerPrefix e.g. {@link customerRunnerPrefix}(platformRoot)
581
+ * @returns {{ changed: boolean, added: string[], repaired: string[], removed: string[], total: number }}
582
+ */
583
+ export function mergeCustomerKitHooks(settings, runnerPrefix) {
584
+ const desired = renderCustomerHooks(runnerPrefix);
585
+ const hooks =
586
+ settings.hooks && typeof settings.hooks === 'object' && !Array.isArray(settings.hooks)
587
+ ? /** @type {Record<string, unknown>} */ (settings.hooks)
588
+ : {};
589
+ const claimed = new Set();
590
+ const added = [];
591
+ const repaired = [];
592
+ const removed = [];
593
+ const key = (event, tail) => `${event}::${tail}`;
594
+ const desiredByKey = new Map(desired.map((d) => [key(d.event, d.tail), d]));
595
+
596
+ for (const [event, groups] of Object.entries(hooks)) {
597
+ if (!Array.isArray(groups)) continue;
598
+ const keptGroups = [];
599
+ for (const group of groups) {
600
+ if (!group || typeof group !== 'object' || !Array.isArray(group.hooks)) {
601
+ keptGroups.push(group);
602
+ continue;
603
+ }
604
+ const before = group.hooks.length;
605
+ const keptHooks = [];
606
+ for (const h of group.hooks) {
607
+ const tail = ourHookTail(h?.command);
608
+ if (tail === null) {
609
+ keptHooks.push(h);
610
+ continue;
611
+ }
612
+ const k = key(event, tail);
613
+ const want = desiredByKey.get(k);
614
+ const matcherOk = want && (group.matcher ?? '') === (want.matcher ?? '');
615
+ if (!want || claimed.has(k) || !matcherOk) {
616
+ removed.push(`${event}: ${tail}`);
617
+ continue;
618
+ }
619
+ claimed.add(k);
620
+ if (h.command !== want.command || h.timeout !== want.timeout || h.type !== 'command') {
621
+ h.type = 'command';
622
+ h.command = want.command;
623
+ h.timeout = want.timeout;
624
+ repaired.push(`${event}: ${tail}`);
625
+ }
626
+ keptHooks.push(h);
627
+ }
628
+ group.hooks = keptHooks;
629
+ // Drop a group only when THIS merge emptied it.
630
+ if (keptHooks.length > 0 || before === 0) keptGroups.push(group);
631
+ }
632
+ hooks[event] = keptGroups;
633
+ }
634
+
635
+ for (const d of desired) {
636
+ const k = key(d.event, d.tail);
637
+ if (claimed.has(k)) continue;
638
+ const list = Array.isArray(hooks[d.event]) ? /** @type {unknown[]} */ (hooks[d.event]) : [];
639
+ list.push({
640
+ ...(d.matcher ? { matcher: d.matcher } : {}),
641
+ hooks: [{ type: 'command', command: d.command, timeout: d.timeout }],
642
+ });
643
+ hooks[d.event] = list;
644
+ added.push(`${d.event}: ${d.tail}`);
645
+ }
646
+
647
+ const changed = added.length + repaired.length + removed.length > 0;
648
+ if (changed) settings.hooks = hooks;
649
+ return { changed, added, repaired, removed, total: desired.length };
650
+ }
651
+
652
+ /** The plugin's hooks.json document, generated from the same rows. */
653
+ export function renderPluginHooksDocument() {
654
+ const hooks = {};
655
+ for (const d of renderCustomerHooks(PLUGIN_RUNNER_PREFIX)) {
656
+ (hooks[d.event] ??= []).push({
657
+ ...(d.matcher ? { matcher: d.matcher } : {}),
658
+ hooks: [{ type: 'command', command: d.command, timeout: d.timeout }],
659
+ });
660
+ }
661
+ // No top-level `_comment`: GitHub Copilot loads this file too and a strict reader may reject unknown keys. The
662
+ // provenance note lives in plugins/heyspex/README.md (GENERATED from this file's customer-scoped rows; edit
663
+ // scripts/lib/customer-kit.mjs and run `node scripts/lib/customer-kit.mjs --write-plugin-hooks`).
664
+ return {
665
+ hooks,
666
+ };
667
+ }
668
+
669
+ export function renderPluginHooksText() {
670
+ return `${JSON.stringify(renderPluginHooksDocument(), null, 2)}\n`;
671
+ }
672
+
673
+ // ---------------------------------------------------------------------------
674
+ // GitHub Copilot cloud agent: .github/hooks/heyspex.json
675
+ // ---------------------------------------------------------------------------
676
+ // The cloud agent loads NO plugins and no user-level hook files; its only hook source is
677
+ // `.github/hooks/*.json` on the default branch. This is the one place a generated Copilot
678
+ // manifest is justified ([[HS-0GBS-CTF8-RES-copilot-plugin-parity-with-the-spexcode]], option 3).
679
+ // Generated from the SAME customer rows as hooks.json, restricted to the hooks whose Copilot
680
+ // disposition (scripts/lib/hook-dispositions.mjs) is 'mapped'. The cloud agent is Linux bash only,
681
+ // so every entry is `type: "command"` with a `bash` command; no PowerShell, no node -e bootstrap.
682
+
683
+ /** Claude hook event -> Copilot hooks event (camelCase, as documented for the CLI and the cloud agent). */
684
+ export const COPILOT_EVENT_NAMES = Object.freeze({
685
+ SessionStart: 'sessionStart',
686
+ SessionEnd: 'sessionEnd',
687
+ UserPromptSubmit: 'userPromptSubmitted',
688
+ PreToolUse: 'preToolUse',
689
+ PostToolUse: 'postToolUse',
690
+ Stop: 'agentStop',
691
+ PreCompact: 'preCompact',
692
+ });
693
+
694
+ /**
695
+ * Hooks shipped to the cloud agent although their Copilot disposition is 'advisory-degraded': their
696
+ * prompt-time OUTPUT is dropped, but they still run and ARM state (the HS-20SW capture obligation), and
697
+ * the 'mapped' directive-obligation-gate is inert without that arming.
698
+ */
699
+ export const GITHUB_HOOKS_STATE_ARMING = Object.freeze(['intent-classifier-trigger']);
700
+
701
+ /** Claude tool name -> Copilot tool name (documented: ask_user bash create edit glob grep powershell task view web_fetch). */
702
+ const COPILOT_TOOL_NAMES = Object.freeze({
703
+ Write: 'create',
704
+ Edit: 'edit',
705
+ MultiEdit: 'edit',
706
+ NotebookEdit: 'edit',
707
+ Bash: 'bash',
708
+ PowerShell: 'powershell',
709
+ Agent: 'task',
710
+ Task: 'task',
711
+ WebFetch: 'web_fetch',
712
+ WebSearch: 'web_search',
713
+ });
714
+
715
+ /**
716
+ * Translate a Claude matcher to Copilot tool names. MCP tools become a suffix regex because the MCP
717
+ * tool-name spelling inside hook payloads is not documented (unverified live); tools with no Copilot
718
+ * counterpart (Skill, Workflow, ExitPlanMode) are dropped.
719
+ * @param {string | undefined} matcher
720
+ * @returns {string | null | undefined} undefined = no matcher, null = nothing translatable
721
+ */
722
+ export function toCopilotMatcher(matcher) {
723
+ if (!matcher) return undefined;
724
+ const out = [];
725
+ for (const part of matcher.split('|')) {
726
+ let t = null;
727
+ if (part === '.*') t = '.*';
728
+ else if (COPILOT_TOOL_NAMES[part]) t = COPILOT_TOOL_NAMES[part];
729
+ else if (part.startsWith(MCP_PROJECT_PREFIX)) t = '.*' + part.slice(MCP_PROJECT_PREFIX.length);
730
+ else if (part.startsWith(MCP_PLUGIN_PREFIX)) t = '.*' + part.slice(MCP_PLUGIN_PREFIX.length);
731
+ if (t && !out.includes(t)) out.push(t);
732
+ }
733
+ if (out.includes('.*')) return '.*';
734
+ return out.length ? out.join('|') : null;
735
+ }
736
+
737
+ /** Hooks the cloud-agent manifest carries (see the module comment above). */
738
+ export function githubHookNames() {
739
+ const names = new Set();
740
+ for (const r of customerHookRows()) {
741
+ if (dispositionFor(r.hook, 'copilot')?.disposition === 'mapped' || GITHUB_HOOKS_STATE_ARMING.includes(r.hook)) names.add(r.hook);
742
+ }
743
+ return names;
744
+ }
745
+
746
+ /**
747
+ * The bash command for one hook: resolve the runtime from node_modules/heyspex first, then the checkout's own
748
+ * scripts/, else exit 0 (fail open; Copilot turns any non-zero exit into a deny).
749
+ * @param {{ hook: string, args?: string[] }} row
750
+ */
751
+ export function githubHookCommand(row) {
752
+ const tail = hookTail(row);
753
+ return (
754
+ 'R=node_modules/heyspex/scripts/claude-hooks/_run.mjs; ' +
755
+ '[ -f "$R" ] || R=./scripts/claude-hooks/_run.mjs; ' +
756
+ '[ -f "$R" ] || exit 0; ' +
757
+ `exec node "$R" ${tail}`
758
+ );
759
+ }
760
+
761
+ /** The cloud agent's .github/hooks/heyspex.json document, generated from the customer rows. */
762
+ export function renderGithubHooksDocument() {
763
+ const allowed = githubHookNames();
764
+ const hooks = {};
765
+ for (const row of customerHookRows()) {
766
+ if (!allowed.has(row.hook)) continue;
767
+ const event = COPILOT_EVENT_NAMES[row.event];
768
+ if (!event) continue;
769
+ const matcher = toCopilotMatcher(row.matcher);
770
+ if (matcher === null) continue; // a tool-scoped row with no Copilot tool to match
771
+ (hooks[event] ??= []).push({
772
+ type: 'command',
773
+ ...(matcher ? { matcher } : {}),
774
+ bash: githubHookCommand(row),
775
+ timeoutSec: row.timeout,
776
+ });
777
+ }
778
+ return { version: 1, hooks };
779
+ }
780
+
781
+ export function renderGithubHooksText() {
782
+ return `${JSON.stringify(renderGithubHooksDocument(), null, 2)}\n`;
783
+ }
784
+
785
+ // ---------------------------------------------------------------------------
786
+ // Double-firing detector (plugin AND repo hooks)
787
+ // ---------------------------------------------------------------------------
788
+ // Copilot CLI reads hooks from the plugin AND from .claude/settings.json / .github/hooks in the repo, and
789
+ // Claude Code does the same for plugin + .claude/settings.json: with both present every hook runs twice.
790
+ // Rule: pick one of plugin OR repo hooks. This detector is pure (injected fs), so the doctor and a test share it.
791
+
792
+ const readJsonFile = (deps, file) => {
793
+ try {
794
+ const text = deps.readFile(file);
795
+ return text == null ? undefined : JSON.parse(text);
796
+ } catch {
797
+ return null; // present but unparseable
798
+ }
799
+ };
800
+
801
+ /** @param {unknown} settings */
802
+ function countOurRows(settings) {
803
+ let n = 0;
804
+ const hooks = settings && typeof settings === 'object' && settings.hooks && typeof settings.hooks === 'object' ? settings.hooks : {};
805
+ for (const groups of Object.values(hooks)) {
806
+ for (const g of Array.isArray(groups) ? groups : []) {
807
+ // Claude shape: { matcher, hooks: [{ command }] }; Copilot .github/hooks shape: { bash } / { command }.
808
+ const cmds = Array.isArray(g?.hooks) ? g.hooks.map((h) => h?.command) : [g?.command, g?.bash];
809
+ for (const c of cmds) if (typeof c === 'string' && (ourHookTail(c) !== null || /claude-hooks\/_run\.mjs/.test(c))) n++;
810
+ }
811
+ }
812
+ return n;
813
+ }
814
+
815
+ /**
816
+ * Whether BOTH repo-level HeySpex hooks AND an installed heyspex plugin are present for a project.
817
+ *
818
+ * Repo hooks: HeySpex rows in <project>/.claude/settings.json, .claude/settings.local.json or
819
+ * <project>/.github/hooks/*.json. Plugin evidence (any of): ~/.claude/plugins/installed_plugins.json with a
820
+ * "heyspex@..." key; `enabledPlugins` naming heyspex in ~/.claude/settings.json or the project's Claude settings;
821
+ * a heyspex directory under ~/.copilot/installed-plugins or ~/.copilot/plugins (best effort: the Copilot plugin
822
+ * list file is not documented, `copilot plugin list` is the authority). If no plugin registry can be read at all
823
+ * and repo hooks exist, the answer is 'unknown' (never a failure).
824
+ * @param {{ targetRoot: string, home?: string, deps?: { readFile: (p: string) => string | null, listDir?: (p: string) => string[] } }} opts
825
+ * @returns {{ status: 'ok' | 'warn' | 'unknown', detail: string, repoHooks: number, plugin: boolean | null, evidence: string[] }}
826
+ */
827
+ export function detectDoubleFire({ targetRoot, home = os.homedir(), deps = fsDeps }) {
828
+ const evidence = [];
829
+ const repoFiles = ['.claude/settings.json', '.claude/settings.local.json'].map((f) => path.join(targetRoot, f));
830
+ let repoHooks = 0;
831
+ for (const f of repoFiles) {
832
+ const n = countOurRows(readJsonFile(deps, f));
833
+ if (n) { repoHooks += n; evidence.push(`${n} HeySpex hook row(s) in ${f}`); }
834
+ }
835
+ const ghDir = path.join(targetRoot, '.github', 'hooks');
836
+ for (const name of (deps.listDir ? deps.listDir(ghDir) : []).filter((x) => x.endsWith('.json'))) {
837
+ const n = countOurRows(readJsonFile(deps, path.join(ghDir, name)));
838
+ if (n) { repoHooks += n; evidence.push(`${n} HeySpex hook row(s) in .github/hooks/${name}`); }
839
+ }
840
+
841
+ let readable = 0;
842
+ let plugin = false;
843
+ const installed = readJsonFile(deps, path.join(home, '.claude', 'plugins', 'installed_plugins.json'));
844
+ if (installed !== undefined) {
845
+ readable++;
846
+ const keys = Object.keys(installed?.plugins ?? installed ?? {});
847
+ if (keys.some((k) => /^heyspex(@|$)/i.test(k))) { plugin = true; evidence.push('heyspex in ~/.claude/plugins/installed_plugins.json'); }
848
+ }
849
+ const userSettings = path.join(home, '.claude', 'settings.json');
850
+ for (const f of [userSettings, ...repoFiles]) {
851
+ const st = readJsonFile(deps, f);
852
+ if (st === undefined) continue;
853
+ if (f === userSettings) readable++; // a project file proves nothing about what is installed elsewhere
854
+ const ep = st?.enabledPlugins;
855
+ if (ep && typeof ep === 'object' && Object.entries(ep).some(([k, v]) => /^heyspex(@|$)/i.test(k) && v !== false)) {
856
+ plugin = true;
857
+ evidence.push(`heyspex in enabledPlugins of ${f}`);
858
+ }
859
+ }
860
+ if (deps.listDir) {
861
+ for (const sub of ['installed-plugins', 'plugins']) {
862
+ const dir = path.join(home, '.copilot', sub);
863
+ const names = deps.listDir(dir);
864
+ if (names.length) readable++;
865
+ if (names.some((n) => /heyspex/i.test(n))) {
866
+ plugin = true;
867
+ evidence.push(`heyspex under ${dir}`);
868
+ } else {
869
+ // one level deeper: <marketplace>/<plugin> layouts
870
+ for (const n of names) {
871
+ if (deps.listDir(path.join(dir, n)).some((m) => /heyspex/i.test(m))) {
872
+ plugin = true;
873
+ evidence.push(`heyspex under ${path.join(dir, n)}`);
874
+ }
875
+ }
876
+ }
877
+ }
878
+ }
879
+
880
+ if (repoHooks === 0) return { status: 'ok', detail: 'no repo-level HeySpex hooks to double up with a plugin', repoHooks, plugin: plugin || null, evidence };
881
+ if (plugin) {
882
+ return {
883
+ status: 'warn',
884
+ detail: `HeySpex hooks are registered in the repo AND the heyspex plugin is installed, so every hook fires twice (${evidence.join('; ')})`,
885
+ repoHooks,
886
+ plugin: true,
887
+ evidence,
888
+ };
889
+ }
890
+ if (readable === 0) {
891
+ return { status: 'unknown', detail: 'repo-level HeySpex hooks present, but no plugin registry could be read to rule out a second copy', repoHooks, plugin: null, evidence };
892
+ }
893
+ return { status: 'ok', detail: 'repo-level hooks only; no heyspex plugin found', repoHooks, plugin: false, evidence };
894
+ }
895
+
896
+
897
+ // ---------------------------------------------------------------------------
898
+ // Harness commands (.claude/commands, .cursor/commands)
899
+ // ---------------------------------------------------------------------------
900
+
901
+ /**
902
+ * The two harness-command trees `scripts/sync-skills.mjs` generates. Each
903
+ * names the INSTALLED tree (what a target repo carries) and, in priority
904
+ * order, where its CANONICAL content is read from: the packaged mirror
905
+ * (`dist/cli-templates/harness-commands/**`, a published install) then the
906
+ * platform checkout's own tree (a dev checkout before the first build).
907
+ * Re-exported by connect-checks.ts — one definition.
908
+ */
909
+ export const HARNESS_COMMAND_SURFACES = Object.freeze([
910
+ {
911
+ installedDir: Object.freeze(['.claude', 'commands']),
912
+ packagedDir: Object.freeze(['dist', 'cli-templates', 'harness-commands', 'claude']),
913
+ devSourceDir: Object.freeze(['.claude', 'commands']),
914
+ },
915
+ {
916
+ installedDir: Object.freeze(['.cursor', 'commands']),
917
+ packagedDir: Object.freeze(['dist', 'cli-templates', 'harness-commands', 'cursor']),
918
+ devSourceDir: Object.freeze(['.cursor', 'commands']),
919
+ },
920
+ ]);
921
+
922
+ /**
923
+ * Directory holding the canonical content for one surface, or null.
924
+ * @param {string} platformRoot
925
+ * @param {(typeof HARNESS_COMMAND_SURFACES)[number]} surface
926
+ * @param {{ joinPath: (...s: string[]) => string, listDir: (dir: string) => string[] }} deps
927
+ * @returns {string | null}
928
+ */
929
+ export function resolveCanonicalCommandDir(platformRoot, surface, deps) {
930
+ const hasMd = (dir) => deps.listDir(dir).some((f) => f.endsWith('.md'));
931
+ const packaged = deps.joinPath(platformRoot, ...surface.packagedDir);
932
+ if (hasMd(packaged)) return packaged;
933
+ const devSource = deps.joinPath(platformRoot, ...surface.devSourceDir);
934
+ if (hasMd(devSource)) return devSource;
935
+ return null;
936
+ }
937
+
938
+ /** Launcher aliases: npm scripts that have an `hs` verb. */
939
+ const NPM_ALIASES = Object.freeze({ doctor: 'doctor', setup: 'doctor --fix' });
940
+ const REPO_DIRS = 'bin|scripts|dev-factory|vendor|spexharness';
941
+
942
+ /**
943
+ * Platform scripts the shipped commands invoke directly that have an `hs`
944
+ * verb (`bin/hs.mjs` SPAWN_TARGETS) — the verb is what a customer runs.
945
+ */
946
+ const SCRIPT_VERBS = Object.freeze({
947
+ 'scripts/heyspex-doctor.mjs': 'doctor',
948
+ 'scripts/hs-view.mjs': 'view',
949
+ 'scripts/hs-capture-cli.mjs': 'capture',
950
+ 'scripts/hs-next.mjs': 'next',
951
+ 'scripts/hs-mode.mjs': 'mode',
952
+ 'scripts/hs-settings.mjs': 'settings',
953
+ 'scripts/hs-companion.mjs': 'companion',
954
+ 'scripts/wip-ledger-health.mjs': 'wip',
955
+ 'scripts/dev-server-status.mjs': 'server status',
956
+ 'scripts/dev-server-stop.mjs': 'server stop',
957
+ });
958
+
959
+ /** Where the `heyspex` package sits in a customer repo, repo-relative. */
960
+ export const PORTABLE_PACKAGE_DIR = 'node_modules/heyspex';
961
+
962
+ /**
963
+ * Rewrite repo-relative platform invocations to PORTABLE ones — forms that
964
+ * resolve from the customer repo on any machine, never this machine's path:
965
+ * `node bin/hs.mjs <args>` → `hs <args>`
966
+ * `node scripts/heyspex-doctor.mjs`→ `hs doctor` (SCRIPT_VERBS)
967
+ * `node dev-factory/x.mjs` → `node node_modules/heyspex/dev-factory/x.mjs`
968
+ * `npm run doctor|setup` → `hs doctor [--fix]`
969
+ * `npm run <script>` → `npm --prefix node_modules/heyspex run <script>`
970
+ * Prose mentions of a path (no `node ` in front) are left alone. The platform
971
+ * root is accepted (and ignored) so every caller keeps its signature.
972
+ * @param {string} content
973
+ * @param {string} [_platformRoot]
974
+ */
975
+ export function rewritePlatformPaths(content, _platformRoot) {
976
+ return String(content)
977
+ .replace(/\bnode bin\/hs\.mjs\b/g, 'hs')
978
+ .replace(new RegExp(`\\bnode (${REPO_DIRS})/([\\w./-]+\\.m?js)\\b`, 'g'), (_m, dir, rest) => {
979
+ const rel = `${dir}/${rest}`;
980
+ return SCRIPT_VERBS[rel] ? `hs ${SCRIPT_VERBS[rel]}` : `node ${PORTABLE_PACKAGE_DIR}/${rel}`;
981
+ })
982
+ .replace(/\bnpm run ([\w:.-]+)/g, (_m, script) =>
983
+ NPM_ALIASES[script] ? `hs ${NPM_ALIASES[script]}` : `npm --prefix ${PORTABLE_PACKAGE_DIR} run ${script}`,
984
+ );
985
+ }
986
+
987
+ const MARKER_RE = /\n<!-- heyspex-kit: managed by hs install \(sha256:([0-9a-f]{16})\)[^\n]*-->\n?$/;
988
+
989
+ function shortHash(text) {
990
+ return createHash('sha256').update(text, 'utf8').digest('hex').slice(0, 16);
991
+ }
992
+
993
+ /**
994
+ * The customer copy of a canonical command: platform paths made portable,
995
+ * then a trailing marker carrying the hash of the body. The marker is how a
996
+ * re-run tells "ours, untouched, safe to update" from "the customer edited
997
+ * it, leave it alone".
998
+ */
999
+ export function renderKitCommand(content, platformRoot) {
1000
+ const body = `${rewritePlatformPaths(content, platformRoot).replace(/\s+$/, '')}\n`;
1001
+ return (
1002
+ `${body}\n<!-- heyspex-kit: managed by hs install (sha256:${shortHash(body)}). ` +
1003
+ 'Edit freely: once edited, hs install leaves this file alone. -->\n'
1004
+ );
1005
+ }
1006
+
1007
+ /**
1008
+ * @param {string} content
1009
+ * @returns {'managed' | 'customized' | 'unmanaged'}
1010
+ */
1011
+ export function kitFileState(content) {
1012
+ const m = String(content).match(MARKER_RE);
1013
+ if (!m) return 'unmanaged';
1014
+ const body = String(content).slice(0, m.index).replace(/\n$/, '');
1015
+ return shortHash(`${body}\n`) === m[1] ? 'managed' : 'customized';
1016
+ }
1017
+
1018
+ /**
1019
+ * Whether to write one kit file. `raw` is the canonical (unrendered) content:
1020
+ * an existing file equal to it is an old verbatim copy (`hs connect
1021
+ * --fix-commands` before this change) and is safe to replace.
1022
+ * @returns {{ write: boolean, reason: 'missing' | 'unchanged' | 'update' | 'legacy-copy' | 'customized' | 'forced' }}
1023
+ */
1024
+ export function planKitFileWrite({ existing, rendered, raw, force }) {
1025
+ if (existing === null || existing === undefined) return { write: true, reason: 'missing' };
1026
+ if (existing === rendered) return { write: false, reason: 'unchanged' };
1027
+ if (kitFileState(existing) === 'managed') return { write: true, reason: 'update' };
1028
+ if (existing === raw) return { write: true, reason: 'legacy-copy' };
1029
+ return force ? { write: true, reason: 'forced' } : { write: false, reason: 'customized' };
1030
+ }
1031
+
1032
+ /**
1033
+ * Every canonical harness command file, as `(installedDir, file, content,
1034
+ * raw)`. With `targetRoot` set to a repo other than the platform, the list is
1035
+ * filtered to customer-scoped commands and `content` is the rendered customer
1036
+ * copy; otherwise `content === raw` (the platform's own trees).
1037
+ * @param {string} platformRoot
1038
+ * @param {{ joinPath: (...s: string[]) => string, readFile: (p: string) => string | null, listDir: (dir: string) => string[] }} deps
1039
+ * @param {{ targetRoot?: string, isPlatformTarget?: boolean }} [opts]
1040
+ * @returns {{ installedDir: readonly string[], file: string, content: string, raw: string }[]}
1041
+ */
1042
+ export function listKitCommandFiles(platformRoot, deps, opts = {}) {
1043
+ const customer =
1044
+ opts.targetRoot !== undefined &&
1045
+ !(opts.isPlatformTarget ?? samePlatformPath(opts.targetRoot, platformRoot));
1046
+ const out = [];
1047
+ for (const surface of HARNESS_COMMAND_SURFACES) {
1048
+ const dir = resolveCanonicalCommandDir(platformRoot, surface, deps);
1049
+ if (!dir) continue;
1050
+ const files = deps
1051
+ .listDir(dir)
1052
+ .filter((f) => f.endsWith('.md'))
1053
+ .sort();
1054
+ for (const file of files) {
1055
+ if (customer && !isCustomerCommand(file)) continue;
1056
+ const raw = deps.readFile(deps.joinPath(dir, file));
1057
+ if (raw === null) continue;
1058
+ out.push({
1059
+ installedDir: surface.installedDir,
1060
+ file,
1061
+ raw,
1062
+ content: customer ? renderKitCommand(raw, platformRoot) : raw,
1063
+ });
1064
+ }
1065
+ }
1066
+ return out;
1067
+ }
1068
+
1069
+ // ---------------------------------------------------------------------------
1070
+ // Audit (hs doctor)
1071
+ // ---------------------------------------------------------------------------
1072
+
1073
+ const fsDeps = {
1074
+ joinPath: (...s) => path.join(...s),
1075
+ readFile: (p) => {
1076
+ try {
1077
+ return fs.readFileSync(p, 'utf8');
1078
+ } catch {
1079
+ return null;
1080
+ }
1081
+ },
1082
+ listDir: (d) => {
1083
+ try {
1084
+ return fs.readdirSync(d);
1085
+ } catch {
1086
+ return [];
1087
+ }
1088
+ },
1089
+ };
1090
+
1091
+ /**
1092
+ * What a customer repo is missing or carries stale, without writing anything.
1093
+ * @param {{ targetRoot: string, platformRoot: string, deps?: typeof fsDeps }} opts
1094
+ */
1095
+ export function auditCustomerKit({ targetRoot, platformRoot, deps = fsDeps }) {
1096
+ const report = {
1097
+ settingsError: null,
1098
+ /** `stalePaths`: rows still on the legacy absolute machine path (a subset of `repaired`). */
1099
+ hooks: { added: [], repaired: [], removed: [], stalePaths: [], total: 0 },
1100
+ /** The committed launcher the hook rows point at. */
1101
+ launcher: { path: [...CUSTOMER_LAUNCHERS.claude.rel].join('/'), state: 'missing' },
1102
+ commands: { missing: [], stale: [], customized: [] },
1103
+ };
1104
+ const settingsPath = deps.joinPath(targetRoot, '.claude', 'settings.json');
1105
+ const raw = deps.readFile(settingsPath);
1106
+ let settings = {};
1107
+ if (raw !== null) {
1108
+ try {
1109
+ const parsed = JSON.parse(raw);
1110
+ settings = parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
1111
+ } catch (err) {
1112
+ report.settingsError = `${settingsPath}: ${err instanceof Error ? err.message : String(err)}`;
1113
+ }
1114
+ }
1115
+ if (!report.settingsError) {
1116
+ const stalePaths = legacyAbsoluteHookRows(settings);
1117
+ const r = mergeCustomerKitHooks(structuredClone(settings), customerRunnerPrefix(platformRoot));
1118
+ report.hooks = { added: r.added, repaired: r.repaired, removed: r.removed, stalePaths, total: r.total };
1119
+ }
1120
+ const launcherPlan = planCustomerLauncherWrite(
1121
+ deps.readFile(deps.joinPath(targetRoot, ...CUSTOMER_LAUNCHERS.claude.rel)),
1122
+ );
1123
+ report.launcher.state = launcherPlan.reason === 'update' ? 'stale' : launcherPlan.reason === 'missing' ? 'missing' : 'current';
1124
+ for (const f of listKitCommandFiles(platformRoot, deps, { targetRoot, isPlatformTarget: false })) {
1125
+ if (f.installedDir[0] !== '.claude') continue; // .cursor is the cursor-hooks/connect checks' business
1126
+ const rel = [...f.installedDir, f.file].join('/');
1127
+ const existing = deps.readFile(deps.joinPath(targetRoot, ...f.installedDir, f.file));
1128
+ const plan = planKitFileWrite({ existing, rendered: f.content, raw: f.raw, force: false });
1129
+ if (plan.reason === 'missing') report.commands.missing.push(rel);
1130
+ else if (plan.reason === 'update' || plan.reason === 'legacy-copy') report.commands.stale.push(rel);
1131
+ else if (plan.reason === 'customized') report.commands.customized.push(rel);
1132
+ }
1133
+ return report;
1134
+ }
1135
+
1136
+ // ---------------------------------------------------------------------------
1137
+ // CLI: regenerate plugins/heyspex/hooks/hooks.json
1138
+ // ---------------------------------------------------------------------------
1139
+
1140
+ /**
1141
+ * "Was THIS module the script node was started with?" — bundle-safe.
1142
+ *
1143
+ * The plain comparison every script in this repo used (`import.meta.url ===
1144
+ * pathToFileURL(argv[1])`) is wrong inside the esbuild CLI bundle
1145
+ * (`dist/cli/*.mjs`, scripts/build-cli-bundle.mjs): there `import.meta.url`
1146
+ * is the BUNDLE file's url for every module in it, and the bundle IS argv[1],
1147
+ * so the test reads true for all of them at once and this file's CLI entry
1148
+ * hijacked `hs connect --check` (it printed the usage line and no report).
1149
+ * Requiring the module's own basename in the url rules the bundle out: a
1150
+ * bundle is named after its entry (`hs-connect.mjs`), never after a library.
1151
+ * Pure (every input injectable) so the three shapes are unit-tested.
1152
+ *
1153
+ * @param {string} importMetaUrl The module's `import.meta.url`.
1154
+ * @param {string} selfBasename The module's own file name, e.g. `customer-kit.mjs`.
1155
+ * @param {string | undefined} [argv1] `process.argv[1]` (default: the real one).
1156
+ */
1157
+ export function invokedDirectly(importMetaUrl, selfBasename, argv1 = process.argv[1]) {
1158
+ try {
1159
+ if (!argv1) return false;
1160
+ const self = fileURLToPath(importMetaUrl);
1161
+ if (path.basename(self) !== selfBasename) return false;
1162
+ return pathToFileURL(path.resolve(argv1)).href === pathToFileURL(self).href;
1163
+ } catch {
1164
+ return false;
1165
+ }
1166
+ }
1167
+
1168
+ const SELF = fileURLToPath(import.meta.url);
1169
+ if (invokedDirectly(import.meta.url, 'customer-kit.mjs')) {
1170
+ const platformRoot = path.resolve(path.dirname(SELF), '..', '..');
1171
+ const target = path.join(platformRoot, 'plugins', 'heyspex', 'hooks', 'hooks.json');
1172
+ const want = renderPluginHooksText();
1173
+ // EOL-insensitive: git's autocrlf may check the file out with CRLF.
1174
+ const same = (fsDeps.readFile(target) ?? '').replace(/\r\n/g, '\n') === want;
1175
+ if (process.argv.includes('--write-plugin-hooks')) {
1176
+ if (!same) fs.writeFileSync(target, want, 'utf8');
1177
+ console.log(`customer-kit: ${same ? 'unchanged' : 'wrote'} ${path.relative(platformRoot, target)}`);
1178
+ } else if (process.argv.includes('--check-plugin-hooks')) {
1179
+ if (!same) {
1180
+ console.error('customer-kit: plugins/heyspex/hooks/hooks.json is stale — run `node scripts/lib/customer-kit.mjs --write-plugin-hooks`');
1181
+ process.exit(1);
1182
+ }
1183
+ console.log('customer-kit: plugins/heyspex/hooks/hooks.json up to date');
1184
+ } else if (process.argv.includes('--write-github-hooks') || process.argv.includes('--check-github-hooks')) {
1185
+ const ghTarget = path.join(platformRoot, '.github', 'hooks', 'heyspex.json');
1186
+ const ghWant = renderGithubHooksText();
1187
+ const ghSame = (fsDeps.readFile(ghTarget) ?? '').replace(/\r\n/g, '\n') === ghWant;
1188
+ if (process.argv.includes('--write-github-hooks')) {
1189
+ if (!ghSame) {
1190
+ fs.mkdirSync(path.dirname(ghTarget), { recursive: true });
1191
+ fs.writeFileSync(ghTarget, ghWant, 'utf8');
1192
+ }
1193
+ console.log(`customer-kit: ${ghSame ? 'unchanged' : 'wrote'} ${path.relative(platformRoot, ghTarget).replace(/\\/g, '/')}`);
1194
+ } else if (!ghSame) {
1195
+ console.error('customer-kit: .github/hooks/heyspex.json is stale - run `node scripts/lib/customer-kit.mjs --write-github-hooks`');
1196
+ process.exit(1);
1197
+ } else {
1198
+ console.log('customer-kit: .github/hooks/heyspex.json up to date');
1199
+ }
1200
+ } else {
1201
+ console.log('usage: node scripts/lib/customer-kit.mjs --write-plugin-hooks | --check-plugin-hooks');
1202
+ }
1203
+ }