heyspex 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (322) hide show
  1. package/README.md +21 -0
  2. package/bin/hs.mjs +931 -98
  3. package/bin/spexcode.mjs +262 -0
  4. package/dev-factory/engine/worktree.mjs +740 -0
  5. package/dev-factory/lib/code-repo-root.mjs +67 -0
  6. package/dev-factory/lib/context-budget.mjs +84 -0
  7. package/dev-factory/lib/epic-detect.mjs +190 -0
  8. package/dev-factory/lib/fleet-queue-file.mjs +846 -0
  9. package/dev-factory/lib/fleet-queue.mjs +426 -0
  10. package/dev-factory/lib/fleet-state.mjs +14 -18
  11. package/dev-factory/lib/model-catalog.mjs +338 -0
  12. package/dev-factory/lib/seat-context.mjs +199 -0
  13. package/dev-factory/lib/spec-io.mjs +93 -0
  14. package/dev-factory/lib/sufficiency-score.mjs +8 -2
  15. package/dev-factory/lib/worktree-allocator.mjs +744 -0
  16. package/dev-factory/model-catalog.json +222 -0
  17. package/dist/cli/hs-check.mjs +1085 -103
  18. package/dist/cli/hs-claim.mjs +936 -63
  19. package/dist/cli/hs-connect.mjs +9786 -1910
  20. package/dist/cli/hs-defer.mjs +5267 -3053
  21. package/dist/cli/hs-dev.mjs +564 -0
  22. package/dist/cli/hs-env.mjs +385 -299
  23. package/dist/cli/hs-index-rebuild.mjs +7129 -0
  24. package/dist/cli/hs-index-reconcile.mjs +7852 -0
  25. package/dist/cli/hs-init.mjs +777 -138
  26. package/dist/cli/hs-install.mjs +6583 -485
  27. package/dist/cli/hs-join.mjs +10137 -2368
  28. package/dist/cli/hs-vault-pull.mjs +4659 -5882
  29. package/dist/cli/hs-vault.mjs +3793 -1537
  30. package/dist/cli/hs-workspace.mjs +2550 -524
  31. package/dist/cli-templates/customer-vault/specs/entity-model.md +594 -601
  32. package/dist/cli-templates/customer-vault/specs/protocols/canonical-frontmatter-templates.md +110 -177
  33. package/dist/cli-templates/customer-vault/specs/protocols/destructive-action-checklist.md +122 -0
  34. package/dist/cli-templates/customer-vault/specs/protocols/memory-protocol.md +13 -15
  35. package/dist/cli-templates/customer-vault/specs/protocols/session-close-protocol.md +91 -0
  36. package/dist/cli-templates/customer-vault/specs/protocols/spec-hierarchy.md +150 -168
  37. package/dist/cli-templates/customer-vault/specs/protocols/spec-summary-authoring.md +83 -0
  38. package/dist/cli-templates/harness-commands/claude/hs-auto.md +36 -0
  39. package/dist/cli-templates/harness-commands/claude/hs-backfill.md +28 -0
  40. package/dist/cli-templates/harness-commands/claude/hs-capture.md +62 -0
  41. package/dist/cli-templates/harness-commands/claude/hs-defer.md +29 -0
  42. package/dist/cli-templates/harness-commands/claude/hs-dev.md +20 -0
  43. package/dist/cli-templates/harness-commands/claude/hs-doctor.md +32 -0
  44. package/dist/cli-templates/harness-commands/claude/hs-ground.md +28 -0
  45. package/dist/cli-templates/harness-commands/claude/hs-handoff.md +33 -0
  46. package/dist/cli-templates/harness-commands/claude/hs-log.md +27 -0
  47. package/dist/cli-templates/harness-commands/claude/hs-next.md +29 -0
  48. package/dist/cli-templates/harness-commands/claude/hs-promote.md +70 -0
  49. package/dist/cli-templates/harness-commands/claude/hs-push.md +35 -0
  50. package/dist/cli-templates/harness-commands/claude/hs-qc.md +27 -0
  51. package/dist/cli-templates/harness-commands/claude/hs-res.md +63 -0
  52. package/dist/cli-templates/harness-commands/claude/hs-resolve.md +23 -0
  53. package/dist/cli-templates/harness-commands/claude/hs-review.md +23 -0
  54. package/dist/cli-templates/harness-commands/claude/hs-view.md +26 -0
  55. package/dist/cli-templates/harness-commands/claude/kill-ports.md +32 -0
  56. package/dist/cli-templates/harness-commands/claude/prototype.md +91 -0
  57. package/dist/cli-templates/harness-commands/cursor/hs-auto.md +40 -0
  58. package/dist/cli-templates/harness-commands/cursor/hs-backfill.md +31 -0
  59. package/dist/cli-templates/harness-commands/cursor/hs-capture.md +65 -0
  60. package/dist/cli-templates/harness-commands/cursor/hs-defer.md +32 -0
  61. package/dist/cli-templates/harness-commands/cursor/hs-dev.md +24 -0
  62. package/dist/cli-templates/harness-commands/cursor/hs-doctor.md +36 -0
  63. package/dist/cli-templates/harness-commands/cursor/hs-ground.md +31 -0
  64. package/dist/cli-templates/harness-commands/cursor/hs-handoff.md +37 -0
  65. package/dist/cli-templates/harness-commands/cursor/hs-log.md +31 -0
  66. package/dist/cli-templates/harness-commands/cursor/hs-next.md +32 -0
  67. package/dist/cli-templates/harness-commands/cursor/hs-promote.md +73 -0
  68. package/dist/cli-templates/harness-commands/cursor/hs-push.md +38 -0
  69. package/dist/cli-templates/harness-commands/cursor/hs-qc.md +30 -0
  70. package/dist/cli-templates/harness-commands/cursor/hs-res.md +67 -0
  71. package/dist/cli-templates/harness-commands/cursor/hs-resolve.md +26 -0
  72. package/dist/cli-templates/harness-commands/cursor/hs-review.md +26 -0
  73. package/dist/cli-templates/harness-commands/cursor/hs-view.md +30 -0
  74. package/dist/cli-templates/harness-commands/cursor/kill-ports.md +35 -0
  75. package/dist/cli-templates/harness-commands/cursor/prototype.md +95 -0
  76. package/docs/mcp-client-config.md +259 -6
  77. package/package.json +231 -124
  78. package/plugins/heyspex/.claude-plugin/plugin.json +12 -0
  79. package/plugins/heyspex/.cursor-plugin/plugin.json +15 -0
  80. package/plugins/heyspex/.mcp.json +12 -0
  81. package/plugins/heyspex/bin/launch-mcp.mjs +95 -0
  82. package/plugins/heyspex/bin/run-hook.mjs +62 -0
  83. package/plugins/heyspex/hooks/cursor-hooks.json +227 -0
  84. package/plugins/heyspex/hooks/hooks.json +361 -0
  85. package/plugins/heyspex/mcp.json +13 -0
  86. package/scripts/check-mcp-freshness.mjs +17 -9
  87. package/scripts/claude-hooks/_env-vault-events.mjs +68 -0
  88. package/scripts/claude-hooks/_guardrail-events.mjs +96 -0
  89. package/scripts/claude-hooks/_guardrail-loader.mjs +494 -0
  90. package/scripts/claude-hooks/_intent-classifier-router.mjs +410 -0
  91. package/scripts/claude-hooks/_intent-classifier-semantic.mjs +351 -0
  92. package/scripts/claude-hooks/_run.mjs +120 -0
  93. package/scripts/claude-hooks/_transcript-text.mjs +60 -0
  94. package/scripts/claude-hooks/_vault-retrieval.mjs +322 -0
  95. package/scripts/claude-hooks/agent-spawn-delegation-gate.mjs +133 -0
  96. package/scripts/claude-hooks/capture-workflow-gate.mjs +11 -0
  97. package/scripts/claude-hooks/check-auto-memory-budget.mjs +396 -0
  98. package/scripts/claude-hooks/check-claude-md-budget.mjs +327 -0
  99. package/scripts/claude-hooks/check-mcp-freshness-hook.mjs +14 -0
  100. package/scripts/claude-hooks/claim-before-code-gate.mjs +11 -0
  101. package/scripts/claude-hooks/claim-liveness-shared.mjs +575 -0
  102. package/scripts/claude-hooks/close-terminal-on-ship.mjs +64 -0
  103. package/scripts/claude-hooks/coo-denylist-gate.mjs +11 -0
  104. package/scripts/claude-hooks/coo-guard-check.mjs +11 -0
  105. package/scripts/claude-hooks/deep-research-cost-gate.mjs +11 -0
  106. package/scripts/claude-hooks/dev-server-task-reminder.mjs +15 -0
  107. package/scripts/claude-hooks/emit-claim-status.mjs +41 -0
  108. package/scripts/claude-hooks/enforce-session-sequence.mjs +11 -0
  109. package/scripts/claude-hooks/enforce-vault-naming.mjs +11 -0
  110. package/scripts/claude-hooks/env-vault-guard.mjs +11 -0
  111. package/scripts/claude-hooks/epic-milestone-phrasing-gate.mjs +11 -0
  112. package/scripts/claude-hooks/git-identity-guard.mjs +11 -0
  113. package/scripts/claude-hooks/git-reflog-audit.mjs +15 -0
  114. package/scripts/claude-hooks/git-stash-gate.mjs +11 -0
  115. package/scripts/claude-hooks/guard-auto-memory-writes.mjs +137 -0
  116. package/scripts/claude-hooks/id-display-format-gate.mjs +11 -0
  117. package/scripts/claude-hooks/inject-communication-style.mjs +66 -0
  118. package/scripts/claude-hooks/inject-coo-seat.mjs +78 -0
  119. package/scripts/claude-hooks/inject-dev-server-awareness.mjs +11 -0
  120. package/scripts/claude-hooks/inject-doctrine.mjs +13 -0
  121. package/scripts/claude-hooks/inject-vault-context.mjs +14 -0
  122. package/scripts/claude-hooks/intent-classifier-trigger.mjs +247 -0
  123. package/scripts/claude-hooks/log-session-activity.mjs +341 -0
  124. package/scripts/claude-hooks/naughty-list-gate.mjs +11 -0
  125. package/scripts/claude-hooks/notify-status-flip.mjs +72 -0
  126. package/scripts/claude-hooks/plan-exit-ingest-gate.mjs +170 -0
  127. package/scripts/claude-hooks/plan-ingest-close-reminder.mjs +66 -0
  128. package/scripts/claude-hooks/plan-review-gate.mjs +11 -0
  129. package/scripts/claude-hooks/preflight-reminder.mjs +262 -0
  130. package/scripts/claude-hooks/protected-action-intent.mjs +221 -0
  131. package/scripts/claude-hooks/research-method-guard.mjs +398 -0
  132. package/scripts/claude-hooks/research-protocol-gate.mjs +11 -0
  133. package/scripts/claude-hooks/session-end.mjs +449 -0
  134. package/scripts/claude-hooks/settings-write-gate.mjs +11 -0
  135. package/scripts/claude-hooks/settlement-check-gate.mjs +39 -0
  136. package/scripts/claude-hooks/status-flip-detect.mjs +17 -0
  137. package/scripts/claude-hooks/terminal-close-shared.mjs +426 -0
  138. package/scripts/claude-hooks/terminal-close-watcher.mjs +122 -0
  139. package/scripts/claude-hooks/wip-checkpoint.mjs +298 -0
  140. package/scripts/cursor-hooks/manifest.mjs +235 -0
  141. package/scripts/dev-server-status.mjs +34 -13
  142. package/scripts/dev-server-stop.mjs +313 -0
  143. package/scripts/heyspex-mode.mjs +268 -0
  144. package/scripts/hs-auto-resolve.mjs +240 -0
  145. package/scripts/hs-auto-source.mjs +398 -0
  146. package/scripts/hs-auto.mjs +134 -0
  147. package/scripts/hs-capture-cli.mjs +326 -0
  148. package/scripts/hs-classifier.mjs +136 -0
  149. package/scripts/hs-companion.mjs +88 -0
  150. package/scripts/hs-gateway.mjs +115 -0
  151. package/scripts/hs-mode.mjs +125 -0
  152. package/scripts/hs-next.mjs +441 -0
  153. package/scripts/hs-settings.mjs +338 -0
  154. package/scripts/hs-view.mjs +333 -0
  155. package/scripts/hs-worktree.mjs +201 -0
  156. package/scripts/install-claude-code-hooks.mjs +270 -0
  157. package/scripts/install-cursor-hooks.mjs +471 -0
  158. package/scripts/lib/agent-env-markers.mjs +38 -0
  159. package/scripts/lib/console-url-resolver.mjs +197 -60
  160. package/scripts/lib/customer-kit.mjs +886 -0
  161. package/scripts/lib/dev-mode.mjs +244 -28
  162. package/scripts/lib/heyspex-home.mjs +72 -0
  163. package/scripts/lib/hs-command.mjs +277 -0
  164. package/scripts/lib/hs-companion-settings.mjs +472 -0
  165. package/scripts/lib/hs-source-commit.mjs +59 -5
  166. package/scripts/lib/hs-source-identity.mjs +526 -0
  167. package/scripts/lib/hs-source-log.mjs +11 -21
  168. package/scripts/lib/hs-source-push.mjs +107 -58
  169. package/scripts/lib/hs-worktree-args.mjs +115 -0
  170. package/scripts/lib/shared-path-helpers.mjs +134 -0
  171. package/scripts/lib/standalone-doctor.mjs +540 -3
  172. package/scripts/lib/subagent-git-lock.mjs +109 -0
  173. package/scripts/lib/template-pin.mjs +34 -0
  174. package/scripts/provenance-lib.mjs +91 -0
  175. package/scripts/stamp-provenance-on-edit.mjs +222 -0
  176. package/scripts/wip-ledger-health.mjs +653 -0
  177. package/spexharness/doctrine/baseline/SPEXCODE-DOCTRINE.baseline.json +29 -0
  178. package/spexharness/src/adapters/audit.mjs +197 -0
  179. package/spexharness/src/adapters/claude-code.mjs +441 -0
  180. package/spexharness/src/chat/tui/ansi.mjs +448 -0
  181. package/spexharness/src/chat/tui/palette.mjs +22 -0
  182. package/spexharness/src/chat/tui/select.mjs +208 -0
  183. package/spexharness/src/config-root.mjs +54 -0
  184. package/spexharness/src/context-budget.mjs +141 -0
  185. package/spexharness/src/dev-server-control.mjs +389 -0
  186. package/spexharness/src/doctrine/behaviour.mjs +371 -0
  187. package/spexharness/src/doctrine/corpus.mjs +733 -0
  188. package/spexharness/src/doctrine/inject.mjs +338 -0
  189. package/spexharness/src/doctrine/merge.mjs +184 -0
  190. package/spexharness/src/doctrine/sources.mjs +252 -0
  191. package/spexharness/src/doctrine/synthesize.mjs +401 -0
  192. package/spexharness/src/doctrine/vault-source.mjs +574 -0
  193. package/spexharness/src/doctrine/writer.mjs +176 -0
  194. package/spexharness/src/events.mjs +84 -0
  195. package/spexharness/src/gates/capture-workflow-gate.mjs +202 -0
  196. package/spexharness/src/gates/claim-before-code-gate.decide.mjs +33 -0
  197. package/spexharness/src/gates/claim-before-code-gate.mjs +129 -0
  198. package/spexharness/src/gates/deep-research-cost-gate.mjs +274 -0
  199. package/spexharness/src/gates/enforce-session-sequence.mjs +895 -0
  200. package/spexharness/src/gates/enforce-vault-naming.mjs +215 -0
  201. package/spexharness/src/gates/epic-milestone-phrasing-gate.mjs +233 -0
  202. package/spexharness/src/gates/git-identity-guard.decide.mjs +436 -0
  203. package/spexharness/src/gates/git-identity-guard.mjs +61 -0
  204. package/spexharness/src/gates/git-reflog-audit.mjs +151 -0
  205. package/spexharness/src/gates/git-stash-gate.decide.mjs +361 -0
  206. package/spexharness/src/gates/git-stash-gate.mjs +159 -0
  207. package/spexharness/src/gates/id-display-format-gate.mjs +190 -0
  208. package/spexharness/src/gates/inject-doctrine-gate.mjs +89 -0
  209. package/spexharness/src/gates/inject-vault-context.mjs +239 -0
  210. package/spexharness/src/gates/naughty-list-gate.mjs +213 -0
  211. package/spexharness/src/gates/plan-review-gate.mjs +233 -0
  212. package/spexharness/src/gates/preflight-reminder-gate.mjs +265 -0
  213. package/spexharness/src/gates/research-protocol-gate.mjs +175 -0
  214. package/spexharness/src/gates/settings-write-gate.decide.mjs +96 -0
  215. package/spexharness/src/gates/settings-write-gate.mjs +104 -0
  216. package/spexharness/src/git-reflog-audit.mjs +294 -0
  217. package/spexharness/src/intent-classifier.mjs +194 -0
  218. package/spexharness/src/intent-directive.mjs +541 -0
  219. package/spexharness/src/profile/learn.mjs +551 -0
  220. package/spexharness/src/profile/proposals.mjs +357 -0
  221. package/spexharness/src/profile/vault-inclusion.mjs +386 -0
  222. package/spexharness/src/redact.mjs +151 -0
  223. package/spexharness/src/rule-table.mjs +819 -0
  224. package/spexharness/src/seat-context.mjs +122 -0
  225. package/spexharness/src/session-bootstrap.mjs +512 -0
  226. package/spexharness/src/session-record.mjs +815 -0
  227. package/spexharness/src/token-inventory.mjs +207 -0
  228. package/vendor/heyspex-mcp/account-switch.mjs +95 -0
  229. package/vendor/heyspex-mcp/alignment-gate.mjs +315 -1
  230. package/vendor/heyspex-mcp/blocker-resolver.mjs +37 -2
  231. package/vendor/heyspex-mcp/breakdown-ladder.mjs +119 -4
  232. package/vendor/heyspex-mcp/breakdown-source.mjs +271 -0
  233. package/vendor/heyspex-mcp/breakdown-writer.mjs +23 -3
  234. package/vendor/heyspex-mcp/capture-buffer-patches.mjs +46 -5
  235. package/vendor/heyspex-mcp/capture-channel.mjs +55 -5
  236. package/vendor/heyspex-mcp/capture-console-client.mjs +80 -1
  237. package/vendor/heyspex-mcp/capture-focus.mjs +1035 -29
  238. package/vendor/heyspex-mcp/capture-outbox.mjs +5 -1
  239. package/vendor/heyspex-mcp/capture-tools.mjs +1219 -0
  240. package/vendor/heyspex-mcp/claim-tools.mjs +1124 -0
  241. package/vendor/heyspex-mcp/cli-verbs.mjs +2076 -0
  242. package/vendor/heyspex-mcp/cli.mjs +201 -15524
  243. package/vendor/heyspex-mcp/cloud-tool-surface.mjs +128 -0
  244. package/vendor/heyspex-mcp/cloud-vault-writes.mjs +412 -0
  245. package/vendor/heyspex-mcp/config-seed.yaml +15 -0
  246. package/vendor/heyspex-mcp/config-settings.mjs +290 -7
  247. package/vendor/heyspex-mcp/console-fetch.mjs +657 -16
  248. package/vendor/heyspex-mcp/console-transport.mjs +166 -0
  249. package/vendor/heyspex-mcp/credentials.mjs +584 -0
  250. package/vendor/heyspex-mcp/defer-spec.mjs +44 -4
  251. package/vendor/heyspex-mcp/deferred-reclaim.mjs +29 -3
  252. package/vendor/heyspex-mcp/design-handlers.mjs +31 -0
  253. package/vendor/heyspex-mcp/design-tools.mjs +4 -1
  254. package/vendor/heyspex-mcp/dev-factory-bridge.mjs +4 -4
  255. package/vendor/heyspex-mcp/dev-mode-core.mjs +45 -0
  256. package/vendor/heyspex-mcp/dev-tools.mjs +124 -0
  257. package/vendor/heyspex-mcp/diagnose-handlers.mjs +54 -0
  258. package/vendor/heyspex-mcp/events-tools.mjs +207 -0
  259. package/vendor/heyspex-mcp/filing-tools.mjs +688 -0
  260. package/vendor/heyspex-mcp/fleet-handlers.mjs +37 -0
  261. package/vendor/heyspex-mcp/fleet-queue-tools.mjs +69 -14
  262. package/vendor/heyspex-mcp/focus-tools.mjs +190 -0
  263. package/vendor/heyspex-mcp/folder-trust-store.mjs +59 -0
  264. package/vendor/heyspex-mcp/http-transport.mjs +300 -0
  265. package/vendor/heyspex-mcp/list-tools.mjs +257 -0
  266. package/vendor/heyspex-mcp/login-handoff.mjs +15 -1
  267. package/vendor/heyspex-mcp/mcp-config.mjs +94 -0
  268. package/vendor/heyspex-mcp/memory-tools.mjs +523 -0
  269. package/vendor/heyspex-mcp/mvp-mode.mjs +53 -6
  270. package/vendor/heyspex-mcp/occupied-keys.mjs +141 -0
  271. package/vendor/heyspex-mcp/on-deck-brief.mjs +3360 -0
  272. package/vendor/heyspex-mcp/on-deck-scheduler.mjs +1708 -0
  273. package/vendor/heyspex-mcp/package-root.mjs +32 -0
  274. package/vendor/heyspex-mcp/peer-presence.mjs +8 -1
  275. package/vendor/heyspex-mcp/plan-ground.mjs +621 -652
  276. package/vendor/heyspex-mcp/plan-push.mjs +906 -21
  277. package/vendor/heyspex-mcp/plan-review.mjs +124 -30
  278. package/vendor/heyspex-mcp/preflight-gate.mjs +0 -0
  279. package/vendor/heyspex-mcp/procedure-handlers.mjs +325 -0
  280. package/vendor/heyspex-mcp/procedure-tools.mjs +540 -351
  281. package/vendor/heyspex-mcp/promote-capture.mjs +386 -69
  282. package/vendor/heyspex-mcp/promote-diagnose.mjs +74 -0
  283. package/vendor/heyspex-mcp/promote-research-to-diagnose.mjs +9 -2
  284. package/vendor/heyspex-mcp/promote-research.mjs +660 -53
  285. package/vendor/heyspex-mcp/promotion-helpers.mjs +251 -0
  286. package/vendor/heyspex-mcp/prompts.mjs +1460 -0
  287. package/vendor/heyspex-mcp/read-path.mjs +29 -9
  288. package/vendor/heyspex-mcp/receipt-shape.mjs +146 -0
  289. package/vendor/heyspex-mcp/recipe-handlers.mjs +117 -0
  290. package/vendor/heyspex-mcp/regen-tasks.mjs +210 -3
  291. package/vendor/heyspex-mcp/register-spec.mjs +266 -54
  292. package/vendor/heyspex-mcp/request-context.mjs +59 -0
  293. package/vendor/heyspex-mcp/research-focus.mjs +207 -4
  294. package/vendor/heyspex-mcp/research-handlers.mjs +623 -0
  295. package/vendor/heyspex-mcp/research-tools.mjs +4 -4
  296. package/vendor/heyspex-mcp/response-budget.mjs +348 -0
  297. package/vendor/heyspex-mcp/review-focus.mjs +25 -1
  298. package/vendor/heyspex-mcp/search-specs.mjs +280 -0
  299. package/vendor/heyspex-mcp/seat-block-ledger.mjs +113 -0
  300. package/vendor/heyspex-mcp/seat-identity.mjs +2 -6
  301. package/vendor/heyspex-mcp/session-owner.mjs +108 -0
  302. package/vendor/heyspex-mcp/session-tools.mjs +152 -0
  303. package/vendor/heyspex-mcp/settings-registry.mjs +232 -0
  304. package/vendor/heyspex-mcp/settings-tools.mjs +85 -12
  305. package/vendor/heyspex-mcp/spec-edit-tools.mjs +446 -0
  306. package/vendor/heyspex-mcp/spec-id-lib.mjs +93 -17
  307. package/vendor/heyspex-mcp/split-research.mjs +301 -56
  308. package/vendor/heyspex-mcp/stdio-rpc.mjs +22 -0
  309. package/vendor/heyspex-mcp/submit-for-review.mjs +132 -6
  310. package/vendor/heyspex-mcp/summary-contract.mjs +105 -0
  311. package/vendor/heyspex-mcp/summary-write-tools.mjs +224 -0
  312. package/vendor/heyspex-mcp/symptom-query.mjs +146 -0
  313. package/vendor/heyspex-mcp/test-helpers.mjs +108 -1
  314. package/vendor/heyspex-mcp/tool-catalog.mjs +2813 -0
  315. package/vendor/heyspex-mcp/tool-dispatch.mjs +639 -0
  316. package/vendor/heyspex-mcp/tool-helpers.mjs +325 -0
  317. package/vendor/heyspex-mcp/validate-linkage.mjs +414 -27
  318. package/vendor/heyspex-mcp/vault-io.mjs +724 -0
  319. package/vendor/heyspex-mcp/vault-less-endpoints.mjs +52 -0
  320. package/vendor/heyspex-mcp/vault-resolve.mjs +366 -68
  321. package/vendor/heyspex-mcp/verify-reproduction.mjs +270 -30
  322. package/vendor/heyspex-mcp/whoami.mjs +91 -0
