heyspex 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (352) hide show
  1. package/.claude-plugin/marketplace.json +14 -0
  2. package/README.md +21 -0
  3. package/bin/hs.mjs +1089 -104
  4. package/bin/spexcode.mjs +262 -0
  5. package/dev-factory/engine/worktree.mjs +740 -0
  6. package/dev-factory/lib/code-repo-root.mjs +67 -0
  7. package/dev-factory/lib/context-budget.mjs +84 -0
  8. package/dev-factory/lib/epic-detect.mjs +190 -0
  9. package/dev-factory/lib/fleet-queue-file.mjs +846 -0
  10. package/dev-factory/lib/fleet-queue.mjs +426 -0
  11. package/dev-factory/lib/fleet-state.mjs +14 -18
  12. package/dev-factory/lib/model-catalog.mjs +338 -0
  13. package/dev-factory/lib/seat-context.mjs +199 -0
  14. package/dev-factory/lib/spec-io.mjs +93 -0
  15. package/dev-factory/lib/sufficiency-score.mjs +8 -2
  16. package/dev-factory/lib/worktree-allocator.mjs +744 -0
  17. package/dev-factory/model-catalog.json +222 -0
  18. package/dist/cli/hs-check.mjs +1127 -103
  19. package/dist/cli/hs-claim.mjs +981 -63
  20. package/dist/cli/hs-connect.mjs +10059 -1729
  21. package/dist/cli/hs-defer.mjs +5507 -3072
  22. package/dist/cli/hs-dev.mjs +576 -0
  23. package/dist/cli/hs-env.mjs +416 -302
  24. package/dist/cli/hs-index-rebuild.mjs +7334 -0
  25. package/dist/cli/hs-index-reconcile.mjs +8138 -0
  26. package/dist/cli/hs-init.mjs +2840 -149
  27. package/dist/cli/hs-install.mjs +7214 -594
  28. package/dist/cli/hs-join.mjs +10640 -2421
  29. package/dist/cli/hs-vault-pull.mjs +8157 -6370
  30. package/dist/cli/hs-vault-push.mjs +6433 -0
  31. package/dist/cli/hs-vault.mjs +4029 -1555
  32. package/dist/cli/hs-workspace.mjs +2702 -538
  33. package/dist/cli-templates/customer-vault/specs/entity-model.md +594 -601
  34. package/dist/cli-templates/customer-vault/specs/protocols/canonical-frontmatter-templates.md +110 -177
  35. package/dist/cli-templates/customer-vault/specs/protocols/destructive-action-checklist.md +122 -0
  36. package/dist/cli-templates/customer-vault/specs/protocols/memory-protocol.md +13 -15
  37. package/dist/cli-templates/customer-vault/specs/protocols/session-close-protocol.md +91 -0
  38. package/dist/cli-templates/customer-vault/specs/protocols/spec-hierarchy.md +150 -168
  39. package/dist/cli-templates/customer-vault/specs/protocols/spec-summary-authoring.md +83 -0
  40. package/dist/cli-templates/harness-commands/claude/hs-auto.md +36 -0
  41. package/dist/cli-templates/harness-commands/claude/hs-backfill.md +28 -0
  42. package/dist/cli-templates/harness-commands/claude/hs-capture.md +62 -0
  43. package/dist/cli-templates/harness-commands/claude/hs-defer.md +29 -0
  44. package/dist/cli-templates/harness-commands/claude/hs-dev.md +20 -0
  45. package/dist/cli-templates/harness-commands/claude/hs-doctor.md +32 -0
  46. package/dist/cli-templates/harness-commands/claude/hs-ground.md +30 -0
  47. package/dist/cli-templates/harness-commands/claude/hs-handoff.md +33 -0
  48. package/dist/cli-templates/harness-commands/claude/hs-log.md +27 -0
  49. package/dist/cli-templates/harness-commands/claude/hs-next.md +29 -0
  50. package/dist/cli-templates/harness-commands/claude/hs-promote.md +70 -0
  51. package/dist/cli-templates/harness-commands/claude/hs-push.md +35 -0
  52. package/dist/cli-templates/harness-commands/claude/hs-qc.md +27 -0
  53. package/dist/cli-templates/harness-commands/claude/hs-res.md +63 -0
  54. package/dist/cli-templates/harness-commands/claude/hs-resolve.md +23 -0
  55. package/dist/cli-templates/harness-commands/claude/hs-review.md +23 -0
  56. package/dist/cli-templates/harness-commands/claude/hs-view.md +28 -0
  57. package/dist/cli-templates/harness-commands/claude/kill-ports.md +32 -0
  58. package/dist/cli-templates/harness-commands/claude/prototype.md +91 -0
  59. package/dist/cli-templates/harness-commands/cursor/hs-auto.md +40 -0
  60. package/dist/cli-templates/harness-commands/cursor/hs-backfill.md +31 -0
  61. package/dist/cli-templates/harness-commands/cursor/hs-capture.md +65 -0
  62. package/dist/cli-templates/harness-commands/cursor/hs-defer.md +32 -0
  63. package/dist/cli-templates/harness-commands/cursor/hs-dev.md +24 -0
  64. package/dist/cli-templates/harness-commands/cursor/hs-doctor.md +36 -0
  65. package/dist/cli-templates/harness-commands/cursor/hs-ground.md +33 -0
  66. package/dist/cli-templates/harness-commands/cursor/hs-handoff.md +37 -0
  67. package/dist/cli-templates/harness-commands/cursor/hs-log.md +31 -0
  68. package/dist/cli-templates/harness-commands/cursor/hs-next.md +32 -0
  69. package/dist/cli-templates/harness-commands/cursor/hs-promote.md +73 -0
  70. package/dist/cli-templates/harness-commands/cursor/hs-push.md +38 -0
  71. package/dist/cli-templates/harness-commands/cursor/hs-qc.md +30 -0
  72. package/dist/cli-templates/harness-commands/cursor/hs-res.md +67 -0
  73. package/dist/cli-templates/harness-commands/cursor/hs-resolve.md +26 -0
  74. package/dist/cli-templates/harness-commands/cursor/hs-review.md +26 -0
  75. package/dist/cli-templates/harness-commands/cursor/hs-view.md +32 -0
  76. package/dist/cli-templates/harness-commands/cursor/kill-ports.md +35 -0
  77. package/dist/cli-templates/harness-commands/cursor/prototype.md +95 -0
  78. package/docs/copilot-plugin-install.md +162 -0
  79. package/docs/driving-heyspex-from-copilot.md +165 -0
  80. package/docs/mcp-client-config.md +259 -6
  81. package/package.json +249 -124
  82. package/plugins/heyspex/.claude-plugin/plugin.json +12 -0
  83. package/plugins/heyspex/.cursor-plugin/plugin.json +15 -0
  84. package/plugins/heyspex/.mcp.json +12 -0
  85. package/plugins/heyspex/README.md +309 -0
  86. package/plugins/heyspex/bin/launch-mcp.mjs +95 -0
  87. package/plugins/heyspex/bin/run-hook.mjs +80 -0
  88. package/plugins/heyspex/hooks/cursor-hooks.json +308 -0
  89. package/plugins/heyspex/hooks/hooks.json +418 -0
  90. package/plugins/heyspex/mcp.json +13 -0
  91. package/scripts/check-mcp-freshness.mjs +17 -9
  92. package/scripts/claude-hooks/_env-vault-events.mjs +68 -0
  93. package/scripts/claude-hooks/_guardrail-events.mjs +96 -0
  94. package/scripts/claude-hooks/_guardrail-loader.mjs +494 -0
  95. package/scripts/claude-hooks/_intent-classifier-router.mjs +456 -0
  96. package/scripts/claude-hooks/_intent-classifier-semantic.mjs +356 -0
  97. package/scripts/claude-hooks/_run.mjs +413 -0
  98. package/scripts/claude-hooks/_transcript-text.mjs +60 -0
  99. package/scripts/claude-hooks/_vault-retrieval.mjs +322 -0
  100. package/scripts/claude-hooks/agent-spawn-delegation-gate.mjs +133 -0
  101. package/scripts/claude-hooks/capture-execution-gate.mjs +51 -0
  102. package/scripts/claude-hooks/capture-workflow-gate.mjs +11 -0
  103. package/scripts/claude-hooks/check-auto-memory-budget.mjs +396 -0
  104. package/scripts/claude-hooks/check-claude-md-budget.mjs +327 -0
  105. package/scripts/claude-hooks/check-mcp-freshness-hook.mjs +14 -0
  106. package/scripts/claude-hooks/claim-before-code-gate.mjs +11 -0
  107. package/scripts/claude-hooks/claim-liveness-shared.mjs +577 -0
  108. package/scripts/claude-hooks/close-terminal-on-ship.mjs +64 -0
  109. package/scripts/claude-hooks/coo-denylist-gate.mjs +11 -0
  110. package/scripts/claude-hooks/coo-guard-check.mjs +11 -0
  111. package/scripts/claude-hooks/deep-research-cost-gate.mjs +11 -0
  112. package/scripts/claude-hooks/dev-server-task-reminder.mjs +15 -0
  113. package/scripts/claude-hooks/diagnose-capture-reminder.mjs +11 -0
  114. package/scripts/claude-hooks/directive-obligation-gate.mjs +11 -0
  115. package/scripts/claude-hooks/emit-claim-status.mjs +41 -0
  116. package/scripts/claude-hooks/enforce-session-sequence.mjs +11 -0
  117. package/scripts/claude-hooks/enforce-vault-naming.mjs +11 -0
  118. package/scripts/claude-hooks/env-vault-guard.mjs +11 -0
  119. package/scripts/claude-hooks/epic-milestone-phrasing-gate.mjs +11 -0
  120. package/scripts/claude-hooks/git-identity-guard.mjs +11 -0
  121. package/scripts/claude-hooks/git-reflog-audit.mjs +15 -0
  122. package/scripts/claude-hooks/git-stash-gate.mjs +11 -0
  123. package/scripts/claude-hooks/guard-auto-memory-writes.mjs +137 -0
  124. package/scripts/claude-hooks/id-display-format-gate.mjs +11 -0
  125. package/scripts/claude-hooks/inject-communication-style.mjs +86 -0
  126. package/scripts/claude-hooks/inject-coo-seat.mjs +78 -0
  127. package/scripts/claude-hooks/inject-dev-server-awareness.mjs +11 -0
  128. package/scripts/claude-hooks/inject-doctrine.mjs +13 -0
  129. package/scripts/claude-hooks/inject-vault-context.mjs +14 -0
  130. package/scripts/claude-hooks/intent-classifier-trigger.mjs +279 -0
  131. package/scripts/claude-hooks/log-session-activity.mjs +341 -0
  132. package/scripts/claude-hooks/naughty-list-gate.mjs +11 -0
  133. package/scripts/claude-hooks/notify-status-flip.mjs +72 -0
  134. package/scripts/claude-hooks/plan-exit-ingest-gate.mjs +170 -0
  135. package/scripts/claude-hooks/plan-ingest-close-reminder.mjs +66 -0
  136. package/scripts/claude-hooks/plan-review-gate.mjs +11 -0
  137. package/scripts/claude-hooks/preflight-reminder.mjs +262 -0
  138. package/scripts/claude-hooks/protected-action-intent.mjs +221 -0
  139. package/scripts/claude-hooks/research-method-guard.mjs +398 -0
  140. package/scripts/claude-hooks/research-protocol-gate.mjs +11 -0
  141. package/scripts/claude-hooks/session-end.mjs +451 -0
  142. package/scripts/claude-hooks/settings-write-gate.mjs +11 -0
  143. package/scripts/claude-hooks/settlement-check-gate.mjs +39 -0
  144. package/scripts/claude-hooks/status-flip-detect.mjs +17 -0
  145. package/scripts/claude-hooks/terminal-close-shared.mjs +426 -0
  146. package/scripts/claude-hooks/terminal-close-watcher.mjs +122 -0
  147. package/scripts/claude-hooks/vault-mirror-catchup.mjs +11 -0
  148. package/scripts/claude-hooks/vault-write-tier-guard.mjs +11 -0
  149. package/scripts/claude-hooks/wip-checkpoint.mjs +302 -0
  150. package/scripts/cursor-hooks/manifest.mjs +281 -0
  151. package/scripts/dev-server-status.mjs +34 -13
  152. package/scripts/dev-server-stop.mjs +313 -0
  153. package/scripts/heyspex-mode.mjs +288 -0
  154. package/scripts/hs-auto-resolve.mjs +240 -0
  155. package/scripts/hs-auto-source.mjs +398 -0
  156. package/scripts/hs-auto.mjs +134 -0
  157. package/scripts/hs-capture-cli.mjs +326 -0
  158. package/scripts/hs-classifier.mjs +136 -0
  159. package/scripts/hs-companion.mjs +88 -0
  160. package/scripts/hs-gateway.mjs +115 -0
  161. package/scripts/hs-mode.mjs +125 -0
  162. package/scripts/hs-next.mjs +441 -0
  163. package/scripts/hs-settings.mjs +338 -0
  164. package/scripts/hs-view.mjs +496 -0
  165. package/scripts/hs-worktree.mjs +201 -0
  166. package/scripts/install-claude-code-hooks.mjs +272 -0
  167. package/scripts/install-cursor-hooks.mjs +471 -0
  168. package/scripts/lib/agent-env-markers.mjs +38 -0
  169. package/scripts/lib/console-url-resolver.mjs +199 -61
  170. package/scripts/lib/customer-kit.mjs +1203 -0
  171. package/scripts/lib/dev-mode.mjs +249 -32
  172. package/scripts/lib/directive-obligations.mjs +349 -0
  173. package/scripts/lib/heyspex-home.mjs +72 -0
  174. package/scripts/lib/hook-dispositions.mjs +210 -0
  175. package/scripts/lib/hook-vault-header.mjs +30 -0
  176. package/scripts/lib/hs-command.mjs +277 -0
  177. package/scripts/lib/hs-companion-settings.mjs +472 -0
  178. package/scripts/lib/hs-source-branch.mjs +196 -0
  179. package/scripts/lib/hs-source-commit.mjs +59 -5
  180. package/scripts/lib/hs-source-identity.mjs +526 -0
  181. package/scripts/lib/hs-source-log.mjs +13 -22
  182. package/scripts/lib/hs-source-preflight.mjs +18 -4
  183. package/scripts/lib/hs-source-push.mjs +386 -307
  184. package/scripts/lib/hs-worktree-args.mjs +115 -0
  185. package/scripts/lib/shared-path-helpers.mjs +134 -0
  186. package/scripts/lib/standalone-doctor.mjs +540 -3
  187. package/scripts/lib/subagent-git-lock.mjs +109 -0
  188. package/scripts/lib/template-pin.mjs +34 -0
  189. package/scripts/lib/window-placement.mjs +109 -0
  190. package/scripts/provenance-lib.mjs +91 -0
  191. package/scripts/stamp-provenance-on-edit.mjs +222 -0
  192. package/scripts/wip-ledger-health.mjs +653 -0
  193. package/spexharness/doctrine/baseline/SPEXCODE-DOCTRINE.baseline.json +29 -0
  194. package/spexharness/src/adapters/audit.mjs +197 -0
  195. package/spexharness/src/adapters/claude-code.mjs +449 -0
  196. package/spexharness/src/adapters/copilot.mjs +236 -0
  197. package/spexharness/src/adapters/detect-harness.mjs +34 -0
  198. package/spexharness/src/chat/tui/ansi.mjs +448 -0
  199. package/spexharness/src/chat/tui/palette.mjs +22 -0
  200. package/spexharness/src/chat/tui/select.mjs +208 -0
  201. package/spexharness/src/config-root.mjs +54 -0
  202. package/spexharness/src/context-budget.mjs +141 -0
  203. package/spexharness/src/dev-server-control.mjs +389 -0
  204. package/spexharness/src/doctrine/behaviour.mjs +371 -0
  205. package/spexharness/src/doctrine/corpus.mjs +733 -0
  206. package/spexharness/src/doctrine/inject.mjs +338 -0
  207. package/spexharness/src/doctrine/merge.mjs +184 -0
  208. package/spexharness/src/doctrine/sources.mjs +252 -0
  209. package/spexharness/src/doctrine/synthesize.mjs +401 -0
  210. package/spexharness/src/doctrine/vault-source.mjs +574 -0
  211. package/spexharness/src/doctrine/writer.mjs +176 -0
  212. package/spexharness/src/events.mjs +84 -0
  213. package/spexharness/src/gates/_shell-write-targets.mjs +77 -0
  214. package/spexharness/src/gates/capture-workflow-gate.mjs +202 -0
  215. package/spexharness/src/gates/claim-before-code-gate.decide.mjs +33 -0
  216. package/spexharness/src/gates/claim-before-code-gate.mjs +129 -0
  217. package/spexharness/src/gates/deep-research-cost-gate.mjs +275 -0
  218. package/spexharness/src/gates/diagnose-capture-reminder.mjs +112 -0
  219. package/spexharness/src/gates/directive-obligation-gate.decide.mjs +132 -0
  220. package/spexharness/src/gates/directive-obligation-gate.mjs +183 -0
  221. package/spexharness/src/gates/enforce-session-sequence.mjs +895 -0
  222. package/spexharness/src/gates/enforce-vault-naming.mjs +215 -0
  223. package/spexharness/src/gates/epic-milestone-phrasing-gate.mjs +233 -0
  224. package/spexharness/src/gates/git-identity-guard.decide.mjs +436 -0
  225. package/spexharness/src/gates/git-identity-guard.mjs +61 -0
  226. package/spexharness/src/gates/git-reflog-audit.mjs +151 -0
  227. package/spexharness/src/gates/git-stash-gate.decide.mjs +361 -0
  228. package/spexharness/src/gates/git-stash-gate.mjs +159 -0
  229. package/spexharness/src/gates/id-display-format-gate.mjs +190 -0
  230. package/spexharness/src/gates/inject-doctrine-gate.mjs +89 -0
  231. package/spexharness/src/gates/inject-vault-context.mjs +239 -0
  232. package/spexharness/src/gates/naughty-list-gate.mjs +213 -0
  233. package/spexharness/src/gates/plan-review-gate.mjs +233 -0
  234. package/spexharness/src/gates/preflight-reminder-gate.mjs +265 -0
  235. package/spexharness/src/gates/research-protocol-gate.mjs +175 -0
  236. package/spexharness/src/gates/settings-write-gate.decide.mjs +96 -0
  237. package/spexharness/src/gates/settings-write-gate.mjs +104 -0
  238. package/spexharness/src/gates/vault-mirror-catchup.mjs +124 -0
  239. package/spexharness/src/gates/vault-write-tier-guard.decide.mjs +125 -0
  240. package/spexharness/src/gates/vault-write-tier-guard.mjs +42 -0
  241. package/spexharness/src/git-reflog-audit.mjs +294 -0
  242. package/spexharness/src/intent-classifier.mjs +196 -0
  243. package/spexharness/src/intent-directive.mjs +626 -0
  244. package/spexharness/src/profile/learn.mjs +551 -0
  245. package/spexharness/src/profile/proposals.mjs +357 -0
  246. package/spexharness/src/profile/vault-inclusion.mjs +386 -0
  247. package/spexharness/src/redact.mjs +151 -0
  248. package/spexharness/src/rule-table.mjs +849 -0
  249. package/spexharness/src/seat-context.mjs +122 -0
  250. package/spexharness/src/session-bootstrap.mjs +512 -0
  251. package/spexharness/src/session-record.mjs +815 -0
  252. package/spexharness/src/token-inventory.mjs +207 -0
  253. package/vendor/heyspex-mcp/account-switch.mjs +96 -0
  254. package/vendor/heyspex-mcp/alignment-gate.mjs +315 -1
  255. package/vendor/heyspex-mcp/blocker-resolver.mjs +37 -2
  256. package/vendor/heyspex-mcp/breakdown-ladder.mjs +119 -4
  257. package/vendor/heyspex-mcp/breakdown-source.mjs +271 -0
  258. package/vendor/heyspex-mcp/breakdown-writer.mjs +23 -3
  259. package/vendor/heyspex-mcp/capture-buffer-patches.mjs +77 -8
  260. package/vendor/heyspex-mcp/capture-channel.mjs +55 -5
  261. package/vendor/heyspex-mcp/capture-console-client.mjs +117 -3
  262. package/vendor/heyspex-mcp/capture-focus.mjs +1061 -31
  263. package/vendor/heyspex-mcp/capture-outbox.mjs +15 -5
  264. package/vendor/heyspex-mcp/capture-sources.mjs +195 -0
  265. package/vendor/heyspex-mcp/capture-tools.mjs +1264 -0
  266. package/vendor/heyspex-mcp/claim-tools.mjs +1147 -0
  267. package/vendor/heyspex-mcp/cli-verbs.mjs +2074 -0
  268. package/vendor/heyspex-mcp/cli.mjs +207 -15524
  269. package/vendor/heyspex-mcp/close-branch-report.mjs +102 -0
  270. package/vendor/heyspex-mcp/cloud-tool-surface.mjs +128 -0
  271. package/vendor/heyspex-mcp/cloud-vault-writes.mjs +602 -0
  272. package/vendor/heyspex-mcp/config-seed.yaml +15 -0
  273. package/vendor/heyspex-mcp/config-settings.mjs +290 -7
  274. package/vendor/heyspex-mcp/console-fetch.mjs +822 -17
  275. package/vendor/heyspex-mcp/console-transport.mjs +172 -0
  276. package/vendor/heyspex-mcp/credentials.mjs +585 -0
  277. package/vendor/heyspex-mcp/defer-spec.mjs +44 -4
  278. package/vendor/heyspex-mcp/deferred-reclaim.mjs +29 -3
  279. package/vendor/heyspex-mcp/design-handlers.mjs +31 -0
  280. package/vendor/heyspex-mcp/design-tools.mjs +4 -1
  281. package/vendor/heyspex-mcp/dev-factory-bridge.mjs +4 -4
  282. package/vendor/heyspex-mcp/dev-mode-core.mjs +45 -0
  283. package/vendor/heyspex-mcp/dev-tools.mjs +131 -0
  284. package/vendor/heyspex-mcp/diagnose-handlers.mjs +54 -0
  285. package/vendor/heyspex-mcp/events-tools.mjs +207 -0
  286. package/vendor/heyspex-mcp/filing-tools.mjs +723 -0
  287. package/vendor/heyspex-mcp/fleet-handlers.mjs +37 -0
  288. package/vendor/heyspex-mcp/fleet-queue-tools.mjs +71 -15
  289. package/vendor/heyspex-mcp/focus-tools.mjs +190 -0
  290. package/vendor/heyspex-mcp/folder-trust-store.mjs +60 -0
  291. package/vendor/heyspex-mcp/http-transport.mjs +302 -0
  292. package/vendor/heyspex-mcp/list-tools.mjs +257 -0
  293. package/vendor/heyspex-mcp/login-handoff.mjs +15 -1
  294. package/vendor/heyspex-mcp/mcp-config.mjs +107 -0
  295. package/vendor/heyspex-mcp/memory-tools.mjs +523 -0
  296. package/vendor/heyspex-mcp/mvp-mode.mjs +53 -6
  297. package/vendor/heyspex-mcp/obligation-backstop.mjs +103 -0
  298. package/vendor/heyspex-mcp/occupied-keys.mjs +142 -0
  299. package/vendor/heyspex-mcp/on-deck-brief.mjs +3377 -0
  300. package/vendor/heyspex-mcp/on-deck-scheduler.mjs +1708 -0
  301. package/vendor/heyspex-mcp/package-root.mjs +32 -0
  302. package/vendor/heyspex-mcp/peer-presence.mjs +8 -1
  303. package/vendor/heyspex-mcp/plan-ground.mjs +621 -652
  304. package/vendor/heyspex-mcp/plan-push.mjs +908 -22
  305. package/vendor/heyspex-mcp/plan-review.mjs +124 -30
  306. package/vendor/heyspex-mcp/preflight-gate.mjs +0 -0
  307. package/vendor/heyspex-mcp/procedure-handlers.mjs +327 -0
  308. package/vendor/heyspex-mcp/procedure-tools.mjs +1181 -970
  309. package/vendor/heyspex-mcp/promote-capture.mjs +388 -69
  310. package/vendor/heyspex-mcp/promote-diagnose.mjs +74 -0
  311. package/vendor/heyspex-mcp/promote-research-to-diagnose.mjs +9 -2
  312. package/vendor/heyspex-mcp/promote-research.mjs +666 -53
  313. package/vendor/heyspex-mcp/promotion-helpers.mjs +251 -0
  314. package/vendor/heyspex-mcp/prompts.mjs +1460 -0
  315. package/vendor/heyspex-mcp/read-path.mjs +191 -15
  316. package/vendor/heyspex-mcp/receipt-shape.mjs +146 -0
  317. package/vendor/heyspex-mcp/recipe-handlers.mjs +117 -0
  318. package/vendor/heyspex-mcp/regen-tasks.mjs +210 -3
  319. package/vendor/heyspex-mcp/register-spec.mjs +266 -54
  320. package/vendor/heyspex-mcp/request-context.mjs +83 -0
  321. package/vendor/heyspex-mcp/research-focus.mjs +207 -4
  322. package/vendor/heyspex-mcp/research-handlers.mjs +625 -0
  323. package/vendor/heyspex-mcp/research-tools.mjs +4 -4
  324. package/vendor/heyspex-mcp/response-budget.mjs +348 -0
  325. package/vendor/heyspex-mcp/review-focus.mjs +25 -1
  326. package/vendor/heyspex-mcp/roadmap-default.mjs +70 -0
  327. package/vendor/heyspex-mcp/search-specs.mjs +280 -0
  328. package/vendor/heyspex-mcp/seat-block-ledger.mjs +113 -0
  329. package/vendor/heyspex-mcp/seat-identity.mjs +2 -6
  330. package/vendor/heyspex-mcp/session-owner.mjs +109 -0
  331. package/vendor/heyspex-mcp/session-tools.mjs +162 -0
  332. package/vendor/heyspex-mcp/settings-registry.mjs +232 -0
  333. package/vendor/heyspex-mcp/settings-tools.mjs +85 -12
  334. package/vendor/heyspex-mcp/spec-edit-tools.mjs +453 -0
  335. package/vendor/heyspex-mcp/spec-id-lib.mjs +93 -17
  336. package/vendor/heyspex-mcp/split-research.mjs +304 -56
  337. package/vendor/heyspex-mcp/stdio-rpc.mjs +22 -0
  338. package/vendor/heyspex-mcp/submit-for-review.mjs +150 -7
  339. package/vendor/heyspex-mcp/summary-contract.mjs +105 -0
  340. package/vendor/heyspex-mcp/summary-write-tools.mjs +228 -0
  341. package/vendor/heyspex-mcp/symptom-query.mjs +146 -0
  342. package/vendor/heyspex-mcp/test-helpers.mjs +123 -1
  343. package/vendor/heyspex-mcp/tool-catalog.mjs +2819 -0
  344. package/vendor/heyspex-mcp/tool-dispatch.mjs +657 -0
  345. package/vendor/heyspex-mcp/tool-helpers.mjs +325 -0
  346. package/vendor/heyspex-mcp/validate-linkage.mjs +414 -27
  347. package/vendor/heyspex-mcp/vault-io.mjs +720 -0
  348. package/vendor/heyspex-mcp/vault-less-endpoints.mjs +52 -0
  349. package/vendor/heyspex-mcp/vault-resolve.mjs +554 -68
  350. package/vendor/heyspex-mcp/verify-reproduction.mjs +270 -30
  351. package/vendor/heyspex-mcp/whoami.mjs +92 -0
  352. package/vendor/heyspex-mcp/write-outbox.mjs +512 -0
