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
@@ -10,8 +10,26 @@
10
10
  * 3. missing_manifest_refs — roadmap manifest entry pointing to a missing file
11
11
  * (basename/unique-id match only — family idCore soft-match is intentionally
12
12
  * NOT enough, so stale Naming V3 era-1/2 listings cannot greenlight).
13
+ * Every other ref resolves file-key-first (see buildSpecIndex /
14
+ * resolveSpecRef below, shared with register-spec.mjs): the family+TYPE
15
+ * core is a soft match for key-less legacy refs only, and only when unique.
13
16
  * 4. parent_child_mismatches — one side of a parent/child link not pointing back.
14
17
  *
18
+ * Plus one INFORMATIONAL (non-defect) bucket:
19
+ *
20
+ * 5. frozen_parent_child_mismatches — a parent/child mismatch where at least
21
+ * one side is `status: closed` / `status: superseded`. Per
22
+ * [[closed-trackable-specs-are-historical-record-not-living-docs]] and
23
+ * [[spec-hierarchy]] §"Closed specs are historical record — frozen +
24
+ * immutable", a closed/superseded spec's frontmatter is frozen: only the
25
+ * close-out change-log row, `status:`, `close_reason:` and `superseded_by:`
26
+ * may still be touched. A `children:` / `epics:` roster is NOT on that
27
+ * list, so roster bookkeeping on a frozen spec is a FORBIDDEN edit and the
28
+ * mismatch is unfixable by protocol — a validator false positive, not vault
29
+ * debt. These are reported separately so a reader can see they exist and
30
+ * why they are excluded, and they do NOT affect the top-level `valid`
31
+ * verdict.
32
+ *
15
33
  * Reachability is expressed in the manifest-v3 hierarchy
16
34
  * ([[HS-TASK-JN9H-manifest-schema-v3-and-horizon-retirement]]):
17
35
  * roadmap → milestones (roadmap_manifest.milestones)
@@ -31,13 +49,33 @@
31
49
  */
32
50
  import { readFileSync, readdirSync, existsSync } from 'node:fs';
33
51
  import { join, relative } from 'node:path';
34
- import { toCanonicalFamilyFirstId } from './spec-id-lib.mjs';
52
+ import {
53
+ toCanonicalFamilyFirstId,
54
+ parseSpecId,
55
+ fileKeyFromBasename,
56
+ lookupFileKeyOf,
57
+ bareFileKeyOf,
58
+ } from './spec-id-lib.mjs';
35
59
 
36
60
  // Entity kinds that must be reachable from the roadmap. Everything else
37
61
  // (research, protocol, memory-*, capture, diagnose, agent, review, adr, …) is
38
62
  // exempt — those are referenced by wikilink, not parented into the manifest.
39
63
  const SUBJECT_TYPES = new Set(['milestone', 'epic', 'task']);
40
64
 
65
+ // Statuses that freeze a spec's frontmatter as historical record. Roster
66
+ // bookkeeping (`children:` / `epics:`) on one of these is a forbidden edit per
67
+ // [[closed-trackable-specs-are-historical-record-not-living-docs]] — the roster
68
+ // is accurate to what was true at close, and "fixing" it corrupts the record.
69
+ const FROZEN_STATUSES = new Set(['closed', 'superseded']);
70
+
71
+ // A spec is frozen when its `status:` is closed/superseded. Records built by an
72
+ // older caller (or a test fixture) may carry no `status` at all — absent status
73
+ // is never frozen, so the defect path stays the default.
74
+ function isFrozenSpec(spec) {
75
+ const status = spec && typeof spec.status === 'string' ? spec.status.trim().toLowerCase() : '';
76
+ return FROZEN_STATUSES.has(status);
77
+ }
78
+
41
79
  // ---------------------------------------------------------------------------
42
80
  // Frontmatter extraction (regex-over-raw — byte-tolerant, no YAML dependency)
43
81
  // ---------------------------------------------------------------------------
