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,653 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * wip-ledger-health — visibility + restore for the WIP hand-off ledger
4
+ * (`.heyspex/wip-state.json`) and its archive (`.heyspex/wip-archive/*.jsonl`).
5
+ *
6
+ * Why this exists: the ledger is a JSON object of `epic id -> entry` that only
7
+ * gets pruned/archived on a WRITE (a peer lane, [[HS-B8OK-PFMN]], is adding
8
+ * age-based archive/shrink rules to that write path). Nobody can see the
9
+ * ledger's health — line count against the 600-line budget, how many entries
10
+ * are aging toward archival, whether one entry is disproportionately large —
11
+ * without waiting for a write to warn about it. This module answers that
12
+ * read-only, and gives a way to pull an archived entry back onto the live
13
+ * ledger without hand-editing JSON.
14
+ *
15
+ * Three faces share this core (per the "capability = core → MCP + CLI" rule):
16
+ * - hs CLI `hs wip status` / `hs wip restore <id>` (bin/hs.mjs → this file)
17
+ * - `node scripts/wip-ledger-health.mjs status|restore …` direct invocation
18
+ *
19
+ * Every exported function is pure w.r.t. process state — no `process.exit`
20
+ * anywhere outside `main()` (Windows + Node 24: an exit mid-request throws
21
+ * away the real exit code — this repo already removed ~150 such calls). Every
22
+ * function also takes `ledgerPath`/`archiveDir`/`now` as arguments, defaulting
23
+ * to the real `.heyspex/` paths and the real clock ONLY inside `main()`, so
24
+ * tests never touch the real vault and never depend on the wall clock.
25
+ *
26
+ * Run directly:
27
+ * node scripts/wip-ledger-health.mjs status [--json]
28
+ * node scripts/wip-ledger-health.mjs restore <id> [--json] [--force]
29
+ */
30
+ import { readFileSync, writeFileSync, readdirSync, statSync } from "node:fs";
31
+ import path from "node:path";
32
+ import { fileURLToPath, pathToFileURL } from "node:url";
33
+
34
+ import { getAuthHeader, makeConsoleFetch } from "../vendor/heyspex-mcp/console-fetch.mjs";
35
+ import { resolveConsoleUrl } from "./lib/console-url-resolver.mjs";
36
+ import { resolveDevMode } from "./lib/dev-mode.mjs";
37
+
38
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
39
+ const PKG_ROOT = path.resolve(__dirname, "..");
40
+
41
+ export const BUDGET_LINES = 600;
42
+ // Age tiers: fresh [0, FRESH_MAX_DAYS], aging (FRESH_MAX_DAYS, STALE_MIN_DAYS],
43
+ // stale (STALE_MIN_DAYS, ∞) — boundaries are inclusive on the LOWER tier,
44
+ // mirroring the write path's own `age > cutoff` comparisons exactly (an entry
45
+ // sitting exactly ON a cutoff has not yet crossed it).
46
+ //
47
+ // These numbers MUST match the real cutoffs the write path enforces:
48
+ // `WIP_COMPACT_AFTER_DAYS` (shrink window start) and `WIP_STALE_AFTER_DAYS`
49
+ // (archive-out threshold) in spex-console/src/lib/wip-archive.ts — the write
50
+ // path shrinks an entry in place for `age > compactAfterDays && age <=
51
+ // staleAfterDays` and archives it out for `age > staleAfterDays`. This file
52
+ // stays plain Node with no dependency on that TypeScript module, so the two
53
+ // constant sets can't be imported into sync — if either cutoff there moves,
54
+ // move FRESH_MAX_DAYS / STALE_MIN_DAYS here too, or this health report will
55
+ // describe a different ledger than the one the write path is about to produce.
56
+ export const FRESH_MAX_DAYS = 2;
57
+ export const STALE_MIN_DAYS = 7;
58
+
59
+ export const EXIT = { OK: 0, REFUSED: 1, NOT_FOUND: 1, BAD_INVOCATION: 2 };
60
+
61
+ // ---- path defaults (real filesystem only reached from main()) -------------
62
+
63
+ export function defaultVaultRoot() {
64
+ return process.env.HS_VAULT_ROOT || PKG_ROOT;
65
+ }
66
+
67
+ export function defaultLedgerPath(vaultRoot = defaultVaultRoot()) {
68
+ return path.join(vaultRoot, ".heyspex", "wip-state.json");
69
+ }
70
+
71
+ export function defaultArchiveDir(vaultRoot = defaultVaultRoot()) {
72
+ return path.join(vaultRoot, ".heyspex", "wip-archive");
73
+ }
74
+
75
+ // ---- age classification ----------------------------------------------------
76
+
77
+ /**
78
+ * Classify an age in days into 'fresh' | 'aging' | 'stale'. Never touches the
79
+ * clock — callers compute `ageDays` via `ageDaysFrom` against an explicit `now`.
80
+ *
81
+ * Boundaries are `<=`, not `<`, to mirror the write path's own `age > cutoff`
82
+ * checks exactly: an entry sitting at exactly FRESH_MAX_DAYS has not yet
83
+ * crossed into the shrink window (write path leaves it alone), and one at
84
+ * exactly STALE_MIN_DAYS has not yet crossed into archive-out (write path
85
+ * still just shrinks it). See the constant comments above for where the real
86
+ * cutoffs live.
87
+ */
88
+ export function classifyAge(ageDays) {
89
+ if (ageDays <= FRESH_MAX_DAYS) return "fresh";
90
+ if (ageDays <= STALE_MIN_DAYS) return "aging";
91
+ return "stale";
92
+ }
93
+
94
+ export function ageDaysFrom(updatedAtIso, now = new Date()) {
95
+ const nowMs = now instanceof Date ? now.getTime() : now;
96
+ const thenMs = new Date(updatedAtIso).getTime();
97
+ return (nowMs - thenMs) / (24 * 60 * 60 * 1000);
98
+ }
99
+
100
+ /** Count newline characters the way `wc -l` does (no off-by-one on a trailing newline). */
101
+ export function countLines(raw) {
102
+ const matches = raw.match(/\n/g);
103
+ return matches ? matches.length : 0;
104
+ }
105
+
106
+ // ---- ledger read ------------------------------------------------------------
107
+
108
+ /**
109
+ * Read + parse the live ledger. Never throws — every failure mode (missing
110
+ * file, unreadable, unparseable, wrong shape) comes back as `{ ok: false, error }`
111
+ * so callers can print a clear message instead of a stack trace.
112
+ */
113
+ export function readLedger(ledgerPath) {
114
+ let raw;
115
+ try {
116
+ raw = readFileSync(ledgerPath, "utf8");
117
+ } catch (err) {
118
+ if (err && err.code === "ENOENT") {
119
+ return { ok: false, error: `ledger file not found: ${ledgerPath}` };
120
+ }
121
+ return {
122
+ ok: false,
123
+ error: `could not read ledger file ${ledgerPath}: ${err.message}`,
124
+ };
125
+ }
126
+ let data;
127
+ try {
128
+ data = JSON.parse(raw);
129
+ } catch (err) {
130
+ return {
131
+ ok: false,
132
+ error: `ledger file ${ledgerPath} is not valid JSON: ${err.message}`,
133
+ };
134
+ }
135
+ if (data === null || typeof data !== "object" || Array.isArray(data)) {
136
+ return {
137
+ ok: false,
138
+ error: `ledger file ${ledgerPath} does not contain a JSON object of entries`,
139
+ };
140
+ }
141
+ return { ok: true, data, raw, lineCount: countLines(raw) };
142
+ }
143
+
144
+ // ---- archive read -----------------------------------------------------------
145
+
146
+ function listArchiveFiles(archiveDir) {
147
+ try {
148
+ return readdirSync(archiveDir)
149
+ .filter((f) => f.endsWith(".jsonl"))
150
+ .sort();
151
+ } catch {
152
+ return [];
153
+ }
154
+ }
155
+
156
+ function readArchiveSummary(archiveDir) {
157
+ const files = listArchiveFiles(archiveDir);
158
+ let totalSizeBytes = 0;
159
+ for (const f of files) {
160
+ try {
161
+ totalSizeBytes += statSync(path.join(archiveDir, f)).size;
162
+ } catch {
163
+ // A file that vanished between readdir and stat doesn't count. Not an
164
+ // error worth surfacing in a health report.
165
+ }
166
+ }
167
+ return {
168
+ fileCount: files.length,
169
+ totalSizeBytes,
170
+ totalSizeKB: totalSizeBytes / 1024,
171
+ };
172
+ }
173
+
174
+ /**
175
+ * Scan every `.heyspex/wip-archive/*.jsonl` file for records `{ id, archivedAt,
176
+ * entry }` matching `id`, and return the one with the newest `archivedAt`
177
+ * (or null if `id` was never archived). Malformed lines/files are skipped,
178
+ * never thrown.
179
+ */
180
+ function findNewestArchived(archiveDir, id) {
181
+ const files = listArchiveFiles(archiveDir);
182
+ let newest = null;
183
+ for (const file of files) {
184
+ const filePath = path.join(archiveDir, file);
185
+ let content;
186
+ try {
187
+ content = readFileSync(filePath, "utf8");
188
+ } catch {
189
+ continue;
190
+ }
191
+ for (const line of content.split("\n")) {
192
+ const trimmed = line.trim();
193
+ if (!trimmed) continue;
194
+ let record;
195
+ try {
196
+ record = JSON.parse(trimmed);
197
+ } catch {
198
+ continue;
199
+ }
200
+ if (!record || record.id !== id) continue;
201
+ const ts = Date.parse(record.archivedAt);
202
+ const newestTs = newest ? Date.parse(newest.archivedAt) : -Infinity;
203
+ if (!newest || ts > newestTs) {
204
+ newest = {
205
+ archivedAt: record.archivedAt,
206
+ entry: record.entry,
207
+ sourceFile: file,
208
+ };
209
+ }
210
+ }
211
+ }
212
+ return newest;
213
+ }
214
+
215
+ // ---- status report ----------------------------------------------------------
216
+
217
+ /**
218
+ * Build the `hs wip status` health report. Pure: takes `ledgerPath`,
219
+ * `archiveDir`, and `now` explicitly; never reads the wall clock itself.
220
+ */
221
+ export function buildReport({
222
+ ledgerPath,
223
+ archiveDir,
224
+ now = new Date(),
225
+ budgetLines = BUDGET_LINES,
226
+ }) {
227
+ const ledgerResult = readLedger(ledgerPath);
228
+ if (!ledgerResult.ok) {
229
+ return { ok: false, error: ledgerResult.error, ledgerPath };
230
+ }
231
+ const { data, lineCount } = ledgerResult;
232
+ const ids = Object.keys(data);
233
+
234
+ const tiers = { fresh: 0, aging: 0, stale: 0 };
235
+ let oldest = null;
236
+ let largest = null;
237
+
238
+ for (const id of ids) {
239
+ const entry = data[id] ?? {};
240
+ const ageDays = ageDaysFrom(entry.updatedAt, now);
241
+ tiers[classifyAge(ageDays)] += 1;
242
+
243
+ if (!oldest || ageDays > oldest.ageDays) {
244
+ oldest = { id, ageDays };
245
+ }
246
+
247
+ const sizeBytes = Buffer.byteLength(JSON.stringify(entry), "utf8");
248
+ if (!largest || sizeBytes > largest.sizeBytes) {
249
+ largest = { id, sizeBytes, sizeKB: sizeBytes / 1024 };
250
+ }
251
+ }
252
+
253
+ return {
254
+ ok: true,
255
+ ledgerPath,
256
+ lineCount,
257
+ budgetLines,
258
+ overBudget: lineCount > budgetLines,
259
+ overUnderLines: lineCount - budgetLines,
260
+ entryCount: ids.length,
261
+ tiers,
262
+ oldest: oldest ? { id: oldest.id, ageDays: oldest.ageDays } : null,
263
+ largest: largest
264
+ ? { id: largest.id, sizeKB: largest.sizeKB }
265
+ : null,
266
+ archive: readArchiveSummary(archiveDir),
267
+ checkedAt: (now instanceof Date ? now : new Date(now)).toISOString(),
268
+ };
269
+ }
270
+
271
+ export function formatHumanReport(report) {
272
+ if (!report.ok) {
273
+ return `wip status: ${report.error}`;
274
+ }
275
+ const overUnderWord = report.overBudget ? "over" : "under";
276
+ const overUnderCount = Math.abs(report.overUnderLines);
277
+ const lines = [
278
+ `ledger: ${report.ledgerPath}`,
279
+ `lines: ${report.lineCount} / ${report.budgetLines} budget (${overUnderCount} lines ${overUnderWord} budget)`,
280
+ `entries: ${report.entryCount}`,
281
+ `age tiers: fresh ${report.tiers.fresh} (<=${FRESH_MAX_DAYS}d), aging ${report.tiers.aging} (${FRESH_MAX_DAYS}-${STALE_MIN_DAYS}d), stale ${report.tiers.stale} (>${STALE_MIN_DAYS}d)`,
282
+ report.oldest
283
+ ? `oldest entry: ${report.oldest.id} (${report.oldest.ageDays.toFixed(1)} days old)`
284
+ : "oldest entry: (ledger is empty)",
285
+ report.largest
286
+ ? `largest entry: ${report.largest.id} (${report.largest.sizeKB.toFixed(1)} KB)`
287
+ : "largest entry: (ledger is empty)",
288
+ `archive: ${report.archive.fileCount} file(s), ${report.archive.totalSizeKB.toFixed(1)} KB total`,
289
+ ];
290
+ return lines.join("\n");
291
+ }
292
+
293
+ // ---- restore ------------------------------------------------------------
294
+
295
+ /**
296
+ * Pull the newest archived record for `id` back onto the live ledger.
297
+ * - Refuses (code 1) when `id` is already live, unless `force`.
298
+ * - Not-found (code 1) when `id` was never archived.
299
+ * - On success, writes the ledger back with the same 2-space formatting +
300
+ * trailing newline the file already uses, so the diff is only the
301
+ * restored entry.
302
+ */
303
+ export function restoreEntry({
304
+ id,
305
+ ledgerPath,
306
+ archiveDir,
307
+ force = false,
308
+ now = new Date(),
309
+ }) {
310
+ const ledgerResult = readLedger(ledgerPath);
311
+ if (!ledgerResult.ok) {
312
+ return { ok: false, code: 1, message: ledgerResult.error };
313
+ }
314
+ const { data } = ledgerResult;
315
+
316
+ const archived = findNewestArchived(archiveDir, id);
317
+ if (!archived) {
318
+ return {
319
+ ok: false,
320
+ code: 1,
321
+ message: `no archived entry found for ${id} under ${archiveDir}`,
322
+ };
323
+ }
324
+
325
+ const hadLive = Object.prototype.hasOwnProperty.call(data, id);
326
+ if (hadLive && !force) {
327
+ return {
328
+ ok: false,
329
+ code: 1,
330
+ message:
331
+ `refusing to overwrite live entry for ${id} — ` +
332
+ `live updatedAt=${data[id]?.updatedAt ?? "unknown"}, ` +
333
+ `archived updatedAt=${archived.entry?.updatedAt ?? "unknown"} ` +
334
+ `(archived ${archived.archivedAt} in ${archived.sourceFile}). ` +
335
+ `Pass --force to overwrite.`,
336
+ };
337
+ }
338
+
339
+ data[id] = archived.entry;
340
+ const newRaw = JSON.stringify(data, null, 2) + "\n";
341
+ writeFileSync(ledgerPath, newRaw, "utf8");
342
+
343
+ return {
344
+ ok: true,
345
+ code: 0,
346
+ id,
347
+ archivedAt: archived.archivedAt,
348
+ sourceFile: archived.sourceFile,
349
+ ledgerPath,
350
+ newLineCount: countLines(newRaw),
351
+ overwritten: hadLive,
352
+ checkedAt: (now instanceof Date ? now : new Date(now)).toISOString(),
353
+ };
354
+ }
355
+
356
+ export function formatHumanRestore(result) {
357
+ if (!result.ok) {
358
+ return `wip restore: ${result.message}`;
359
+ }
360
+ const verb = result.overwritten ? "restored (overwrote live entry)" : "restored";
361
+ return (
362
+ `${verb}: ${result.id}\n` +
363
+ ` archivedAt: ${result.archivedAt}\n` +
364
+ ` from: ${result.sourceFile}\n` +
365
+ ` ledger: ${result.ledgerPath} (now ${result.newLineCount} lines)`
366
+ );
367
+ }
368
+
369
+ // ---- cloud-mode dispatch ----------------------------------------------------
370
+ //
371
+ // `hs wip status`/`hs wip restore` used to be local-filesystem-only: no HTTP,
372
+ // no cloud awareness, so either command against a cloud vault either read a
373
+ // stale/absent local file or silently reported "not found" even though the
374
+ // ledger actually lives in Neon (`wip_ledger` / `wip_ledger_archive` —
375
+ // `spex-console/src/lib/wip-state.ts` + `wip-ledger-archive-neon.ts`). This
376
+ // section detects cloud reliance and, when it is unambiguously configured,
377
+ // dispatches to the console's HTTP API instead of touching the filesystem —
378
+ // mirroring the transport `scripts/lib/hs-companion-settings.mjs`'s
379
+ // `makeCompanionConsoleFetch` already uses (shared `resolveConsoleUrl` + the
380
+ // shared MCP bearer ladder from `vendor/heyspex-mcp/console-fetch.mjs`; there
381
+ // is no WIP-specific loopback token equivalent to `COMPANION_UI_TOKEN`, so the
382
+ // ladder here is just the shared one).
383
+ //
384
+ // Reliance detection is deliberately conservative: `resolveDevMode`
385
+ // (`scripts/lib/dev-mode.mjs`) defaults an UNCONFIGURED machine to
386
+ // prod/cloud (cloud is the default reliance for a HeySpex customer; local is
387
+ // opt-in for a HeySpex admin's own dev machine — see the repo's CLAUDE.md).
388
+ // That default is exactly the case a hermetic test run or a CI box hits (no
389
+ // `HEYSPEX_DEV_MODE` env, no `~/.heyspex/dev-mode.json`), and this file's own
390
+ // existing tests assume LOCAL file behavior with nothing configured. So an
391
+ // UNRESOLVED default is treated as local here — only an EXPLICITLY resolved
392
+ // cloud reliance (`sources.reliance` naming an env override, a machine-local
393
+ // `dev-mode.json` override, or a cloud account setting — anything but
394
+ // "default") switches this CLI to the HTTP path. This keeps every existing
395
+ // local-mode call site (including this file's own test suite) byte-identical
396
+ // when nothing about dev/cloud mode has been configured.
397
+
398
+ /** True only when reliance was EXPLICITLY resolved to "cloud" (never the unconfigured default — see the note above). */
399
+ export function isCloudReliance({ env = process.env, homeDir } = {}) {
400
+ try {
401
+ const resolved = resolveDevMode(homeDir ? { env, homeDir } : { env });
402
+ if (resolved?.sources?.reliance === "default") return false;
403
+ return resolved?.reliance === "cloud";
404
+ } catch {
405
+ return false; // fail open to the historical local-file behavior.
406
+ }
407
+ }
408
+
409
+ /**
410
+ * The console fetch this command uses in cloud mode: vendor transport + the
411
+ * shared URL resolver + the shared MCP bearer ladder (`HEYSPEX_MCP_TOKEN` env
412
+ * → `~/.heyspex/credentials.yaml` `mcp_token`) — the same building blocks
413
+ * `makeCompanionConsoleFetch` in `scripts/lib/hs-companion-settings.mjs` uses.
414
+ */
415
+ export function makeWipConsoleFetch({
416
+ url = null,
417
+ env = process.env,
418
+ homeDir,
419
+ vaultRoot,
420
+ fetchImpl,
421
+ } = {}) {
422
+ const getConsoleUrl = () =>
423
+ (
424
+ url ??
425
+ resolveConsoleUrl({
426
+ repoRoot: vaultRoot ?? env.HS_VAULT_ROOT ?? process.cwd(),
427
+ env,
428
+ ...(homeDir ? { homeDir } : {}),
429
+ })
430
+ ).replace(/\/+$/, "");
431
+ return makeConsoleFetch({
432
+ getConsoleUrl,
433
+ ...(fetchImpl ? { fetchImpl } : {}),
434
+ getAuthHeader: () => getAuthHeader(homeDir ? { env, homeDir } : { env }),
435
+ }).consoleFetch;
436
+ }
437
+
438
+ /** One-line failure text for a non-ok transport result (vendor console-fetch shape). */
439
+ function describeCloudFailure(result, verb = "GET") {
440
+ if (!result || result.status === 0) {
441
+ const detail = result?.error ? ` (${result.error})` : "";
442
+ return result?.authMessage ?? `console unreachable at ${result?.url ?? "?"}${detail}`;
443
+ }
444
+ const body = result.body;
445
+ const msg =
446
+ (body && typeof body === "object" && (body.message ?? body.error ?? body.raw)) ??
447
+ (typeof body === "string" ? body : JSON.stringify(body));
448
+ return `${verb} failed ${result.status}: ${msg}`;
449
+ }
450
+
451
+ /**
452
+ * Cloud-mode `hs wip restore <id>` — POSTs
453
+ * `/api/specs/:id/wip/restore` (`spex-console/src/app/api/specs/[id]/wip/restore/route.ts`)
454
+ * instead of touching a local file. The result shape is close enough to
455
+ * {@link restoreEntry}'s that {@link formatHumanCloudRestore} reads the same
456
+ * way; there is no `ledgerPath`/`sourceFile` in cloud mode, so `consoleUrl`
457
+ * stands in for both.
458
+ */
459
+ export async function restoreEntryCloud({ id, force = false, consoleFetch }) {
460
+ const res = await consoleFetch(`/api/specs/${encodeURIComponent(id)}/wip/restore`, {
461
+ method: "POST",
462
+ body: { force },
463
+ });
464
+ if (res.ok) {
465
+ return {
466
+ ok: true,
467
+ code: 0,
468
+ id,
469
+ archivedAt: res.body?.archivedAt ?? null,
470
+ overwritten: res.body?.overwritten === true,
471
+ consoleUrl: res.url,
472
+ };
473
+ }
474
+ return {
475
+ ok: false,
476
+ code: 1,
477
+ message: describeCloudFailure(res, "POST"),
478
+ };
479
+ }
480
+
481
+ export function formatHumanCloudRestore(result) {
482
+ if (!result.ok) {
483
+ return `wip restore: ${result.message}`;
484
+ }
485
+ const verb = result.overwritten ? "restored (overwrote live entry)" : "restored";
486
+ return (
487
+ `${verb}: ${result.id}\n` +
488
+ ` archivedAt: ${result.archivedAt}\n` +
489
+ ` console: ${result.consoleUrl}`
490
+ );
491
+ }
492
+
493
+ /**
494
+ * Cloud-mode `hs wip status` — builds a report shaped closely enough to
495
+ * {@link buildReport}'s that {@link formatHumanCloudReport} reads the same
496
+ * way, from `GET /api/wip` (the whole ledger for the caller's vault) instead
497
+ * of a local file. There is no cloud route for an archive summary today, so
498
+ * `archive` is reported `null` rather than fabricated — the
499
+ * `/api/cron/wip-archive-purge` retention job is the only visibility into the
500
+ * cloud archive right now.
501
+ */
502
+ export async function buildCloudReport({
503
+ consoleFetch,
504
+ now = new Date(),
505
+ budgetLines = BUDGET_LINES,
506
+ }) {
507
+ const res = await consoleFetch("/api/wip");
508
+ if (!res.ok) {
509
+ return { ok: false, error: describeCloudFailure(res, "GET"), consoleUrl: res.url };
510
+ }
511
+ const state =
512
+ res.body?.wip_state && typeof res.body.wip_state === "object" ? res.body.wip_state : {};
513
+ const ids = Object.keys(state);
514
+
515
+ const tiers = { fresh: 0, aging: 0, stale: 0 };
516
+ let oldest = null;
517
+ let largest = null;
518
+ for (const id of ids) {
519
+ const entry = state[id] ?? {};
520
+ const ageDays = ageDaysFrom(entry.updatedAt, now);
521
+ tiers[classifyAge(ageDays)] += 1;
522
+ if (!oldest || ageDays > oldest.ageDays) oldest = { id, ageDays };
523
+ const sizeBytes = Buffer.byteLength(JSON.stringify(entry), "utf8");
524
+ if (!largest || sizeBytes > largest.sizeBytes) {
525
+ largest = { id, sizeBytes, sizeKB: sizeBytes / 1024 };
526
+ }
527
+ }
528
+
529
+ return {
530
+ ok: true,
531
+ consoleUrl: res.url,
532
+ budgetLines,
533
+ entryCount: ids.length,
534
+ tiers,
535
+ oldest: oldest ? { id: oldest.id, ageDays: oldest.ageDays } : null,
536
+ largest: largest ? { id: largest.id, sizeKB: largest.sizeKB } : null,
537
+ archive: null,
538
+ checkedAt: (now instanceof Date ? now : new Date(now)).toISOString(),
539
+ };
540
+ }
541
+
542
+ export function formatHumanCloudReport(report) {
543
+ if (!report.ok) return `wip status: ${report.error}`;
544
+ const lines = [
545
+ `console: ${report.consoleUrl}`,
546
+ `entries: ${report.entryCount}`,
547
+ `age tiers: fresh ${report.tiers.fresh} (<=${FRESH_MAX_DAYS}d), aging ${report.tiers.aging} (${FRESH_MAX_DAYS}-${STALE_MIN_DAYS}d), stale ${report.tiers.stale} (>${STALE_MIN_DAYS}d)`,
548
+ report.oldest
549
+ ? `oldest entry: ${report.oldest.id} (${report.oldest.ageDays.toFixed(1)} days old)`
550
+ : "oldest entry: (ledger is empty)",
551
+ report.largest
552
+ ? `largest entry: ${report.largest.id} (${report.largest.sizeKB.toFixed(1)} KB)`
553
+ : "largest entry: (ledger is empty)",
554
+ "archive: cloud archive summary not available from this CLI yet — see the wip-archive-purge cron for retention",
555
+ ];
556
+ return lines.join("\n");
557
+ }
558
+
559
+ // ---- CLI entrypoint ---------------------------------------------------------
560
+
561
+ function usageText() {
562
+ return [
563
+ "hs wip — WIP ledger health + restore",
564
+ "",
565
+ " hs wip status [--json] Health report on the live ledger",
566
+ " hs wip restore <id> [--json] [--force] Restore an archived entry onto the live ledger",
567
+ "",
568
+ " Against a cloud vault (explicitly configured cloud reliance), both",
569
+ " subcommands talk to the console's HTTP API instead of the local file.",
570
+ ].join("\n");
571
+ }
572
+
573
+ export function main(argv = process.argv.slice(2)) {
574
+ const [sub, ...rest] = argv;
575
+ const asJson = rest.includes("--json");
576
+ const vaultRoot = defaultVaultRoot();
577
+ const ledgerPath = defaultLedgerPath(vaultRoot);
578
+ const archiveDir = defaultArchiveDir(vaultRoot);
579
+
580
+ const emit = (text, code) => {
581
+ const stream = code === 0 ? process.stdout : process.stderr;
582
+ stream.write(text.endsWith("\n") ? text : text + "\n");
583
+ process.exit(code);
584
+ };
585
+
586
+ const cloud = isCloudReliance();
587
+
588
+ if (sub === "status") {
589
+ if (cloud) {
590
+ const consoleFetch = makeWipConsoleFetch({ vaultRoot });
591
+ buildCloudReport({ consoleFetch }).then(
592
+ (report) => {
593
+ if (asJson) {
594
+ emit(JSON.stringify(report, null, 2), report.ok ? 0 : 1);
595
+ return;
596
+ }
597
+ emit(formatHumanCloudReport(report), report.ok ? 0 : 1);
598
+ },
599
+ (err) => emit(`wip status: ${err?.message ?? err}`, 1),
600
+ );
601
+ return;
602
+ }
603
+ const report = buildReport({ ledgerPath, archiveDir });
604
+ if (asJson) {
605
+ emit(JSON.stringify(report, null, 2), report.ok ? 0 : 1);
606
+ return;
607
+ }
608
+ emit(formatHumanReport(report), report.ok ? 0 : 1);
609
+ return;
610
+ }
611
+
612
+ if (sub === "restore") {
613
+ const positional = rest.filter((a) => !a.startsWith("--"));
614
+ const id = positional[0];
615
+ const force = rest.includes("--force");
616
+ if (!id) {
617
+ emit(
618
+ "hs wip restore: missing <id>.\n" + usageText(),
619
+ EXIT.BAD_INVOCATION,
620
+ );
621
+ return;
622
+ }
623
+ if (cloud) {
624
+ const consoleFetch = makeWipConsoleFetch({ vaultRoot });
625
+ restoreEntryCloud({ id, force, consoleFetch }).then(
626
+ (result) => {
627
+ if (asJson) {
628
+ emit(JSON.stringify(result, null, 2), result.ok ? 0 : result.code);
629
+ return;
630
+ }
631
+ emit(formatHumanCloudRestore(result), result.ok ? 0 : result.code);
632
+ },
633
+ (err) => emit(`wip restore: ${err?.message ?? err}`, 1),
634
+ );
635
+ return;
636
+ }
637
+ const result = restoreEntry({ id, ledgerPath, archiveDir, force });
638
+ if (asJson) {
639
+ emit(JSON.stringify(result, null, 2), result.ok ? 0 : result.code);
640
+ return;
641
+ }
642
+ emit(formatHumanRestore(result), result.ok ? 0 : result.code);
643
+ return;
644
+ }
645
+
646
+ emit(usageText(), EXIT.BAD_INVOCATION);
647
+ }
648
+
649
+ const invokedDirectly =
650
+ process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
651
+ if (invokedDirectly) {
652
+ main();
653
+ }
@@ -0,0 +1,29 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "edition": "baseline",
4
+ "learnedAt": "2026-09-11T10:05:22.053Z",
5
+ "curatedAt": "2026-10-03",
6
+ "_comment": "Baseline edition of SpexCode Doctrine: the foundational layer that ships in the heyspex npm package and is injected into customer sessions (owner ruling 2026-10-03, task HS-A2PO). Every principle is copied verbatim from the learned set (spexharness/doctrine/SPEXCODE-DOCTRINE.json, learnedAt above), with its confidence kept and its evidence ids dropped (they name the owner's own sessions). Selection rule: generic engineering practice only. Never add a rule about the spex-console dev server, Neon, the COO seat, dev-factory, the owner's own machine or preferences, or any heyspex-app path; spexharness/src/doctrine/inject.test.mjs fails if one appears. A customer's own learned layer is built on top of this file, never written into it.",
7
+ "sources": {
8
+ "curatedFrom": "spexharness/doctrine/SPEXCODE-DOCTRINE.json",
9
+ "principleCountInSource": 1481
10
+ },
11
+ "principles": [
12
+ { "text": "Treat a spec as done only after its own gate has run and exact pass/fail counts are reported.", "category": "verification", "confidence": 0.92, "stance": "do", "evidence": [] },
13
+ { "text": "Before reporting done, re-check every item in the stated range, not just the one flagged as missed, and explain the cause of any gap.", "category": "verification", "confidence": 0.9, "stance": "do", "evidence": [] },
14
+ { "text": "Back every claimed acceptance criterion with something actually run — tests, typecheck, lint, or measured timings — instead of asserting completion.", "category": "verification", "confidence": 0.9, "stance": "do", "evidence": [] },
15
+ { "text": "Never claim a build complete or flip status on faith; only after typecheck and the real tests are green.", "category": "verification", "confidence": 0.88, "stance": "avoid", "evidence": [] },
16
+ { "text": "When an exit test is unmet or a regression is found, say so explicitly in the artefact instead of papering over it or closing on a number you set out to beat and did not.", "category": "verification", "confidence": 0.85, "stance": "do", "evidence": [] },
17
+ { "text": "Report exactly what was not done, could not be done, or failed, and name the blocker, rather than glossing over an untested branch or a red acceptance criterion.", "category": "verification", "confidence": 0.85, "stance": "do", "evidence": [] },
18
+ { "text": "Touch only the files in scope; when an out-of-scope edit is unavoidable, keep it minimal and flag it explicitly.", "category": "session-hygiene", "confidence": 0.88, "stance": "do", "evidence": [] },
19
+ { "text": "When a design question is open, research it with the designated tools instead of guessing an answer or a number.", "category": "research-grounding", "confidence": 0.88, "stance": "do", "evidence": [] },
20
+ { "text": "Confirming an outline or stub is not doing the work; fill every section with real sourced content before reporting research done.", "category": "research-grounding", "confidence": 0.85, "stance": "avoid", "evidence": [] },
21
+ { "text": "Irreversible or transactional writes are two-phase: present a proposal and take a real human confirmation turn before executing, never in the same turn the proposal is authored.", "category": "safety-permissions", "confidence": 0.85, "stance": "do", "evidence": [] },
22
+ { "text": "Run at the most restrictive permission mode until the folder is trusted, and never auto-approve a block-severity gate.", "category": "safety-permissions", "confidence": 0.85, "stance": "do", "evidence": [] },
23
+ { "text": "Disclose known out-of-scope failures, deliberate deviations, and residual risk in the final report rather than burying them.", "category": "communication", "confidence": 0.85, "stance": "do", "evidence": [] },
24
+ { "text": "End every close-out or status report with an explicit per-item outcome word (fixed, partly fixed, not fixed, not started), stated before the detail, so a found bug is never left ambiguous.", "category": "communication", "confidence": 0.85, "stance": "do", "evidence": [] },
25
+ { "text": "Session titles stay short and descriptive in plain words, never raw spec/task ids, and remain sticky through follow-ups and sub-steps rather than changing at each turn.", "category": "naming", "confidence": 0.85, "stance": "do", "evidence": [] },
26
+ { "text": "Parallelise independent workstreams by spawning sub-agents instead of serialising everything in the main thread.", "category": "delegation", "confidence": 0.84, "stance": "do", "evidence": [] }
27
+ ],
28
+ "conflicts": []
29
+ }