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
@@ -19,6 +19,15 @@
19
19
  * `status:` — the status API owns coercion, rollup, and spec events ([[HS-DIAG-DOSH]]),
20
20
  * and the reproduction gate lives inside it, so a clean record is what unlocks the
21
21
  * close rather than this module deciding on its own.
22
+ *
23
+ * Cloud contract ([[HS-7UJZ-O86I-DIAG]]): a diagnose filed through the console in
24
+ * cloud mode exists ONLY as a Neon row — there is no `specs/diagnoses/*.md` for the
25
+ * disk scan to find. When the session's console reliance is `cloud` the diagnose is
26
+ * resolved and the verdict recorded THROUGH the console (`GET` + `PUT
27
+ * /api/specs/:id`, the same frontmatter fields + change-log row the disk path
28
+ * writes); in local reliance the disk scan runs first, exactly as before, and the
29
+ * console is consulted only when the scan finds nothing. The close still goes
30
+ * through the status PATCH either way.
22
31
  */
23
32
 
24
33
  import { existsSync, readdirSync, readFileSync } from 'node:fs';
@@ -78,15 +87,7 @@ export const SPAWNED_WORK_FIELDS = ['spawned_epics', 'spawned_tasks'];
78
87
  * ([[HS-B1ZT-QCP2-DIAG-heyspex-promote-research-cannot-resolve]]).
79
88
  */