@@ -73,6 +111,47 @@ function listItems(block, field) {
73
111
  return out;
74
112
  }
75
113
 
114
+ // `field:` may be written as an inline YAML flow list — `aliases: [foo, bar]`
115
+ // — rather than the block-list form `listItems()` handles. Splits on commas;
116
+ // each item is unquoted/trimmed. Returns null when the field is absent or not
117
+ // a flow list (caller falls back to listItems()).
118
+ function flowListItems(block, field) {
119
+ // Quote-aware: an item may contain `,` or `]` inside quotes (e.g.
120
+ // `"/report/[token]"`, `"[[basename]] links"`), so the closing `]` is the one
121
+ // that ends the line, not the first one seen.
122
+ const m = block.match(new RegExp(`^[ \\t]*${field}:[ \\t]*\\[(.*)\\][ \\t]*$`, 'm'));
123
+ if (!m) return null;
124
+ const inner = m[1].trim();
125
+ if (!inner) return [];
126
+ const items = [];
127
+ let cur = '';
128
+ let quote = null;
129
+ for (const ch of inner) {
130
+ if (quote) {
131
+ if (ch === quote) quote = null;
132
+ else cur += ch;
133
+ } else if (ch === '"' || ch === "'") {
134
+ quote = ch;
135
+ } else if (ch === ',') {
136
+ items.push(cur);
137
+ cur = '';
138
+ } else {
139
+ cur += ch;
140
+ }
141
+ }
142
+ items.push(cur);
143
+ return items.map((x) => x.trim()).filter(Boolean);
144
+ }
145
+
146
+ // `aliases:` — HS-WEGK-OQGQ: a wikilink target matching any of a spec's
147
+ // declared aliases must resolve, honoring both the inline flow-list form
148
+ // (`aliases: [foo, bar]`) and the block-list form.
149
+ function aliasesField(block) {
150
+ const flow = flowListItems(block, 'aliases');
151
+ if (flow !== null) return flow;
152
+ return listItems(block, 'aliases');
153
+ }
154
+
76
155
  // `[[Foo|alias]]` / `[[Foo#anchor]]` / `Foo` → `Foo`. Returns null for empties.
