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,448 @@
1
+ // spexharness/src/chat/tui/ansi.mjs
2
+ //
3
+ // Pure ANSI/VT drawing primitives for the `hs chat` TUI presenter (HS-TJYK T1). No I/O —
4
+ // every helper here takes plain values and returns a string; the caller (status-bar.mjs,
5
+ // welcome.mjs, presenter.mjs, ...) decides when and where to write it. This posture matches
6
+ // every other spexharness/src/chat/*.mjs module (see chat-sigint.mjs's header).
7
+ //
8
+ // Sequences implemented (epic [[HS-TJYK-8CMW-EPIC-interactive-shell-parity-heyspex]] §T1):
9
+ // sgr(...codes) CSI codes 'm' e.g. sgr(1) -> ESC[1m
10
+ // fg(hex, depth) 24-bit / 256-color / no-op foreground color
11
+ // bold / dim / reset SGR 1 / 2 / 0
12
+ // cursorTo(row, col) CSI row;col H
13
+ // cursorUp(n) CSI n A — relative cursor move, up n rows (no scroll)
14
+ // cursorDown(n) CSI n B — relative cursor move, down n rows (no scroll)
15
+ // cursorToCol(col) CSI col G — absolute column, current row unchanged
16
+ // saveCursor/restoreCursor DECSC/DECRC — ESC 7 / ESC 8 (NOT CSI sequences)
17
+ // setScrollRegion(t, b) DECSTBM — CSI t;b r
18
+ // resetScrollRegion CSI r
19
+ // clearLine CSI 2K
20
+ // clearScreenDown CSI J (erase from cursor to end of screen)
21
+ // enableBracketedPaste CSI ?2004 h
22
+ // disableBracketedPaste CSI ?2004 l
23
+ // hideCursor/showCursor CSI ?25l / CSI ?25h
24
+ // pickColorDepth(stdout, env) 24 | 8 | 0
25
+ // visibleWidth(text) terminal columns `text` occupies — Unicode/grapheme-aware, CSI ignored
26
+ // truncateVisible(text, cols) cut to `cols` columns without splitting a grapheme or an escape
27
+ //
28
+ // `fg`'s 256-color path uses the standard xterm 6x6x6 color-cube + grayscale-ramp mapping
29
+ // (the same nearest-approximation formula used by chalk/ansi-styles): each RGB channel is
30
+ // quantized to a 0-5 cube step, or — when r === g === b — mapped onto the 24-step grayscale
31
+ // ramp (indices 232-255) instead, since that ramp resolves neutrals far better than the
32
+ // color cube's corners. This is a reasonable-default choice the task spec left open; no other
33
+ // convention exists elsewhere in this repo to match.
34
+
35
+ /** ESC (0x1b). Exported so callers building their own raw sequences share one constant. */
36
+ export const ESC = '';
37
+
38
+ /** CSI <codes joined by ';'> m — e.g. sgr(1, 32) -> ESC[1;32m. sgr() with no args -> ESC[m. */
39
+ export function sgr(...codes) {
40
+ return `${ESC}[${codes.join(';')}m`;
41
+ }
42
+
43
+ /** SGR bold (1), dim (2), and the reset-all sequence (0). Constants, not functions — the
44
+ * other named sequences below (cursorTo, setScrollRegion, ...) are functions only because
45
+ * they take arguments; these three don't, so they stay plain strings for callers to inline. */
46
+ export const bold = sgr(1);
47
+ export const dim = sgr(2);
48
+ export const reset = sgr(0);
49
+
50
+ function hexToRgb(hex) {
51
+ const clean = hex.startsWith('#') ? hex.slice(1) : hex;
52
+ const r = parseInt(clean.slice(0, 2), 16);
53
+ const g = parseInt(clean.slice(2, 4), 16);
54
+ const b = parseInt(clean.slice(4, 6), 16);
55
+ return { r, g, b };
56
+ }
57
+
58
+ /** Nearest xterm-256 palette index for an RGB triple — 6x6x6 color cube (16-231) with a
59
+ * 24-step grayscale ramp (232-255) used for near-neutral colors. Standard chalk/ansi-styles
60
+ * formula. */
61
+ function rgbToAnsi256(r, g, b) {
62
+ if (r === g && g === b) {
63
+ if (r < 8) return 16;
64
+ if (r > 248) return 231;
65
+ return Math.round(((r - 8) / 247) * 24) + 232;
66
+ }
67
+ return (
68
+ 16 +
69
+ 36 * Math.round((r / 255) * 5) +
70
+ 6 * Math.round((g / 255) * 5) +
71
+ Math.round((b / 255) * 5)
72
+ );
73
+ }
74
+
75
+ /**
76
+ * Foreground color escape for a hex color at a given color depth.
77
+ * depth 24 -> CSI 38;2;R;G;B m (truecolor)
78
+ * depth 8 -> CSI 38;5;<nearest xterm-256 index> m
79
+ * depth 0 -> '' (no color support — caller should render plain text)
80
+ */
81
+ export function fg(hex, depth) {
82
+ if (depth === 24) {
83
+ const { r, g, b } = hexToRgb(hex);
84
+ return `${ESC}[38;2;${r};${g};${b}m`;
85
+ }
86
+ if (depth === 8) {
87
+ const { r, g, b } = hexToRgb(hex);
88
+ return `${ESC}[38;5;${rgbToAnsi256(r, g, b)}m`;
89
+ }
90
+ return '';
91
+ }
92
+
93
+ /**
94
+ * Background color escape for a hex color at a given color depth — same three-way split as
95
+ * `fg` (depth 24 truecolor / depth 8 nearest-256 / depth 0 no-op), CSI 48 instead of 38.
96
+ * input-box.mjs's submitted-input transcript line uses this for its "highlighted" `❯ text`
97
+ * segment (HS-05L9 T10, dock-anchored layout).
98
+ */
99
+ export function bg(hex, depth) {
100
+ if (depth === 24) {
101
+ const { r, g, b } = hexToRgb(hex);
102
+ return `${ESC}[48;2;${r};${g};${b}m`;
103
+ }
104
+ if (depth === 8) {
105
+ const { r, g, b } = hexToRgb(hex);
106
+ return `${ESC}[48;5;${rgbToAnsi256(r, g, b)}m`;
107
+ }
108
+ return '';
109
+ }
110
+
111
+ /** CSI row;col H — absolute cursor position (1-indexed, as the terminal expects). */
112
+ export function cursorTo(row, col) {
113
+ return `${ESC}[${row};${col}H`;
114
+ }
115
+
116
+ /** CSI n A — move the cursor UP n rows (default 1), relative to its current position.
117
+ * Never scrolls (unlike a bare '\n' at the bottom row) — status-bar.mjs's DECSTBM-homing
118
+ * fix pairs this with a leading '\n' precisely to get that "scroll only if already at the
119
+ * last row" behavior; cursorUp alone never scrolls either way. */
120
+ export function cursorUp(n = 1) {
121
+ return `${ESC}[${n}A`;
122
+ }
123
+
124
+ /** CSI n B — move the cursor DOWN n rows (default 1), relative to its current position. The
125
+ * mirror image of `cursorUp` — HS-U4AJ (T11)'s slash-menu wiring in input-box.mjs uses this
126
+ * to reverse `positionOnActiveRow()`'s upward move (go from the active editing row back down
127
+ * to the dock's own resting position) before a menu-driven mid-edit dock rewrite. Never
128
+ * scrolls, same as `cursorUp`. */
129
+ export function cursorDown(n = 1) {
130
+ return `${ESC}[${n}B`;
131
+ }
132
+
133
+ /** CSI col G — move the cursor to an absolute COLUMN on the current row (row unchanged). */
134
+ export function cursorToCol(col) {
135
+ return `${ESC}[${col}G`;
136
+ }
137
+
138
+ /** DECSC — save cursor position (ESC 7, not a CSI sequence). */
139
+ export const saveCursor = `${ESC}7`;
140
+
141
+ /** DECRC — restore cursor position (ESC 8, not a CSI sequence). */
142
+ export const restoreCursor = `${ESC}8`;
143
+
144
+ /** DECSTBM — set the scrolling region to rows top..bottom (CSI top;bottom r). */
145
+ export function setScrollRegion(top, bottom) {
146
+ return `${ESC}[${top};${bottom}r`;
147
+ }
148
+
149
+ /** DECSTBM with no operands — reset the scrolling region to the full screen. */
150
+ export const resetScrollRegion = `${ESC}[r`;
151
+
152
+ /** CSI 2K — clear the entire current line (cursor position unchanged). */
153
+ export const clearLine = `${ESC}[2K`;
154
+
155
+ /** CSI J — erase from the cursor to the end of the screen (cursor position unchanged). */
156
+ export const clearScreenDown = `${ESC}[J`;
157
+
158
+ /** CSI 2J (erase the entire visible screen) + CSI 3J (erase the terminal's own scrollback —
159
+ * an xterm extension, harmless where unsupported) + CSI H (home the cursor to row 1, col 1).
160
+ * The `/clear` built-in's sequence (HS-MW9H, T12) — clears what mouse-wheel/Page Up would
161
+ * still scroll back to, not just the current viewport, then homes the cursor so whatever
162
+ * redraws next (the dock) starts at the top-left. `builtins.mjs` writes this through
163
+ * `presenter.notice()`, never straight to `stdout`, so the TTY presenter's dock still gets
164
+ * its own redraw immediately after (dock.mjs's `writeAbove` contract). */
165
+ export const clearScreen = `${ESC}[2J${ESC}[3J${ESC}[H`;
166
+
167
+ /** Newlines enough to walk the cursor from row 1 to the LAST row of a `rows`-tall viewport.
168
+ *
169
+ * WHY (2026-09-14, owner report: "the text box should be anchored to the bottom of the screen
170
+ * like Claude Code"): the dock (dock.mjs) is a FLOW renderer by design — it has no scroll
171
+ * region and no absolute positioning, so it renders wherever the cursor happens to be, i.e.
172
+ * immediately under the transcript. On a fresh session that transcript is only the welcome
173
+ * header, so on a tall terminal the input box + status bar floated in the middle of the
174
+ * screen with dead space below them and a caret parked in the void.
175
+ *
176
+ * The fix needs no layout engine: fill the viewport ONCE at session open so the cursor is
177
+ * already sitting on the last row before the header is written. Every write from then on
178
+ * scrolls the screen instead of advancing down it, which pins the dock's last line to the
179
+ * bottom row for the whole session — the header and transcript grow UPWARD out of the bottom,
180
+ * exactly the shape Claude Code has. Blank rows fall off the top as real content arrives.
181
+ *
182
+ * Deliberately newlines rather than `cursorTo(rows, 1)`: a newline on the last row scrolls on
183
+ * every terminal there is, whereas absolute positioning is the class of sequence this whole
184
+ * renderer was rewritten to avoid depending on (see dock.mjs's header).
185
+ *
186
+ * @param {number} rows - the terminal's height (`stdout.rows`). Non-finite/<= 1 yields ''.
187
+ */
188
+ export function fillViewport(rows) {
189
+ const n = Number(rows);
190
+ if (!Number.isFinite(n) || n <= 1) return '';
191
+ return '\n'.repeat(Math.floor(n) - 1);
192
+ }
193
+
194
+ /** CSI ?2004 h / l — enable/disable bracketed-paste reporting (DECSET/DECRST 2004). Opportunistic
195
+ * per the T9 research recommendation: emit it, parse the markers if they arrive, never depend
196
+ * on it (conhost support is unsettled; Windows Terminal honours it when announced). */
197
+ export const enableBracketedPaste = `${ESC}[?2004h`;
198
+ export const disableBracketedPaste = `${ESC}[?2004l`;
199
+
200
+ /** CSI ?25l — hide the text cursor. */
201
+ export const hideCursor = `${ESC}[?25l`;
202
+
203
+ /** CSI ?25h — show the text cursor. */
204
+ export const showCursor = `${ESC}[?25h`;
205
+
206
+ /**
207
+ * Pick the color depth to render at: 24 (truecolor), 8 (256-color), or 0 (no color).
208
+ *
209
+ * @param {{ isTTY?: boolean, hasColors?: (n: number) => boolean }} stdout
210
+ * @param {Record<string, string | undefined>} env
211
+ * @returns {24 | 8 | 0}
212
+ */
213
+ // --- Visible width (Unicode-aware column counting) -----------------------------------------
214
+ //
215
+ // The shell-parity epic (HS-TJYK) left "Unicode width on Windows Terminal/conhost" as an
216
+ // unsettled test target: `truncateVisible` used to count one column per UTF-16 code unit, so
217
+ // any line carrying CJK, emoji, combining marks or the U+E000 HeySpex glyph was cut at the
218
+ // wrong column — and because dock.mjs's erase-by-height math trusts the truncated line to fit
219
+ // in `stdout.columns`, a line that then wrapped left a stale row behind on every redraw.
220
+ //
221
+ // What counts as what (`visibleWidth`):
222
+ // CSI escape sequences (`ESC [ params intermediates final`, SGR included) 0 — stepped over
223
+ // East Asian Wide / Fullwidth code points (CJK, Hangul syllables, kana,
224
+ // fullwidth forms, wide symbols/emoji — compact range table below, plus
225
+ // the engine's own `\p{Emoji_Presentation}` so new emoji stay wide) 2
226
+ // Combining marks (Mn/Me/Mc), format chars (Cf: ZWJ, ZWNJ, ZWSP, BOM,
227
+ // word joiner...), variation selectors, Hangul Jungseong/Jongseong jamo 0
228
+ // Private-use code points (U+E000–U+F8FF, planes 15/16) 1 — the
229
+ // HeySpex icon font renders them single-cell; they are EAW "Ambiguous", so
230
+ // they never appear in the wide table
231
+ // Everything else (ASCII, Latin, controls, ...) 1
232
+ //
233
+ // Widths are summed per GRAPHEME, not per code point: `Intl.Segmenter` (granularity
234
+ // 'grapheme', Node 24) groups an emoji ZWJ sequence (👨‍👩‍👧), a base + combining accent (é as
235
+ // e + U+0301), a flag (regional-indicator pair) or a keycap into one cluster, whose width is
236
+ // the widest of its parts — so a family emoji is ONE 2-column grapheme, `e + U+0301` is 1, and
237
+ // a text-default symbol forced to emoji presentation by VS16 (❤ + U+FE0F) is 2, matching
238
+ // what Windows Terminal actually draws. A grapheme that is pure ASCII (e.g. "\r\n", which the
239
+ // segmenter also clusters) keeps one column per code unit, so plain-ASCII behavior is exactly
240
+ // what it was before this landed.
241
+
242
+ /** One complete CSI sequence at the start of the string: ESC `[`, parameter bytes (0x30–0x3F),
243
+ * intermediate bytes (0x20–0x2F), one final byte (0x40–0x7E). SGR (`ESC[...m`) is the only
244
+ * shape status-bar.mjs / dock.mjs actually wrap text in, but any CSI is stepped over whole
245
+ * so a truncation point can never land mid-escape. Other ESC-prefixed shapes (DECSC `ESC 7`,
246
+ * OSC) are never part of line content and fall through as ordinary characters. */
247
+ const CSI_RE = /^\x1b\[[0-?]*[ -/]*[@-~]/;
248
+ const CSI_RE_G = /\x1b\[[0-?]*[ -/]*[@-~]/g;
249
+
250
+ /** Sorted, non-overlapping [first, last] ranges of East Asian Width W (Wide) and F (Fullwidth)
251
+ * code points (EastAsianWidth.txt, Unicode 15.1), merged where adjacent. Looked up by binary
252
+ * search. Ambiguous ("A") widths — box drawing, Greek/Cyrillic, the private-use area — are
253
+ * deliberately NOT here: Windows Terminal and conhost render them single-cell. */
254
+ const WIDE_RANGES = [
255
+ [0x1100, 0x115f], // Hangul Jamo (choseong)
256
+ [0x231a, 0x231b], [0x2329, 0x232a], [0x23e9, 0x23ec], [0x23f0, 0x23f0], [0x23f3, 0x23f3],
257
+ [0x25fd, 0x25fe], [0x2614, 0x2615], [0x2648, 0x2653], [0x267f, 0x267f], [0x2693, 0x2693],
258
+ [0x26a1, 0x26a1], [0x26aa, 0x26ab], [0x26bd, 0x26be], [0x26c4, 0x26c5], [0x26ce, 0x26ce],
259
+ [0x26d4, 0x26d4], [0x26ea, 0x26ea], [0x26f2, 0x26f3], [0x26f5, 0x26f5], [0x26fa, 0x26fa],
260
+ [0x26fd, 0x26fd], [0x2705, 0x2705], [0x270a, 0x270b], [0x2728, 0x2728], [0x274c, 0x274c],
261
+ [0x274e, 0x274e], [0x2753, 0x2755], [0x2757, 0x2757], [0x2795, 0x2797], [0x27b0, 0x27b0],
262
+ [0x27bf, 0x27bf], [0x2b1b, 0x2b1c], [0x2b50, 0x2b50], [0x2b55, 0x2b55],
263
+ [0x2e80, 0x2e99], [0x2e9b, 0x2ef3], [0x2f00, 0x2fd5], [0x2ff0, 0x2fff], // CJK radicals, IDC
264
+ [0x3000, 0x303e], [0x3041, 0x3096], [0x3099, 0x30ff], // CJK punctuation, hiragana, katakana
265
+ [0x3105, 0x312f], [0x3131, 0x318e], [0x3190, 0x31e5], [0x31ef, 0x321e], [0x3220, 0x3247],
266
+ [0x3250, 0x4dbf], [0x4e00, 0xa48c], [0xa490, 0xa4c6], [0xa960, 0xa97c], // CJK unified, Yi
267
+ [0xac00, 0xd7a3], // Hangul syllables
268
+ [0xf900, 0xfaff], [0xfe10, 0xfe19], [0xfe30, 0xfe52], [0xfe54, 0xfe66], [0xfe68, 0xfe6b],
269
+ [0xff01, 0xff60], [0xffe0, 0xffe6], // fullwidth forms
270
+ [0x16fe0, 0x16fe4], [0x16ff0, 0x16ff1], [0x17000, 0x187f7], [0x18800, 0x18cd5],
271
+ [0x18cff, 0x18d08], [0x1aff0, 0x1aff3], [0x1aff5, 0x1affb], [0x1affd, 0x1affe],
272
+ [0x1b000, 0x1b122], [0x1b132, 0x1b132], [0x1b150, 0x1b152], [0x1b155, 0x1b155],
273
+ [0x1b164, 0x1b167], [0x1b170, 0x1b2fb], // Tangut, Khitan, Nushu, kana supplements
274
+ [0x1f004, 0x1f004], [0x1f0cf, 0x1f0cf], [0x1f18e, 0x1f18e], [0x1f191, 0x1f19a],
275
+ [0x1f200, 0x1f202], [0x1f210, 0x1f23b], [0x1f240, 0x1f248], [0x1f250, 0x1f251],
276
+ [0x1f260, 0x1f265], [0x1f300, 0x1f320], [0x1f32d, 0x1f335], [0x1f337, 0x1f37c],
277
+ [0x1f37e, 0x1f393], [0x1f3a0, 0x1f3ca], [0x1f3cf, 0x1f3d3], [0x1f3e0, 0x1f3f0],
278
+ [0x1f3f4, 0x1f3f4], [0x1f3f8, 0x1f43e], [0x1f440, 0x1f440], [0x1f442, 0x1f4fc],
279
+ [0x1f4ff, 0x1f53d], [0x1f54b, 0x1f54e], [0x1f550, 0x1f567], [0x1f57a, 0x1f57a],
280
+ [0x1f595, 0x1f596], [0x1f5a4, 0x1f5a4], [0x1f5fb, 0x1f64f], [0x1f680, 0x1f6c5],
281
+ [0x1f6cc, 0x1f6cc], [0x1f6d0, 0x1f6d2], [0x1f6d5, 0x1f6d7], [0x1f6dc, 0x1f6df],
282
+ [0x1f6eb, 0x1f6ec], [0x1f6f4, 0x1f6fc], [0x1f7e0, 0x1f7eb], [0x1f7f0, 0x1f7f0],
283
+ [0x1f90c, 0x1f93a], [0x1f93c, 0x1f945], [0x1f947, 0x1f9ff], [0x1fa70, 0x1fa7c],
284
+ [0x1fa80, 0x1fa89], [0x1fa90, 0x1fabe], [0x1fabf, 0x1fac6], [0x1face, 0x1fadc],
285
+ [0x1fae0, 0x1fae9], [0x1faf0, 0x1faf8], // emoji blocks
286
+ [0x20000, 0x2fffd], [0x30000, 0x3fffd], // CJK extensions B+
287
+ ];
288
+
289
+ /** Emoji the Unicode data says default to emoji (2-cell) presentation. The engine's own
290
+ * property tables (ICU) — a forward-compat net for emoji minted after WIDE_RANGES was written. */
291
+ const EMOJI_PRESENTATION_RE = /^\p{Emoji_Presentation}$/u;
292
+
293
+ /** Code points that occupy no column of their own: nonspacing/enclosing/spacing combining marks
294
+ * (Mn/Me/Mc — includes every variation selector U+FE00–FE0F and U+E0100–E01EF), format
295
+ * characters (Cf — ZWJ U+200D, ZWNJ, ZWSP, BOM, tag characters used by subdivision flags...). */
296
+ const ZERO_WIDTH_RE = /^[\p{Mn}\p{Me}\p{Mc}\p{Cf}]$/u;
297
+
298
+ /** Extended_Pictographic base + VS16 (U+FE0F) = forced emoji presentation, 2 cells. */
299
+ const PICTOGRAPHIC_RE = /^\p{Extended_Pictographic}$/u;
300
+ const REGIONAL_INDICATOR_RE = /^\p{Regional_Indicator}$/u;
301
+
302
+ let graphemeSegmenter = null;
303
+ function segmenter() {
304
+ if (graphemeSegmenter === null) {
305
+ graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
306
+ }
307
+ return graphemeSegmenter;
308
+ }
309
+
310
+ function isWideCodePoint(cp) {
311
+ if (cp < 0x1100) return false;
312
+ let lo = 0;
313
+ let hi = WIDE_RANGES.length - 1;
314
+ while (lo <= hi) {
315
+ const mid = (lo + hi) >> 1;
316
+ const [first, last] = WIDE_RANGES[mid];
317
+ if (cp < first) hi = mid - 1;
318
+ else if (cp > last) lo = mid + 1;
319
+ else return true;
320
+ }
321
+ return EMOJI_PRESENTATION_RE.test(String.fromCodePoint(cp));
322
+ }
323
+
324
+ function isZeroWidthCodePoint(cp) {
325
+ if (cp < 0x300) return false; // ASCII + Latin-1: always one column (matches the old rule)
326
+ if (cp >= 0x1160 && cp <= 0x11ff) return true; // Hangul Jungseong/Jongseong (conjoining jamo)
327
+ if (cp >= 0xd7b0 && cp <= 0xd7ff) return true; // Hangul Jamo Extended-B (same)
328
+ return ZERO_WIDTH_RE.test(String.fromCodePoint(cp));
329
+ }
330
+
331
+ /** Column width of ONE grapheme cluster (no escape sequences inside). */
332
+ function graphemeWidth(g) {
333
+ // Pure-ASCII cluster (a single char, or "\r\n"): one column per code unit — the pre-Unicode
334
+ // rule, preserved byte-for-byte.
335
+ let ascii = true;
336
+ for (let i = 0; i < g.length; i += 1) {
337
+ if (g.charCodeAt(i) > 0x7f) { ascii = false; break; }
338
+ }
339
+ if (ascii) return g.length;
340
+
341
+ let width = 0;
342
+ let sawVS16 = false;
343
+ let sawPictographic = false;
344
+ for (const ch of g) {
345
+ const cp = ch.codePointAt(0);
346
+ if (cp === 0xfe0f) { sawVS16 = true; continue; }
347
+ if (cp === 0x20e3) return 2; // COMBINING ENCLOSING KEYCAP: 1️⃣ renders 2 cells
348
+ if (isZeroWidthCodePoint(cp)) continue;
349
+ if (REGIONAL_INDICATOR_RE.test(ch)) return 2; // flag pair (or a lone RI) — one wide cell
350
+ if (isWideCodePoint(cp)) { width = 2; continue; }
351
+ if (!sawPictographic && PICTOGRAPHIC_RE.test(ch)) sawPictographic = true;
352
+ if (width < 1) width = 1;
353
+ }
354
+ if (sawVS16 && sawPictographic) return 2;
355
+ return width;
356
+ }
357
+
358
+ /** Split `text` into alternating plain / escape tokens: `{ esc: true, s }` for each CSI
359
+ * sequence, `{ esc: false, s }` for the plain runs between them (each run then segmented
360
+ * into graphemes by the caller). */
361
+ function* tokens(text) {
362
+ CSI_RE_G.lastIndex = 0;
363
+ let last = 0;
364
+ let m;
365
+ while ((m = CSI_RE_G.exec(text)) !== null) {
366
+ if (m.index > last) yield { esc: false, s: text.slice(last, m.index) };
367
+ yield { esc: true, s: m[0] };
368
+ last = m.index + m[0].length;
369
+ }
370
+ if (last < text.length) yield { esc: false, s: text.slice(last) };
371
+ }
372
+
373
+ function* graphemes(run) {
374
+ // Fast path: an all-ASCII run is one grapheme per code unit (the pre-Unicode rule) — skip
375
+ // the segmenter, which would otherwise fuse "\r\n" and change plain-ASCII results.
376
+ let ascii = true;
377
+ for (let i = 0; i < run.length; i += 1) {
378
+ if (run.charCodeAt(i) > 0x7f) { ascii = false; break; }
379
+ }
380
+ if (ascii) {
381
+ for (let i = 0; i < run.length; i += 1) yield run[i];
382
+ return;
383
+ }
384
+ for (const { segment } of segmenter().segment(run)) yield segment;
385
+ }
386
+
387
+ /**
388
+ * Number of terminal columns `text` occupies when printed — CSI escape sequences ignored,
389
+ * East Asian Wide/Fullwidth graphemes (and emoji, including ZWJ sequences) 2, combining marks /
390
+ * format characters / variation selectors 0, private-use glyphs (U+E000–F8FF) 1, everything
391
+ * else 1. See the section header above for the full table.
392
+ */
393
+ export function visibleWidth(text) {
394
+ const s = String(text ?? '');
395
+ let width = 0;
396
+ for (const tok of tokens(s)) {
397
+ if (tok.esc) continue;
398
+ for (const g of graphemes(tok.s)) width += graphemeWidth(g);
399
+ }
400
+ return width;
401
+ }
402
+
403
+ /**
404
+ * Truncate `text` to `cols` VISIBLE columns — ANSI CSI/SGR sequences are copied through in full
405
+ * and never counted, and never split, so a truncation point can never land mid-escape; and the
406
+ * cut is grapheme-aware and width-aware (`visibleWidth`'s rules), so it never lands inside an
407
+ * emoji ZWJ sequence or between a base and its combining mark, and a 2-column character that
408
+ * does not fit in the remaining space is dropped whole rather than half-drawn.
409
+ * Moved here from status-bar.mjs (HS-05L9 T10) so dock.mjs can share the same primitive —
410
+ * every line dock.mjs renders goes through this, not just the status bar's.
411
+ *
412
+ * Escape sequences immediately following the last kept grapheme are still copied (so a
413
+ * trailing `reset` survives when the cut lands exactly at the end of a colored run); the walk
414
+ * stops at the first grapheme that does not fit.
415
+ */
416
+ export function truncateVisible(text, cols) {
417
+ if (!(cols > 0)) return '';
418
+ let visible = 0;
419
+ let out = '';
420
+ for (const tok of tokens(text)) {
421
+ if (tok.esc) {
422
+ out += tok.s;
423
+ continue;
424
+ }
425
+ for (const g of graphemes(tok.s)) {
426
+ const w = graphemeWidth(g);
427
+ if (visible >= cols || visible + w > cols) return out;
428
+ out += g;
429
+ visible += w;
430
+ }
431
+ }
432
+ return out;
433
+ }
434
+
435
+ export function pickColorDepth(stdout, env) {
436
+ if (env.NO_COLOR !== undefined) return 0;
437
+ if (!stdout || !stdout.isTTY) return 0;
438
+
439
+ const hasColors = typeof stdout.hasColors === 'function' ? stdout.hasColors.bind(stdout) : null;
440
+
441
+ if ((hasColors && hasColors(16777216)) || env.WT_SESSION !== undefined || env.COLORTERM === 'truecolor' || env.COLORTERM === '24bit') {
442
+ return 24;
443
+ }
444
+ if (hasColors && hasColors(256)) {
445
+ return 8;
446
+ }
447
+ return 0;
448
+ }
@@ -0,0 +1,22 @@
1
+ // spexharness/src/chat/tui/palette.mjs
2
+ //
3
+ // GENERATED by scripts/gen-tui-palette.mjs — do not edit by hand.
4
+ // Source: spex-console/public/design-system/tokens.dtcg.json (dark theme).
5
+ // Regenerate: node scripts/gen-tui-palette.mjs
6
+ // Verify without writing: node scripts/gen-tui-palette.mjs --check
7
+
8
+ export const PALETTE = Object.freeze({
9
+ brand: '#00fa8e',
10
+ working: '#ff8a3d',
11
+ pending: '#f7c948',
12
+ blocked: '#ff5a44',
13
+ review: '#8b76e0',
14
+ approved: '#3b82f6',
15
+ secondary: '#b2b2b2',
16
+ stale: '#707070',
17
+ primary: '#ffffff',
18
+ highlight: '#3a3d42',
19
+ highlightSubtle: '#333333',
20
+ });
21
+
22
+ export const PALETTE_SOURCE = 'spex-console/public/design-system/tokens.dtcg.json';
@@ -0,0 +1,208 @@
1
+ // spexharness/src/chat/tui/select.mjs
2
+ //
3
+ // Arrow-key select prompt (HS-TJYK-8CMW-EPIC §Phase 2, task HS-3I1X, T15) — the numbered-list
4
+ // "walk with the arrows or a number and confirm with Enter" prompt every peer agent (Claude
5
+ // Code, Command Code, Cursor Agent) already uses for a folder-trust/tool-permission ask,
6
+ // replacing SpexCode's own typed y/n answer for those two asks. Parity source priority (owner
7
+ // ruling 2026-09-11): 1. Claude Code, 2. Command Code, 3. Cursor Agent — hence the `❯ n. label`
8
+ // list shape (Claude Code's own), not Cursor's bracketed `[a]` hotkey row.
9
+ //
10
+ // PURE PROMPT, NO DOCK — unlike input-box.mjs/status-bar.mjs, this module does not render
11
+ // through the shared dock (dock.mjs): it owns a small self-contained block (title + lines +
12
+ // numbered options + footer) and erases/rewrites THAT block itself, with its own
13
+ // cursorUp+clearScreenDown accounting (the same "erase the last K lines, rewrite them"
14
+ // technique dock.mjs uses, duplicated here rather than shared because select() needs its own
15
+ // synchronous Promise-driven keypress loop, not a `set()`/`erase()` pair a caller drives).
16
+ // Callers that ALSO use the shared dock (hs-chat.ts's folder-trust and tool-permission asks)
17
+ // are responsible for erasing the dock's own block before calling `select()` and redrawing it
18
+ // after — the same "erase what you own before something else draws in the same rows" contract
19
+ // input-box.mjs already follows for the menu/status-bar coordination; select() only ever
20
+ // touches the rows IT wrote.
21
+ //
22
+ // Raw-mode discipline mirrors keys.mjs's `bindKeys` exactly (same rule, deliberately
23
+ // duplicated rather than imported — `bindKeys` is wired to a `readline.Interface`'s own
24
+ // Shift+Tab/Escape hooks and has no notion of Up/Down/digit/key navigation or resolving a
25
+ // value, so reusing it here would mean threading a throwaway `rl` through just to satisfy its
26
+ // signature): raw mode is set only when `stdin.setRawMode` exists AND `stdin` was not already
27
+ // raw, and is only ever turned back OFF by THIS call if THIS call was the one that turned it
28
+ // on — never double-toggling a mode some other owner (an outer readline Interface) already
29
+ // holds.
30
+ //
31
+ // Non-TTY stdin: `select()` throws (rejects with) `SELECT_NEEDS_TTY` synchronously, before
32
+ // registering any listener or touching raw mode — the caller's contract (Scope 2/3: "TTY-only
33
+ // wiring") is to catch this and fall back to the existing typed y/n prompt, never to call
34
+ // `select()` speculatively and hope.
35
+
36
+ import readline from 'node:readline';
37
+
38
+ import { fg, reset, cursorUp, clearScreenDown, hideCursor, showCursor } from './ansi.mjs';
39
+ import { PALETTE } from './palette.mjs';
40
+
41
+ /** Thrown (as a rejected promise, `select` being `async`) when `stdin` is not a real TTY —
42
+ * the caller's signal to fall back to the typed prompt instead of hanging on a stream that
43
+ * will never deliver a raw keypress. Both `error.message` and `error.code` are set to this
44
+ * string so either check works. */
45
+ export const SELECT_NEEDS_TTY = 'SELECT_NEEDS_TTY';
46
+
47
+ /** Wrap `text` in `fg(hex, depth)` ... `reset` — at depth 0 `fg` returns '', so this degrades
48
+ * to plain text with no dangling reset (same convention as every other tui/*.mjs
49
+ * colorSegment helper — input-box.mjs, slash-menu.mjs, status-bar.mjs). */
50
+ function colorSegment(text, hex, depth) {
51
+ const prefix = fg(hex, depth);
52
+ return prefix ? `${prefix}${text}${reset}` : text;
53
+ }
54
+
55
+ /** Build this prompt's full block of lines (title, explanatory lines verbatim, the numbered
56
+ * option list with the selected row in brand green, and the footer hint) — pure, no I/O,
57
+ * called fresh on every render so `selectedIndex` is always reflected. `lines` are printed
58
+ * exactly as given (a caller that wants a colored path/sentence, e.g. the folder-trust ask's
59
+ * brand-colored path, pre-colors it itself — this module never re-colors caller text). */
60
+ function buildLines({ title, lines, options, selectedIndex, footer, depth }) {
61
+ const out = [];
62
+ if (title) out.push(colorSegment(String(title), PALETTE.primary, depth));
63
+ for (const line of Array.isArray(lines) ? lines : []) {
64
+ out.push(String(line ?? ''));
65
+ }
66
+ options.forEach((opt, i) => {
67
+ const n = i + 1;
68
+ const marker = i === selectedIndex ? '❯' : ' ';
69
+ const text = `${marker} ${n}. ${opt.label}`;
70
+ out.push(i === selectedIndex ? colorSegment(text, PALETTE.brand, depth) : text);
71
+ });
72
+ if (footer) out.push(colorSegment(String(footer), PALETTE.stale, depth));
73
+ return out;
74
+ }
75
+
76
+ /** The index a single typed character jumps to, or -1 — a digit ("1".."9") jumps by 1-indexed
77
+ * position; a single-character `option.key` (case-insensitive) jumps by identity. Multi-
78
+ * character option keys (the common case — hs-chat.ts's callers use ids like "yes"/"exit",
79
+ * not single-letter hotkeys) simply never match here, which is fine: Up/Down and the digit
80
+ * jump always work regardless of what `key` looks like. */
81
+ function jumpIndexForChar(ch, options) {
82
+ if (typeof ch !== 'string' || ch.length !== 1) return -1;
83
+ if (/^[1-9]$/.test(ch)) {
84
+ const idx = Number(ch) - 1;
85
+ return idx < options.length ? idx : -1;
86
+ }
87
+ return options.findIndex(
88
+ (o) => typeof o?.key === 'string' && o.key.length === 1 && o.key.toLowerCase() === ch.toLowerCase(),
89
+ );
90
+ }
91
+
92
+ /**
93
+ * @param {object} opts
94
+ * @param {string} [opts.title]
95
+ * @param {string[]} [opts.lines] - explanatory lines, printed verbatim between the title and
96
+ * the option list.
97
+ * @param {Array<{ key: string, label: string, hint?: string }>} opts.options - non-empty.
98
+ * `hint`, when given, is appended to the option's row in the secondary color.
99
+ * @param {NodeJS.ReadStream} [opts.stdin] - defaults to `process.stdin`. Must be a real TTY
100
+ * (`isTTY === true`) or this rejects with `SELECT_NEEDS_TTY`.
101
+ * @param {{ write: (s: string) => unknown }} [opts.stdout] - defaults to `process.stdout`.
102
+ * @param {24 | 8 | 0} [opts.depth]
103
+ * @param {string} [opts.footer] - the navigation hint line (e.g. "↑/↓ to navigate · enter to
104
+ * select · esc to exit"), rendered in the secondary/stale color as the block's last line.
105
+ * @returns {Promise<string | null>} the chosen option's `key` on Enter, `null` on Escape.
106
+ */
107
+ export async function select({
108
+ title,
109
+ lines = [],
110
+ options,
111
+ stdin = process.stdin,
112
+ stdout = process.stdout,
113
+ depth = 0,
114
+ footer,
115
+ } = {}) {
116
+ if (!Array.isArray(options) || options.length === 0) {
117
+ throw new Error('select(): options must be a non-empty array');
118
+ }
119
+ if (stdin.isTTY !== true) {
120
+ const err = new Error(SELECT_NEEDS_TTY);
121
+ err.code = SELECT_NEEDS_TTY;
122
+ throw err;
123
+ }
124
+
125
+ const alreadyRaw = stdin.isRaw === true;
126
+ const canSetRaw = typeof stdin.setRawMode === 'function';
127
+
128
+ readline.emitKeypressEvents(stdin);
129
+
130
+ let rawModeSetByUs = false;
131
+ if (canSetRaw && !alreadyRaw) {
132
+ stdin.setRawMode(true);
133
+ rawModeSetByUs = true;
134
+ }
135
+
136
+ let selectedIndex = 0;
137
+ let height = 0;
138
+
139
+ stdout.write(hideCursor);
140
+
141
+ function render() {
142
+ const rendered = buildLines({ title, lines, options, selectedIndex, footer, depth });
143
+ let seq = '';
144
+ if (height > 0) seq += `${cursorUp(height)}\r${clearScreenDown}`;
145
+ for (const line of rendered) seq += `${line}\n`;
146
+ stdout.write(seq);
147
+ height = rendered.length;
148
+ }
149
+
150
+ function erase() {
151
+ if (height === 0) return;
152
+ stdout.write(`${cursorUp(height)}\r${clearScreenDown}`);
153
+ height = 0;
154
+ }
155
+
156
+ return new Promise((resolve) => {
157
+ function cleanup() {
158
+ stdin.removeListener('keypress', onKeypress);
159
+ if (rawModeSetByUs && canSetRaw) stdin.setRawMode(false);
160
+ stdout.write(showCursor);
161
+ }
162
+
163
+ function finish(result) {
164
+ erase();
165
+ cleanup();
166
+ resolve(result);
167
+ }
168
+
169
+ function onKeypress(str, key) {
170
+ if (!key) {
171
+ const jump = jumpIndexForChar(str, options);
172
+ if (jump !== -1 && jump !== selectedIndex) {
173
+ selectedIndex = jump;
174
+ render();
175
+ }
176
+ return;
177
+ }
178
+ if (key.name === 'up') {
179
+ selectedIndex = (selectedIndex - 1 + options.length) % options.length;
180
+ render();
181
+ return;
182
+ }
183
+ if (key.name === 'down') {
184
+ selectedIndex = (selectedIndex + 1) % options.length;
185
+ render();
186
+ return;
187
+ }
188
+ if (key.name === 'return' || key.name === 'enter') {
189
+ finish(options[selectedIndex].key);
190
+ return;
191
+ }
192
+ if (key.name === 'escape') {
193
+ finish(null);
194
+ return;
195
+ }
196
+ const jump = jumpIndexForChar(str, options);
197
+ if (jump !== -1 && jump !== selectedIndex) {
198
+ selectedIndex = jump;
199
+ render();
200
+ }
201
+ }
202
+
203
+ stdin.on('keypress', onKeypress);
204
+ render();
205
+ });
206
+ }
207
+
208
+ export default { select, SELECT_NEEDS_TTY };