80
89
  export function resolveDiagnoseRef(vaultRoot, rawRef) {
81
- const ref =
82
- typeof rawRef === 'string'
83
- ? rawRef
84
- .trim()
85
- .replace(/^\[\[|\]\]$/g, '')
86
- .replace(/^['"]|['"]$/g, '')
87
- .replace(/\.md$/i, '')
88
- .trim()
89
- : '';
90
+ const ref = normalizeDiagnoseRef(rawRef);
90
91
  if (!ref) return { error: 'INVALID_ARGS', message: 'spec_id is required' };
91
92
 
92
93
  const dir = join(vaultRoot, 'specs', 'diagnoses');
@@ -99,6 +100,7 @@ export function resolveDiagnoseRef(vaultRoot, rawRef) {
99
100
  const load = (file) => {
100
101
  const absPath = join(dir, file);
101
102
  return {
103
+ source: 'disk',
102
104
  basename: file.replace(/\.md$/i, ''),
103
105
  absPath,
104
106
  relPath: join('specs', 'diagnoses', file),
@@ -146,6 +148,138 @@ export function resolveDiagnoseRef(vaultRoot, rawRef) {
146
148
  return notFound;
147
149
  }
148
150
 
151
+ /** Strip a spec reference to the bare basename / id: `[[x]]`, quotes, `.md`. */
152
+ function normalizeDiagnoseRef(rawRef) {
153
+ return typeof rawRef === 'string'
154
+ ? rawRef
155
+ .trim()
156
+ .replace(/^\[\[|\]\]$/g, '')
157
+ .replace(/^['"]|['"]$/g, '')
158
+ .replace(/\.md$/i, '')
159
+ .trim()
160
+ : '';
161
+ }
162
+
163
+ /**
164
+ * Resolve a diagnose reference through the console — `GET /api/specs/:id`, the
165
+ * same read `heyspex_submit_for_review` makes ([[HS-7UJZ-O86I-DIAG]]).
166
+ *
167
+ * The route accepts the frontmatter id, the basename, and (GET-only) a unique
168
+ * id prefix, and answers `404 ambiguous_id` with candidates when a prefix names
169
+ * more than one spec — mapped here onto the same error codes the disk resolver
170
+ * returns so a caller reads one contract. A transport failure or a non-404
171
+ * refusal is NOT "not found": it is reported as `CONSOLE_UNREACHABLE` so the
172
+ * caller can tell "no such diagnose" from "could not ask".
173
+ */
174
+ export async function resolveDiagnoseViaConsole(consoleFetch, rawRef) {
175
+ const ref = normalizeDiagnoseRef(rawRef);
176
+ if (!ref) return { error: 'INVALID_ARGS', message: 'spec_id is required' };
177
+ const route = `GET /api/specs/${ref}`;
178
+ if (typeof consoleFetch !== 'function') {
179
+ return { error: 'CONSOLE_UNREACHABLE', message: `${route}: no console transport available` };
180
+ }
181
+ let res;
182
+ try {
183
+ res = await consoleFetch(`/api/specs/${encodeURIComponent(ref)}`);
184
+ } catch (err) {
185
+ return { error: 'CONSOLE_UNREACHABLE', message: `${route} failed: ${err?.message ?? err}` };
186
+ }
187
+ const notFound = {
188
+ error: 'DIAGNOSE_NOT_FOUND',
189
+ message: `diagnose ${rawRef} is not in the console's index either (${route} → 404)`,
190
+ status: 404,
191
+ };
192
+ if (!res?.ok) {
193
+ if (res?.status === 404) {
194
+ if (res.body?.error === 'ambiguous_id' && Array.isArray(res.body?.candidates)) {
195
+ return {
196
+ error: 'DIAGNOSE_ID_AMBIGUOUS',
197
+ message: `spec_id ${rawRef} is a prefix of ${res.body.candidates.length} specs in the console's index — pass the full basename instead`,
198
+ candidates: res.body.candidates,
199
+ };
200
+ }
201
+ return notFound;
202
+ }
203
+ return {
204
+ error: 'CONSOLE_UNREACHABLE',
205
+ message: `${route} returned ${res?.status ?? 0}${res?.error ? `: ${res.error}` : ''}`,
206
+ status: res?.status ?? 0,
207
+ console_response: res?.body ?? null,
208
+ };
209
+ }
210
+ const spec = res.body?.spec;
211
+ if (!spec || typeof spec !== 'object') return notFound;
212
+ const frontmatter =
213
+ spec.frontmatter && typeof spec.frontmatter === 'object' ? { ...spec.frontmatter } : {};
214
+ const relPath = typeof spec.relPath === 'string' ? spec.relPath : null;
215
+ const basename =
216
+ (typeof spec.basename === 'string' && spec.basename) ||
217
+ (relPath ? relPath.split('/').pop().replace(/\.md$/i, '') : null) ||
218
+ String(spec.id ?? frontmatter.id ?? ref);
219
+ return {
220
+ record: {
221
+ source: 'console',
222
+ basename,
223
+ specId: String(frontmatter.id ?? spec.id ?? basename),
224
+ relPath,
225
+ type: String(frontmatter.type ?? spec.type ?? ''),
226
+ status: String(frontmatter.status ?? spec.status ?? ''),
227
+ frontmatter,
228
+ body: typeof spec.body === 'string' ? spec.body : '',
229
+ },
230
+ };
231
+ }
232
+
233
+ /**
234
+ * Pick the record to write the verdict on — disk or console ([[HS-7UJZ-O86I-DIAG]]).
235
+ *
236
+ * `cloud` reliance asks the console FIRST: that is where a cloud-filed diagnose
237
+ * lives, and the close gate in the status API reads the same index, so a disk
238
+ * write there would be a split brain the gate never sees. Only a console 404
239
+ * falls back to the local scan (an unpushed file in a vault clone). Any other
240
+ * reliance keeps the disk scan first — byte-identical to the pre-cloud path —
241
+ * and only asks the console when the scan finds nothing; a console that cannot
242
+ * be reached then leaves the disk answer (`DIAGNOSE_NOT_FOUND`) standing, with
243
+ * the console's answer attached.
244
+ */
245
+ export async function resolveDiagnoseRecord({ vaultRoot, consoleFetch, rawRef, reliance = null }) {
246
+ if (reliance === 'cloud') {
247
+ const viaConsole = await resolveDiagnoseViaConsole(consoleFetch, rawRef);
248
+ if (viaConsole.error !== 'DIAGNOSE_NOT_FOUND') return viaConsole;
249
+ const local = resolveDiagnoseRef(vaultRoot, rawRef);
250
+ if (!local.error) return local;
251
+ return { ...viaConsole, message: `${viaConsole.message}; ${local.message}` };
252
+ }
253
+ const local = resolveDiagnoseRef(vaultRoot, rawRef);
254
+ if (local.error !== 'DIAGNOSE_NOT_FOUND') return local;
255
+ const viaConsole = await resolveDiagnoseViaConsole(consoleFetch, rawRef);
256
+ if (viaConsole.error === 'DIAGNOSE_NOT_FOUND' || viaConsole.error === 'CONSOLE_UNREACHABLE') {
257
+ return {
258
+ ...local,
259
+ message: `${local.message}; ${viaConsole.message}`,
260
+ console: { error: viaConsole.error, status: viaConsole.status ?? null },
261
+ };
262
+ }
263
+ return viaConsole;
264
+ }
265
+
266
+ /** One frontmatter link value (string or list) as bare basenames. */
267
+ function refListFromFrontmatter(value) {
268
+ const entries = Array.isArray(value) ? value : typeof value === 'string' ? [value] : [];
269
+ const out = [];
270
+ for (const entry of entries) {
271
+ const ref = normalizeDiagnoseRef(typeof entry === 'string' ? entry : '');
272
+ if (ref) out.push(ref);
273
+ }
274
+ return out;
275
+ }
276
+
277
+ /** {@link spawnedWorkRefs} for parsed frontmatter (the console's `spec.frontmatter`). */
278
+ export function spawnedWorkRefsFromFrontmatter(frontmatter) {
279
+ if (!frontmatter || typeof frontmatter !== 'object') return [];
280
+ return SPAWNED_WORK_FIELDS.flatMap((field) => refListFromFrontmatter(frontmatter[field]));
281
+ }
282
+
149
283
  /** Every spec this diagnose spawned — the link fields, never a parent edge. */
150
284
  export function spawnedWorkRefs(block) {
151
285
  const out = [];
@@ -181,6 +315,33 @@ export function buildVerifiedContent(raw, { result, evidence, by, now, today })
181
315
  return `${fm.open}${block}${fm.close}${body}`;
182
316
  }
183
317
 
318
+ function changeLogMessage(result, evidence) {
319
+ const verdict =
320
+ result === 'no_longer_reproduces'
321
+ ? 'Reproduction re-run: NO LONGER REPRODUCES.'
322
+ : 'Reproduction re-run: STILL REPRODUCES.';
323
+ return `${verdict} ${evidence}`;
324
+ }
325
+
326
+ /**
327
+ * The console twin of {@link buildVerifiedContent}: the `{ frontmatter, body }`
328
+ * pair `PUT /api/specs/:id` takes, carrying the same four verdict fields and the
329
+ * same change-log row. The fields REPLACE any prior verdict (object spread), as
330
+ * `setScalarField` does on disk. `updated:` is left to the console — `updateSpec`
331
+ * stamps it on every write.
332
+ */
333
+ export function buildVerifiedConsoleWrite(record, { result, evidence, by, now, today }) {
334
+ const frontmatter = {
335
+ ...record.frontmatter,
336
+ [REPRODUCTION_RERUN_FIELDS.at]: now,
337
+ [REPRODUCTION_RERUN_FIELDS.by]: by,
338
+ [REPRODUCTION_RERUN_FIELDS.result]: result,
339
+ [REPRODUCTION_RERUN_FIELDS.evidence]: evidence,
340
+ };
341
+ const body = appendChangeLogRow(record.body ?? '', today, by, changeLogMessage(result, evidence));
342
+ return { frontmatter, body };
343
+ }
344
+
184
345
  /**
185
346
  * @param {{
186
347
  * vaultRoot: string,
@@ -188,7 +349,12 @@ export function buildVerifiedContent(raw, { result, evidence, by, now, today })
188
349
  * args?: Record<string, unknown>,
189
350
  * clock?: () => string,
190
351
  * agent?: string,
352
+ * getReliance?: () => Promise<string|null>,
191
353
  * }} ctx
354
+ *
355
+ * `getReliance` resolves the session's console reliance (`cloud` | `local` |
356
+ * null). It defaults to "unknown", which keeps the disk-first order — the
357
+ * handler injects the real dev-mode resolver.
192
358
  */
193
359
  export async function executeVerifyReproduction({
194
360
  vaultRoot,
@@ -196,6 +362,7 @@ export async function executeVerifyReproduction({
196
362
  args = {},
197
363
  clock = isoNow,
198
364
  agent = 'agent',
365
+ getReliance = async () => null,
199
366
  }) {
200
367
  const result = typeof args.result === 'string' ? args.result.trim().toLowerCase() : '';
201
368
  if (!REPRODUCTION_VERDICTS.includes(result)) {
@@ -214,18 +381,45 @@ export async function executeVerifyReproduction({
214
381
  };
215
382
  }
216
383
 
217
- const resolved = resolveDiagnoseRef(vaultRoot, args.spec_id);
384
+ let reliance = null;
385
+ try {
386
+ reliance = await getReliance();
387
+ } catch {
388
+ reliance = null;
389
+ }
390
+ const resolved = await resolveDiagnoseRecord({
391
+ vaultRoot,
392
+ consoleFetch,
393
+ rawRef: args.spec_id,
394
+ reliance,
395
+ });
218
396
  if (resolved.error) return resolved;
219
397
  const diagnose = resolved.record;
398
+ const viaConsole = diagnose.source === 'console';
220
399
 
221
- const fm = getFrontmatterBlock(diagnose.raw);
222
- if (!fm) {
223
- return {
224
- error: 'DIAGNOSE_FRONTMATTER_MISSING',
225
- message: `${diagnose.basename} has no frontmatter — cannot record a reproduction re-run`,
226
- };
400
+ let fm = null;
401
+ let type;
402
+ let specId;
403
+ let statusBefore;
404
+ let spawned;
405
+ if (viaConsole) {
406
+ type = diagnose.type.trim().toLowerCase();
407
+ specId = diagnose.specId;
408
+ statusBefore = (diagnose.status || 'open').trim().toLowerCase();
409
+ spawned = spawnedWorkRefsFromFrontmatter(diagnose.frontmatter);
410
+ } else {
411
+ fm = getFrontmatterBlock(diagnose.raw);
412
+ if (!fm) {
413
+ return {
414
+ error: 'DIAGNOSE_FRONTMATTER_MISSING',
415
+ message: `${diagnose.basename} has no frontmatter — cannot record a reproduction re-run`,
416
+ };
417
+ }
418
+ type = (getScalarField(fm.block, 'type') ?? '').trim().toLowerCase();
419
+ specId = getScalarField(fm.block, 'id') ?? diagnose.basename;
420
+ statusBefore = (getScalarField(fm.block, 'status') ?? 'open').trim().toLowerCase();
421
+ spawned = spawnedWorkRefs(fm.block);
227
422
  }
228
- const type = (getScalarField(fm.block, 'type') ?? '').trim().toLowerCase();
229
423
  if (type && type !== 'diagnose') {
230
424
  return {
231
425
  error: 'NOT_A_DIAGNOSE',
@@ -234,27 +428,47 @@ export async function executeVerifyReproduction({
234
428
  };
235
429
  }
236
430
 
237
- const specId = getScalarField(fm.block, 'id') ?? diagnose.basename;
238
- const statusBefore = (getScalarField(fm.block, 'status') ?? 'open').trim().toLowerCase();
239
- const spawned = spawnedWorkRefs(fm.block);
240
-
241
431
  const now = clock();
242
432
  const today = todayDate();
243
433
  const by = typeof args.by === 'string' && args.by.trim() ? args.by.trim() : agent;
244
434
 
245
- const content = buildVerifiedContent(diagnose.raw, { result, evidence, by, now, today });
246
- const commit = commitWrites([{ kind: 'update', absPath: diagnose.absPath, content }]);
247
- if (!commit.ok) {
248
- return {
249
- error: 'WRITE_FAILED',
250
- message: commit.error?.message ?? 'unknown write failure',
251
- };
435
+ if (viaConsole) {
436
+ // Neon-only diagnose ([[HS-7UJZ-O86I-DIAG]]): the verdict lands through the
437
+ // console's own spec write, `PUT /api/specs/:id` — the route every cloud-mode
438
+ // edit already goes through (cloud-vault-writes.mjs) — so the status API's
439
+ // close gate reads the verdict from the same index this call just wrote.
440
+ const write = buildVerifiedConsoleWrite(diagnose, { result, evidence, by, now, today });
441
+ const put = await consoleFetch(`/api/specs/${encodeURIComponent(specId)}`, {
442
+ method: 'PUT',
443
+ body: write,
444
+ });
445
+ if (!put?.ok) {
446
+ return {
447
+ error: 'WRITE_FAILED',
448
+ message:
449
+ `PUT /api/specs/${specId} returned ${put?.status ?? 0}` +
450
+ (put?.error ? `: ${put.error}` : '') +
451
+ ' — the verdict was NOT recorded.',
452
+ status: put?.status ?? 0,
453
+ console_response: put?.body ?? null,
454
+ };
455
+ }
456
+ } else {
457
+ const content = buildVerifiedContent(diagnose.raw, { result, evidence, by, now, today });
458
+ const commit = commitWrites([{ kind: 'update', absPath: diagnose.absPath, content }]);
459
+ if (!commit.ok) {
460
+ return {
461
+ error: 'WRITE_FAILED',
462
+ message: commit.error?.message ?? 'unknown write failure',
463
+ };
464
+ }
252
465
  }
253
466
 
254
467
  const recorded = {
255
468
  spec_id: specId,
256
469
  basename: diagnose.basename,
257
- path: diagnose.relPath,
470
+ path: diagnose.relPath ?? null,
471
+ recorded_via: viaConsole ? 'console' : 'disk',
258
472
  result,
259
473
  evidence,
260
474
  by,
@@ -262,6 +476,7 @@ export async function executeVerifyReproduction({
262
476
  spawned_work: spawned,
263
477
  status_before: statusBefore,
264
478
  };
479
+ const where = viaConsole ? "in the console's index" : 'on disk';
265
480
 
266
481
  // A run that still reproduces records and closes nothing — the diagnose is left
267
482
  // exactly as it was, and its Resolution row stays open.
@@ -293,6 +508,31 @@ export async function executeVerifyReproduction({
293
508
  body: { status: 'closed', close_reason: `Reproduction re-run ${now}: no longer reproduces.` },
294
509
  });
295
510
  if (!res?.ok) {
511
+ const refusalReason = typeof res?.body?.error === 'string' ? res.body.error : null;
512
+ // `REPRODUCTION_NOT_REVERIFIED` is the ONE refusal this call cannot honestly
513
+ // report as a soft "verdict recorded, could not close". The gate refuses on a
514
+ // missing/unclean verdict — and the clean verdict was written to disk a few
515
+ // lines above, by this very call. So the gate did not see the file it is
516
+ // gating on, and nothing the caller does next clears the reason it was given.
517
+ // Reported softly, an agent read it literally and handed off a finished bug as
518
+ // unfinished ([[HS-SC2Q-E7TN-DIAG-closing-a-diagnose-takes-two-calls-the]]).
519
+ // Surfaced as an error, so it reads as the console-side fault it is.
520
+ if (refusalReason === 'REPRODUCTION_NOT_REVERIFIED') {
521
+ return {
522
+ ...recorded,
523
+ error: 'CLOSE_GATE_STALE_VERDICT',
524
+ closed: false,
525
+ status: res?.status,
526
+ console_response: res?.body,
527
+ message:
528
+ `The clean verdict IS ${where} in \`${diagnose.basename}\`, but the console's close gate ` +
529
+ 'refused with `REPRODUCTION_NOT_REVERIFIED` — it read a copy of the frontmatter from before ' +
530
+ 'this write. This is a console-side staleness fault, not unfinished work: do NOT report the ' +
531
+ 'diagnose as un-closed. Verify the console build carries the read-your-own-write heal in ' +
532
+ '`apply-status-transition.ts`; re-running this call closes the spec once the console has ' +
533
+ 'caught up.',
534
+ };
535
+ }
296
536
  return {
297
537
  ...recorded,
298
538
  closed: false,
@@ -300,7 +540,7 @@ export async function executeVerifyReproduction({
300
540
  status: res?.status,
301
541
  console_response: res?.body,
302
542
  message:
303
- 'Verdict recorded on disk, but the status API did not close the spec. The record stands — ' +
543
+ `Verdict recorded ${where}, but the status API did not close the spec. The record stands — ` +
304
544
  're-run the close once the reason in `console_response` is cleared.',
305
545
  };
306
546
  }
@@ -0,0 +1,91 @@
1
+ // whoami.mjs — "which account does this machine's credential belong to?"
2
+ //
3
+ // ONE probe shared by `hs whoami` (cli-verbs.mjs `runWhoamiCli`) and the
4
+ // install/connect identity gate (spex-console/src/lib/cli/install-identity.ts),
5
+ // so the account a human is shown is exactly the account a later write would
6
+ // bind to ([[HS-HAQI-KODY-DIAG-hs-install-and-hs-connect-bind-a-new]]).
7
+ //
8
+ // Plain Node ESM, zero dependencies (spex-console imports it straight from
9
+ // TypeScript, same as folder-trust-store.mjs). Never prints, logs or returns
10
+ // token material: callers only ever see an identity verdict.
11
+
12
+ import { homedir } from 'node:os';
13
+
14
+ import { readBearer } from './credentials.mjs';
15
+
16
+ /** Console route that resolves an `hsmcp_` / `hscmp_` bearer to its account. */
17
+ export const SESSION_PATH = '/api/mcp/session';
18
+
19
+ /**
20
+ * The bearer `hs whoami`, `hs connect` / `hs join` and the MCP server all send:
21
+ * `HEYSPEX_MCP_TOKEN` env first, then the top-level `mcp_token:` key of
22
+ * `~/.heyspex/credentials.yaml` — resolved by the ONE shared reader
23
+ * (`./credentials.mjs`, [[HS-HL0D-WW9R-TASK-share-one-bearer-reader-and-one-console]]),
24
+ * the same module `readPairedToken()` in spex-console/src/lib/cli/control-shared.ts
25
+ * delegates to. Never a first `hsmcp_`/`hscmp_` match anywhere in the file: a
26
+ * Companion token under `clerk:` is not this machine's MCP bearer, and probing
27
+ * it would show the human one account and bind another.
28
+ *
29
+ * @param {{ env?: NodeJS.ProcessEnv | Record<string, string | undefined>, homeDir?: string, readFile?: (p: string) => string }} [opts]
30
+ * @returns {string | null}
31
+ */
32
+ export function readStoredBearer({ env = process.env, homeDir = homedir(), readFile } = {}) {
33
+ return readBearer({ env, homeDir, readFile }).token;
34
+ }
35
+
36
+ /**
37
+ * Ask `consoleUrl` who `token` belongs to.
38
+ *
39
+ * Returns one of:
40
+ * { kind: 'no_credential' }
41
+ * { kind: 'identified', accountId, email, displayName, consoleUrl }
42
+ * { kind: 'rejected', status } — 401/403: token unknown or revoked
43
+ * { kind: 'unsupported', status } — 404/405: console predates this route
44
+ * { kind: 'unreachable', message } — network error / non-JSON / 5xx
45
+ *
46
+ * @param {{ consoleUrl?: string, token?: string | null, fetchImpl?: (url: string, init?: object) => Promise<any> }} [opts]
47
+ */
48
+ export async function probeCredentialIdentity({ consoleUrl, token, fetchImpl = fetch } = {}) {
49
+ if (!token) return { kind: 'no_credential' };
50
+ const base = String(consoleUrl ?? '').trim().replace(/\/+$/, '');
51
+ let res;
52
+ try {
53
+ res = await fetchImpl(`${base}${SESSION_PATH}`, {
54
+ method: 'GET',
55
+ headers: { authorization: `Bearer ${token}` },
56
+ cache: 'no-store',
57
+ });
58
+ } catch (err) {
59
+ return { kind: 'unreachable', message: err?.message ?? String(err) };
60
+ }
61
+ if (res.status === 401 || res.status === 403) return { kind: 'rejected', status: res.status };
62
+ if (res.status === 404 || res.status === 405) return { kind: 'unsupported', status: res.status };
63
+ let body = null;
64
+ try {
65
+ body = await res.json();
66
+ } catch {
67
+ body = null;
68
+ }
69
+ if (!res.ok || !body || typeof body.userId !== 'string' || !body.userId.trim()) {
70
+ return { kind: 'unreachable', message: `console returned ${res.status}` };
71
+ }
72
+ return {
73
+ kind: 'identified',
74
+ accountId: body.userId.trim(),
75
+ email: typeof body.email === 'string' && body.email.trim() ? body.email.trim() : null,
76
+ displayName:
77
+ typeof body.displayName === 'string' && body.displayName.trim() ? body.displayName.trim() : null,
78
+ // How the console authenticated this caller (the route's `mode`, e.g. a
79
+ // bearer); the `hs login` receipt shows it ([[HS-CRNE-WW9R-TASK-give-hs-install-and-hs-login-the]]).
80
+ mode: typeof body.mode === 'string' && body.mode.trim() ? body.mode.trim() : null,
81
+ consoleUrl: base,
82
+ };
83
+ }
84
+
85
+ /**
86
+ * `email (account id)`, or just the account id when the console has no email for it.
87
+ * @param {{ email?: string | null, accountId: string }} identity
88
+ */
89
+ export function formatIdentity({ email, accountId }) {
90
+ return email ? `${email} (${accountId})` : accountId;
91
+ }