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
@@ -17,14 +17,17 @@
17
17
  *
18
18
  * `RUNTIME_FILES` is the same set `scripts/lib/packed-runtime-imports.test.mjs`
19
19
  * proves ships in the published tarball (its two explicitly-diagnosed
20
- * dynamic-import targets, the `hs source` entrypoints it spawns, and the two
21
- * `dist/cli/*.mjs` bundle entries `hs init`/`hs install` dispatch to) — this
20
+ * dynamic-import targets, the `hs source` entrypoints it spawns, and the
21
+ * `dist/cli/*.mjs` bundle entries `hs init`/`hs install`/`hs dev` dispatch to) — this
22
22
  * is deliberately the SAME list, not a re-derivation, so the two checks can
23
23
  * never silently drift apart (one says "ships", the other says "present").
24
24
  */
25
- import { existsSync } from "node:fs";
25
+ import { spawnSync } from "node:child_process";
26
+ import { existsSync, readFileSync } from "node:fs";
26
27
  import { join } from "node:path";
27
28
 
29
+ import { TEMPLATE_CORE_DESTS, TEMPLATE_VERSION } from "./template-pin.mjs";
30
+
28
31
  /**
29
32
  * Runtime files a published install must have next to `bin/hs.mjs` — each
30
33
  * entry is a path-segment array (portable across `path.join`/posix).
@@ -39,6 +42,9 @@ export const RUNTIME_FILES = Object.freeze([
39
42
  ["scripts", "lib", "hs-source-secret-scan.mjs"],
40
43
  ["dist", "cli", "hs-init.mjs"],
41
44
  ["dist", "cli", "hs-install.mjs"],
45
+ // HS-2HII: the `hs dev` bundle — invariant 10's visibility verb for a mode
46
+ // override, so a package missing it is named on the first command.
47
+ ["dist", "cli", "hs-dev.mjs"],
42
48
  ]);
43
49
 
44
50
  /**
@@ -87,3 +93,534 @@ export function runtimeFilesCheckRow(pkgRoot, opts = {}) {
87
93
  "if it's still missing after a clean reinstall, the published package itself is broken.",
88
94
  };
89
95
  }
96
+
97
+ // ---------------------------------------------------------------------------
98
+ // `vault-config` — the vault's `.heyspex/config.yaml` exists
99
+ // ([[HS-4C76-9W86-DIAG-heyspex-config-yaml-has-no-single-home]] fix 5)
100
+ // ---------------------------------------------------------------------------
101
+
102
+ /**
103
+ * Path segments of the HeySpex workflow-settings file, relative to the VAULT
104
+ * root (ADR-0009 §1: the vault root is its one and only home; it is
105
+ * git-tracked there). Kept as a segment array like {@link RUNTIME_FILES}.
106
+ */
107
+ export const VAULT_CONFIG_REL = Object.freeze([".heyspex", "config.yaml"]);
108
+
109
+ /**
110
+ * Does `<vaultRoot>/.heyspex/config.yaml` exist? Returns `{ ok, path }` where
111
+ * `path` is the POSIX-joined RELATIVE path (never absolute — same
112
+ * no-machine-path-leak rule as {@link checkRuntimeFiles}). `exists` is the
113
+ * same injectable seam. A null/empty `vaultRoot` (no vault resolved at all)
114
+ * reports `ok: false` with `resolved: false` so the row can say WHY.
115
+ */
116
+ export function checkVaultConfig(vaultRoot, { exists = existsSync } = {}) {
117
+ const relPosix = VAULT_CONFIG_REL.join("/");
118
+ if (typeof vaultRoot !== "string" || vaultRoot.length === 0) {
119
+ return { ok: false, resolved: false, path: relPosix };
120
+ }
121
+ const abs = join(vaultRoot, ...VAULT_CONFIG_REL);
122
+ return { ok: exists(abs), resolved: true, path: relPosix };
123
+ }
124
+
125
+ /**
126
+ * Render {@link checkVaultConfig}'s result as one `hs doctor` check row —
127
+ * the same `id`/`status`/`detail`/optional `human` shape as
128
+ * {@link runtimeFilesCheckRow}. FAILS when the file is missing: with it
129
+ * absent every settings reader falls to its registry/documented default
130
+ * (MVP mode off, strict task grain — the HS-4C76 symptom), and until the
131
+ * fix the settings faces refused to write at all. Both remedies now seed
132
+ * the file from the shipped template: `hs init` (in the vault directory),
133
+ * or any `hs settings set` / `heyspex_settings_set` write.
134
+ */
135
+ export function vaultConfigCheckRow(vaultRoot, opts = {}) {
136
+ const { ok, resolved, path: relPosix } = checkVaultConfig(vaultRoot, opts);
137
+ if (ok) {
138
+ return {
139
+ id: "vault-config",
140
+ status: "ok",
141
+ detail: `${relPosix} present at the vault root`,
142
+ };
143
+ }
144
+ if (!resolved) {
145
+ return {
146
+ id: "vault-config",
147
+ status: "fail",
148
+ detail: `${relPosix} not checked — no vault root resolved`,
149
+ human:
150
+ "No vault was found (no heyspex.yaml / specs/ up from the invoked directory, no registry " +
151
+ "pointer). Run `hs init` in the directory that should become the vault — it seeds " +
152
+ `${relPosix} from the shipped template too.`,
153
+ };
154
+ }
155
+ return {
156
+ id: "vault-config",
157
+ status: "fail",
158
+ detail: `${relPosix} missing at the vault root`,
159
+ human:
160
+ `The vault has no ${relPosix}, so every HeySpex settings reader (console, MCP, CLI, hooks) ` +
161
+ "falls back to defaults — MVP mode reads as OFF and close-out gates go strict. Run `hs init` " +
162
+ "in the vault directory to seed it from the shipped template (an existing file is never " +
163
+ "touched), or any `hs settings set <key> <value>` — the settings core now seeds the file on " +
164
+ "its first write instead of refusing. Then commit it with the vault (ADR-0009).",
165
+ };
166
+ }
167
+
168
+ /** Marker file that only the HeySpex platform vault has: it IS the template source. */
169
+ const PLATFORM_VAULT_MARKER =
170
+ "specs/protocols/dev-factory-delegation-routing.md";
171
+
172
+ /** `template_version:` from raw `heyspex.yaml` text, or null (mirrors `readTemplateVersion` in init-git-finalize.ts). */
173
+ function readTemplatePin(yamlText) {
174
+ const m = /^template_version:\s*(.+?)\s*$/m.exec(yamlText ?? "");
175
+ if (!m) return null;
176
+ const v = m[1]
177
+ .replace(/^"(.*)"$/, "$1")
178
+ .replace(/^'(.*)'$/, "$1")
179
+ .trim();
180
+ return v.length > 0 ? v : null;
181
+ }
182
+
183
+ /**
184
+ * The `vault-template` doctor row: does a customer vault carry the current
185
+ * HeySpex template (HS-KH0Q)? Pure over injectable `exists` / `readFile`
186
+ * (default node:fs; `readFile` returns the text or null), same row shape as
187
+ * {@link vaultConfigCheckRow}.
188
+ * - platform vault (marker protocol present) -> ok: it is the template source;
189
+ * - any core dest missing -> fail, naming the files, repair `hs init --customer-vault .`;
190
+ * - `heyspex.yaml` pins {@link TEMPLATE_VERSION} -> ok;
191
+ * - pin older/newer/absent while the files exist -> warn.
192
+ */
193
+ export function templateCheckRow(vaultRoot, opts = {}) {
194
+ const exists = opts.exists ?? existsSync;
195
+ const readFile =
196
+ opts.readFile ??
197
+ ((p) => {
198
+ try {
199
+ return readFileSync(p, "utf8");
200
+ } catch {
201
+ return null;
202
+ }
203
+ });
204
+ const id = "vault-template";
205
+ const repair = "hs init --customer-vault .";
206
+ if (typeof vaultRoot !== "string" || vaultRoot.length === 0) {
207
+ return {
208
+ id,
209
+ status: "fail",
210
+ detail: "template not checked — no vault root resolved",
211
+ human: `No vault was found. Run \`${repair}\` in the directory that should become the vault.`,
212
+ };
213
+ }
214
+ const at = (rel) => join(vaultRoot, ...rel.split("/"));
215
+ if (exists(at(PLATFORM_VAULT_MARKER))) {
216
+ return {
217
+ id,
218
+ status: "ok",
219
+ detail: "platform vault (the template source, not a customer vault)",
220
+ };
221
+ }
222
+ const missing = TEMPLATE_CORE_DESTS.filter((rel) => !exists(at(rel)));
223
+ if (missing.length > 0) {
224
+ return {
225
+ id,
226
+ status: "fail",
227
+ detail: `missing template file(s): ${missing.join(", ")}`,
228
+ human:
229
+ `The vault is missing ${missing.length} shipped template file(s): ${missing.join(", ")}. ` +
230
+ `Run \`${repair}\` in the vault directory — it adds only what is missing and never overwrites an existing file.`,
231
+ };
232
+ }
233
+ const pin = readTemplatePin(readFile(at("heyspex.yaml")));
234
+ if (pin === TEMPLATE_VERSION) {
235
+ return {
236
+ id,
237
+ status: "ok",
238
+ detail: `template v${TEMPLATE_VERSION} pinned; all ${TEMPLATE_CORE_DESTS.length} core files present`,
239
+ };
240
+ }
241
+ return {
242
+ id,
243
+ status: "warn",
244
+ detail: pin
245
+ ? `heyspex.yaml pins template v${pin}, current is v${TEMPLATE_VERSION}`
246
+ : `heyspex.yaml has no template_version pin (current is v${TEMPLATE_VERSION})`,
247
+ human: `Core template files are present but the pin is not current. Run \`${repair}\` to refresh the pin and add any newer shipped files.`,
248
+ };
249
+ }
250
+
251
+ /**
252
+ * The `hs doctor` repair for a pre-v2 `~/.heyspex/registry.json`: the argv
253
+ * `--fix` hands to Node from the repo root (`bin/hs.mjs vault migrate-registry`,
254
+ * [[HS-RXLG-WW9R-TASK-key-registry-json-per-account-schema-v2]] AC5).
255
+ */
256
+ export const REGISTRY_SCHEMA_FIX = Object.freeze([
257
+ "bin/hs.mjs",
258
+ "vault",
259
+ "migrate-registry",
260
+ ]);
261
+
262
+ /**
263
+ * Classify the raw text of `~/.heyspex/registry.json` (`null` = no file). Pure.
264
+ * Mirrors `registry-file.ts`'s `inspectVaultRegistry` + `hasLegacyAccountState`
265
+ * on the fields a doctor needs:
266
+ * - `missing` — nothing registered on this machine yet;
267
+ * - `unreadable` — the file is not a JSON object (the loader reads it as
268
+ * empty, and the next write would start over);
269
+ * - `v1` — machine-global pointers, rows with no account;
270
+ * - `legacy` — v2, but some state is still keyed to `legacy` (migrated
271
+ * while signed out, or written before migration);
272
+ * - `v2` — per-account pointers, every row keyed.
273
+ */
274
+ export function classifyRegistryFile(rawText) {
275
+ if (rawText === null || rawText === undefined) return { kind: "missing" };
276
+ let json;
277
+ try {
278
+ json = JSON.parse(rawText);
279
+ } catch {
280
+ return { kind: "unreadable" };
281
+ }
282
+ if (!json || typeof json !== "object" || Array.isArray(json))
283
+ return { kind: "unreadable" };
284
+ if (json.schema_version !== 2) return { kind: "v1" };
285
+ const LEGACY = "legacy";
286
+ const rows = [
287
+ ...(Array.isArray(json.workspaces) ? json.workspaces : []),
288
+ ...(Array.isArray(json.vaults) ? json.vaults : []),
289
+ ];
290
+ const accounts =
291
+ json.accounts && typeof json.accounts === "object" ? json.accounts : {};
292
+ const legacy =
293
+ Object.prototype.hasOwnProperty.call(accounts, LEGACY) ||
294
+ json.current_account_id === LEGACY ||
295
+ rows.some(
296
+ (r) => r && typeof r === "object" && (r.account_id ?? LEGACY) === LEGACY,
297
+ );
298
+ return { kind: legacy ? "legacy" : "v2" };
299
+ }
300
+
301
+ /**
302
+ * Render {@link classifyRegistryFile} as one `hs doctor` row (`registry-schema`).
303
+ * A v1 file (or v2 with `legacy` state) is a `warn` that carries
304
+ * {@link REGISTRY_SCHEMA_FIX}, so `hs doctor --fix` runs `hs vault
305
+ * migrate-registry`. It is a warn, not a fail: the loader still reads a v1
306
+ * file. An unreadable file is a warn with no fix — the migration refuses to
307
+ * overwrite it, so a human looks first.
308
+ */
309
+ export function registrySchemaCheckRow(rawText) {
310
+ const { kind } = classifyRegistryFile(rawText);
311
+ const id = "registry-schema";
312
+ if (kind === "missing") {
313
+ return {
314
+ id,
315
+ status: "ok",
316
+ detail: "no ~/.heyspex/registry.json yet (nothing registered)",
317
+ };
318
+ }
319
+ if (kind === "v2") {
320
+ return {
321
+ id,
322
+ status: "ok",
323
+ detail: "schema v2 (active vault/workspace kept per account)",
324
+ };
325
+ }
326
+ if (kind === "unreadable") {
327
+ return {
328
+ id,
329
+ status: "warn",
330
+ detail: "~/.heyspex/registry.json is unreadable — it loads as empty",
331
+ human:
332
+ "The registry file is not valid JSON, so every reader treats it as empty and the next " +
333
+ "`hs connect` / `hs vault register` would start it over. Fix or move it aside by hand " +
334
+ "(`hs vault migrate-registry` will not overwrite it).",
335
+ };
336
+ }
337
+ const detail =
338
+ kind === "v1"
339
+ ? "schema v1 — one machine-global active vault for every account"
340
+ : "schema v2, but some rows are still keyed to `legacy` (no account)";
341
+ return {
342
+ id,
343
+ status: "warn",
344
+ detail: `${detail}; fix: \`hs vault migrate-registry\``,
345
+ fix: [...REGISTRY_SCHEMA_FIX],
346
+ human:
347
+ "Run `hs vault migrate-registry --dry-run` to see the plan, then `hs vault migrate-registry` " +
348
+ "(or `hs doctor --fix`) while signed in: it rewrites ~/.heyspex/registry.json as schema v2 " +
349
+ "and keys each row to the account PROVEN for it (its heyspex.yaml `account:`, or your cloud " +
350
+ "vault roster). Unproven rows stay `legacy` and keep resolving. A second run changes nothing.",
351
+ };
352
+ }
353
+
354
+ // ---------------------------------------------------------------------------
355
+ // `connection` — the local-vs-cloud view, folded from `hs connect --check
356
+ // --json` ([[HS-W73V-WW9R-TASK-make-both-doctor-shapes-fail-on-cloud]],
357
+ // invariant 8: proof from both sides)
358
+ // ---------------------------------------------------------------------------
359
+ //
360
+ // ONE fold for both doctor shapes: the full doctor (`scripts/heyspex-doctor.mjs`,
361
+ // source checkout) and the published-install doctor (`bin/hs.mjs`
362
+ // `runStandaloneDoctor`) both feed the SAME report through
363
+ // `foldConnectionReport`, so a broken setup reads the same on any machine.
364
+ // Neither doctor keeps its own list of comparison rows: the report is computed
365
+ // by `spex-console/src/lib/cli/connect-checks.ts` from the routing fact table
366
+ // (`routing-facts.ts`), and a cloud-view row is recognised by the field it
367
+ // carries (`agree`: only the seven local-vs-cloud rows have it), never by a
368
+ // second copy of its id list.
369
+
370
+ /** The connection section's summary row id; per-artifact rows are `connect-<id>`. */
371
+ export const CONNECTION_ROW_ID = "connect";
372
+
373
+ /**
374
+ * The connect-check report on a stream. `hs connect --check --json` prints its
375
+ * payload as ONE JSON line, but other output may precede or follow it (the
376
+ * identity note, a customer-kit line from the bundle), so every `{`-led line
377
+ * is tried and the LAST one with the report's shape wins; a pretty-printed
378
+ * whole-stream document is the fallback. Pure.
379
+ * @returns {{ ok: true, report: object } | { ok: false, error: string }}
380
+ */
381
+ export function parseConnectCheckOutput(text) {
382
+ const isReport = (v) =>
383
+ v &&
384
+ typeof v === "object" &&
385
+ typeof v.connected === "boolean" &&
386
+ Array.isArray(v.checks);
387
+ const candidates = [];
388
+ for (const line of String(text ?? "").split(/\r?\n/)) {
389
+ if (!/^\s*\{/.test(line)) continue;
390
+ try {
391
+ candidates.push(JSON.parse(line));
392
+ } catch {
393
+ // not a one-line document
394
+ }
395
+ }
396
+ try {
397
+ candidates.push(JSON.parse(String(text ?? "")));
398
+ } catch {
399
+ // not one whole document either
400
+ }
401
+ const report = candidates.filter(isReport).pop();
402
+ if (report) return { ok: true, report };
403
+ if (candidates.length === 0) {
404
+ return { ok: false, error: "`hs connect --check --json` printed no JSON" };
405
+ }
406
+ return {
407
+ ok: false,
408
+ error: "`hs connect --check --json` returned an unexpected shape",
409
+ };
410
+ }
411
+
412
+ /** A local-vs-cloud row: the only rows of the report that carry `agree`. Pure. */
413
+ export function isCloudViewRow(check) {
414
+ return (
415
+ Boolean(check) && typeof check === "object" && Object.hasOwn(check, "agree")
416
+ );
417
+ }
418
+
419
+ const sideText = (v) =>
420
+ v === null || v === undefined || v === "" ? "(unknown)" : String(v);
421
+
422
+ /**
423
+ * One report row to one doctor row. The severity rule (HS-W73V AC1/AC2):
424
+ * - local artifact rows keep the pre-W73V behaviour: `present` (and
425
+ * `not_applicable`, which the report's own `connected` already accepts) is
426
+ * ok, anything else a warn with no fix ("not connected" is a
427
+ * recommendation, never a failure);
428
+ * - a cloud-view row whose two sides DISAGREE (`agree: false`) is a `fail`
429
+ * whose `human` is the row's own `repair` command, verbatim;
430
+ * - a cloud-view row whose other side could not be read (`agree: null`,
431
+ * not `not_applicable`) is a `warn` naming the cause: unknown is never
432
+ * success, and never a failure on its own;
433
+ * - agreeing and not-applicable cloud rows are ok.
434
+ * Cloud rows keep `local` / `cloud` / `agree` / `repair` / `fact` so the JSON
435
+ * shows both sides next to each other.
436
+ */
437
+ function foldCheckRow(check, idPrefix) {
438
+ const base = {
439
+ id: `${idPrefix}-${check.id}`,
440
+ label: `Connect: ${check.label ?? check.id}`,
441
+ fix: null,
442
+ };
443
+ if (!isCloudViewRow(check)) {
444
+ return {
445
+ ...base,
446
+ // `not_applicable` counts as connected in the report itself (`connected`).
447
+ status:
448
+ check.status === "present" || check.status === "not_applicable"
449
+ ? "ok"
450
+ : "warn",
451
+ detail: check.detail
452
+ ? `${check.status} (${check.detail})`
453
+ : String(check.status),
454
+ };
455
+ }
456
+ const view = {
457
+ local: check.local ?? null,
458
+ cloud: check.cloud ?? null,
459
+ agree: check.agree ?? null,
460
+ repair: check.repair ?? null,
461
+ ...(check.fact ? { fact: check.fact } : {}),
462
+ };
463
+ const sides = `local: ${sideText(view.local)} | cloud: ${sideText(view.cloud)}`;
464
+ if (check.status === "not_applicable") {
465
+ return {
466
+ ...base,
467
+ ...view,
468
+ status: "ok",
469
+ detail: `not applicable — ${check.detail ?? "nothing to compare"}`,
470
+ };
471
+ }
472
+ if (view.agree === true) {
473
+ return { ...base, ...view, status: "ok", detail: `${sides} — agree` };
474
+ }
475
+ if (view.agree === false) {
476
+ return {
477
+ ...base,
478
+ ...view,
479
+ status: "fail",
480
+ detail: `${sides} — DISAGREE: ${check.detail ?? "the two sides differ"}`,
481
+ human:
482
+ view.repair ??
483
+ "Run `hs connect --check` to see the disagreement and its repair.",
484
+ };
485
+ }
486
+ return {
487
+ ...base,
488
+ ...view,
489
+ status: "warn",
490
+ detail: `local: ${sideText(view.local)} | cloud: unknown — ${check.detail ?? "the cloud side could not be read"}`,
491
+ ...(view.repair ? { human: view.repair } : {}),
492
+ };
493
+ }
494
+
495
+ /**
496
+ * Fold a `hs connect --check --json` result (`parseConnectCheckOutput`'s
497
+ * `{ ok, report }`, or `{ ok: false, error }` when it could not be run) into
498
+ * doctor rows: one summary row (`connect`) plus one row per report row
499
+ * (`connect-<id>`). The summary FAILS when any cloud row fails; otherwise it
500
+ * is a warn for an unconnected repo or an unknown cloud side, else ok. Rows
501
+ * never carry a `fix`: doctor never runs `hs connect` for you, `--fix`
502
+ * included. Pure.
503
+ *
504
+ * Returns `{ rows, json, ok }`: `json` is the raw report (doctor's
505
+ * `connection` key) plus `cloud_view` counts; `ok` is false iff a row failed.
506
+ */
507
+ export function foldConnectionReport(
508
+ surface,
509
+ {
510
+ idPrefix = CONNECTION_ROW_ID,
511
+ label = "HeySpex connection (hs connect --check)",
512
+ connectCommand = "hs connect",
513
+ checkCommand = "hs connect --check",
514
+ } = {},
515
+ ) {
516
+ if (!surface?.ok) {
517
+ const error = surface?.error ?? "no report";
518
+ return {
519
+ rows: [
520
+ {
521
+ id: idPrefix,
522
+ label,
523
+ status: "warn",
524
+ detail: `connection state unknown — ${error}`,
525
+ human: `Run \`${checkCommand}\` directly to see the full report — this check never runs it for you.`,
526
+ fix: null,
527
+ },
528
+ ],
529
+ json: { checked: false, connected: null, error },
530
+ ok: true,
531
+ };
532
+ }
533
+ const report = surface.report;
534
+ const checks = Array.isArray(report.checks) ? report.checks : [];
535
+ const rows = checks.map((c) => foldCheckRow(c, idPrefix));
536
+ const cloudRows = rows.filter((_r, i) => isCloudViewRow(checks[i]));
537
+ const failed = cloudRows.filter((r) => r.status === "fail");
538
+ const unknown = cloudRows.filter((r) => r.status === "warn");
539
+ const agreed = cloudRows.filter((r) => r.status === "ok" && r.agree === true);
540
+ const localConnected = checks
541
+ .filter((c) => !isCloudViewRow(c))
542
+ .every((c) => c.status === "present" || c.status === "not_applicable");
543
+
544
+ let summary;
545
+ if (failed.length > 0) {
546
+ const repairs = [...new Set(failed.map((r) => r.repair).filter(Boolean))];
547
+ summary = {
548
+ status: "fail",
549
+ detail: `${failed.length} local-vs-cloud row(s) disagree: ${failed.map((r) => r.id.slice(idPrefix.length + 1)).join(", ")}`,
550
+ human: `Run the repair named on each failing row: ${repairs.join("; ")}`,
551
+ };
552
+ } else if (!localConnected) {
553
+ summary = {
554
+ status: "warn",
555
+ detail: `not connected to HeySpex — run \`${connectCommand}\``,
556
+ human: `Run \`${connectCommand}\` to wire this repo up to HeySpex — this check recommends it but never runs it for you.`,
557
+ };
558
+ } else if (unknown.length > 0) {
559
+ summary = {
560
+ status: "warn",
561
+ detail: `connected locally; the cloud side is unknown on ${unknown.length} row(s) — ${unknown[0].detail.replace(/^.*?cloud: unknown — /, "")}`,
562
+ };
563
+ } else {
564
+ summary = {
565
+ status: "ok",
566
+ detail: `connected — ${checks.length} row(s); local and cloud agree on ${agreed.length} row(s)`,
567
+ };
568
+ }
569
+ return {
570
+ rows: [{ id: idPrefix, label, fix: null, ...summary }, ...rows],
571
+ json: {
572
+ checked: true,
573
+ connected: report.connected,
574
+ repo_root: report.repo_root,
575
+ checks,
576
+ ...(report.satellite ? { satellite: report.satellite } : {}),
577
+ cloud_view: {
578
+ agree: agreed.length,
579
+ disagree: failed.length,
580
+ unknown: unknown.length,
581
+ },
582
+ },
583
+ ok: failed.length === 0,
584
+ };
585
+ }
586
+
587
+ /**
588
+ * Run the BUNDLED `dist/cli/hs-connect.mjs connect --check --json` of a
589
+ * published install (the package ships it; `scripts/heyspex-doctor.mjs` does
590
+ * not) and parse its report. The bundle's cloud view is GET-only by
591
+ * construction (`connect-checks.ts` `cloudGet`). `spawn` / `exists` are
592
+ * injectable for tests.
593
+ * @returns {{ ok: true, report: object } | { ok: false, error: string }}
594
+ */
595
+ export function runBundledConnectCheck(
596
+ pkgRoot,
597
+ {
598
+ cwd,
599
+ env = process.env,
600
+ spawn = spawnSync,
601
+ timeoutMs = 60_000,
602
+ exists = existsSync,
603
+ } = {},
604
+ ) {
605
+ const script = join(pkgRoot, "dist", "cli", "hs-connect.mjs");
606
+ if (!exists(script)) {
607
+ return {
608
+ ok: false,
609
+ error: "dist/cli/hs-connect.mjs is missing from this install",
610
+ };
611
+ }
612
+ const r = spawn(process.execPath, [script, "connect", "--check", "--json"], {
613
+ cwd,
614
+ env,
615
+ encoding: "utf8",
616
+ timeout: timeoutMs,
617
+ maxBuffer: 16 * 1024 * 1024,
618
+ });
619
+ if (r.error) {
620
+ return {
621
+ ok: false,
622
+ error: `could not run \`hs connect --check --json\`: ${r.error.message}`,
623
+ };
624
+ }
625
+ return parseConnectCheckOutput(r.stdout);
626
+ }
@@ -0,0 +1,109 @@
1
+ // scripts/lib/subagent-git-lock.mjs
2
+ //
3
+ // The orchestrator (COO)'s hard lock around a delegated sub-agent's work
4
+ // window (HS-H8KI-P3TJ, epic HS-GG01-P3TJ). While set, EVERY destructive git
5
+ // command in the covered session is denied outright by
6
+ // scripts/claude-hooks/git-stash-gate.mjs's runGitDestructiveGate — no intent
7
+ // file can override it (deny beats ask beats allow; this task may only
8
+ // ESCALATE toward deny, never soften toward allow — see
9
+ // specs/memory/decisions/protected-shared-tree-actions-assisted.md).
10
+ //
11
+ // SPIKE VERDICT (HS-H8KI, static analysis, no harness spawn): Claude Code's
12
+ // PreToolUse hook payload carries NO sub-agent identity distinguishing an
13
+ // orchestrator's own tool calls from a spawned sub-agent's. The only identity
14
+ // field present anywhere in this repo's hook payloads is `session_id`
15
+ // (resolved by scripts/claude-hooks/claim-liveness-shared.mjs's
16
+ // resolveSessionId) — and it is the SAME value for every tool call inside one
17
+ // CLI session, orchestrator and any Task-tool-spawned sub-agent alike (they
18
+ // share one process/session). Every hook in scripts/claude-hooks/ that reads
19
+ // `transcript_path` (enforce-session-sequence.mjs, naughty-list-gate.mjs,
20
+ // id-display-format-gate.mjs) uses it for OTHER purposes; none of them, and no
21
+ // other file in this repo, reads an `agentId`/`isSidechain`/`sidechain` field —
22
+ // because the standard PreToolUse schema does not carry one
23
+ // (hook_event_name, session_id, transcript_path, cwd, tool_name, tool_input,
24
+ // tool_response are the complete field set observed across every hook here).
25
+ // Conclusion: per-agent locking is NOT possible from the hook payload alone.
26
+ // This module therefore implements the SESSION-WIDE fallback the task
27
+ // prescribes for that case — keyed by session id (so concurrent sessions on
28
+ // different checkouts/agents never share lock state), but NOT distinguishing
29
+ // the orchestrator's own tool calls from its delegated sub-agent's within that
30
+ // one session. That is the correct scope for the orchestrator's own use: it
31
+ // sets the lock immediately before spawning a sub-agent for destructive-git-
32
+ // adjacent delegated work and clears it right after the sub-agent returns, so
33
+ // the lock's live window IS the delegation window regardless of which of the
34
+ // two (orchestrator or sub-agent) happens to issue the git command.
35
+ //
36
+ // A session id may be unresolvable (e.g. a bare script run outside any hook
37
+ // context) — that case files under a fixed NO_SESSION_KEY so it still works
38
+ // but is trivially inspectable in .heyspex/subagent-locks/ instead of being
39
+ // silently lost.
40
+
41
+ import fs from 'node:fs';
42
+ import path from 'node:path';
43
+
44
+ const LOCK_DIR_NAME = 'subagent-locks';
45
+ const NO_SESSION_KEY = '_no-session-id';
46
+
47
+ /** Filesystem-safe key for a session id (or the fixed fallback key). Exported for tests. */
48
+ export function lockKey(sessionId) {
49
+ const trimmed = typeof sessionId === 'string' ? sessionId.trim() : '';
50
+ if (!trimmed) return NO_SESSION_KEY;
51
+ return trimmed.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 128);
52
+ }
53
+
54
+ export function lockDir(root) {
55
+ return path.join(root, '.heyspex', LOCK_DIR_NAME);
56
+ }
57
+
58
+ export function lockPath(root, sessionId) {
59
+ return path.join(lockDir(root), `${lockKey(sessionId)}.json`);
60
+ }
61
+
62
+ /**
63
+ * Set the session-wide hard lock. Idempotent — calling it again while already
64
+ * set just overwrites the record (last setter wins; there is deliberately no
65
+ * "already locked" error since the orchestrator is the only writer of its own
66
+ * lock file).
67
+ *
68
+ * @returns {object} the record written
69
+ */
70
+ export function setSubagentLock({ root, sessionId = null, reason = null, setBy = null, now = Date.now() }) {
71
+ const dir = lockDir(root);
72
+ fs.mkdirSync(dir, { recursive: true });
73
+ const record = {
74
+ sessionId: sessionId ?? null,
75
+ reason: reason ?? null,
76
+ setBy: setBy ?? null,
77
+ setAt: new Date(now).toISOString(),
78
+ };
79
+ const target = lockPath(root, sessionId);
80
+ const tmp = `${target}.${process.pid}.tmp`;
81
+ fs.writeFileSync(tmp, JSON.stringify(record, null, 2));
82
+ fs.renameSync(tmp, target);
83
+ return record;
84
+ }
85
+
86
+ /** Clear the session-wide hard lock. Returns true if a lock file was actually removed. */
87
+ export function clearSubagentLock({ root, sessionId = null }) {
88
+ try {
89
+ fs.rmSync(lockPath(root, sessionId), { force: true });
90
+ return true;
91
+ } catch {
92
+ return false;
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Read the current lock record for `sessionId`, or null when unset/unreadable.
98
+ * Never throws — a corrupt lock file reads as "no lock" (fail toward allowing
99
+ * the gate to proceed to its next check, never toward a crash).
100
+ */
101
+ export function checkSubagentLock({ root, sessionId = null }) {
102
+ try {
103
+ const raw = fs.readFileSync(lockPath(root, sessionId), 'utf8');
104
+ const parsed = JSON.parse(raw);
105
+ return parsed && typeof parsed === 'object' ? parsed : null;
106
+ } catch {
107
+ return null;
108
+ }
109
+ }