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,313 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * dev-server-stop — stop the spex-console Next.js dev server from any terminal.
4
+ *
5
+ * Pairs with scripts/dev-server-status.mjs (ground-truth probe). Kills ONLY
6
+ * processes attributable to the console — the lockfile-recorded launcher pid
7
+ * (+ its child tree), HTTP-identified console listeners, and node processes on
8
+ * the lockfile's own port — then clears the discovery files the launcher
9
+ * leaves behind when force-killed (exit handlers do not run under
10
+ * `taskkill /F`).
11
+ *
12
+ * HS-W7E3: this script used to kill EVERY listener on the candidate port range
13
+ * and took down Docker Desktop's backend (listening on 3002) as collateral.
14
+ * Unattributable listeners are now left alone and reported; the old sweep is
15
+ * opt-in behind `--force-ports`, never the default.
16
+ *
17
+ * Faces:
18
+ * - npm run console:stop
19
+ * - hs server stop
20
+ *
21
+ * Run: node scripts/dev-server-stop.mjs [--json] [--dry-run] [--force-ports]
22
+ */
23
+ import fs from 'node:fs';
24
+ import path from 'node:path';
25
+ import { spawnSync } from 'node:child_process';
26
+ import { fileURLToPath, pathToFileURL } from 'node:url';
27
+
28
+ import { getDevServerStatus, summarizeStatus } from './dev-server-status.mjs';
29
+
30
+ function defaultVaultRoot() {
31
+ if (process.env.HS_VAULT_ROOT) return process.env.HS_VAULT_ROOT;
32
+ return path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
33
+ }
34
+
35
+ function discoveryPaths(vaultRoot) {
36
+ const dir = path.join(vaultRoot, '.heyspex');
37
+ return {
38
+ lock: path.join(dir, 'dev-server.lock.json'),
39
+ consoleUrl: path.join(dir, 'console-url'),
40
+ };
41
+ }
42
+
43
+ function clearDiscovery(vaultRoot) {
44
+ const paths = discoveryPaths(vaultRoot);
45
+ const removed = [];
46
+ for (const [key, file] of Object.entries(paths)) {
47
+ try {
48
+ if (fs.existsSync(file)) {
49
+ fs.rmSync(file, { force: true });
50
+ removed.push(key);
51
+ }
52
+ } catch {
53
+ // Best-effort — status re-check is the source of truth.
54
+ }
55
+ }
56
+ return removed;
57
+ }
58
+
59
+ function pidAlive(pid) {
60
+ if (!pid || typeof pid !== 'number') return false;
61
+ try {
62
+ process.kill(pid, 0);
63
+ return true;
64
+ } catch (err) {
65
+ return err.code === 'EPERM';
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Kill a process and its descendants. Mirrors ensure-next-dev.mjs killChildTree:
71
+ * on Windows `shell: true` means the listening pid is often a grandchild, so
72
+ * `taskkill /T /F` is required.
73
+ */
74
+ function killProcessTree(pid) {
75
+ if (!pidAlive(pid)) {
76
+ return { pid, killed: false, reason: 'not_alive' };
77
+ }
78
+ if (process.platform === 'win32') {
79
+ const result = spawnSync('taskkill', ['/pid', String(pid), '/T', '/F'], {
80
+ stdio: 'ignore',
81
+ windowsHide: true,
82
+ });
83
+ return {
84
+ pid,
85
+ killed: result.status === 0,
86
+ reason: result.status === 0 ? 'taskkill' : `taskkill_exit_${result.status ?? 'err'}`,
87
+ };
88
+ }
89
+ try {
90
+ process.kill(-pid, 'SIGTERM'); // process group when possible
91
+ } catch {
92
+ try {
93
+ process.kill(pid, 'SIGTERM');
94
+ } catch {
95
+ // Already gone.
96
+ }
97
+ }
98
+ // Sync grace (~1s) then SIGKILL — Atomics.wait is the portable sleep.
99
+ try {
100
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 1000);
101
+ } catch {
102
+ // SharedArrayBuffer unavailable — fall through to immediate SIGKILL.
103
+ }
104
+ if (pidAlive(pid)) {
105
+ try {
106
+ process.kill(-pid, 'SIGKILL');
107
+ } catch {
108
+ try {
109
+ process.kill(pid, 'SIGKILL');
110
+ } catch {
111
+ // ignore
112
+ }
113
+ }
114
+ }
115
+ return { pid, killed: !pidAlive(pid), reason: 'signal' };
116
+ }
117
+
118
+ // Local twin of dev-server-status.mjs's portFromUrl (not exported there).
119
+ function portFromUrl(url) {
120
+ if (typeof url !== 'string') return null;
121
+ try {
122
+ const u = new URL(url);
123
+ if (u.port) return Number(u.port);
124
+ return u.protocol === 'https:' ? 443 : 80;
125
+ } catch {
126
+ return null;
127
+ }
128
+ }
129
+
130
+ /**
131
+ * Decide what to kill — pure and unit-testable (HS-W7E3). A pid is targeted
132
+ * ONLY when it is attributable to the console:
133
+ * - the lockfile's recorded launcher pid (killProcessTree covers its
134
+ * children, so the `next dev` grandchild goes with it), or
135
+ * - a listener the HTTP probe identified as spex-console, or
136
+ * - a node-named listener on the exact port the lockfile points at (a hung
137
+ * console child that stopped answering HTTP).
138
+ * Every other listener on the candidate range (Docker's backend, a Firecrawl
139
+ * container, any unrelated dev tool) is SKIPPED and reported, unless the
140
+ * caller explicitly opted into the old sweep with `--force-ports`.
141
+ */
142
+ export function planKills(status, { forcePorts = false } = {}) {
143
+ const targets = new Map();
144
+ const skipped = [];
145
+ const addTarget = (pid, reason, port = null) => {
146
+ if (!pid || typeof pid !== 'number') return;
147
+ const entry = targets.get(pid) || { pid, ports: [], reasons: [] };
148
+ if (port != null && !entry.ports.includes(port)) entry.ports.push(port);
149
+ if (!entry.reasons.includes(reason)) entry.reasons.push(reason);
150
+ targets.set(pid, entry);
151
+ };
152
+
153
+ const lock = status.lockfile || {};
154
+ const lockPort = portFromUrl(lock.url);
155
+ if (lock.present && lock.pidAlive && lock.pid) {
156
+ addTarget(lock.pid, 'lockfile pid (console launcher; tree kill covers its children)');
157
+ }
158
+
159
+ for (const server of status.servers || []) {
160
+ if (server.probe === 'console') {
161
+ addTarget(server.pid, `HTTP-identified spex-console on :${server.port}`, server.port);
162
+ } else if (
163
+ lock.present &&
164
+ lockPort != null &&
165
+ server.port === lockPort &&
166
+ /^node(\.exe)?$/i.test(server.name || '')
167
+ ) {
168
+ addTarget(server.pid, `node process on the lockfile's port :${server.port}`, server.port);
169
+ } else if (forcePorts) {
170
+ addTarget(server.pid, `--force-ports sweep on :${server.port}`, server.port);
171
+ } else if (server.pid) {
172
+ skipped.push({
173
+ pid: server.pid,
174
+ port: server.port,
175
+ name: server.name || '',
176
+ reason: 'not attributable to the console (use --force-ports to sweep)',
177
+ });
178
+ }
179
+ }
180
+
181
+ return { targets: [...targets.values()], skipped };
182
+ }
183
+
184
+ function describeTarget(t) {
185
+ const ports = t.ports.length ? ` (:${t.ports.join(', :')})` : '';
186
+ return `pid ${t.pid}${ports} — ${t.reasons.join('; ')}`;
187
+ }
188
+
189
+ function describeSkipped(s) {
190
+ return `pid ${s.pid}${s.name ? ` (${s.name})` : ''} on :${s.port} — ${s.reason}`;
191
+ }
192
+
193
+ export async function stopDevServer({ vaultRoot, forcePorts = false, dryRun = false } = {}) {
194
+ const root = vaultRoot || defaultVaultRoot();
195
+ const before = await getDevServerStatus({ vaultRoot: root });
196
+ const plan = planKills(before, { forcePorts });
197
+ const hadLock = before.lockfile?.present === true;
198
+ // HS-W7E3: "running" means console-attributed work to do — an unrelated
199
+ // listener on a candidate port (Docker on 3002…) is NOT the console running.
200
+ const wasRunning = before.running || plan.targets.length > 0;
201
+ const skippedNote = plan.skipped.length
202
+ ? `\nLeft alone (not the console):\n ${plan.skipped.map(describeSkipped).join('\n ')}`
203
+ : '';
204
+
205
+ if (dryRun) {
206
+ const wouldKill = plan.targets.length
207
+ ? `Would kill:\n ${plan.targets.map(describeTarget).join('\n ')}`
208
+ : 'Would kill: nothing (no process attributable to the console).';
209
+ return {
210
+ stopped: false,
211
+ alreadyStopped: !wasRunning && !hadLock,
212
+ dryRun: true,
213
+ killed: [],
214
+ wouldKill: plan.targets,
215
+ skipped: plan.skipped,
216
+ cleared: [],
217
+ before,
218
+ after: before,
219
+ message: `Dev server stop — DRY RUN (nothing killed, discovery files untouched).\n${wouldKill}${skippedNote}`,
220
+ };
221
+ }
222
+
223
+ if (!wasRunning && !hadLock) {
224
+ return {
225
+ stopped: false,
226
+ alreadyStopped: true,
227
+ killed: [],
228
+ skipped: plan.skipped,
229
+ cleared: [],
230
+ before,
231
+ after: before,
232
+ message: `Dev server: already stopped.${skippedNote}`,
233
+ };
234
+ }
235
+
236
+ const killed = plan.targets.map((t) => ({ ...killProcessTree(t.pid), attribution: t.reasons }));
237
+
238
+ // Force-kill skips launcher exit handlers — clear discovery ourselves.
239
+ const cleared = clearDiscovery(root);
240
+
241
+ // Give OS sockets a beat to release before re-probe.
242
+ await new Promise((r) => setTimeout(r, 400));
243
+ const after = await getDevServerStatus({ vaultRoot: root });
244
+
245
+ // Success = no CONSOLE-identified server remains. Unrelated listeners on
246
+ // candidate ports are deliberately left standing (HS-W7E3), so the old
247
+ // `servers.length === 0` condition would false-fail every stop.
248
+ if (!after.running) {
249
+ if (after.lockfile?.present) clearDiscovery(root);
250
+ return {
251
+ stopped: true,
252
+ alreadyStopped: false,
253
+ killed,
254
+ skipped: plan.skipped,
255
+ cleared,
256
+ before,
257
+ after: await getDevServerStatus({ vaultRoot: root }),
258
+ message: wasRunning
259
+ ? `Dev server: stopped${killed.length ? ` (killed pid ${killed.map((k) => k.pid).join(', ')})` : ''}.${skippedNote}`
260
+ : `Dev server: cleared stale lock (nothing console-attributable was listening).${skippedNote}`,
261
+ };
262
+ }
263
+
264
+ return {
265
+ stopped: false,
266
+ alreadyStopped: false,
267
+ killed,
268
+ skipped: plan.skipped,
269
+ cleared,
270
+ before,
271
+ after,
272
+ message:
273
+ `Dev server: STOP FAILED — console still listening.\n` +
274
+ summarizeStatus(after) +
275
+ `\nTry: npm run console:status then node scripts/dev-server-stop.mjs --force-ports (sweeps ALL candidate-port listeners — check --dry-run first)`,
276
+ };
277
+ }
278
+
279
+ const invokedDirectly = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
280
+ if (invokedDirectly) {
281
+ const asJson = process.argv.includes('--json');
282
+ const dryRun = process.argv.includes('--dry-run');
283
+ const forcePorts = process.argv.includes('--force-ports');
284
+ stopDevServer({ dryRun, forcePorts })
285
+ .then((result) => {
286
+ if (asJson) {
287
+ process.stdout.write(
288
+ JSON.stringify(
289
+ {
290
+ stopped: result.stopped,
291
+ alreadyStopped: result.alreadyStopped,
292
+ ...(result.dryRun ? { dryRun: true, wouldKill: result.wouldKill } : {}),
293
+ killed: result.killed,
294
+ skipped: result.skipped,
295
+ cleared: result.cleared,
296
+ before: result.before,
297
+ after: result.after,
298
+ message: result.message,
299
+ },
300
+ null,
301
+ 2,
302
+ ) + '\n',
303
+ );
304
+ } else {
305
+ process.stdout.write(result.message + '\n');
306
+ }
307
+ process.exit(result.stopped || result.alreadyStopped || result.dryRun ? 0 : 1);
308
+ })
309
+ .catch((err) => {
310
+ process.stderr.write(`Dev server stop failed: ${err?.message ?? err}\n`);
311
+ process.exit(1);
312
+ });
313
+ }
@@ -0,0 +1,268 @@
1
+ #!/usr/bin/env node
2
+ // heyspex-mode.mjs — the master on/off switch for the HeySpex agent layer.
3
+ //
4
+ // HeySpex layers a lot onto a plain Claude Code session: ~25 lifecycle hooks,
5
+ // an MCP server, a statusline, and an always-loaded agent contract (CLAUDE.md).
6
+ // That is the point when you are working the HeySpex protocol — and pure noise
7
+ // when you just want to edit a file in this repo. This module is the single
8
+ // place that answers "is HeySpex on right now?", and the code that flips it.
9
+ //
10
+ // Resolution order (first match wins):
11
+ // 1. HEYSPEX_MODE env var — `off`/`0`/`disabled` or `on`/`1`/`enabled`.
12
+ // A per-shell escape hatch that never touches disk.
13
+ // 2. .heyspex/mode.json — the persisted machine-local choice (gitignored).
14
+ // 3. Default: ON. Absent/unreadable/corrupt state means HeySpex works out of
15
+ // the box, and a broken state file can never silently disable the
16
+ // guardrails. Fail-open is deliberate.
17
+ //
18
+ // `isHeySpexOff()` runs on EVERY hook invocation, so it stays dependency-free
19
+ // and does at most one small synchronous read.
20
+
21
+ import { createHash } from 'node:crypto';
22
+ import fs from 'node:fs';
23
+ import path from 'node:path';
24
+ import { fileURLToPath } from 'node:url';
25
+
26
+ export const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
27
+
28
+ const STATE_REL = '.heyspex/mode.json';
29
+ const BACKUP_DIR_REL = '.heyspex/mode-backup';
30
+ const CLAUDE_MD_REL = 'CLAUDE.md';
31
+ const CLAUDE_MD_OFF_REL = 'CLAUDE.heyspex-off.md';
32
+ const LOCAL_SETTINGS_REL = '.claude/settings.local.json';
33
+
34
+ const OFF_WORDS = new Set(['off', '0', 'false', 'disabled', 'disable', 'no']);
35
+ const ON_WORDS = new Set(['on', '1', 'true', 'enabled', 'enable', 'yes']);
36
+
37
+ /** Resolve a repo-relative path against the repo root. */
38
+ function at(repoRoot, rel) {
39
+ return path.join(repoRoot, ...rel.split('/'));
40
+ }
41
+
42
+ function readJson(file) {
43
+ try {
44
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
45
+ } catch {
46
+ return null;
47
+ }
48
+ }
49
+
50
+ function writeJson(file, value) {
51
+ fs.mkdirSync(path.dirname(file), { recursive: true });
52
+ fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
53
+ }
54
+
55
+ function sha256(buf) {
56
+ return createHash('sha256').update(buf).digest('hex');
57
+ }
58
+
59
+ /**
60
+ * The env override, if the user set one. Returns 'on' | 'off' | null.
61
+ * Exported so `hs mode status` can tell the user their env var is overriding
62
+ * the persisted state — otherwise "I turned it off but it's still on" is
63
+ * an unexplainable mystery.
64
+ */
65
+ export function envOverride() {
66
+ const raw = String(process.env.HEYSPEX_MODE ?? '').trim().toLowerCase();
67
+ if (!raw) return null;
68
+ if (OFF_WORDS.has(raw)) return 'off';
69
+ if (ON_WORDS.has(raw)) return 'on';
70
+ return null;
71
+ }
72
+
73
+ /** The persisted state file, or null when absent/corrupt. */
74
+ export function readState(repoRoot = REPO_ROOT) {
75
+ return readJson(at(repoRoot, STATE_REL));
76
+ }
77
+
78
+ /**
79
+ * THE hot-path question, called by every hook through `_run.mjs`.
80
+ * Fail-open: any surprise resolves to "HeySpex is on".
81
+ */
82
+ export function isHeySpexOff(repoRoot = REPO_ROOT) {
83
+ try {
84
+ const override = envOverride();
85
+ if (override) return override === 'off';
86
+ return readState(repoRoot)?.mode === 'off';
87
+ } catch {
88
+ return false;
89
+ }
90
+ }
91
+
92
+ /** Current effective mode plus where it came from. */
93
+ export function resolveMode(repoRoot = REPO_ROOT) {
94
+ const override = envOverride();
95
+ const state = readState(repoRoot);
96
+ const persisted = state?.mode === 'off' ? 'off' : 'on';
97
+ return {
98
+ mode: override ?? persisted,
99
+ source: override ? 'env:HEYSPEX_MODE' : state ? STATE_REL : 'default',
100
+ overriddenByEnv: Boolean(override) && override !== persisted,
101
+ persisted,
102
+ state,
103
+ };
104
+ }
105
+
106
+ // ---------------------------------------------------------------------------
107
+ // Surface 1 — the MCP server
108
+ //
109
+ // `.claude/settings.local.json` is gitignored and machine-local, which makes it
110
+ // exactly the right lever: turning HeySpex off must not dirty a tracked file
111
+ // that a teammate would then see in `git status`. Claude Code reads this at
112
+ // startup, so a restart is required either way.
113
+ // ---------------------------------------------------------------------------
114
+
115
+ function applyMcp(repoRoot, enabled) {
116
+ const file = at(repoRoot, LOCAL_SETTINGS_REL);
117
+ const settings = readJson(file) ?? {};
118
+ if (enabled) {
119
+ settings.enableAllProjectMcpServers = true;
120
+ const list = new Set(settings.enabledMcpjsonServers ?? []);
121
+ list.add('heyspex');
122
+ settings.enabledMcpjsonServers = [...list];
123
+ delete settings.disabledMcpjsonServers;
124
+ } else {
125
+ settings.enableAllProjectMcpServers = false;
126
+ settings.enabledMcpjsonServers = (settings.enabledMcpjsonServers ?? []).filter((s) => s !== 'heyspex');
127
+ const disabled = new Set(settings.disabledMcpjsonServers ?? []);
128
+ disabled.add('heyspex');
129
+ settings.disabledMcpjsonServers = [...disabled];
130
+ }
131
+ writeJson(file, settings);
132
+ return path.relative(repoRoot, file).replace(/\\/g, '/');
133
+ }
134
+
135
+ export function mcpEnabled(repoRoot = REPO_ROOT) {
136
+ const settings = readJson(at(repoRoot, LOCAL_SETTINGS_REL)) ?? {};
137
+ if ((settings.disabledMcpjsonServers ?? []).includes('heyspex')) return false;
138
+ if (settings.enableAllProjectMcpServers === true) return true;
139
+ return (settings.enabledMcpjsonServers ?? []).includes('heyspex');
140
+ }
141
+
142
+ // ---------------------------------------------------------------------------
143
+ // Surface 2 — CLAUDE.md, the always-loaded agent contract
144
+ //
145
+ // This is the one tracked file the switch has to touch: as long as CLAUDE.md
146
+ // carries the HeySpex contract, the agent keeps following HeySpex protocol even
147
+ // with every hook silenced. So `off` swaps in a short stub and stashes the real
148
+ // file under .heyspex/mode-backup/ with its sha256. `on` restores it and
149
+ // verifies the hash, so a round-trip is provably byte-identical.
150
+ //
151
+ // If you edit CLAUDE.md while HeySpex is off, `on` refuses to clobber your work
152
+ // and tells you where the backup is.
153
+ // ---------------------------------------------------------------------------
154
+
155
+ const CLAUDE_MD_STUB = `# Project Instructions for AI Agents
156
+
157
+ **HeySpex is currently OFF for this checkout.**
158
+
159
+ This repository normally runs the HeySpex agent protocol — a spec vault under
160
+ \`specs/\`, an MCP server, lifecycle hooks, and a full agent contract in this
161
+ file. All of that is disabled right now, so treat this as an ordinary codebase:
162
+ read the code, make the change that was asked for, run the tests.
163
+
164
+ Do **not** file specs, claim work, recall vault memories, or run HeySpex
165
+ procedures while this notice is here — the tools backing them are turned off.
166
+
167
+ To turn HeySpex back on:
168
+
169
+ \`\`\`bash
170
+ npm run heyspex:on # or: node bin/hs.mjs mode on
171
+ \`\`\`
172
+
173
+ Then restart Claude Code. The real agent contract is restored from
174
+ \`.heyspex/mode-backup/CLAUDE.md\`.
175
+ `;
176
+
177
+ function applyClaudeMd(repoRoot, enabled, state, force = false) {
178
+ const live = at(repoRoot, CLAUDE_MD_REL);
179
+ const backup = at(repoRoot, `${BACKUP_DIR_REL}/CLAUDE.md`);
180
+
181
+ if (!enabled) {
182
+ if (state?.claudeMd?.stubbed) return { action: 'already-stubbed' };
183
+ if (!fs.existsSync(live)) return { action: 'skipped', reason: 'no CLAUDE.md' };
184
+ const original = fs.readFileSync(live);
185
+ fs.mkdirSync(path.dirname(backup), { recursive: true });
186
+ fs.writeFileSync(backup, original);
187
+ // Ship the stub alongside the backup too, so `mode status` can tell a
188
+ // pristine stub from one the user has since edited.
189
+ fs.writeFileSync(live, CLAUDE_MD_STUB, 'utf8');
190
+ return {
191
+ action: 'stubbed',
192
+ originalSha256: sha256(original),
193
+ stubSha256: sha256(Buffer.from(CLAUDE_MD_STUB, 'utf8')),
194
+ backup: path.relative(repoRoot, backup).replace(/\\/g, '/'),
195
+ };
196
+ }
197
+
198
+ if (!state?.claudeMd?.stubbed) return { action: 'nothing-to-restore' };
199
+ if (!fs.existsSync(backup)) {
200
+ return { action: 'failed', reason: `backup missing at ${BACKUP_DIR_REL}/CLAUDE.md` };
201
+ }
202
+ // Refuse to silently discard edits made to the stub while HeySpex was off.
203
+ // `--force` is the way out: it says "I've read my edits, restore anyway".
204
+ if (fs.existsSync(live) && !force) {
205
+ const current = sha256(fs.readFileSync(live));
206
+ if (state.claudeMd.stubSha256 && current !== state.claudeMd.stubSha256) {
207
+ return {
208
+ action: 'failed',
209
+ reason:
210
+ `CLAUDE.md changed while HeySpex was off — refusing to overwrite it.\n` +
211
+ ` Your edits: CLAUDE.md · the real contract: ${BACKUP_DIR_REL}/CLAUDE.md\n` +
212
+ ` Salvage anything you want to keep, then rerun with --force to restore.`,
213
+ };
214
+ }
215
+ }
216
+ const restored = fs.readFileSync(backup);
217
+ fs.writeFileSync(live, restored);
218
+ const ok = !state.claudeMd.originalSha256 || sha256(restored) === state.claudeMd.originalSha256;
219
+ return { action: 'restored', verified: ok };
220
+ }
221
+
222
+ export function claudeMdStubbed(repoRoot = REPO_ROOT) {
223
+ return Boolean(readState(repoRoot)?.claudeMd?.stubbed);
224
+ }
225
+
226
+ // ---------------------------------------------------------------------------
227
+ // The flip
228
+ // ---------------------------------------------------------------------------
229
+
230
+ /**
231
+ * Turn the whole HeySpex layer on or off.
232
+ *
233
+ * Hooks and the statusline need no file surgery — they read `isHeySpexOff()`
234
+ * themselves at fire time, via `scripts/claude-hooks/_run.mjs`. Only the MCP
235
+ * server and CLAUDE.md have to be physically changed, because Claude Code
236
+ * consumes both before any HeySpex code gets to run.
237
+ */
238
+ export function setMode(target, { repoRoot = REPO_ROOT, force = false } = {}) {
239
+ if (target !== 'on' && target !== 'off') {
240
+ throw new Error(`setMode: expected 'on' or 'off', got '${target}'`);
241
+ }
242
+ const enabled = target === 'on';
243
+ const prior = readState(repoRoot);
244
+ const steps = [];
245
+
246
+ const claudeMd = applyClaudeMd(repoRoot, enabled, prior, force);
247
+ if (claudeMd.action === 'failed') {
248
+ return { ok: false, mode: prior?.mode ?? 'on', error: claudeMd.reason, steps };
249
+ }
250
+ steps.push({ surface: 'CLAUDE.md', ...claudeMd });
251
+
252
+ steps.push({ surface: 'MCP server', action: enabled ? 'enabled' : 'disabled', file: applyMcp(repoRoot, enabled) });
253
+ steps.push({ surface: 'hooks', action: enabled ? 'live' : 'silenced (all exit 0)' });
254
+ steps.push({ surface: 'statusline', action: enabled ? 'live' : 'blank' });
255
+
256
+ const next = {
257
+ mode: target,
258
+ changedAt: new Date().toISOString(),
259
+ claudeMd: enabled
260
+ ? { stubbed: false }
261
+ : claudeMd.action === 'stubbed'
262
+ ? { stubbed: true, originalSha256: claudeMd.originalSha256, stubSha256: claudeMd.stubSha256 }
263
+ : (prior?.claudeMd ?? { stubbed: false }),
264
+ };
265
+ writeJson(at(repoRoot, STATE_REL), next);
266
+
267
+ return { ok: true, mode: target, steps, restartRequired: true };
268
+ }