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
@@ -39,6 +39,13 @@ import http from 'node:http';
39
39
  import { spawnSync } from 'node:child_process';
40
40
  import { fileURLToPath, pathToFileURL } from 'node:url';
41
41
 
42
+ // The runnable form of a HeySpex command on THIS machine — `hs …` when the bin
43
+ // is on PATH, else an absolute-path `node "<install>/bin/hs.mjs" …`. Never an
44
+ // `npx` form: npx resolves from the READER's working directory, and outside a
45
+ // checkout `npx hs` installs the unrelated public package hs@2.0.1
46
+ // ([[HS-I6HT-EKYU-DIAG-every-message-tells-the-user-to-run-hs]]).
47
+ import { hsCommand } from './lib/hs-command.mjs';
48
+
42
49
  // `/api/version` is the spex-console liveness endpoint (cheap JSON, no page
43
50
  // render). Probing `/` false-negatives during Next dev JIT compiles — the full
44
51
  // app shell can exceed 2s while API routes the UI already uses keep returning
@@ -109,14 +116,30 @@ function readLockfile(vaultRoot) {
109
116
  return { present: false, pid: null, url: null, startedAt: null, host: null, lan: null };
110
117
  }
111
118
 
119
+ /**
120
+ * Count of rows in `.heyspex/index-write-failures.jsonl` — the durable, operator-visible
121
+ * record `spex-console/src/lib/vault.ts`'s `recordIndexWriteFailure` appends to whenever a
122
+ * Neon index upsert fails and the write falls through to disk-only (QC finding, harness
123
+ * security epic HS-KRK5). A `console.error` line alone scrolls out of view; surfacing the
124
+ * count here means `heyspex_dev_server_status` (and `hs dev-status`) actually show it.
125
+ * Missing/unreadable file => 0, never a throw — this status check must never itself fail.
126
+ */
127
+ function countIndexWriteFailures(vaultRoot) {
128
+ const file = path.join(vaultRoot, '.heyspex', 'index-write-failures.jsonl');
129
+ try {
130
+ const raw = fs.readFileSync(file, 'utf8');
131
+ return raw.split(/\r?\n/).filter((line) => line.trim().length > 0).length;
132
+ } catch {
133
+ return 0;
134
+ }
135
+ }
136
+
112
137
  function readConsoleUrlFile(vaultRoot) {
113
- for (const name of ['console-url', 'cockpit-url']) { // legacy-cockpit fallback
114
- try {
115
- const raw = fs.readFileSync(path.join(vaultRoot, '.heyspex', name), 'utf8').trim();
116
- if (raw.length > 0) return raw.replace(/\/+$/, '');
117
- } catch {
118
- // try next candidate
119
- }
138
+ try {
139
+ const raw = fs.readFileSync(path.join(vaultRoot, '.heyspex', 'console-url'), 'utf8').trim();
140
+ if (raw.length > 0) return raw.replace(/\/+$/, '');
141
+ } catch {
142
+ // missing or unreadable discovery file
120
143
  }
121
144
  return null;
122
145
  }
@@ -135,11 +158,6 @@ export function writeConsoleUrlFile(vaultRoot, url) {
135
158
  const normalized = String(url).trim().replace(/\/+$/, '');
136
159
  fs.writeFileSync(tmp, `${normalized}\n`, 'utf8');
137
160
  fs.renameSync(tmp, file);
138
- try {
139
- fs.writeFileSync(path.join(dir, 'cockpit-url'), `${normalized}\n`, 'utf8'); // legacy-cockpit shadow
140
- } catch {
141
- // Best-effort — canonical console-url above is authoritative.
142
- }
143
161
  return normalized;
144
162
  }
145
163
 
@@ -463,7 +481,7 @@ const DISCREPANCY_GUIDANCE_TEXT = {
463
481
  port_mismatch:
464
482
  'The lockfile points at one port but the live console server is answering on a different one. A server IS running (see `servers`) — do not start a new one; use the port shown in `servers`, not the lockfile\'s port.',
465
483
  multiple_servers:
466
- 'More than one console server is answering at once — this is the corrupted-.next state two concurrent `next dev` processes cause. Do NOT start another. Stop the extras first (`hs server stop` / `npm run console:stop`).',
484
+ `More than one console server is answering at once — this is the corrupted-.next state two concurrent \`next dev\` processes cause. Do NOT start another. Stop the extras first (\`${hsCommand('server stop')}\` / \`npm run console:stop\`).`,
467
485
  page_probe_failed:
468
486
  'The API probe answered but a real page route failed to render (HS-D4O9-DIAG: e.g. a missing dependency leaves every page throwing while API handlers keep answering 200). The server is up but likely broken — check server logs / reinstall deps. Restarting the dev server will usually NOT fix this on its own, so do not treat this flag as "not running".',
469
487
  };
@@ -647,6 +665,9 @@ export async function getDevServerStatus({ vaultRoot, ports, pagePath } = {}) {
647
665
  // entry is restart-BLOCKING (evidence a server exists), never a restart
648
666
  // trigger. See DISCREPANCY_GUIDANCE_TEXT above for the per-code rationale.
649
667
  discrepancyGuidance: buildDiscrepancyGuidance([...new Set(discrepancies)]),
668
+ // Additive (QC finding, harness security epic HS-KRK5): a count, not the raw rows —
669
+ // an operator glancing at status output needs "is this happening", not a full replay.
670
+ indexWriteFailures: countIndexWriteFailures(root),
650
671
  platform: process.platform,
651
672
  checkedAt: new Date().toISOString(),
652
673
  };
@@ -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,288 @@
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
+ /**
29
+ * The root the mode switch reads and writes. In the platform checkout that is REPO_ROOT (unchanged). When this file
30
+ * runs from an INSTALLED package (node_modules/heyspex/...) REPO_ROOT is the package, not the project the dev is
31
+ * working in: the switch used to write inside node_modules and the hook runner never saw it. So: the project
32
+ * (CLAUDE_PROJECT_DIR, else the nearest ancestor of cwd holding a state dir or .git, else cwd).
33
+ */
34
+ export function resolveModeRoot(repoRoot = REPO_ROOT, env = process.env, cwd = process.cwd()) {
35
+ if (!repoRoot.split(path.sep).includes('node_modules')) return repoRoot;
36
+ const fromEnv = String(env.CLAUDE_PROJECT_DIR ?? '').trim();
37
+ if (fromEnv) return fromEnv;
38
+ let d = cwd;
39
+ for (;;) {
40
+ if (fs.existsSync(path.join(d, '.hey' + 'spex')) || fs.existsSync(path.join(d, '.git'))) return d;
41
+ const up = path.dirname(d);
42
+ if (up === d) return cwd;
43
+ d = up;
44
+ }
45
+ }
46
+ export const MODE_ROOT = resolveModeRoot();
47
+
48
+ const STATE_REL = '.heyspex/mode.json';
49
+ const BACKUP_DIR_REL = '.heyspex/mode-backup';
50
+ const CLAUDE_MD_REL = 'CLAUDE.md';
51
+ const CLAUDE_MD_OFF_REL = 'CLAUDE.heyspex-off.md';
52
+ const LOCAL_SETTINGS_REL = '.claude/settings.local.json';
53
+
54
+ const OFF_WORDS = new Set(['off', '0', 'false', 'disabled', 'disable', 'no']);
55
+ const ON_WORDS = new Set(['on', '1', 'true', 'enabled', 'enable', 'yes']);
56
+
57
+ /** Resolve a repo-relative path against the repo root. */
58
+ function at(repoRoot, rel) {
59
+ return path.join(repoRoot, ...rel.split('/'));
60
+ }
61
+
62
+ function readJson(file) {
63
+ try {
64
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
65
+ } catch {
66
+ return null;
67
+ }
68
+ }
69
+
70
+ function writeJson(file, value) {
71
+ fs.mkdirSync(path.dirname(file), { recursive: true });
72
+ fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
73
+ }
74
+
75
+ function sha256(buf) {
76
+ return createHash('sha256').update(buf).digest('hex');
77
+ }
78
+
79
+ /**
80
+ * The env override, if the user set one. Returns 'on' | 'off' | null.
81
+ * Exported so `hs mode status` can tell the user their env var is overriding
82
+ * the persisted state — otherwise "I turned it off but it's still on" is
83
+ * an unexplainable mystery.
84
+ */
85
+ export function envOverride() {
86
+ const raw = String(process.env.HEYSPEX_MODE ?? '').trim().toLowerCase();
87
+ if (!raw) return null;
88
+ if (OFF_WORDS.has(raw)) return 'off';
89
+ if (ON_WORDS.has(raw)) return 'on';
90
+ return null;
91
+ }
92
+
93
+ /** The persisted state file, or null when absent/corrupt. */
94
+ export function readState(repoRoot = REPO_ROOT) {
95
+ return readJson(at(repoRoot, STATE_REL));
96
+ }
97
+
98
+ /**
99
+ * THE hot-path question, called by every hook through `_run.mjs`.
100
+ * Fail-open: any surprise resolves to "HeySpex is on".
101
+ */
102
+ export function isHeySpexOff(repoRoot = REPO_ROOT) {
103
+ try {
104
+ const override = envOverride();
105
+ if (override) return override === 'off';
106
+ return readState(repoRoot)?.mode === 'off';
107
+ } catch {
108
+ return false;
109
+ }
110
+ }
111
+
112
+ /** Current effective mode plus where it came from. */
113
+ export function resolveMode(repoRoot = REPO_ROOT) {
114
+ const override = envOverride();
115
+ const state = readState(repoRoot);
116
+ const persisted = state?.mode === 'off' ? 'off' : 'on';
117
+ return {
118
+ mode: override ?? persisted,
119
+ source: override ? 'env:HEYSPEX_MODE' : state ? STATE_REL : 'default',
120
+ overriddenByEnv: Boolean(override) && override !== persisted,
121
+ persisted,
122
+ state,
123
+ };
124
+ }
125
+
126
+ // ---------------------------------------------------------------------------
127
+ // Surface 1 — the MCP server
128
+ //
129
+ // `.claude/settings.local.json` is gitignored and machine-local, which makes it
130
+ // exactly the right lever: turning HeySpex off must not dirty a tracked file
131
+ // that a teammate would then see in `git status`. Claude Code reads this at
132
+ // startup, so a restart is required either way.
133
+ // ---------------------------------------------------------------------------
134
+
135
+ function applyMcp(repoRoot, enabled) {
136
+ const file = at(repoRoot, LOCAL_SETTINGS_REL);
137
+ const settings = readJson(file) ?? {};
138
+ if (enabled) {
139
+ settings.enableAllProjectMcpServers = true;
140
+ const list = new Set(settings.enabledMcpjsonServers ?? []);
141
+ list.add('heyspex');
142
+ settings.enabledMcpjsonServers = [...list];
143
+ delete settings.disabledMcpjsonServers;
144
+ } else {
145
+ settings.enableAllProjectMcpServers = false;
146
+ settings.enabledMcpjsonServers = (settings.enabledMcpjsonServers ?? []).filter((s) => s !== 'heyspex');
147
+ const disabled = new Set(settings.disabledMcpjsonServers ?? []);
148
+ disabled.add('heyspex');
149
+ settings.disabledMcpjsonServers = [...disabled];
150
+ }
151
+ writeJson(file, settings);
152
+ return path.relative(repoRoot, file).replace(/\\/g, '/');
153
+ }
154
+
155
+ export function mcpEnabled(repoRoot = REPO_ROOT) {
156
+ const settings = readJson(at(repoRoot, LOCAL_SETTINGS_REL)) ?? {};
157
+ if ((settings.disabledMcpjsonServers ?? []).includes('heyspex')) return false;
158
+ if (settings.enableAllProjectMcpServers === true) return true;
159
+ return (settings.enabledMcpjsonServers ?? []).includes('heyspex');
160
+ }
161
+
162
+ // ---------------------------------------------------------------------------
163
+ // Surface 2 — CLAUDE.md, the always-loaded agent contract
164
+ //
165
+ // This is the one tracked file the switch has to touch: as long as CLAUDE.md
166
+ // carries the HeySpex contract, the agent keeps following HeySpex protocol even
167
+ // with every hook silenced. So `off` swaps in a short stub and stashes the real
168
+ // file under .heyspex/mode-backup/ with its sha256. `on` restores it and
169
+ // verifies the hash, so a round-trip is provably byte-identical.
170
+ //
171
+ // If you edit CLAUDE.md while HeySpex is off, `on` refuses to clobber your work
172
+ // and tells you where the backup is.
173
+ // ---------------------------------------------------------------------------
174
+
175
+ const CLAUDE_MD_STUB = `# Project Instructions for AI Agents
176
+
177
+ **HeySpex is currently OFF for this checkout.**
178
+
179
+ This repository normally runs the HeySpex agent protocol — a spec vault under
180
+ \`specs/\`, an MCP server, lifecycle hooks, and a full agent contract in this
181
+ file. All of that is disabled right now, so treat this as an ordinary codebase:
182
+ read the code, make the change that was asked for, run the tests.
183
+
184
+ Do **not** file specs, claim work, recall vault memories, or run HeySpex
185
+ procedures while this notice is here — the tools backing them are turned off.
186
+
187
+ To turn HeySpex back on:
188
+
189
+ \`\`\`bash
190
+ npm run heyspex:on # or: node bin/hs.mjs mode on
191
+ \`\`\`
192
+
193
+ Then restart Claude Code. The real agent contract is restored from
194
+ \`.heyspex/mode-backup/CLAUDE.md\`.
195
+ `;
196
+
197
+ function applyClaudeMd(repoRoot, enabled, state, force = false) {
198
+ const live = at(repoRoot, CLAUDE_MD_REL);
199
+ const backup = at(repoRoot, `${BACKUP_DIR_REL}/CLAUDE.md`);
200
+
201
+ if (!enabled) {
202
+ if (state?.claudeMd?.stubbed) return { action: 'already-stubbed' };
203
+ if (!fs.existsSync(live)) return { action: 'skipped', reason: 'no CLAUDE.md' };
204
+ const original = fs.readFileSync(live);
205
+ fs.mkdirSync(path.dirname(backup), { recursive: true });
206
+ fs.writeFileSync(backup, original);
207
+ // Ship the stub alongside the backup too, so `mode status` can tell a
208
+ // pristine stub from one the user has since edited.
209
+ fs.writeFileSync(live, CLAUDE_MD_STUB, 'utf8');
210
+ return {
211
+ action: 'stubbed',
212
+ originalSha256: sha256(original),
213
+ stubSha256: sha256(Buffer.from(CLAUDE_MD_STUB, 'utf8')),
214
+ backup: path.relative(repoRoot, backup).replace(/\\/g, '/'),
215
+ };
216
+ }
217
+
218
+ if (!state?.claudeMd?.stubbed) return { action: 'nothing-to-restore' };
219
+ if (!fs.existsSync(backup)) {
220
+ return { action: 'failed', reason: `backup missing at ${BACKUP_DIR_REL}/CLAUDE.md` };
221
+ }
222
+ // Refuse to silently discard edits made to the stub while HeySpex was off.
223
+ // `--force` is the way out: it says "I've read my edits, restore anyway".
224
+ if (fs.existsSync(live) && !force) {
225
+ const current = sha256(fs.readFileSync(live));
226
+ if (state.claudeMd.stubSha256 && current !== state.claudeMd.stubSha256) {
227
+ return {
228
+ action: 'failed',
229
+ reason:
230
+ `CLAUDE.md changed while HeySpex was off — refusing to overwrite it.\n` +
231
+ ` Your edits: CLAUDE.md · the real contract: ${BACKUP_DIR_REL}/CLAUDE.md\n` +
232
+ ` Salvage anything you want to keep, then rerun with --force to restore.`,
233
+ };
234
+ }
235
+ }
236
+ const restored = fs.readFileSync(backup);
237
+ fs.writeFileSync(live, restored);
238
+ const ok = !state.claudeMd.originalSha256 || sha256(restored) === state.claudeMd.originalSha256;
239
+ return { action: 'restored', verified: ok };
240
+ }
241
+
242
+ export function claudeMdStubbed(repoRoot = REPO_ROOT) {
243
+ return Boolean(readState(repoRoot)?.claudeMd?.stubbed);
244
+ }
245
+
246
+ // ---------------------------------------------------------------------------
247
+ // The flip
248
+ // ---------------------------------------------------------------------------
249
+
250
+ /**
251
+ * Turn the whole HeySpex layer on or off.
252
+ *
253
+ * Hooks and the statusline need no file surgery — they read `isHeySpexOff()`
254
+ * themselves at fire time, via `scripts/claude-hooks/_run.mjs`. Only the MCP
255
+ * server and CLAUDE.md have to be physically changed, because Claude Code
256
+ * consumes both before any HeySpex code gets to run.
257
+ */
258
+ export function setMode(target, { repoRoot = REPO_ROOT, force = false } = {}) {
259
+ if (target !== 'on' && target !== 'off') {
260
+ throw new Error(`setMode: expected 'on' or 'off', got '${target}'`);
261
+ }
262
+ const enabled = target === 'on';
263
+ const prior = readState(repoRoot);
264
+ const steps = [];
265
+
266
+ const claudeMd = applyClaudeMd(repoRoot, enabled, prior, force);
267
+ if (claudeMd.action === 'failed') {
268
+ return { ok: false, mode: prior?.mode ?? 'on', error: claudeMd.reason, steps };
269
+ }
270
+ steps.push({ surface: 'CLAUDE.md', ...claudeMd });
271
+
272
+ steps.push({ surface: 'MCP server', action: enabled ? 'enabled' : 'disabled', file: applyMcp(repoRoot, enabled) });
273
+ steps.push({ surface: 'hooks', action: enabled ? 'live' : 'silenced (all exit 0)' });
274
+ steps.push({ surface: 'statusline', action: enabled ? 'live' : 'blank' });
275
+
276
+ const next = {
277
+ mode: target,
278
+ changedAt: new Date().toISOString(),
279
+ claudeMd: enabled
280
+ ? { stubbed: false }
281
+ : claudeMd.action === 'stubbed'
282
+ ? { stubbed: true, originalSha256: claudeMd.originalSha256, stubSha256: claudeMd.stubSha256 }
283
+ : (prior?.claudeMd ?? { stubbed: false }),
284
+ };
285
+ writeJson(at(repoRoot, STATE_REL), next);
286
+
287
+ return { ok: true, mode: target, steps, restartRequired: true };
288
+ }