@@ -0,0 +1,338 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * hs settings — CLI face of the HeySpex settings capability.
4
+ * Dispatched from bin/hs.mjs. Delegates to the ONE settings core
5
+ * (vendor/heyspex-mcp/config-settings.mjs) per the three-faces principle
6
+ * ([[capability-three-faces-no-per-capability-slash]]).
7
+ *
8
+ * Usage:
9
+ * hs settings list [prefix] [--json]
10
+ * hs settings get <dotted.key> [--json]
11
+ * hs settings set <dotted.key> <value> [--json] [--confirmed-by-user]
12
+ *
13
+ * WRITE-PERMISSION GUARDRAIL (agent-settings-protocol, owner directive
14
+ * 2026-07-11): settings writes require explicit user permission. A human typing
15
+ * `hs settings set` in their own terminal IS the permission. But when this CLI
16
+ * runs inside an agent harness (Claude Code sets CLAUDECODE=1 / CLAUDE_AGENT for
17
+ * its shell tool), the write is agent-initiated: it is refused unless
18
+ * `--confirmed-by-user` is passed, and that flag may ONLY be passed after the
19
+ * user explicitly approved the exact change in the conversation. This holds
20
+ * regardless of agent_behaviors.ask_forgiveness_not_permission — that switch
21
+ * governs behaviors, never its own controls. All writes are audited to
22
+ * .heyspex/settings-history.jsonl.
23
+ */
24
+
25
+ import fs from 'node:fs';
26
+ import path from 'node:path';
27
+ import process from 'node:process';
28
+ import { pathToFileURL } from 'node:url';
29
+
30
+ import {
31
+ listConfigLeaves,
32
+ getEffectiveSetting,
33
+ setConfigLeaf,
34
+ validateSettingKey,
35
+ } from '../vendor/heyspex-mcp/config-settings.mjs';
36
+ import {
37
+ SETTINGS_REGISTRY,
38
+ getSettingEntry,
39
+ isKnownSetting,
40
+ } from '../vendor/heyspex-mcp/settings-registry.mjs';
41
+ import { getActiveConfig } from '../vendor/heyspex-mcp/vault-resolve.mjs';
42
+
43
+ const HELP = `hs settings — read/change HeySpex workflow settings (.heyspex/config.yaml at the VAULT root)
44
+
45
+ Usage:
46
+ hs settings list [prefix] [--json] List all scalar settings (optionally under a dotted prefix)
47
+ hs settings get <dotted.key> [--json] Read one setting (e.g. agent_behaviors.capture_on_idea.enabled)
48
+ hs settings set <dotted.key> <value> Change one setting (value: string | number | true | false | null)
49
+ [--confirmed-by-user] [--json]
50
+
51
+ The settings file lives at <vault root>/.heyspex/config.yaml (ADR-0009). If it is
52
+ missing, \`hs settings set\` creates it from the shipped seed template first and
53
+ then applies the change (reads never create it) — \`hs init\` seeds the same file.
54
+
55
+ Write permission (agent-settings-protocol):
56
+ A human running \`hs settings set\` directly is inherently permissioned. Inside an
57
+ agent harness (CLAUDECODE / CLAUDE_AGENT env present) the write is refused unless
58
+ --confirmed-by-user is passed — and an agent may pass it ONLY after the user
59
+ explicitly approved the exact change. Writes are audited to
60
+ .heyspex/settings-history.jsonl. Agents should prefer the MCP tool
61
+ heyspex_settings_set (two-step confirm).
62
+ `;
63
+
64
+ /**
65
+ * The vault root this face reads/writes. `bin/hs.mjs` always forwards
66
+ * `HS_VAULT_ROOT`; the last fallback (this script run directly) asks the shared
67
+ * vault resolver — NEVER this code checkout. Since the 2026-08-31 repo split the
68
+ * vault is a sibling repo, so falling back to `scripts/..` silently read/wrote a
69
+ * `.heyspex/config.yaml` the console and MCP never look at
70
+ * ([[HS-4C76-9W86-DIAG-heyspex-config-yaml-has-no-single-home]]).
71
+ */
72
+ function vaultRoot() {
73
+ return process.env.HS_VAULT_ROOT || process.env.HEYSPEX_VAULT_ROOT || getActiveConfig().repoRootAbs;
74
+ }
75
+
76
+ function isAgentHarness() {
77
+ return Boolean(
78
+ process.env.CLAUDECODE ||
79
+ process.env.CLAUDE_AGENT ||
80
+ process.env.CLAUDE_CODE_SESSION_ID ||
81
+ process.env.CURSOR_AGENT,
82
+ );
83
+ }
84
+
85
+ /**
86
+ * True when `root` is the HeySpex platform vault rather than a
87
+ * customer-provisioned one. `hs init --customer-vault` unconditionally stamps
88
+ * `template_version: <…>` into the scaffolded vault's `heyspex.yaml`
89
+ * (`spex-console/src/lib/cli/init-git-finalize.ts` `gitFinalizeHook` /
90
+ * `ensureTemplateVersionLine`) — the platform vault's own marker never
91
+ * carries that field. That stamp is the one on-disk, offline-readable signal
92
+ * that distinguishes the two without a network/account lookup (the MCP/CLI
93
+ * process is standalone, per `vendor/heyspex-mcp/vault-resolve.mjs`'s module
94
+ * doc). A vault with no `heyspex.yaml` at all (e.g. this code checkout run
95
+ * directly, with no vault marker) is treated as platform — it is not a
96
+ * customer scaffold. Registry-declared admin-only entries (e.g.
97
+ * `close_out.run_command_drift_doctor`) are hidden from `hs settings list`
98
+ * everywhere `template_version` is present ([[HS-5MNS-15XI-TASK]] AC4).
99
+ */
100
+ export function isHeyspexPlatformVault(root) {
101
+ let raw;
102
+ try {
103
+ raw = fs.readFileSync(path.join(root, 'heyspex.yaml'), 'utf8');
104
+ } catch {
105
+ return true; // no marker at all — not a customer scaffold
106
+ }
107
+ return !/^template_version:\s*\S+/m.test(raw);
108
+ }
109
+
110
+ /** How a value's `source` reads in a one-line human sentence. */
111
+ function describeSource(source) {
112
+ if (source === 'file') return 'settings file';
113
+ if (source === 'registry_default') return 'shipped default';
114
+ return 'absent';
115
+ }
116
+
117
+ /**
118
+ * Build the `hs settings list` result: every registry entry this vault ships
119
+ * (admin-only entries dropped in a non-platform vault), each carrying its
120
+ * effective value/source plus its shipped default and description, followed
121
+ * by any keys the settings file has that the registry does not declare
122
+ * (marked `unrecognised: true`) — the freeform namespaces the registry has
123
+ * not been taught yet (`agent_behaviors.*`, `mvp_mode.*`, …) land here too,
124
+ * unchanged from their pre-registry values.
125
+ * ([[HS-5MNS-15XI-TASK-show-every-declared-setting-in-the]])
126
+ *
127
+ * @param {string} root
128
+ * @param {string} [prefix] - optional dotted-key prefix filter
129
+ * @returns {{ declared: object[], unrecognised: object[] }}
130
+ */
131
+ export function buildListEntries(root, prefix, options = {}) {
132
+ const trimmedPrefix = (prefix ?? '').trim();
133
+ const matchesPrefix = (key) =>
134
+ !trimmedPrefix || key === trimmedPrefix || key.startsWith(trimmedPrefix + '.');
135
+ const platform = isHeyspexPlatformVault(root);
136
+
137
+ const declared = [];
138
+ for (const entry of SETTINGS_REGISTRY) {
139
+ if (entry.adminOnly && !platform) continue;
140
+ if (!matchesPrefix(entry.key)) continue;
141
+ const effective = getEffectiveSetting(root, entry.key, options);
142
+ declared.push({
143
+ key: entry.key,
144
+ value: effective.value,
145
+ source: effective.source,
146
+ default: entry.default,
147
+ type: entry.type,
148
+ description: entry.description,
149
+ cliFlag: entry.cliFlag,
150
+ adminOnly: entry.adminOnly,
151
+ enforced: entry.enforced,
152
+ ...(entry.type === 'enum' ? { values: entry.values } : {}),
153
+ });
154
+ }
155
+
156
+ const unrecognised = listConfigLeaves(root, options)
157
+ .filter((leaf) => !isKnownSetting(leaf.key))
158
+ .filter((leaf) => matchesPrefix(leaf.key))
159
+ .map((leaf) => ({ key: leaf.key, value: leaf.value, unrecognised: true }));
160
+
161
+ return { declared, unrecognised };
162
+ }
163
+
164
+ /**
165
+ * Build the `hs settings get <key>` result: the effective value (file value
166
+ * when present, else the registry's shipped default), which of the two it
167
+ * came from, and — for a name the registry does not declare AND that has no
168
+ * value in the file either — an explicit "not declared" report instead of a
169
+ * silent empty value. ([[HS-7W0S-15XI-TASK-return-the-effective-value-from-the]])
170
+ *
171
+ * @param {string} root
172
+ * @param {string} key
173
+ * @returns {object}
174
+ */
175
+ export function resolveGet(root, key, options = {}) {
176
+ const effective = getEffectiveSetting(root, key, options);
177
+ if (effective.source === 'absent') {
178
+ return { ok: true, key, found: false, known: false, value: null, source: 'absent' };
179
+ }
180
+ const entry = getSettingEntry(key);
181
+ return {
182
+ ok: true,
183
+ key,
184
+ found: effective.found,
185
+ known: Boolean(entry),
186
+ value: effective.value,
187
+ source: effective.source,
188
+ ...(entry ? { default: entry.default, description: entry.description } : {}),
189
+ };
190
+ }
191
+
192
+ /** Render `resolveGet`'s result as the plain-text `hs settings get` body. */
193
+ function renderGetText(result) {
194
+ if (result.source === 'absent') {
195
+ return `${result.key} is not set and is not declared in the settings registry (no shipped default to fall back to)\n`;
196
+ }
197
+ return `${result.key} = ${JSON.stringify(result.value)} [${describeSource(result.source)}]\n`;
198
+ }
199
+
200
+ /** Render `buildListEntries`'s result as the plain-text `hs settings list` body. */
201
+ function renderListText({ declared, unrecognised }) {
202
+ const lines = [];
203
+ for (const row of declared) {
204
+ lines.push(`${row.key} = ${JSON.stringify(row.value)} [${describeSource(row.source)}]`);
205
+ lines.push(` ${row.description}`);
206
+ }
207
+ if (unrecognised.length) {
208
+ if (declared.length) lines.push('');
209
+ lines.push('Unrecognised (not declared in the settings registry):');
210
+ for (const row of unrecognised) {
211
+ lines.push(` ${row.key} = ${JSON.stringify(row.value)}`);
212
+ }
213
+ }
214
+ if (!declared.length && !unrecognised.length) return 'no settings found\n';
215
+ return lines.join('\n') + '\n';
216
+ }
217
+
218
+ function parseValueToken(token) {
219
+ if (token === undefined) return undefined;
220
+ if (token === 'null') return null;
221
+ if (token === 'true') return true;
222
+ if (token === 'false') return false;
223
+ if (/^-?\d+(\.\d+)?$/.test(token)) return Number(token);
224
+ return token;
225
+ }
226
+
227
+ function out(obj, asJson, plain) {
228
+ if (asJson) process.stdout.write(JSON.stringify(obj, null, 2) + '\n');
229
+ else process.stdout.write(plain);
230
+ }
231
+
232
+ function main(argv) {
233
+ // bin/hs.mjs forwards the full argv (leading `settings` token included).
234
+ const args = argv[0] === 'settings' ? argv.slice(1) : argv.slice();
235
+ if (args.includes('--help') || args.includes('-h') || args.length === 0) {
236
+ process.stdout.write(HELP);
237
+ process.exit(args.length === 0 ? 2 : 0);
238
+ }
239
+ const asJson = args.includes('--json');
240
+ const confirmedByUser = args.includes('--confirmed-by-user');
241
+ const positional = args.filter((a) => !a.startsWith('--'));
242
+ const [verb, keyArg, ...valueTokens] = positional;
243
+ const root = vaultRoot();
244
+
245
+ if (verb === 'list') {
246
+ const prefix = keyArg?.trim() || '';
247
+ const { declared, unrecognised } = buildListEntries(root, prefix);
248
+ out(
249
+ { ok: true, count: declared.length + unrecognised.length, declared, unrecognised },
250
+ asJson,
251
+ renderListText({ declared, unrecognised }),
252
+ );
253
+ process.exit(0);
254
+ }
255
+
256
+ if (verb === 'get') {
257
+ const check = validateSettingKey(keyArg ?? '');
258
+ if (!check.ok) {
259
+ process.stderr.write(`hs settings get: ${check.reason}\n`);
260
+ process.exit(2);
261
+ }
262
+ const result = resolveGet(root, keyArg);
263
+ out(result, asJson, renderGetText(result));
264
+ process.exit(0);
265
+ }
266
+
267
+ if (verb === 'set') {
268
+ const check = validateSettingKey(keyArg ?? '');
269
+ if (!check.ok) {
270
+ process.stderr.write(`hs settings set: ${check.reason}\n`);
271
+ process.exit(2);
272
+ }
273
+ if (valueTokens.length === 0) {
274
+ process.stderr.write('hs settings set: missing <value> (string | number | true | false | null)\n');
275
+ process.exit(2);
276
+ }
277
+ const value = parseValueToken(valueTokens.join(' '));
278
+
279
+ if (isAgentHarness() && !confirmedByUser) {
280
+ const msg =
281
+ `hs settings set: REFUSED — this shell is running inside an agent harness, and settings writes ` +
282
+ `require explicit user permission (agent-settings-protocol; independent of ` +
283
+ `ask_forgiveness_not_permission). Show the user the exact change and, only after they approve, ` +
284
+ `re-run with --confirmed-by-user (or use the MCP tool heyspex_settings_set, which has a ` +
285
+ `built-in confirm step).\n`;
286
+ process.stderr.write(msg);
287
+ process.exit(3);
288
+ }
289
+
290
+ const actor = isAgentHarness() ? 'agent (CLI, user-confirmed)' : 'human (CLI)';
291
+ const result = setConfigLeaf(root, keyArg, value, { actor, face: 'cli' });
292
+ if (!result.ok) {
293
+ process.stderr.write(`hs settings set: ${result.error} — ${result.message}\n`);
294
+ process.exit(1);
295
+ }
296
+ // HS-4C76 fix 3: the core seeds a missing config.yaml from the template on
297
+ // the first write instead of refusing — say so, since the user may not have
298
+ // known the file was absent (it is git-tracked at the vault root).
299
+ if (result.seeded === true) {
300
+ process.stderr.write(
301
+ `hs settings set: note — ${path.join(root, '.heyspex', 'config.yaml')} did not exist and was created from ` +
302
+ `the seed template before applying this change. Commit it with the vault (ADR-0009).\n`,
303
+ );
304
+ }
305
+ // The CLI face shares the core with heyspex_settings_set, so it inherits the
306
+ // measured `audited` flag ([[HS-IOJW-5YDH-DIAG]] sweep). A settings write with
307
+ // no audit row is a partial, and must not print as a clean success.
308
+ if (result.audited !== true) {
309
+ process.stderr.write(
310
+ `hs settings set: WARNING — the value was applied to .heyspex/config.yaml but the audit append to ` +
311
+ `.heyspex/settings-history.jsonl FAILED. The change is live and UNAUDITED; check that file is writable.\n`,
312
+ );
313
+ }
314
+ out(
315
+ { ok: true, ...result },
316
+ asJson,
317
+ `${keyArg}: ${JSON.stringify(result.previous)} → ${JSON.stringify(result.value)}${result.created ? ' (created)' : ''}${result.seeded === true ? ' (config.yaml seeded from template)' : ''}${result.audited === true ? '' : ' (UNAUDITED — audit append failed)'}\n`,
318
+ );
319
+ process.exit(0);
320
+ }
321
+
322
+ process.stderr.write(`hs settings: unknown subcommand \`${verb ?? ''}\` (expected: list | get | set)\n${HELP}`);
323
+ process.exit(2);
324
+ }
325
+
326
+ // Only auto-run when executed directly (`node scripts/hs-settings.mjs …` or via
327
+ // bin/hs.mjs's spawn) — not when a test imports this module for its exported
328
+ // helpers (buildListEntries, isHeyspexPlatformVault, …).
329
+ const isMain = (() => {
330
+ try {
331
+ return import.meta.url === pathToFileURL(process.argv[1] ?? '').href;
332
+ } catch {
333
+ return false;
334
+ }
335
+ })();
336
+ if (isMain) {
337
+ main(process.argv.slice(2));
338
+ }
@@ -0,0 +1,333 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * hs view <surface> [spec-id] — open ONE console surface in a chromeless
4
+ * app-mode browser window (the "solo surface" console gauge).
5
+ *
6
+ * Per [[HS-RU2R-I87U-RES-solo-surface-cli-launcher-single]]: launches
7
+ * Edge/Chrome with `--app=<url>` (NOT `--kiosk`, which forces fullscreen and
8
+ * ignores window sizing) pointed at the console's `/solo/<surface>` route.
9
+ * Probes ground-truth dev-server status first (same core as `hs server
10
+ * status`) so a down console yields a clear message, never a blank window.
11
+ *
12
+ * Open = run it; close = close the window; re-run = reopen (idempotent).
13
+ *
14
+ * Usage:
15
+ * hs view pipeline # follow the focus pin
16
+ * hs view pipeline HS-I87U-CAP # fixed anchor spec
17
+ * hs view docs HS-NP1Y # single-doc reader, live-refreshing
18
+ * hs view pipeline --follow capture # track the capture-on-deck pin
19
+ * hs view pipeline --follow capture --url https://console.example.com
20
+ * hs view pipeline --width 510 --height 900 --browser edge|chrome
21
+ */
22
+ import { spawn } from 'node:child_process';
23
+ import path from 'node:path';
24
+ import { fileURLToPath, pathToFileURL } from 'node:url';
25
+
26
+ import { getDevServerStatus } from './dev-server-status.mjs';
27
+ import { resolveConsoleUrl, DEFAULT_CONSOLE_URL } from './lib/console-url-resolver.mjs';
28
+ // The runnable form of a HeySpex command on THIS machine — `hs …` when the bin
29
+ // is on PATH, else an absolute-path `node "<install>/bin/hs.mjs" …`. Never an
30
+ // `npx` form: npx resolves from the READER's working directory, and outside a
31
+ // checkout `npx hs` installs the unrelated public package hs@2.0.1
32
+ // ([[HS-I6HT-EKYU-DIAG-every-message-tells-the-user-to-run-hs]]).
33
+ import { hsCommand } from './lib/hs-command.mjs';
34
+ import { collectOccupiedFamilyKeysFromVault, parseSpecId } from '../vendor/heyspex-mcp/spec-id-lib.mjs';
35
+
36
+ function defaultVaultRoot() {
37
+ // This file lives at <vaultRoot>/scripts/, so one level up is the root.
38
+ return path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
39
+ }
40
+
41
+ /** Levenshtein edit distance — small local helper, no dependency pulled in for it. */
42
+ function editDistance(a, b) {
43
+ const m = a.length;
44
+ const n = b.length;
45
+ const dp = Array.from({ length: m + 1 }, (_, i) => [i, ...Array(n).fill(0)]);
46
+ for (let j = 0; j <= n; j++) dp[0][j] = j;
47
+ for (let i = 1; i <= m; i++) {
48
+ for (let j = 1; j <= n; j++) {
49
+ dp[i][j] =
50
+ a[i - 1] === b[j - 1]
51
+ ? dp[i - 1][j - 1]
52
+ : 1 + Math.min(dp[i - 1][j - 1], dp[i - 1][j], dp[i][j - 1]);
53
+ }
54
+ }
55
+ return dp[m][n];
56
+ }
57
+
58
+ /**
59
+ * Nearest occupied family keys to a typo'd spec token — walks the vault
60
+ * (fresh every call, unlike `.heyspex/vault-index.json`) via the shared
61
+ * spec-id-lib helper, never re-implements the vault scan.
62
+ */
63
+ function suggestFamilyKeys(rawSpec, vaultRoot, limit = 5) {
64
+ const parsed = parseSpecId(rawSpec);
65
+ const needle = (parsed?.familyKey ?? rawSpec.replace(/^HS-/i, '')).toUpperCase();
66
+ const occupied = [...collectOccupiedFamilyKeysFromVault(vaultRoot)];
67
+ return occupied
68
+ .map((key) => ({ key, dist: editDistance(needle, key) }))
69
+ .sort((x, y) => x.dist - y.dist || x.key.localeCompare(y.key))
70
+ .slice(0, limit)
71
+ .filter((c) => c.dist <= Math.max(2, Math.ceil(needle.length / 2)))
72
+ .map((c) => `HS-${c.key}`);
73
+ }
74
+
75
+ /**
76
+ * Resolve a spec id/token against the running console before opening a
77
+ * browser window (same resolution rules as `GET /api/specs/:id`, including
78
+ * its bare-family-token prefix fallback). Returns `{ ok: true }`,
79
+ * `{ ok: false, kind: 'ambiguous', candidates }`, or
80
+ * `{ ok: false, kind: 'not_found', suggestions }`.
81
+ */
82
+ async function resolveSpec(base, rawSpec, vaultRoot) {
83
+ let res;
84
+ try {
85
+ res = await fetch(`${base}/api/specs/${encodeURIComponent(rawSpec)}`);
86
+ } catch (err) {
87
+ // Network hiccup against a console we already confirmed is up — don't
88
+ // block the window on it, just open unvalidated.
89
+ return { ok: true, skipped: true, reason: err?.message || String(err) };
90
+ }
91
+ if (res.ok) return { ok: true };
92
+ let body = null;
93
+ try {
94
+ body = await res.json();
95
+ } catch {
96
+ // non-JSON error body — fall through to generic not_found handling
97
+ }
98
+ if (res.status === 404 && body?.error === 'ambiguous_id' && Array.isArray(body.candidates)) {
99
+ return { ok: false, kind: 'ambiguous', candidates: body.candidates };
100
+ }
101
+ if (res.status !== 404) {
102
+ // Not a verdict on the spec — a hosted console answers an unauthenticated
103
+ // probe with a sign-in redirect or a 401/403. Open unvalidated rather than
104
+ // claiming the doc does not exist.
105
+ return { ok: true, skipped: true, reason: `HTTP ${res.status}` };
106
+ }
107
+ return { ok: false, kind: 'not_found', suggestions: suggestFamilyKeys(rawSpec, vaultRoot) };
108
+ }
109
+
110
+ /** Anchor sources the window can follow live. `pin` is the default behavior. */
111
+ const FOLLOW_MODES = new Set(['pin', 'capture']);
112
+
113
+ /** Opening width for a surface with nothing better to say. */
114
+ export const DEFAULT_WINDOW_WIDTH = 510;
115
+
116
+ /**
117
+ * Surfaces that want a different opening width than the default.
118
+ *
119
+ * `inspector` is one narrow pane meant to sit beside a terminal — at 510 it
120
+ * takes screen it does not need, and its one-pane layout is designed around
121
+ * 350 ([[HS-LO2G-AQVI-TASK-one-pane-inspector-window-doc-takes-the]]).
122
+ */
123
+ export const SURFACE_WINDOW_WIDTH = {
124
+ inspector: 350,
125
+ };
126
+
127
+ /**
128
+ * Resolve the window width: an explicit `--width` always wins, otherwise the
129
+ * surface's own default, otherwise {@link DEFAULT_WINDOW_WIDTH}.
130
+ *
131
+ * `explicitWidth` is null/undefined when the flag was not passed. Deciding that
132
+ * by COMPARING against the fallback would silently drop `--width 510` on the
133
+ * inspector back to 350, so the caller tracks "was it passed" instead.
134
+ */
135
+ export function resolveWindowWidth(surface, explicitWidth) {
136
+ if (typeof explicitWidth === 'number' && Number.isFinite(explicitWidth) && explicitWidth > 0) {
137
+ return explicitWidth;
138
+ }
139
+ return SURFACE_WINDOW_WIDTH[surface] ?? DEFAULT_WINDOW_WIDTH;
140
+ }
141
+
142
+ function parseArgs(argv) {
143
+ const args = {
144
+ surface: null,
145
+ spec: null,
146
+ // null until `--width` is seen; resolved per-surface once the surface is known.
147
+ width: null,
148
+ height: 900,
149
+ browser: 'edge',
150
+ follow: null,
151
+ url: null,
152
+ };
153
+ for (let i = 0; i < argv.length; i++) {
154
+ const a = argv[i];
155
+ if (a === '--help' || a === '-h') return { help: true };
156
+ if (a === '--width') { args.width = Number(argv[++i]) || args.width; continue; }
157
+ if (a === '--height') { args.height = Number(argv[++i]) || args.height; continue; }
158
+ if (a === '--browser') { args.browser = String(argv[++i] || 'edge'); continue; }
159
+ if (a === '--url') {
160
+ const raw = String(argv[++i] || '').trim();
161
+ if (!/^https?:\/\/\S+$/.test(raw)) {
162
+ process.stderr.write(`hs view: --url expects an http(s) URL (got "${raw || '(nothing)'}")\n`);
163
+ process.exitCode = 2;
164
+ return { help: true };
165
+ }
166
+ args.url = raw.replace(/\/+$/, '');
167
+ continue;
168
+ }
169
+ if (a === '--follow') {
170
+ const mode = String(argv[++i] || '').trim().toLowerCase();
171
+ if (!FOLLOW_MODES.has(mode)) {
172
+ process.stderr.write(
173
+ `hs view: --follow expects ${[...FOLLOW_MODES].join(' or ')} (got "${mode || '(nothing)'}")\n`,
174
+ );
175
+ process.exitCode = 2;
176
+ return { help: true };
177
+ }
178
+ args.follow = mode;
179
+ continue;
180
+ }
181
+ if (a.startsWith('--')) {
182
+ process.stderr.write(`hs view: unknown flag ${a}\n`);
183
+ process.exitCode = 2;
184
+ return { help: true };
185
+ }
186
+ if (!args.surface) args.surface = a;
187
+ else if (!args.spec) args.spec = a;
188
+ }
189
+ return args;
190
+ }
191
+
192
+ function usage() {
193
+ process.stdout.write(
194
+ [
195
+ 'hs view <surface> [spec-id] [--follow pin|capture] [--url https://…]',
196
+ ' [--width N] [--height N] [--browser edge|chrome]',
197
+ '',
198
+ 'Open one console surface in a chromeless app-mode browser window.',
199
+ 'Surfaces: pipeline, docs, inspector, whats-next, captures, session-log, settings',
200
+ ' (registry: src/app/solo/[surface]/page.tsx).',
201
+ 'Without a spec-id the pipeline and docs views follow the focus pin live.',
202
+ '--follow capture tracks the capture-on-deck pin instead, so the window',
203
+ ' re-anchors itself onto each new capture rather than opening another.',
204
+ '--url opens against a named console (a hosted one) instead of probing for',
205
+ ' a local dev server — the cloud-relay path the desktop companion uses.',
206
+ '',
207
+ ].join('\n'),
208
+ );
209
+ }
210
+
211
+ /**
212
+ * Only a localhost/loopback resolution still requires a running local dev
213
+ * server; anything else (a paired or `hs dev mode:cloud` hosted console) is
214
+ * opened directly. An unparseable URL is treated as local so it still goes
215
+ * through the dev-server probe instead of being launched blind.
216
+ */
217
+ function isLocalConsoleUrl(url) {
218
+ try {
219
+ const host = new URL(url).hostname;
220
+ return host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '[::1]';
221
+ } catch {
222
+ return true;
223
+ }
224
+ }
225
+
226
+ function launchAppWindow(url, { width, height, browser }) {
227
+ const exe = browser === 'chrome' ? 'chrome' : 'msedge';
228
+ const flags = [`--app=${url}`, `--window-size=${width},${height}`];
229
+ let child;
230
+ if (process.platform === 'win32') {
231
+ // `start` resolves msedge/chrome via the App Paths registry; the empty ''
232
+ // is start's window-title slot, without it the exe would be eaten as title.
233
+ child = spawn('cmd.exe', ['/c', 'start', '', exe, ...flags], {
234
+ detached: true,
235
+ stdio: 'ignore',
236
+ });
237
+ } else if (process.platform === 'darwin') {
238
+ const app = browser === 'chrome' ? 'Google Chrome' : 'Microsoft Edge';
239
+ child = spawn('open', ['-na', app, '--args', ...flags], { detached: true, stdio: 'ignore' });
240
+ } else {
241
+ const bin = browser === 'chrome' ? 'google-chrome' : 'microsoft-edge';
242
+ child = spawn(bin, flags, { detached: true, stdio: 'ignore' });
243
+ }
244
+ child.unref();
245
+ }
246
+
247
+ async function main() {
248
+ const args = parseArgs(process.argv.slice(2));
249
+ if (args.help || !args.surface) {
250
+ usage();
251
+ if (!args.help) process.exitCode = 2;
252
+ return;
253
+ }
254
+
255
+ // Now that the surface is known, settle the width: an explicit `--width`
256
+ // stands, otherwise the surface's own opening width.
257
+ args.width = resolveWindowWidth(args.surface, args.width);
258
+
259
+ // `--url` names the console explicitly — the cloud-relay path, where the
260
+ // companion opens a window against the hosted console it is paired with and
261
+ // there is no local dev server to probe. Without it, the machine's own
262
+ // console resolution decides: `hs dev mode:cloud` / `hs mcp pair` stamp a
263
+ // hosted URL into ~/.heyspex/credentials.yaml, and a non-local resolution
264
+ // needs no dev server either. Only a localhost resolution keeps the old
265
+ // behavior of requiring (and probing for) a running local console.
266
+ const vaultRoot = process.env.HS_VAULT_ROOT || defaultVaultRoot();
267
+ let base = args.url;
268
+ if (!base) {
269
+ const resolved = resolveConsoleUrl({ repoRoot: vaultRoot });
270
+ if (!isLocalConsoleUrl(resolved)) {
271
+ base = resolved.replace(/\/+$/, '');
272
+ } else {
273
+ const status = await getDevServerStatus({ vaultRoot: process.env.HS_VAULT_ROOT });
274
+ if (!status.running) {
275
+ process.stderr.write(
276
+ 'hs view: this machine points at a local console but no spex-console dev server is running.\n' +
277
+ ' Start it first (npm run dev), then re-run this command.\n' +
278
+ ` (Meant the hosted console? Run \`${hsCommand('dev mode:cloud')}\`, or pass --url https://…)\n`,
279
+ );
280
+ process.exitCode = 1;
281
+ return;
282
+ }
283
+ const consoleServer = (status.servers || []).find((s) => s.probe === 'console');
284
+ base = (consoleServer?.url || DEFAULT_CONSOLE_URL).replace(/\/+$/, '');
285
+ }
286
+ }
287
+
288
+ if (args.spec) {
289
+ const resolution = await resolveSpec(base, args.spec, vaultRoot);
290
+ if (!resolution.ok) {
291
+ if (resolution.kind === 'ambiguous') {
292
+ process.stderr.write(
293
+ `hs view: "${args.spec}" is ambiguous — it matches more than one spec:\n` +
294
+ resolution.candidates.map((c) => ` ${c}\n`).join('') +
295
+ 'Pass the full basename to disambiguate.\n',
296
+ );
297
+ } else {
298
+ process.stderr.write(`hs view: no spec found for "${args.spec}".\n`);
299
+ if (resolution.suggestions.length) {
300
+ process.stderr.write(
301
+ 'Did you mean:\n' + resolution.suggestions.map((s) => ` ${s}\n`).join(''),
302
+ );
303
+ }
304
+ }
305
+ process.exitCode = 1;
306
+ return;
307
+ }
308
+ }
309
+
310
+ // A fixed `?spec=` wins over any follow mode (both here and in the route),
311
+ // so only send `follow` when there is no fixed anchor to override it.
312
+ const params = new URLSearchParams();
313
+ if (args.spec) params.set('spec', args.spec);
314
+ else if (args.follow) params.set('follow', args.follow);
315
+ const search = params.toString();
316
+ const query = search ? `?${search}` : '';
317
+ const url = `${base}/solo/${encodeURIComponent(args.surface)}${query}`;
318
+
319
+ launchAppWindow(url, args);
320
+ process.stdout.write(
321
+ `hs view: opened ${url} in an app-mode ${args.browser === 'chrome' ? 'Chrome' : 'Edge'} window ` +
322
+ `(${args.width}x${args.height}). Close the window to close; re-run to reopen.\n`,
323
+ );
324
+ }
325
+
326
+ // Run only as the CLI entry point. Without this guard, importing the module —
327
+ // which is how `resolveWindowWidth` is tested — would spawn a browser window.
328
+ if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) {
329
+ main().catch((err) => {
330
+ process.stderr.write(`hs view: ${err?.message || err}\n`);
331
+ process.exitCode = 1;
332
+ });
333
+ }