77
156
  function stripWikilink(value) {
78
157
  if (typeof value !== 'string') return null;
@@ -97,6 +176,237 @@ function idCore(value) {
97
176
  return m ? m[1].toUpperCase() : null;
98
177
  }
99
178
 
179
+ // ---------------------------------------------------------------------------
180
+ // Naming V3 identity ([[HS-J6IE-K2EL-DIAG-heyspex-register-spec-resolves-a-task]])
181
+ //
182
+ // Under Naming V3 (`HS-<fileKey>-<familyKey>-<TYPE>-<slug>`) the UNIQUE key of a
183
+ // spec is its FILE key — the token right after `HS-`. The family key is a
184
+ // surname shared by a whole milestone's descendants, so `idCore` (family+TYPE)
185
+ // is NOT an identity: two tasks in one family (HS-OCL4-NYPR-TASK-… and
186
+ // HS-0HOF-NYPR-TASK-…) collapse to the same idCore. A first-wins idCore map
187
+ // therefore resolved one to the other and `heyspex_register_spec` re-parented
188
+ // an unrelated spec. The helpers below are the ONE resolution rule both this
189
+ // module and register-spec.mjs use:
190
+ //
191
+ // basename (exact) → id (exact) → file key (unique) → [aliases, opt-in]
192
+ // → idCore soft match ONLY for a ref that carries no file key, and ONLY when
193
+ // exactly one spec matches; two or more → AMBIGUOUS (candidates named).
194
+ // A bare family token (`HS-NYPR`) that matches no file key but names a
195
+ // family is AMBIGUOUS too, never a silent pick.
196
+ // ---------------------------------------------------------------------------
197
+
198
+ // File key carried by a ref in any accepted form: bare `HS-OCL4`, display
199
+ // `HS-OCL4-NYPR`, full era-3 id/basename, a `[[wikilink]]` already stripped by
200
+ // the caller, or an era-1/2 basename whose slug leads with the key.
201
+ function refFileKey(ref) {
202
+ if (typeof ref !== 'string' || !ref.trim()) return null;
203
+ // Pasted/typed refs may arrive as `hs-ocl4`; the stored shape is `HS-`.
204
+ const t = ref.trim().replace(/^hs-/i, 'HS-');
205
+ return lookupFileKeyOf(t) ?? fileKeyFromBasename(t) ?? null;
206
+ }
207
+
208
+ // The file key a spec record is STORED under: basename first (the era-3
209
+ // filename always leads with it), then the stored `id:` — but only in its
210
+ // stored shapes (bare `HS-OCL4` or a full era-3 id). The two-token DISPLAY form
211
+ // lookupFileKeyOf accepts for pasted refs is never a stored id, and reading it
212
+ // here would misfile e.g. `HS-RECIPE-feature` under file key "RECIPE".
213
+ function storedFileKey(basename, id) {
214
+ const fromBasename = refFileKey(basename);
215
+ if (fromBasename) return fromBasename;
216
+ if (typeof id !== 'string' || !id.trim()) return null;
217
+ const bare = bareFileKeyOf(id);
218
+ if (bare) return bare;
219
+ const p = parseSpecId(id);
220
+ if (p?.era === 3 && p.fileKey) return p.fileKey;
221
+ return fileKeyFromBasename(id) ?? null;
222
+ }
223
+
224
+ export function specFileKey(spec) {
225
+ if (!spec) return null;
226
+ if (spec.fileKey !== undefined) return spec.fileKey;
227
+ return storedFileKey(spec.basename, spec.id);
228
+ }
229
+
230
+ // Family key of a spec record: explicit frontmatter `family:` when the walker
231
+ // captured it, else the positional family parsed from basename/id.
232
+ function specFamilyKey(spec) {
233
+ if (!spec) return null;
234
+ if (typeof spec.family === 'string' && spec.family.trim()) return spec.family.trim().toUpperCase();
235
+ const p = parseSpecId(spec.basename) ?? parseSpecId(spec.id);
236
+ return p?.familyKey ?? null;
237
+ }
238
+
239
+ const pushMulti = (map, key, value) => {
240
+ if (!key) return;
241
+ const arr = map.get(key);
242
+ if (arr) arr.push(value);
243
+ else map.set(key, [value]);
244
+ };
245
+
246
+ /**
247
+ * Build the lookup index every resolver below reads. Collisions are KEPT (each
248
+ * multimap holds every spec for a key) so a lookup can refuse ambiguity instead
249
+ * of taking the first file the directory walk happened to yield.
250
+ */
251
+ export function buildSpecIndex(specs) {
252
+ const byBasename = new Map();
253
+ const byId = new Map(); // id → spec[]
254
+ const byFileKey = new Map(); // fileKey → spec[]
255
+ const byIdCore = new Map(); // family+TYPE core → spec[]
256
+ const byFamily = new Map(); // familyKey → spec[]
257
+ const byAlias = new Map();
258
+ for (const s of specs) {
259
+ byBasename.set(s.basename, s);
260
+ if (s.id) pushMulti(byId, s.id, s);
261
+ pushMulti(byFileKey, specFileKey(s), s);
262
+ const core = s.idCore ?? idCore(s.id) ?? idCore(s.basename);
263
+ pushMulti(byIdCore, core, s);
264
+ pushMulti(byFamily, specFamilyKey(s), s);
265
+ for (const a of s.aliases || []) {
266
+ if (a && !byAlias.has(a)) byAlias.set(a, s);
267
+ }
268
+ }
269
+ // A review companion (`type: review` — `<subject>.review.md` beside an epic,
270
+ // or `HS-<key>-<family>-REV-…` under specs/reviews) carries its SUBJECT's
271
+ // file key by convention. That is a pairing, not an identity collision, so
272
+ // the subject keeps sole claim to the key; two non-review files on one key
273
+ // stay a real (refused) collision.
274
+ const preferSubjects = (map) => {
275
+ for (const [key, arr] of map) {
276
+ if (arr.length < 2) continue;
277
+ const subjects = arr.filter((s) => s.type !== 'review');
278
+ if (subjects.length >= 1 && subjects.length < arr.length) map.set(key, subjects);
279
+ }
280
+ };
281
+ preferSubjects(byFileKey);
282
+ preferSubjects(byIdCore);
283
+ preferSubjects(byFamily);
284
+ return { byBasename, byId, byFileKey, byIdCore, byFamily, byAlias };
285
+ }
286
+
287
+ // Title slug of an id/basename in any era, or '' (used only to break an
288
+ // idCore tie between a slugged legacy ref and the one sibling that kept its slug).
289
+ function titleSlugOf(value) {
290
+ const p = parseSpecId(value);
291
+ return p?.titleSlug ? p.titleSlug.toLowerCase() : '';
292
+ }
293
+
294
+ const candidateList = (specs) =>
295
+ specs.map((s) => ({ id: s.id, basename: s.basename, file: s.file ?? null }));
296
+
297
+ /**
298
+ * Resolve one reference against the index.
299
+ *
300
+ * @returns {{ spec: object|null, ambiguous: boolean, candidates: object[], matched_by: string|null }}
301
+ * `spec` is set on a unique hit; `ambiguous: true` + `candidates` when the
302
+ * ref names more than one spec; both empty when nothing matched.
303
+ */
304
+ export function resolveSpecRef(index, ref, { aliases = false, pathFallback = false } = {}) {
305
+ const none = { spec: null, ambiguous: false, candidates: [], matched_by: null };
306
+ if (!ref) return none;
307
+ const raw = stripWikilink(ref) ?? String(ref);
308
+ const t = raw.replace(/\.md$/i, '').trim();
309
+ if (!t) return none;
310
+
311
+ const unique = (arr, how) =>
312
+ arr && arr.length === 1
313
+ ? { spec: arr[0], ambiguous: false, candidates: [], matched_by: how }
314
+ : arr && arr.length > 1
315
+ ? { spec: null, ambiguous: true, candidates: candidateList(arr), matched_by: how }
316
+ : null;
317
+
318
+ const exact = index.byBasename.get(t);
319
+ if (exact) return { spec: exact, ambiguous: false, candidates: [], matched_by: 'basename' };
320
+
321
+ const byId = unique(index.byId.get(t), 'id');
322
+ if (byId) return byId;
323
+
324
+ const fileKey = refFileKey(t);
325
+ if (fileKey) {
326
+ const hit = unique(index.byFileKey.get(fileKey), 'file_key');
327
+ if (hit) return hit;
328
+ }
329
+
330
+ if (aliases) {
331
+ const alias = index.byAlias.get(t);
332
+ if (alias) return { spec: alias, ambiguous: false, candidates: [], matched_by: 'alias' };
333
+ }
334
+
335
+ // A ref that CARRIES a file key named exactly one spec or nothing — never a
336
+ // sibling. Only a key-less (era-1/2) ref may fall through to the soft match.
337
+ if (!fileKey) {
338
+ const coreHits = index.byIdCore.get(idCore(t));
339
+ if (coreHits && coreHits.length > 1) {
340
+ // Pre-rename slugged link (`HS-Q1HZ-TASK-hs-vault-cli-…`) vs the era-3
341
+ // sibling that kept that exact slug (`HS-GLPP-Q1HZ-TASK-hs-vault-cli-…`):
342
+ // family + TYPE + identical slug is one file, not a family. Slug-less or
343
+ // differently-slugged refs stay ambiguous.
344
+ const slug = titleSlugOf(t);
345
+ const sameSlug = slug ? coreHits.filter((s) => titleSlugOf(s.basename) === slug) : [];
346
+ if (sameSlug.length === 1) {
347
+ return { spec: sameSlug[0], ambiguous: false, candidates: [], matched_by: 'id_core_slug' };
348
+ }
349
+ }
350
+ const hit = unique(coreHits, 'id_core');
351
+ if (hit) return hit;
352
+ }
353
+
354
+ // Bare token (`HS-XXXX`): bareFileKeyOf is shape-only, so a FAMILY key reads
355
+ // as a file key too. No file-key hit above → if it names a family, refuse.
356
+ const bare = bareFileKeyOf(t);
357
+ if (bare) {
358
+ const fam = index.byFamily.get(bare);
359
+ if (fam && fam.length) {
360
+ return { spec: null, ambiguous: true, candidates: candidateList(fam), matched_by: 'family' };
361
+ }
362
+ }
363
+
364
+ if (pathFallback && t.includes('/')) {
365
+ const base = t.split('/').pop();
366
+ const b = index.byBasename.get(base);
367
+ if (b) return { spec: b, ambiguous: false, candidates: [], matched_by: 'basename' };
368
+ const i = unique(index.byId.get(base), 'id');
369
+ if (i) return i;
370
+ if (aliases) {
371
+ const a = index.byAlias.get(base);
372
+ if (a) return { spec: a, ambiguous: false, candidates: [], matched_by: 'alias' };
373
+ }
374
+ }
375
+ return none;
376
+ }
377
+
378
+ /**
379
+ * Does `ref` (a list entry / pointer field) name exactly `spec`? Exact
380
+ * basename or id, or the same file key. The family+TYPE idCore is accepted
381
+ * ONLY when neither side carries a file key (two legacy era-1/2 strings) —
382
+ * once a file key exists on either side it is the identity, and a stale
383
+ * family-core string must not be mistaken for a reference to this spec.
384
+ */
385
+ export function refNamesSpec(ref, spec) {
386
+ if (!ref || !spec) return false;
387
+ const t = (stripWikilink(ref) ?? String(ref)).replace(/\.md$/i, '').trim();
388
+ if (!t) return false;
389
+ if (t === spec.basename || t === spec.id) return true;
390
+ const refKey = refFileKey(t);
391
+ const specKey = specFileKey(spec);
392
+ if (refKey || specKey) return !!refKey && refKey === specKey;
393
+ const core = spec.idCore ?? idCore(spec.id) ?? idCore(spec.basename);
394
+ return !!core && idCore(t) === core;
395
+ }
396
+
397
+ /** Text-level twin of {@link refNamesSpec} for two bare ref strings. */
398
+ export function refsNameSameSpec(a, b) {
399
+ const ta = (stripWikilink(a) ?? String(a ?? '')).replace(/\.md$/i, '').trim();
400
+ const tb = (stripWikilink(b) ?? String(b ?? '')).replace(/\.md$/i, '').trim();
401
+ if (!ta || !tb) return false;
402
+ if (ta === tb) return true;
403
+ const ka = refFileKey(ta);
404
+ const kb = refFileKey(tb);
405
+ if (ka || kb) return !!ka && ka === kb;
406
+ const ca = idCore(ta);
407
+ return !!ca && ca === idCore(tb);
408
+ }
409
+
100
410
  // Every "real" `[[wikilink]]` in the file, with 1-based line numbers for
101
411
  // actionable reporting. Links inside fenced code blocks (``` / ~~~) and inline
102
412
  // code spans (`...`) are skipped — specs routinely show `[[example]]` syntax as
@@ -182,6 +492,33 @@ function extractLineageTableLinks(raw) {
182
492
  * Read every `.md` under `<vaultRoot>/specs/` into a normalized record the
183
493
  * analyzer consumes. Exported so the analyzer can be unit-tested with synthetic
184
494
  * records (no disk) while the real path walks the vault.
495
+ *
496
+ * ## Why this one stays a whole-vault walk
497
+ *
498
+ * Judged by [[HS-FDVS-8KK5-TASK-route-sweep-every-api-route-reads]] under
499
+ * [[HS-49AL-8KK5-EPIC-hot-row-cache-and-bounded-agent-tools]], and kept.
500
+ * Every answer this module produces is a property of the ENTIRE graph, not of
501
+ * any page of it:
502
+ *
503
+ * - `orphan_specs` — "not reachable from the roadmap" is only knowable once
504
+ * every edge has been seen. A page cannot tell an orphan from a spec whose
505
+ * parent lives on the next page.
506
+ * - `broken_wikilinks` — a link resolves against the set of all filenames.
507
+ * - `parent_child_mismatches` — needs both ends of every link at once.
508
+ *
509
+ * Paging the input would not make the analysis cheaper, only wrong. Note also
510
+ * what this is NOT: it is not a console request path. It runs from the
511
+ * `heyspex_validate_linkage` MCP tool and the pre-commit `validate` CLI verb —
512
+ * both explicitly whole-vault operations a human or agent asked for, and both
513
+ * outside the "no request path does work proportional to vault size" rule the
514
+ * epic enforces. Its RESPONSE size is bounded separately by
515
+ * [[HS-4H8T-8KK5-TASK-every-mcp-read-tool-declares-a-byte]].
516
+ *
517
+ * The walk itself retires when linkage validity is maintained incrementally
518
+ * from the outbox instead of recomputed by re-reading the vault
519
+ * ([[HS-D26S-8KK5-EPIC-incremental-indexing-from-the-outbox]]); no task under
520
+ * it covers this module yet, so that is the follow-up to file, not a fix to
521
+ * make here.
185
522
  */
186
523
  export function collectVaultSpecs(vaultRoot) {
187
524
  const specsDir = join(vaultRoot, 'specs');
@@ -202,11 +539,30 @@ export function collectVaultSpecs(vaultRoot) {
202
539
  specs.push({
203
540
  id,
204
541
  type,
542
+ // `status:` drives the frozen-spec carve-out for parent/child mismatches
543
+ // (see FROZEN_STATUSES). Read as a plain scalar — absent means not frozen.
544
+ status: scalarField(block, 'status'),
205
545
  basename,
206
546
  idCore: idCore(id) || idCore(basename),
547
+ // Naming V3 identity: the file key is the UNIQUE key (see specFileKey);
548
+ // `family:` is the shared surname, kept only so a bare family token can
549
+ // be refused as ambiguous rather than silently matched.
550
+ fileKey: storedFileKey(basename, id),
551
+ family: scalarField(block, 'family'),
207
552
  file: relative(vaultRoot, abs).split('\\').join('/'),
208
- parent: stripWikilink(scalarField(block, 'parent')),
209
- milestone: stripWikilink(scalarField(block, 'milestone')),
553
+ aliases: aliasesField(block),
554
+ parent: stripWikilink(scalarField(block, 'parent')) || (() => {
555
+ // HS-OZFL-OPVL: `parent:` written as a one-item YAML block list rather
556
+ // than the canonical scalar form must still be read, or the spec is
557
+ // falsely flagged as an orphan.
558
+ const items = listItems(block, 'parent').map(stripWikilink).filter(Boolean);
559
+ return items[0] ?? null;
560
+ })(),
561
+ milestone: stripWikilink(scalarField(block, 'milestone')) || (() => {
562
+ // HS-OZFL-OPVL: same list-form fallback as `parent:` above, for epics.
563
+ const items = listItems(block, 'milestone').map(stripWikilink).filter(Boolean);
564
+ return items[0] ?? null;
565
+ })(),
210
566
  children: listItems(block, 'children').map(stripWikilink).filter(Boolean),
211
567
  epics: listItems(block, 'epics').map(stripWikilink).filter(Boolean),
212
568
  related: listItems(block, 'related').map(stripWikilink).filter(Boolean),
@@ -243,20 +599,19 @@ export function collectVaultSpecs(vaultRoot) {
243
599
  * `--staged-only`). null = whole vault.
244
600
  */
245
601
  export function analyzeLinkage(specs, { includeWarnings = true, scopeIds = null } = {}) {
246
- const byBasename = new Map();
247
- const byId = new Map();
248
- const byIdCore = new Map();
249
- for (const s of specs) {
250
- byBasename.set(s.basename, s);
251
- if (s.id) byId.set(s.id, s);
252
- if (s.idCore && !byIdCore.has(s.idCore)) byIdCore.set(s.idCore, s);
253
- }
254
-
255
- const resolveRef = (ref) => {
256
- if (!ref) return null;
257
- const t = stripWikilink(ref) ?? ref;
258
- return byBasename.get(t) || byId.get(t) || byIdCore.get(idCore(t)) || null;
259
- };
602
+ // One shared index + resolver with register-spec.mjs (HS-J6IE): basename →
603
+ // id → unique file key → alias → idCore soft match (key-less refs only, and
604
+ // only when unique). An AMBIGUOUS ref (names two specs in one family) is
605
+ // treated as NOT resolving here — it cannot be a valid edge to one spec, and
606
+ // it must never be satisfied by whichever sibling the walk met first.
607
+ //
608
+ // HS-WEGK-OQGQ: `.md` is stripped and a folder-prefixed link
609
+ // (`[[protocols/testing-strategy]]`) falls back to its last path segment so
610
+ // it resolves folder-agnostically (spex-console/src/lib/vault-shared.ts).
611
+ const index = buildSpecIndex(specs);
612
+ const byBasename = index.byBasename;
613
+ const byId = index.byId;
614
+ const resolveRef = (ref) => resolveSpecRef(index, ref, { aliases: true, pathFallback: true }).spec;
260
615
  // Manifest entries must name an on-disk basename (or unique bare id). Family
261
616
  // idCore alone is NOT enough — after Naming V3, an era-1/2 `HS-<family>-MIL-…`
262
617
  // string shares idCore with the era-3 file but does not match the console's
@@ -264,13 +619,11 @@ export function analyzeLinkage(specs, { includeWarnings = true, scopeIds = null
264
619
  const resolveManifestRef = (ref) => {
265
620
  if (!ref) return null;
266
621
  const t = stripWikilink(ref) ?? ref;
267
- return byBasename.get(t) || byId.get(t) || null;
622
+ const ids = byId.get(t);
623
+ return byBasename.get(t) || (ids && ids.length === 1 ? ids[0] : null) || null;
268
624
  };
269
625
  const refResolves = (ref) => resolveRef(ref) != null;
270
- const refMatchesSpec = (ref, spec) => {
271
- const t = stripWikilink(ref) ?? ref;
272
- return t === spec.basename || t === spec.id || (!!spec.idCore && idCore(t) === spec.idCore);
273
- };
626
+ const refMatchesSpec = (ref, spec) => refNamesSpec(ref, spec);
274
627
  const listContainsSpec = (list, spec) => list.some((ref) => refMatchesSpec(ref, spec));
275
628
 
276
629
  // Manifest reachability index — union of every roadmap's milestone entries.
@@ -285,11 +638,13 @@ export function analyzeLinkage(specs, { includeWarnings = true, scopeIds = null
285
638
  }
286
639
  const milestoneInManifest = (spec) =>
287
640
  manifestMilestoneRefs.has(spec.basename) || manifestMilestoneRefs.has(spec.id);
288
- const inScope = (spec) =>
289
- !scopeIds ||
290
- scopeIds.has(spec.id) ||
291
- scopeIds.has(spec.basename) ||
292
- (!!spec.idCore && scopeIds.has(spec.idCore));
641
+ const inScope = (spec) => {
642
+ if (!scopeIds) return true;
643
+ if (scopeIds.has(spec.id) || scopeIds.has(spec.basename)) return true;
644
+ const key = specFileKey(spec);
645
+ if (key && (scopeIds.has(`HS-${key}`) || scopeIds.has(key))) return true;
646
+ return !!spec.idCore && scopeIds.has(spec.idCore);
647
+ };
293
648
 
294
649
  // Amends reverse map ([[AMENDS-CONTRACT]] / HS-6DMR-EPIC) — the partial,
295
650
  // non-terminal supersession tier. `amends:` lives only on the amending (newer)
@@ -343,14 +698,40 @@ export function analyzeLinkage(specs, { includeWarnings = true, scopeIds = null
343
698
  const broken_wikilinks = [];
344
699
  const missing_manifest_refs = [];
345
700
  const parent_child_mismatches = [];
701
+ // Informational, NOT a defect — see FROZEN_STATUSES and the module header.
702
+ const frozen_parent_child_mismatches = [];
346
703
  const warnings = [];
347
704
  const seenMismatch = new Set();
348
705
 
706
+ const FROZEN_NOTE =
707
+ 'Frozen by protocol — at least one side is closed/superseded, whose roster (`children:`/`epics:`) ' +
708
+ 'is historical record accurate to what was true at close. Editing it is a forbidden edit ' +
709
+ '([[closed-trackable-specs-are-historical-record-not-living-docs]]), so this mismatch is ' +
710
+ 'intentionally unfixable and is excluded from the `valid` verdict. Take no action.';
711
+
349
712
  const pushMismatch = (parent, child, issue, parentSpec, childSpec) => {
350
713
  const key = `${parent}|${child}|${issue}`;
351
714
  if (seenMismatch.has(key)) return;
352
715
  seenMismatch.add(key);
353
716
  if (!inScope(parentSpec) && !inScope(childSpec)) return;
717
+ // Either side frozen → informational bucket, never a defect. Note the
718
+ // unresolvable-ref call sites pass the parent spec for BOTH arguments; a
719
+ // frozen parent there is still correct (repairing it would mean editing the
720
+ // frozen roster), so the same either-side test covers that case.
721
+ const parentFrozen = isFrozenSpec(parentSpec);
722
+ const childFrozen = isFrozenSpec(childSpec);
723
+ if (parentFrozen || childFrozen) {
724
+ frozen_parent_child_mismatches.push({
725
+ parent,
726
+ child,
727
+ issue,
728
+ frozen_side: parentFrozen && childFrozen ? 'both' : parentFrozen ? 'parent' : 'child',
729
+ parent_status: parentSpec?.status ?? null,
730
+ child_status: childSpec?.status ?? null,
731
+ note: FROZEN_NOTE,
732
+ });
733
+ return;
734
+ }
354
735
  parent_child_mismatches.push({ parent, child, issue });
355
736
  };
356
737
 
@@ -525,6 +906,8 @@ export function analyzeLinkage(specs, { includeWarnings = true, scopeIds = null
525
906
  }
526
907
  }
527
908
 
909
+ // NOTE: frozen_parent_child_mismatches is intentionally absent here — a
910
+ // frozen-by-protocol mismatch is not vault debt and must never fail the gate.
528
911
  const valid =
529
912
  orphan_specs.length === 0 &&
530
913
  broken_wikilinks.length === 0 &&
@@ -539,6 +922,8 @@ export function analyzeLinkage(specs, { includeWarnings = true, scopeIds = null
539
922
  broken_wikilinks,
540
923
  missing_manifest_refs,
541
924
  parent_child_mismatches,
925
+ // Informational only — deliberately excluded from `valid` above.
926
+ frozen_parent_child_mismatches,
542
927
  broken_amends,
543
928
  amended_by,
544
929
  broken_iterates_on,
@@ -550,6 +935,8 @@ export function analyzeLinkage(specs, { includeWarnings = true, scopeIds = null
550
935
  broken_links: broken_wikilinks.length,
551
936
  missing_refs: missing_manifest_refs.length,
552
937
  mismatches: parent_child_mismatches.length,
938
+ // Informational count — frozen-by-protocol mismatches excluded from `mismatches`.
939
+ frozen_mismatches: frozen_parent_child_mismatches.length,
553
940
  broken_amends: broken_amends.length,
554
941
  broken_iterates_on: broken_iterates_on.length,
555
942
  },