@@ -0,0 +1,472 @@
1
+ /**
2
+ * hs companion settings — the CLI face of every Companion setting
3
+ * ([[HS-UDH1]] "every Companion setting and every console surface has three
4
+ * faces": settings screen, CLI, MCP).
5
+ *
6
+ * Talks to the SAME console endpoint the Settings > Companion screen uses
7
+ * (`GET`/`PUT /api/companion/settings`, see
8
+ * spex-console/src/app/api/companion/settings/route.ts) and sends the same
9
+ * partial-patch body the screen's `persist()` sends. Nothing here touches
10
+ * companion-settings.json directly — the console owns the file, its cache,
11
+ * and the normalisation.
12
+ *
13
+ * Transport: the shared vendor `makeConsoleFetch` (timeouts, loopback-host
14
+ * variants, dev-restart backoff, sign-in classification) bound to the ONE
15
+ * console-url resolver. Bearer ladder: `COMPANION_UI_TOKEN` env (the local
16
+ * route's optional loopback token) → the vendor `getAuthHeader` ladder
17
+ * (`HEYSPEX_MCP_TOKEN` env → `~/.heyspex/credentials.yaml` `mcp_token`).
18
+ *
19
+ * The console's store SILENTLY falls back to the previous value on a bad
20
+ * enum/type (sanitize() in companion-settings-store.ts — the route only
21
+ * 400s on a non-object body), so the CLI validates against the key table
22
+ * below BEFORE sending and then compares the echoed value with what it
23
+ * asked for; a console-side normalisation is reported, never hidden.
24
+ *
25
+ * Pure module: every function here is I/O-free except through the injected
26
+ * `consoleFetch`, so scripts/lib/hs-companion-settings.test.mjs can drive
27
+ * the whole thing with a fake. scripts/hs-companion.mjs is the process face.
28
+ */
29
+ import process from "node:process";
30
+
31
+ import { getAuthHeader, makeConsoleFetch } from "../../vendor/heyspex-mcp/console-fetch.mjs";
32
+ import { resolveConsoleUrl } from "./console-url-resolver.mjs";
33
+
34
+ export const SETTINGS_PATH = "/api/companion/settings";
35
+
36
+ /** `spexCrawl.userAgentPreset` ids — mirrors SPEXCRAWL_UA_PRESETS in
37
+ * spex-console/src/lib/spexcrawl-user-agents.ts, plus the "custom" escape. */
38
+ const SPEXCRAWL_UA_IDS = [
39
+ "googlebot-desktop", "googlebot-smartphone", "googlebot-image", "googlebot-news",
40
+ "bingbot", "bingbot-mobile", "duckduckbot", "yandexbot", "baiduspider",
41
+ "chrome-desktop", "chrome-android", "safari-macos", "safari-ios",
42
+ "firefox-desktop", "edge-desktop", "spexcrawl", "custom",
43
+ ];
44
+
45
+ /**
46
+ * One row per settable leaf. Mirrors `CompanionSettings` +
47
+ * `DEFAULT_COMPANION_SETTINGS` in spex-console/src/lib/companion-settings-defaults.ts
48
+ * and the accept-rules in companion-settings-store.ts `sanitize()`. Nested
49
+ * objects are flattened to dotted keys; `set` re-nests them into the partial
50
+ * patch the store merges over the current value.
51
+ *
52
+ * type enum | boolean | integer | number | string | list
53
+ * values enum — accepted case-insensitively, sent in canonical case
54
+ * min/max integer / number range (inclusive)
55
+ * nullable `null` / `none` clears the value
56
+ * readOnly companion-owned telemetry; listed, never settable
57
+ */
58
+ export const COMPANION_SETTING_KEYS = [
59
+ { key: "speechEngine", type: "enum", values: ["localParakeet", "localWhisper", "http", "pulse"] },
60
+ { key: "whisperModel", type: "enum", values: ["tiny.en", "base.en", "small.en"] },
61
+ { key: "micDevice", type: "string", note: "display label; selection is by micDeviceId" },
62
+ { key: "micDeviceId", type: "string", note: "capture device id (`list --json` shows devices)" },
63
+ { key: "pttHotkey", type: "string", note: "chord, e.g. Alt+Space" },
64
+ { key: "wakeWordEnabled", type: "boolean" },
65
+ { key: "activationMode", type: "enum", values: ["auto", "desktop", "vr"] },
66
+ { key: "vrPttHotkey", type: "string", note: "chord, or Mouse 4 / Mouse 5" },
67
+ { key: "vrWakeWordEnabled", type: "boolean" },
68
+ { key: "wakeWordThreshold", type: "number", min: 0, max: 1, note: "peak level treated as speech" },
69
+ { key: "confirmBeforeCapture", type: "boolean" },
70
+ { key: "orbVisibility", type: "enum", values: ["always", "while-listening", "hidden"] },
71
+ { key: "orbSize", type: "enum", values: ["small", "medium", "large"] },
72
+ { key: "theme", type: "enum", values: ["dark", "light", "system"] },
73
+ { key: "voiceProfile.present", type: "boolean", readOnly: true },
74
+ { key: "voiceProfile.lastCalibratedAt", type: "string", nullable: true, readOnly: true },
75
+ { key: "soundCuesEnabled", type: "boolean" },
76
+ { key: "soundCuesVolume", type: "integer", min: 0, max: 100 },
77
+ { key: "orbPositionX", type: "number", nullable: true, note: "screen x; null = no default" },
78
+ { key: "orbPositionY", type: "number", nullable: true, note: "screen y; null = no default" },
79
+ { key: "orbPositionLocked", type: "boolean" },
80
+ { key: "captureAutoLaunch.enabled", type: "boolean" },
81
+ { key: "captureAutoLaunch.defaultAgent", type: "string", nullable: true, note: "model-registry entry id" },
82
+ { key: "captureAutoLaunch.fallbackAgents", type: "list", note: "comma-separated entry ids" },
83
+ { key: "spexCrawl.userAgentPreset", type: "enum", values: SPEXCRAWL_UA_IDS },
84
+ { key: "spexCrawl.customUserAgent", type: "string", note: "used only when userAgentPreset=custom" },
85
+ { key: "spexCrawl.respectRobotsTxt", type: "boolean" },
86
+ { key: "spexCrawl.searxngBaseUrl", type: "string", nullable: true, note: "http(s) base URL of a SearXNG instance" },
87
+ ];
88
+
89
+ /**
90
+ * Short verbs — each is `settings set <key> <value>` under the hood. A row
91
+ * with `value` takes no argument (`mute`); a row with `arg` takes one, and
92
+ * called WITHOUT it reads the key instead (`hs companion volume` prints the
93
+ * volume). Adding an alias is one line here; help and dispatch derive from
94
+ * this table.
95
+ */
96
+ export const COMPANION_ALIASES = {
97
+ mute: { key: "soundCuesEnabled", value: "false", help: "Silence feedback cues" },
98
+ unmute: { key: "soundCuesEnabled", value: "true", help: "Feedback cues back on" },
99
+ volume: { key: "soundCuesVolume", arg: "<0-100>", help: "Feedback-cue loudness" },
100
+ orb: { key: "orbVisibility", arg: "always|while-listening|hidden", help: "Idle orb visibility" },
101
+ wake: { key: "wakeWordEnabled", arg: "on|off", help: "Wake-word listening" },
102
+ activation: { key: "activationMode", arg: "auto|desktop|vr", help: "Activation profile (desktop / VR goggle)" },
103
+ hotkey: { key: "pttHotkey", arg: "<chord>", help: "Push-to-talk chord, e.g. Alt+Space" },
104
+ };
105
+
106
+ export const HOST_COMMANDS = ["start", "stop", "status", "build"];
107
+
108
+ const TRUE_WORDS = new Set(["true", "on", "yes", "1", "enabled"]);
109
+ const FALSE_WORDS = new Set(["false", "off", "no", "0", "disabled"]);
110
+ const NULL_WORDS = new Set(["null", "none"]);
111
+
112
+ /** Find a key-table row by exact or case-insensitive name. */
113
+ export function findSettingKey(name) {
114
+ if (typeof name !== "string" || !name) return null;
115
+ const exact = COMPANION_SETTING_KEYS.find((k) => k.key === name);
116
+ if (exact) return exact;
117
+ const lower = name.toLowerCase();
118
+ return COMPANION_SETTING_KEYS.find((k) => k.key.toLowerCase() === lower) ?? null;
119
+ }
120
+
121
+ /** Human form of a row's constraint, for `list`/`get` and help. */
122
+ export function describeConstraint(entry) {
123
+ if (!entry) return "";
124
+ const parts = [];
125
+ if (entry.type === "enum") parts.push(entry.values.join("|"));
126
+ else if (entry.type === "boolean") parts.push("true|false");
127
+ else if (entry.type === "integer" || entry.type === "number") {
128
+ parts.push(
129
+ entry.min != null && entry.max != null
130
+ ? `${entry.min}-${entry.max}`
131
+ : entry.type,
132
+ );
133
+ } else parts.push(entry.type);
134
+ if (entry.nullable) parts.push("or null");
135
+ if (entry.readOnly) parts.push("(read-only)");
136
+ if (entry.note) parts.push(`— ${entry.note}`);
137
+ return parts.join(" ");
138
+ }
139
+
140
+ /**
141
+ * Parse the string a user typed into the JSON value the route expects.
142
+ * Returns `{ ok: true, value }` or `{ ok: false, error }` — the error is the
143
+ * message the CLI prints (and the reason the request is never sent).
144
+ */
145
+ export function coerceValue(entry, raw) {
146
+ const text = String(raw ?? "").trim();
147
+ const lower = text.toLowerCase();
148
+ const { key } = entry;
149
+
150
+ if (entry.nullable && NULL_WORDS.has(lower)) return { ok: true, value: null };
151
+
152
+ switch (entry.type) {
153
+ case "boolean": {
154
+ if (TRUE_WORDS.has(lower)) return { ok: true, value: true };
155
+ if (FALSE_WORDS.has(lower)) return { ok: true, value: false };
156
+ return { ok: false, error: `${key} expects true|false (also on|off, yes|no); got "${text}"` };
157
+ }
158
+ case "enum": {
159
+ const match = entry.values.find((v) => v.toLowerCase() === lower);
160
+ if (match) return { ok: true, value: match };
161
+ return { ok: false, error: `${key} expects one of ${entry.values.join("|")}; got "${text}"` };
162
+ }
163
+ case "integer":
164
+ case "number": {
165
+ const n = Number(text);
166
+ if (text === "" || !Number.isFinite(n)) {
167
+ return { ok: false, error: `${key} expects a ${entry.type}; got "${text}"` };
168
+ }
169
+ if (entry.type === "integer" && !Number.isInteger(n)) {
170
+ return { ok: false, error: `${key} expects a whole number; got "${text}"` };
171
+ }
172
+ if ((entry.min != null && n < entry.min) || (entry.max != null && n > entry.max)) {
173
+ return { ok: false, error: `${key} must be between ${entry.min} and ${entry.max}; got ${text}` };
174
+ }
175
+ return { ok: true, value: n };
176
+ }
177
+ case "list": {
178
+ const items = text.split(",").map((s) => s.trim()).filter(Boolean);
179
+ return { ok: true, value: items };
180
+ }
181
+ case "string":
182
+ default:
183
+ return { ok: true, value: text };
184
+ }
185
+ }
186
+
187
+ /** `"captureAutoLaunch.enabled", true` → `{ captureAutoLaunch: { enabled: true } }`. */
188
+ export function buildPatch(key, value) {
189
+ const parts = key.split(".");
190
+ const patch = {};
191
+ let cursor = patch;
192
+ for (let i = 0; i < parts.length - 1; i++) {
193
+ cursor[parts[i]] = {};
194
+ cursor = cursor[parts[i]];
195
+ }
196
+ cursor[parts[parts.length - 1]] = value;
197
+ return patch;
198
+ }
199
+
200
+ /** Read a dotted key out of the settings object; `undefined` when absent. */
201
+ export function getPath(obj, key) {
202
+ let cur = obj;
203
+ for (const part of key.split(".")) {
204
+ if (cur == null || typeof cur !== "object") return undefined;
205
+ cur = cur[part];
206
+ }
207
+ return cur;
208
+ }
209
+
210
+ /** Flatten nested settings objects to dotted keys (arrays stay values). */
211
+ export function flattenSettings(settings, prefix = "") {
212
+ const out = {};
213
+ for (const [k, v] of Object.entries(settings ?? {})) {
214
+ const full = prefix ? `${prefix}.${k}` : k;
215
+ if (v && typeof v === "object" && !Array.isArray(v)) Object.assign(out, flattenSettings(v, full));
216
+ else out[full] = v;
217
+ }
218
+ return out;
219
+ }
220
+
221
+ /**
222
+ * Parse everything after `hs companion`. Returns one of:
223
+ * { kind: "help", topic: "companion"|"settings" }
224
+ * { kind: "host", sub, rest } — start|stop|status|build
225
+ * { kind: "settings", action: "list", json, url }
226
+ * { kind: "settings", action: "get", key, json, url }
227
+ * { kind: "settings", action: "set", key, value, json, url }
228
+ * { error }
229
+ */
230
+ export function parseCompanionArgs(argv) {
231
+ const [head, ...tail] = argv;
232
+ if (head === undefined) return { kind: "host", sub: "start", rest: [] };
233
+ if (head === "--help" || head === "-h" || head === "help") return { kind: "help", topic: "companion" };
234
+ if (HOST_COMMANDS.includes(head)) return { kind: "host", sub: head, rest: tail };
235
+
236
+ const flags = { json: false, url: null, help: false };
237
+ const positional = [];
238
+ for (let i = 0; i < tail.length; i++) {
239
+ const a = tail[i];
240
+ if (a === "--json") flags.json = true;
241
+ else if (a === "--url") flags.url = tail[++i] ?? null;
242
+ else if (a.startsWith("--url=")) flags.url = a.slice("--url=".length);
243
+ else if (a === "--help" || a === "-h" || a === "help") flags.help = true;
244
+ else if (a.startsWith("--")) return { error: `unknown flag ${a}` };
245
+ else positional.push(a);
246
+ }
247
+
248
+ if (head === "settings") {
249
+ if (flags.help) return { kind: "help", topic: "settings" };
250
+ const [action = "list", ...args] = positional;
251
+ if (action === "list") return { kind: "settings", action: "list", json: flags.json, url: flags.url };
252
+ if (action === "get") {
253
+ if (!args[0]) return { error: "settings get needs a key (hs companion settings get <key>)" };
254
+ return { kind: "settings", action: "get", key: args[0], json: flags.json, url: flags.url };
255
+ }
256
+ if (action === "set") {
257
+ // `set key value` and `set key=value` both read naturally.
258
+ let [key, value] = args;
259
+ if (key && value === undefined && key.includes("=")) {
260
+ const eq = key.indexOf("=");
261
+ value = key.slice(eq + 1);
262
+ key = key.slice(0, eq);
263
+ }
264
+ if (!key || value === undefined) {
265
+ return { error: "settings set needs a key and a value (hs companion settings set <key> <value>)" };
266
+ }
267
+ return { kind: "settings", action: "set", key, value, json: flags.json, url: flags.url };
268
+ }
269
+ return { error: `unknown settings subcommand \`${action}\` (list|get|set)` };
270
+ }
271
+
272
+ const alias = COMPANION_ALIASES[head];
273
+ if (alias) {
274
+ if (flags.help) return { kind: "help", topic: "settings" };
275
+ const value = alias.value ?? positional[0];
276
+ if (value === undefined) {
277
+ return { kind: "settings", action: "get", key: alias.key, json: flags.json, url: flags.url, alias: head };
278
+ }
279
+ return { kind: "settings", action: "set", key: alias.key, value, json: flags.json, url: flags.url, alias: head };
280
+ }
281
+
282
+ return { error: `unknown subcommand \`${head}\`` };
283
+ }
284
+
285
+ /** Bearer for the settings route: loopback UI token first, then the shared MCP ladder. */
286
+ export function companionAuthHeader({ env = process.env, homeDir } = {}) {
287
+ const uiToken = env.COMPANION_UI_TOKEN?.trim();
288
+ if (uiToken) return { Authorization: `Bearer ${uiToken}` };
289
+ return getAuthHeader(homeDir ? { env, homeDir } : { env });
290
+ }
291
+
292
+ /**
293
+ * The console fetch this command uses: vendor transport + shared URL
294
+ * resolver + the bearer ladder above. `url` (from `--url`) short-circuits
295
+ * the resolver; `fetchImpl` is for tests.
296
+ */
297
+ export function makeCompanionConsoleFetch({ url = null, env = process.env, fetchImpl, repoRoot } = {}) {
298
+ const getConsoleUrl = () =>
299
+ (url ?? resolveConsoleUrl({ repoRoot: repoRoot ?? env.HS_VAULT_ROOT ?? process.cwd(), env }))
300
+ .replace(/\/+$/, "");
301
+ return makeConsoleFetch({
302
+ getConsoleUrl,
303
+ ...(fetchImpl ? { fetchImpl } : {}),
304
+ getAuthHeader: () => companionAuthHeader({ env }),
305
+ }).consoleFetch;
306
+ }
307
+
308
+ /** One-line failure text for a non-ok transport result; route errors verbatim. */
309
+ export function describeFailure(result, verb = "GET") {
310
+ if (!result || result.status === 0) {
311
+ const detail = result?.error ? ` (${result.error})` : "";
312
+ return result?.authMessage ?? `console unreachable at ${result?.url ?? "?"}${detail}`;
313
+ }
314
+ const body = result.body;
315
+ const msg =
316
+ (body && typeof body === "object" && (body.message ?? body.error ?? body.raw)) ??
317
+ (typeof body === "string" ? body : JSON.stringify(body));
318
+ return `${verb} ${SETTINGS_PATH} failed ${result.status}: ${msg}`;
319
+ }
320
+
321
+ function formatValue(v) {
322
+ if (v === undefined) return "(not reported by this console)";
323
+ if (v === null) return "null";
324
+ if (Array.isArray(v)) return v.length ? v.join(",") : "(empty)";
325
+ if (typeof v === "string") return v === "" ? '""' : v;
326
+ return String(v);
327
+ }
328
+
329
+ function pad(s, n) {
330
+ return s.length >= n ? s : s + " ".repeat(n - s.length);
331
+ }
332
+
333
+ /** Rows for `list`: the key table ∪ whatever the console reported. */
334
+ export function listRows(settings) {
335
+ const flat = flattenSettings(settings);
336
+ const rows = COMPANION_SETTING_KEYS.map((entry) => ({
337
+ key: entry.key,
338
+ value: flat[entry.key],
339
+ constraint: describeConstraint(entry),
340
+ entry,
341
+ }));
342
+ const known = new Set(COMPANION_SETTING_KEYS.map((k) => k.key));
343
+ for (const [key, value] of Object.entries(flat)) {
344
+ if (!known.has(key)) rows.push({ key, value, constraint: "(not in the CLI key table)", entry: null });
345
+ }
346
+ return rows;
347
+ }
348
+
349
+ export function renderList(rows, { consoleUrl, connected }) {
350
+ const width = Math.max(...rows.map((r) => r.key.length)) + 2;
351
+ const valueWidth = Math.max(...rows.map((r) => formatValue(r.value).length)) + 2;
352
+ const lines = [
353
+ `companion settings @ ${consoleUrl} (companion ${connected ? "connected" : "not connected"})`,
354
+ "",
355
+ ...rows.map((r) => `${pad(r.key, width)}${pad(formatValue(r.value), valueWidth)}${r.constraint}`),
356
+ "",
357
+ "set: hs companion settings set <key> <value> shortcuts: hs companion --help",
358
+ ];
359
+ return lines.join("\n");
360
+ }
361
+
362
+ function constraintJson(entry) {
363
+ if (!entry) return null;
364
+ const c = { type: entry.type };
365
+ if (entry.values) c.values = entry.values;
366
+ if (entry.min != null) c.min = entry.min;
367
+ if (entry.max != null) c.max = entry.max;
368
+ if (entry.nullable) c.nullable = true;
369
+ if (entry.readOnly) c.readOnly = true;
370
+ if (entry.note) c.note = entry.note;
371
+ return c;
372
+ }
373
+
374
+ /**
375
+ * Execute a parsed `settings` command against `consoleFetch` (vendor shape:
376
+ * `consoleFetch(path, { method, body })` → `{ ok, status, body, url }`).
377
+ * Returns `{ ok: true, stdout, exitCode: 0 }` or `{ ok: false, stderr, exitCode }`
378
+ * — the process face prints; nothing here writes to stdio.
379
+ */
380
+ export async function runCompanionSettings(cmd, { consoleFetch }) {
381
+ const fail = (stderr, exitCode = 1) => ({ ok: false, stderr, exitCode });
382
+ const emit = (stdout, json) => ({ ok: true, stdout: cmd.json ? JSON.stringify(json, null, 2) : stdout, exitCode: 0 });
383
+
384
+ if (cmd.action === "list") {
385
+ const res = await consoleFetch(SETTINGS_PATH);
386
+ if (!res.ok) return fail(describeFailure(res, "GET"));
387
+ const consoleUrl = res.url.replace(SETTINGS_PATH, "");
388
+ const rows = listRows(res.body?.settings ?? {});
389
+ const json = {
390
+ consoleUrl,
391
+ connected: res.body?.connected ?? false,
392
+ settings: Object.fromEntries(rows.map((r) => [r.key, r.value ?? null])),
393
+ constraints: Object.fromEntries(rows.filter((r) => r.entry).map((r) => [r.key, constraintJson(r.entry)])),
394
+ devices: res.body?.devices ?? [],
395
+ activation: res.body?.activation ?? null,
396
+ };
397
+ return emit(renderList(rows, json), json);
398
+ }
399
+
400
+ // get / set both need the key resolved.
401
+ const entry = findSettingKey(cmd.key);
402
+ if (cmd.action === "set") {
403
+ if (!entry) return fail(`unknown setting \`${cmd.key}\`\nkeys: ${COMPANION_SETTING_KEYS.filter((k) => !k.readOnly).map((k) => k.key).join(", ")}`, 2);
404
+ if (entry.readOnly) return fail(`${entry.key} is companion-owned (read-only)`, 2);
405
+ const coerced = coerceValue(entry, cmd.value);
406
+ if (!coerced.ok) return fail(coerced.error, 2);
407
+
408
+ const res = await consoleFetch(SETTINGS_PATH, { method: "PUT", body: buildPatch(entry.key, coerced.value) });
409
+ if (!res.ok) return fail(describeFailure(res, "PUT"));
410
+ const actual = getPath(res.body?.settings ?? {}, entry.key);
411
+ const same = JSON.stringify(actual) === JSON.stringify(coerced.value);
412
+ const json = { key: entry.key, value: actual ?? null, requested: coerced.value, normalized: !same };
413
+ if (!same) {
414
+ return fail(
415
+ `${entry.key} = ${formatValue(actual)} — the console kept/normalised the value instead of ${formatValue(coerced.value)}` +
416
+ (actual === undefined ? " (this console does not report that key; is it running the latest build?)" : ""),
417
+ );
418
+ }
419
+ return emit(`${entry.key} = ${formatValue(actual)}`, json);
420
+ }
421
+
422
+ // get
423
+ const res = await consoleFetch(SETTINGS_PATH);
424
+ if (!res.ok) return fail(describeFailure(res, "GET"));
425
+ const key = entry?.key ?? cmd.key;
426
+ const value = getPath(res.body?.settings ?? {}, key);
427
+ if (!entry && value === undefined) {
428
+ return fail(`unknown setting \`${cmd.key}\`\nkeys: ${COMPANION_SETTING_KEYS.map((k) => k.key).join(", ")}`, 2);
429
+ }
430
+ const json = { key, value: value ?? null, constraint: constraintJson(entry) };
431
+ const constraint = entry ? ` ${describeConstraint(entry)}` : "";
432
+ return emit(`${key} = ${formatValue(value)}${constraint}`, json);
433
+ }
434
+
435
+ /** Help text derived from the two tables so it cannot drift from them. */
436
+ export function renderHelp(topic = "companion") {
437
+ const settingsLines = [
438
+ "Settings (console-backed; --json on list/get for agents):",
439
+ " hs companion settings [list] [--json] Every key: current value + allowed values",
440
+ " hs companion settings get <key> [--json] One key",
441
+ " hs companion settings set <key> <value> Change one key (enums case-insensitive; booleans on|off|true|false; null clears)",
442
+ " hs companion settings --help Key table",
443
+ "",
444
+ "Shortcuts (each is `settings set` under the hood; omit the value to read it):",
445
+ ...Object.entries(COMPANION_ALIASES).map(([name, a]) => {
446
+ const usage = `hs companion ${name}${a.arg ? ` ${a.arg}` : ""}`;
447
+ return ` ${pad(usage, 50)}${a.help} (${a.key})`;
448
+ }),
449
+ "",
450
+ " --url <console> Override the resolved console URL for this call",
451
+ ];
452
+ if (topic === "settings") {
453
+ const width = Math.max(...COMPANION_SETTING_KEYS.map((k) => k.key.length)) + 2;
454
+ return [
455
+ ...settingsLines,
456
+ "",
457
+ "Keys:",
458
+ ...COMPANION_SETTING_KEYS.map((k) => ` ${pad(k.key, width)}${describeConstraint(k)}`),
459
+ ].join("\n");
460
+ }
461
+ return [
462
+ "Usage:",
463
+ " hs companion start [--build] Start the tray app (build first if missing)",
464
+ " hs companion stop Quit the tray app",
465
+ " hs companion status Is it running?",
466
+ " hs companion build dotnet build Debug",
467
+ "",
468
+ ...settingsLines,
469
+ "",
470
+ "Shortcut: npm run companion",
471
+ ].join("\n");
472
+ }
@@ -0,0 +1,196 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * hs-source-branch.mjs — `hs source branch declare|show`, plus
4
+ * `resolvePushBranch` used by `hs source push` when no branch is typed
5
+ * ([[HS-G01N-CWPX-TASK-hs-source-declares-the-session-branch]], epic
6
+ * [[HS-B8CY-CWPX-EPIC-one-session-one-reviewable-change]]).
7
+ *
8
+ * The branch DECISION stays on the server: `GET /api/source/session-branch`
9
+ * runs the one shared resolver (`resolveSessionTargetBranch`,
10
+ * spex-console/src/lib/git-export/branch-resolver.ts) — the same function the
11
+ * cloud-write drain calls. This module never re-implements ref validation or
12
+ * the dated-branch rule; it only declares, asks, and falls back to the local
13
+ * HEAD when the console cannot be reached (and says so).
14
+ *
15
+ * The HTTP client is injectable (`fetchImpl`) so tests never touch a network;
16
+ * console URL + bearer reuse the same idiom as `hs-source-log.mjs`.
17
+ */
18
+ import { spawnSync } from 'node:child_process';
19
+ import path from 'node:path';
20
+ import { fileURLToPath } from 'node:url';
21
+
22
+ import { resolveConsoleUrl } from './console-url-resolver.mjs';
23
+ import { bearerHeaderFor } from './hs-source-log.mjs';
24
+ import { hookVaultHeaders } from './hook-vault-header.mjs';
25
+
26
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
27
+ export const REPO_ROOT = path.resolve(__dirname, '..', '..');
28
+
29
+ const ENDPOINT = '/api/source/session-branch';
30
+
31
+ /**
32
+ * Current checked-out branch, or null (detached HEAD / git unavailable).
33
+ * @param {{ cwd?: string, run?: (cmd: string, args: string[], opts: object) => { status: number|null, stdout?: string } }} [opts]
34
+ * @returns {string | null}
35
+ */
36
+ export function currentGitBranch({ cwd = process.cwd(), run = spawnSync } = {}) {
37
+ try {
38
+ const r = run('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd, encoding: 'utf8' });
39
+ const out = (r.stdout ?? '').toString().trim();
40
+ if (r.status !== 0 || !out || out === 'HEAD') return null;
41
+ return out;
42
+ } catch {
43
+ return null;
44
+ }
45
+ }
46
+
47
+ async function callConsole({ method, body, fetchImpl, consoleUrl, repoRoot }) {
48
+ const base = consoleUrl ?? resolveConsoleUrl({ repoRoot });
49
+ const headers = { ...bearerHeaderFor(base), ...hookVaultHeaders() };
50
+ if (body !== undefined) headers['content-type'] = 'application/json';
51
+ const res = await fetchImpl(`${base}${ENDPOINT}`, {
52
+ method,
53
+ headers,
54
+ ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
55
+ });
56
+ let json = null;
57
+ try {
58
+ json = await res.json();
59
+ } catch {
60
+ json = null;
61
+ }
62
+ return { ok: res.ok, status: res.status, json, base };
63
+ }
64
+
65
+ /**
66
+ * Ask the console which branch this session's work should target.
67
+ * Never throws. `via: 'console'` carries the resolver's `source`
68
+ * ('session' | 'dated'); `via: 'git-head'` is the explicit offline fallback.
69
+ * @returns {Promise<{ branch: string, via: 'console'|'git-head', source?: string, note?: string } | { branch: null, via: 'none', note: string }>}
70
+ */
71
+ export async function resolvePushBranch({
72
+ fetchImpl = fetch,
73
+ cwd = process.cwd(),
74
+ consoleUrl,
75
+ repoRoot,
76
+ run,
77
+ } = {}) {
78
+ try {
79
+ const r = await callConsole({ method: 'GET', fetchImpl, consoleUrl, repoRoot });
80
+ const branch = r.ok ? r.json?.target?.branch : null;
81
+ if (typeof branch === 'string' && branch) {
82
+ return { branch, via: 'console', source: r.json.target.source };
83
+ }
84
+ } catch {
85
+ // fall through to the local fallback
86
+ }
87
+ const head = currentGitBranch({ cwd, run });
88
+ if (head) {
89
+ return {
90
+ branch: head,
91
+ via: 'git-head',
92
+ note: `console unreachable — pushing the checked-out branch \`${head}\` instead of the resolver's target`,
93
+ };
94
+ }
95
+ return { branch: null, via: 'none', note: 'console unreachable and no checked-out branch to fall back to' };
96
+ }
97
+
98
+ /**
99
+ * Spec commits the cloud drain made for this session, from the same console
100
+ * endpoint ([[HS-OAD2-CWPX-TASK-hs-source-status-names-the-spec-commits]]).
101
+ * Never throws. `known: false` means the console could not answer — callers
102
+ * must NOT present that as "no commits".
103
+ * @returns {Promise<{ known: true, commits: Array<{repo:string,branch:string,commitSha:string,settledAt:string,specIds:string[]}>, basis?: string } | { known: false }>}
104
+ */
105
+ export async function fetchDrainCommits({ fetchImpl = fetch, consoleUrl, repoRoot } = {}) {
106
+ try {
107
+ const r = await callConsole({ method: 'GET', fetchImpl, consoleUrl, repoRoot });
108
+ if (!r.ok || !Array.isArray(r.json?.drainCommits)) return { known: false };
109
+ return { known: true, commits: r.json.drainCommits, basis: r.json.window?.basis };
110
+ } catch {
111
+ return { known: false };
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Pure formatter for the `hs source status` section.
117
+ * @param {Awaited<ReturnType<typeof fetchDrainCommits>>} result
118
+ * @returns {string[]}
119
+ */
120
+ export function formatDrainCommits(result) {
121
+ const head = 'Cloud spec commits (this session):';
122
+ if (!result.known) return [head, ' unknown (console unreachable)'];
123
+ if (result.commits.length === 0) return [head, ' No cloud spec commits for this session.'];
124
+ const lines = [head];
125
+ for (const c of result.commits) {
126
+ lines.push(` ${c.branch} @ ${String(c.commitSha).slice(0, 7)} ${c.specIds.join(', ')}`);
127
+ }
128
+ if (result.basis === 'last_12h') lines.push(' (no branch declared — showing the last 12 hours)');
129
+ return lines;
130
+ }
131
+
132
+ /**
133
+ * `hs source branch declare [<branch>]` / `hs source branch show`.
134
+ * @param {string[]} argv args after the `branch` verb
135
+ * @returns {Promise<number>} exit code
136
+ */
137
+ export async function runBranchCommand(
138
+ argv,
139
+ { fetchImpl = fetch, cwd = process.cwd(), consoleUrl, repoRoot, run, out = console.log, err = console.error } = {},
140
+ ) {
141
+ const sub = argv[0] ?? 'show';
142
+
143
+ if (sub === 'show') {
144
+ try {
145
+ const r = await callConsole({ method: 'GET', fetchImpl, consoleUrl, repoRoot });
146
+ if (!r.ok || !r.json?.target) {
147
+ err(`hs source branch show: console answered ${r.status} — no target available.`);
148
+ return 1;
149
+ }
150
+ const t = r.json.target;
151
+ out(`target branch: ${t.branch} (${t.source === 'session' ? 'declared by this session' : 'dated fallback — no session branch declared'})`);
152
+ if (t.reason) out(`note: declared branch was refused — ${t.reason}`);
153
+ if (r.json.declaration) out(`declared ${r.json.declaration.declaredAt}, expires ${r.json.declaration.expiresAt}`);
154
+ return 0;
155
+ } catch (e) {
156
+ err(`hs source branch show: console unreachable (${e instanceof Error ? e.message : String(e)}).`);
157
+ return 1;
158
+ }
159
+ }
160
+
161
+ if (sub === 'declare') {
162
+ const branch = argv[1] ?? currentGitBranch({ cwd, run });
163
+ if (!branch) {
164
+ err('hs source branch declare: no branch given and no checked-out branch (detached HEAD?).');
165
+ return 1;
166
+ }
167
+ try {
168
+ const r = await callConsole({ method: 'POST', body: { branch }, fetchImpl, consoleUrl, repoRoot });
169
+ if (!r.ok) {
170
+ err(`hs source branch declare: refused — ${r.json?.reason ?? r.json?.error ?? `HTTP ${r.status}`}`);
171
+ return 1;
172
+ }
173
+ out(`declared session branch: ${r.json.declaration.branch} (expires ${r.json.declaration.expiresAt})`);
174
+ return 0;
175
+ } catch (e) {
176
+ err(`hs source branch declare: console unreachable (${e instanceof Error ? e.message : String(e)}).`);
177
+ return 1;
178
+ }
179
+ }
180
+
181
+ err(`hs source branch: unknown subcommand \`${sub}\` — expected declare|show.`);
182
+ return 2;
183
+ }
184
+
185
+ function invokedDirectly() {
186
+ try {
187
+ return path.resolve(fileURLToPath(import.meta.url)) === path.resolve(process.argv[1] ?? '');
188
+ } catch {
189
+ return false;
190
+ }
191
+ }
192
+
193
+ if (invokedDirectly()) {
194
+ // argv: [node, script, "branch", ...args]
195
+ runBranchCommand(process.argv.slice(3)).then((code) => process.exit(code));
196
+ }