heyspex 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (352) hide show
  1. package/.claude-plugin/marketplace.json +14 -0
  2. package/README.md +21 -0
  3. package/bin/hs.mjs +1089 -104
  4. package/bin/spexcode.mjs +262 -0
  5. package/dev-factory/engine/worktree.mjs +740 -0
  6. package/dev-factory/lib/code-repo-root.mjs +67 -0
  7. package/dev-factory/lib/context-budget.mjs +84 -0
  8. package/dev-factory/lib/epic-detect.mjs +190 -0
  9. package/dev-factory/lib/fleet-queue-file.mjs +846 -0
  10. package/dev-factory/lib/fleet-queue.mjs +426 -0
  11. package/dev-factory/lib/fleet-state.mjs +14 -18
  12. package/dev-factory/lib/model-catalog.mjs +338 -0
  13. package/dev-factory/lib/seat-context.mjs +199 -0
  14. package/dev-factory/lib/spec-io.mjs +93 -0
  15. package/dev-factory/lib/sufficiency-score.mjs +8 -2
  16. package/dev-factory/lib/worktree-allocator.mjs +744 -0
  17. package/dev-factory/model-catalog.json +222 -0
  18. package/dist/cli/hs-check.mjs +1127 -103
  19. package/dist/cli/hs-claim.mjs +981 -63
  20. package/dist/cli/hs-connect.mjs +10059 -1729
  21. package/dist/cli/hs-defer.mjs +5507 -3072
  22. package/dist/cli/hs-dev.mjs +576 -0
  23. package/dist/cli/hs-env.mjs +416 -302
  24. package/dist/cli/hs-index-rebuild.mjs +7334 -0
  25. package/dist/cli/hs-index-reconcile.mjs +8138 -0
  26. package/dist/cli/hs-init.mjs +2840 -149
  27. package/dist/cli/hs-install.mjs +7214 -594
  28. package/dist/cli/hs-join.mjs +10640 -2421
  29. package/dist/cli/hs-vault-pull.mjs +8157 -6370
  30. package/dist/cli/hs-vault-push.mjs +6433 -0
  31. package/dist/cli/hs-vault.mjs +4029 -1555
  32. package/dist/cli/hs-workspace.mjs +2702 -538
  33. package/dist/cli-templates/customer-vault/specs/entity-model.md +594 -601
  34. package/dist/cli-templates/customer-vault/specs/protocols/canonical-frontmatter-templates.md +110 -177
  35. package/dist/cli-templates/customer-vault/specs/protocols/destructive-action-checklist.md +122 -0
  36. package/dist/cli-templates/customer-vault/specs/protocols/memory-protocol.md +13 -15
  37. package/dist/cli-templates/customer-vault/specs/protocols/session-close-protocol.md +91 -0
  38. package/dist/cli-templates/customer-vault/specs/protocols/spec-hierarchy.md +150 -168
  39. package/dist/cli-templates/customer-vault/specs/protocols/spec-summary-authoring.md +83 -0
  40. package/dist/cli-templates/harness-commands/claude/hs-auto.md +36 -0
  41. package/dist/cli-templates/harness-commands/claude/hs-backfill.md +28 -0
  42. package/dist/cli-templates/harness-commands/claude/hs-capture.md +62 -0
  43. package/dist/cli-templates/harness-commands/claude/hs-defer.md +29 -0
  44. package/dist/cli-templates/harness-commands/claude/hs-dev.md +20 -0
  45. package/dist/cli-templates/harness-commands/claude/hs-doctor.md +32 -0
  46. package/dist/cli-templates/harness-commands/claude/hs-ground.md +30 -0
  47. package/dist/cli-templates/harness-commands/claude/hs-handoff.md +33 -0
  48. package/dist/cli-templates/harness-commands/claude/hs-log.md +27 -0
  49. package/dist/cli-templates/harness-commands/claude/hs-next.md +29 -0
  50. package/dist/cli-templates/harness-commands/claude/hs-promote.md +70 -0
  51. package/dist/cli-templates/harness-commands/claude/hs-push.md +35 -0
  52. package/dist/cli-templates/harness-commands/claude/hs-qc.md +27 -0
  53. package/dist/cli-templates/harness-commands/claude/hs-res.md +63 -0
  54. package/dist/cli-templates/harness-commands/claude/hs-resolve.md +23 -0
  55. package/dist/cli-templates/harness-commands/claude/hs-review.md +23 -0
  56. package/dist/cli-templates/harness-commands/claude/hs-view.md +28 -0
  57. package/dist/cli-templates/harness-commands/claude/kill-ports.md +32 -0
  58. package/dist/cli-templates/harness-commands/claude/prototype.md +91 -0
  59. package/dist/cli-templates/harness-commands/cursor/hs-auto.md +40 -0
  60. package/dist/cli-templates/harness-commands/cursor/hs-backfill.md +31 -0
  61. package/dist/cli-templates/harness-commands/cursor/hs-capture.md +65 -0
  62. package/dist/cli-templates/harness-commands/cursor/hs-defer.md +32 -0
  63. package/dist/cli-templates/harness-commands/cursor/hs-dev.md +24 -0
  64. package/dist/cli-templates/harness-commands/cursor/hs-doctor.md +36 -0
  65. package/dist/cli-templates/harness-commands/cursor/hs-ground.md +33 -0
  66. package/dist/cli-templates/harness-commands/cursor/hs-handoff.md +37 -0
  67. package/dist/cli-templates/harness-commands/cursor/hs-log.md +31 -0
  68. package/dist/cli-templates/harness-commands/cursor/hs-next.md +32 -0
  69. package/dist/cli-templates/harness-commands/cursor/hs-promote.md +73 -0
  70. package/dist/cli-templates/harness-commands/cursor/hs-push.md +38 -0
  71. package/dist/cli-templates/harness-commands/cursor/hs-qc.md +30 -0
  72. package/dist/cli-templates/harness-commands/cursor/hs-res.md +67 -0
  73. package/dist/cli-templates/harness-commands/cursor/hs-resolve.md +26 -0
  74. package/dist/cli-templates/harness-commands/cursor/hs-review.md +26 -0
  75. package/dist/cli-templates/harness-commands/cursor/hs-view.md +32 -0
  76. package/dist/cli-templates/harness-commands/cursor/kill-ports.md +35 -0
  77. package/dist/cli-templates/harness-commands/cursor/prototype.md +95 -0
  78. package/docs/copilot-plugin-install.md +162 -0
  79. package/docs/driving-heyspex-from-copilot.md +165 -0
  80. package/docs/mcp-client-config.md +259 -6
  81. package/package.json +249 -124
  82. package/plugins/heyspex/.claude-plugin/plugin.json +12 -0
  83. package/plugins/heyspex/.cursor-plugin/plugin.json +15 -0
  84. package/plugins/heyspex/.mcp.json +12 -0
  85. package/plugins/heyspex/README.md +309 -0
  86. package/plugins/heyspex/bin/launch-mcp.mjs +95 -0
  87. package/plugins/heyspex/bin/run-hook.mjs +80 -0
  88. package/plugins/heyspex/hooks/cursor-hooks.json +308 -0
  89. package/plugins/heyspex/hooks/hooks.json +418 -0
  90. package/plugins/heyspex/mcp.json +13 -0
  91. package/scripts/check-mcp-freshness.mjs +17 -9
  92. package/scripts/claude-hooks/_env-vault-events.mjs +68 -0
  93. package/scripts/claude-hooks/_guardrail-events.mjs +96 -0
  94. package/scripts/claude-hooks/_guardrail-loader.mjs +494 -0
  95. package/scripts/claude-hooks/_intent-classifier-router.mjs +456 -0
  96. package/scripts/claude-hooks/_intent-classifier-semantic.mjs +356 -0
  97. package/scripts/claude-hooks/_run.mjs +413 -0
  98. package/scripts/claude-hooks/_transcript-text.mjs +60 -0
  99. package/scripts/claude-hooks/_vault-retrieval.mjs +322 -0
  100. package/scripts/claude-hooks/agent-spawn-delegation-gate.mjs +133 -0
  101. package/scripts/claude-hooks/capture-execution-gate.mjs +51 -0
  102. package/scripts/claude-hooks/capture-workflow-gate.mjs +11 -0
  103. package/scripts/claude-hooks/check-auto-memory-budget.mjs +396 -0
  104. package/scripts/claude-hooks/check-claude-md-budget.mjs +327 -0
  105. package/scripts/claude-hooks/check-mcp-freshness-hook.mjs +14 -0
  106. package/scripts/claude-hooks/claim-before-code-gate.mjs +11 -0
  107. package/scripts/claude-hooks/claim-liveness-shared.mjs +577 -0
  108. package/scripts/claude-hooks/close-terminal-on-ship.mjs +64 -0
  109. package/scripts/claude-hooks/coo-denylist-gate.mjs +11 -0
  110. package/scripts/claude-hooks/coo-guard-check.mjs +11 -0
  111. package/scripts/claude-hooks/deep-research-cost-gate.mjs +11 -0
  112. package/scripts/claude-hooks/dev-server-task-reminder.mjs +15 -0
  113. package/scripts/claude-hooks/diagnose-capture-reminder.mjs +11 -0
  114. package/scripts/claude-hooks/directive-obligation-gate.mjs +11 -0
  115. package/scripts/claude-hooks/emit-claim-status.mjs +41 -0
  116. package/scripts/claude-hooks/enforce-session-sequence.mjs +11 -0
  117. package/scripts/claude-hooks/enforce-vault-naming.mjs +11 -0
  118. package/scripts/claude-hooks/env-vault-guard.mjs +11 -0
  119. package/scripts/claude-hooks/epic-milestone-phrasing-gate.mjs +11 -0
  120. package/scripts/claude-hooks/git-identity-guard.mjs +11 -0
  121. package/scripts/claude-hooks/git-reflog-audit.mjs +15 -0
  122. package/scripts/claude-hooks/git-stash-gate.mjs +11 -0
  123. package/scripts/claude-hooks/guard-auto-memory-writes.mjs +137 -0
  124. package/scripts/claude-hooks/id-display-format-gate.mjs +11 -0
  125. package/scripts/claude-hooks/inject-communication-style.mjs +86 -0
  126. package/scripts/claude-hooks/inject-coo-seat.mjs +78 -0
  127. package/scripts/claude-hooks/inject-dev-server-awareness.mjs +11 -0
  128. package/scripts/claude-hooks/inject-doctrine.mjs +13 -0
  129. package/scripts/claude-hooks/inject-vault-context.mjs +14 -0
  130. package/scripts/claude-hooks/intent-classifier-trigger.mjs +279 -0
  131. package/scripts/claude-hooks/log-session-activity.mjs +341 -0
  132. package/scripts/claude-hooks/naughty-list-gate.mjs +11 -0
  133. package/scripts/claude-hooks/notify-status-flip.mjs +72 -0
  134. package/scripts/claude-hooks/plan-exit-ingest-gate.mjs +170 -0
  135. package/scripts/claude-hooks/plan-ingest-close-reminder.mjs +66 -0
  136. package/scripts/claude-hooks/plan-review-gate.mjs +11 -0
  137. package/scripts/claude-hooks/preflight-reminder.mjs +262 -0
  138. package/scripts/claude-hooks/protected-action-intent.mjs +221 -0
  139. package/scripts/claude-hooks/research-method-guard.mjs +398 -0
  140. package/scripts/claude-hooks/research-protocol-gate.mjs +11 -0
  141. package/scripts/claude-hooks/session-end.mjs +451 -0
  142. package/scripts/claude-hooks/settings-write-gate.mjs +11 -0
  143. package/scripts/claude-hooks/settlement-check-gate.mjs +39 -0
  144. package/scripts/claude-hooks/status-flip-detect.mjs +17 -0
  145. package/scripts/claude-hooks/terminal-close-shared.mjs +426 -0
  146. package/scripts/claude-hooks/terminal-close-watcher.mjs +122 -0
  147. package/scripts/claude-hooks/vault-mirror-catchup.mjs +11 -0
  148. package/scripts/claude-hooks/vault-write-tier-guard.mjs +11 -0
  149. package/scripts/claude-hooks/wip-checkpoint.mjs +302 -0
  150. package/scripts/cursor-hooks/manifest.mjs +281 -0
  151. package/scripts/dev-server-status.mjs +34 -13
  152. package/scripts/dev-server-stop.mjs +313 -0
  153. package/scripts/heyspex-mode.mjs +288 -0
  154. package/scripts/hs-auto-resolve.mjs +240 -0
  155. package/scripts/hs-auto-source.mjs +398 -0
  156. package/scripts/hs-auto.mjs +134 -0
  157. package/scripts/hs-capture-cli.mjs +326 -0
  158. package/scripts/hs-classifier.mjs +136 -0
  159. package/scripts/hs-companion.mjs +88 -0
  160. package/scripts/hs-gateway.mjs +115 -0
  161. package/scripts/hs-mode.mjs +125 -0
  162. package/scripts/hs-next.mjs +441 -0
  163. package/scripts/hs-settings.mjs +338 -0
  164. package/scripts/hs-view.mjs +496 -0
  165. package/scripts/hs-worktree.mjs +201 -0
  166. package/scripts/install-claude-code-hooks.mjs +272 -0
  167. package/scripts/install-cursor-hooks.mjs +471 -0
  168. package/scripts/lib/agent-env-markers.mjs +38 -0
  169. package/scripts/lib/console-url-resolver.mjs +199 -61
  170. package/scripts/lib/customer-kit.mjs +1203 -0
  171. package/scripts/lib/dev-mode.mjs +249 -32
  172. package/scripts/lib/directive-obligations.mjs +349 -0
  173. package/scripts/lib/heyspex-home.mjs +72 -0
  174. package/scripts/lib/hook-dispositions.mjs +210 -0
  175. package/scripts/lib/hook-vault-header.mjs +30 -0
  176. package/scripts/lib/hs-command.mjs +277 -0
  177. package/scripts/lib/hs-companion-settings.mjs +472 -0
  178. package/scripts/lib/hs-source-branch.mjs +196 -0
  179. package/scripts/lib/hs-source-commit.mjs +59 -5
  180. package/scripts/lib/hs-source-identity.mjs +526 -0
  181. package/scripts/lib/hs-source-log.mjs +13 -22
  182. package/scripts/lib/hs-source-preflight.mjs +18 -4
  183. package/scripts/lib/hs-source-push.mjs +386 -307
  184. package/scripts/lib/hs-worktree-args.mjs +115 -0
  185. package/scripts/lib/shared-path-helpers.mjs +134 -0
  186. package/scripts/lib/standalone-doctor.mjs +540 -3
  187. package/scripts/lib/subagent-git-lock.mjs +109 -0
  188. package/scripts/lib/template-pin.mjs +34 -0
  189. package/scripts/lib/window-placement.mjs +109 -0
  190. package/scripts/provenance-lib.mjs +91 -0
  191. package/scripts/stamp-provenance-on-edit.mjs +222 -0
  192. package/scripts/wip-ledger-health.mjs +653 -0
  193. package/spexharness/doctrine/baseline/SPEXCODE-DOCTRINE.baseline.json +29 -0
  194. package/spexharness/src/adapters/audit.mjs +197 -0
  195. package/spexharness/src/adapters/claude-code.mjs +449 -0
  196. package/spexharness/src/adapters/copilot.mjs +236 -0
  197. package/spexharness/src/adapters/detect-harness.mjs +34 -0
  198. package/spexharness/src/chat/tui/ansi.mjs +448 -0
  199. package/spexharness/src/chat/tui/palette.mjs +22 -0
  200. package/spexharness/src/chat/tui/select.mjs +208 -0
  201. package/spexharness/src/config-root.mjs +54 -0
  202. package/spexharness/src/context-budget.mjs +141 -0
  203. package/spexharness/src/dev-server-control.mjs +389 -0
  204. package/spexharness/src/doctrine/behaviour.mjs +371 -0
  205. package/spexharness/src/doctrine/corpus.mjs +733 -0
  206. package/spexharness/src/doctrine/inject.mjs +338 -0
  207. package/spexharness/src/doctrine/merge.mjs +184 -0
  208. package/spexharness/src/doctrine/sources.mjs +252 -0
  209. package/spexharness/src/doctrine/synthesize.mjs +401 -0
  210. package/spexharness/src/doctrine/vault-source.mjs +574 -0
  211. package/spexharness/src/doctrine/writer.mjs +176 -0
  212. package/spexharness/src/events.mjs +84 -0
  213. package/spexharness/src/gates/_shell-write-targets.mjs +77 -0
  214. package/spexharness/src/gates/capture-workflow-gate.mjs +202 -0
  215. package/spexharness/src/gates/claim-before-code-gate.decide.mjs +33 -0
  216. package/spexharness/src/gates/claim-before-code-gate.mjs +129 -0
  217. package/spexharness/src/gates/deep-research-cost-gate.mjs +275 -0
  218. package/spexharness/src/gates/diagnose-capture-reminder.mjs +112 -0
  219. package/spexharness/src/gates/directive-obligation-gate.decide.mjs +132 -0
  220. package/spexharness/src/gates/directive-obligation-gate.mjs +183 -0
  221. package/spexharness/src/gates/enforce-session-sequence.mjs +895 -0
  222. package/spexharness/src/gates/enforce-vault-naming.mjs +215 -0
  223. package/spexharness/src/gates/epic-milestone-phrasing-gate.mjs +233 -0
  224. package/spexharness/src/gates/git-identity-guard.decide.mjs +436 -0
  225. package/spexharness/src/gates/git-identity-guard.mjs +61 -0
  226. package/spexharness/src/gates/git-reflog-audit.mjs +151 -0
  227. package/spexharness/src/gates/git-stash-gate.decide.mjs +361 -0
  228. package/spexharness/src/gates/git-stash-gate.mjs +159 -0
  229. package/spexharness/src/gates/id-display-format-gate.mjs +190 -0
  230. package/spexharness/src/gates/inject-doctrine-gate.mjs +89 -0
  231. package/spexharness/src/gates/inject-vault-context.mjs +239 -0
  232. package/spexharness/src/gates/naughty-list-gate.mjs +213 -0
  233. package/spexharness/src/gates/plan-review-gate.mjs +233 -0
  234. package/spexharness/src/gates/preflight-reminder-gate.mjs +265 -0
  235. package/spexharness/src/gates/research-protocol-gate.mjs +175 -0
  236. package/spexharness/src/gates/settings-write-gate.decide.mjs +96 -0
  237. package/spexharness/src/gates/settings-write-gate.mjs +104 -0
  238. package/spexharness/src/gates/vault-mirror-catchup.mjs +124 -0
  239. package/spexharness/src/gates/vault-write-tier-guard.decide.mjs +125 -0
  240. package/spexharness/src/gates/vault-write-tier-guard.mjs +42 -0
  241. package/spexharness/src/git-reflog-audit.mjs +294 -0
  242. package/spexharness/src/intent-classifier.mjs +196 -0
  243. package/spexharness/src/intent-directive.mjs +626 -0
  244. package/spexharness/src/profile/learn.mjs +551 -0
  245. package/spexharness/src/profile/proposals.mjs +357 -0
  246. package/spexharness/src/profile/vault-inclusion.mjs +386 -0
  247. package/spexharness/src/redact.mjs +151 -0
  248. package/spexharness/src/rule-table.mjs +849 -0
  249. package/spexharness/src/seat-context.mjs +122 -0
  250. package/spexharness/src/session-bootstrap.mjs +512 -0
  251. package/spexharness/src/session-record.mjs +815 -0
  252. package/spexharness/src/token-inventory.mjs +207 -0
  253. package/vendor/heyspex-mcp/account-switch.mjs +96 -0
  254. package/vendor/heyspex-mcp/alignment-gate.mjs +315 -1
  255. package/vendor/heyspex-mcp/blocker-resolver.mjs +37 -2
  256. package/vendor/heyspex-mcp/breakdown-ladder.mjs +119 -4
  257. package/vendor/heyspex-mcp/breakdown-source.mjs +271 -0
  258. package/vendor/heyspex-mcp/breakdown-writer.mjs +23 -3
  259. package/vendor/heyspex-mcp/capture-buffer-patches.mjs +77 -8
  260. package/vendor/heyspex-mcp/capture-channel.mjs +55 -5
  261. package/vendor/heyspex-mcp/capture-console-client.mjs +117 -3
  262. package/vendor/heyspex-mcp/capture-focus.mjs +1061 -31
  263. package/vendor/heyspex-mcp/capture-outbox.mjs +15 -5
  264. package/vendor/heyspex-mcp/capture-sources.mjs +195 -0
  265. package/vendor/heyspex-mcp/capture-tools.mjs +1264 -0
  266. package/vendor/heyspex-mcp/claim-tools.mjs +1147 -0
  267. package/vendor/heyspex-mcp/cli-verbs.mjs +2074 -0
  268. package/vendor/heyspex-mcp/cli.mjs +207 -15524
  269. package/vendor/heyspex-mcp/close-branch-report.mjs +102 -0
  270. package/vendor/heyspex-mcp/cloud-tool-surface.mjs +128 -0
  271. package/vendor/heyspex-mcp/cloud-vault-writes.mjs +602 -0
  272. package/vendor/heyspex-mcp/config-seed.yaml +15 -0
  273. package/vendor/heyspex-mcp/config-settings.mjs +290 -7
  274. package/vendor/heyspex-mcp/console-fetch.mjs +822 -17
  275. package/vendor/heyspex-mcp/console-transport.mjs +172 -0
  276. package/vendor/heyspex-mcp/credentials.mjs +585 -0
  277. package/vendor/heyspex-mcp/defer-spec.mjs +44 -4
  278. package/vendor/heyspex-mcp/deferred-reclaim.mjs +29 -3
  279. package/vendor/heyspex-mcp/design-handlers.mjs +31 -0
  280. package/vendor/heyspex-mcp/design-tools.mjs +4 -1
  281. package/vendor/heyspex-mcp/dev-factory-bridge.mjs +4 -4
  282. package/vendor/heyspex-mcp/dev-mode-core.mjs +45 -0
  283. package/vendor/heyspex-mcp/dev-tools.mjs +131 -0
  284. package/vendor/heyspex-mcp/diagnose-handlers.mjs +54 -0
  285. package/vendor/heyspex-mcp/events-tools.mjs +207 -0
  286. package/vendor/heyspex-mcp/filing-tools.mjs +723 -0
  287. package/vendor/heyspex-mcp/fleet-handlers.mjs +37 -0
  288. package/vendor/heyspex-mcp/fleet-queue-tools.mjs +71 -15
  289. package/vendor/heyspex-mcp/focus-tools.mjs +190 -0
  290. package/vendor/heyspex-mcp/folder-trust-store.mjs +60 -0
  291. package/vendor/heyspex-mcp/http-transport.mjs +302 -0
  292. package/vendor/heyspex-mcp/list-tools.mjs +257 -0
  293. package/vendor/heyspex-mcp/login-handoff.mjs +15 -1
  294. package/vendor/heyspex-mcp/mcp-config.mjs +107 -0
  295. package/vendor/heyspex-mcp/memory-tools.mjs +523 -0
  296. package/vendor/heyspex-mcp/mvp-mode.mjs +53 -6
  297. package/vendor/heyspex-mcp/obligation-backstop.mjs +103 -0
  298. package/vendor/heyspex-mcp/occupied-keys.mjs +142 -0
  299. package/vendor/heyspex-mcp/on-deck-brief.mjs +3377 -0
  300. package/vendor/heyspex-mcp/on-deck-scheduler.mjs +1708 -0
  301. package/vendor/heyspex-mcp/package-root.mjs +32 -0
  302. package/vendor/heyspex-mcp/peer-presence.mjs +8 -1
  303. package/vendor/heyspex-mcp/plan-ground.mjs +621 -652
  304. package/vendor/heyspex-mcp/plan-push.mjs +908 -22
  305. package/vendor/heyspex-mcp/plan-review.mjs +124 -30
  306. package/vendor/heyspex-mcp/preflight-gate.mjs +0 -0
  307. package/vendor/heyspex-mcp/procedure-handlers.mjs +327 -0
  308. package/vendor/heyspex-mcp/procedure-tools.mjs +1181 -970
  309. package/vendor/heyspex-mcp/promote-capture.mjs +388 -69
  310. package/vendor/heyspex-mcp/promote-diagnose.mjs +74 -0
  311. package/vendor/heyspex-mcp/promote-research-to-diagnose.mjs +9 -2
  312. package/vendor/heyspex-mcp/promote-research.mjs +666 -53
  313. package/vendor/heyspex-mcp/promotion-helpers.mjs +251 -0
  314. package/vendor/heyspex-mcp/prompts.mjs +1460 -0
  315. package/vendor/heyspex-mcp/read-path.mjs +191 -15
  316. package/vendor/heyspex-mcp/receipt-shape.mjs +146 -0
  317. package/vendor/heyspex-mcp/recipe-handlers.mjs +117 -0
  318. package/vendor/heyspex-mcp/regen-tasks.mjs +210 -3
  319. package/vendor/heyspex-mcp/register-spec.mjs +266 -54
  320. package/vendor/heyspex-mcp/request-context.mjs +83 -0
  321. package/vendor/heyspex-mcp/research-focus.mjs +207 -4
  322. package/vendor/heyspex-mcp/research-handlers.mjs +625 -0
  323. package/vendor/heyspex-mcp/research-tools.mjs +4 -4
  324. package/vendor/heyspex-mcp/response-budget.mjs +348 -0
  325. package/vendor/heyspex-mcp/review-focus.mjs +25 -1
  326. package/vendor/heyspex-mcp/roadmap-default.mjs +70 -0
  327. package/vendor/heyspex-mcp/search-specs.mjs +280 -0
  328. package/vendor/heyspex-mcp/seat-block-ledger.mjs +113 -0
  329. package/vendor/heyspex-mcp/seat-identity.mjs +2 -6
  330. package/vendor/heyspex-mcp/session-owner.mjs +109 -0
  331. package/vendor/heyspex-mcp/session-tools.mjs +162 -0
  332. package/vendor/heyspex-mcp/settings-registry.mjs +232 -0
  333. package/vendor/heyspex-mcp/settings-tools.mjs +85 -12
  334. package/vendor/heyspex-mcp/spec-edit-tools.mjs +453 -0
  335. package/vendor/heyspex-mcp/spec-id-lib.mjs +93 -17
  336. package/vendor/heyspex-mcp/split-research.mjs +304 -56
  337. package/vendor/heyspex-mcp/stdio-rpc.mjs +22 -0
  338. package/vendor/heyspex-mcp/submit-for-review.mjs +150 -7
  339. package/vendor/heyspex-mcp/summary-contract.mjs +105 -0
  340. package/vendor/heyspex-mcp/summary-write-tools.mjs +228 -0
  341. package/vendor/heyspex-mcp/symptom-query.mjs +146 -0
  342. package/vendor/heyspex-mcp/test-helpers.mjs +123 -1
  343. package/vendor/heyspex-mcp/tool-catalog.mjs +2819 -0
  344. package/vendor/heyspex-mcp/tool-dispatch.mjs +657 -0
  345. package/vendor/heyspex-mcp/tool-helpers.mjs +325 -0
  346. package/vendor/heyspex-mcp/validate-linkage.mjs +414 -27
  347. package/vendor/heyspex-mcp/vault-io.mjs +720 -0
  348. package/vendor/heyspex-mcp/vault-less-endpoints.mjs +52 -0
  349. package/vendor/heyspex-mcp/vault-resolve.mjs +554 -68
  350. package/vendor/heyspex-mcp/verify-reproduction.mjs +270 -30
  351. package/vendor/heyspex-mcp/whoami.mjs +92 -0
  352. package/vendor/heyspex-mcp/write-outbox.mjs +512 -0
@@ -0,0 +1,96 @@
1
+ #!/usr/bin/env node
2
+ // Guardrail observability writer ([[HS-EPIC-ZGWR]] Phase 6) — append one
3
+ // `guardrail.fired` audit row to `.heyspex/guardrail-history.jsonl` each time a
4
+ // guardrail hook fires (a block, a rewrite, a prime). This is the file-based event
5
+ // log the audit export folds into specs/events/YYYY-MM.jsonl (shared monitoring seam
6
+ // with [[HS-EPIC-0016]]).
7
+ //
8
+ // WHY A LOCAL FILE (both tiers): the Claude Code hooks are dependency-free and have NO
9
+ // Neon access — they cannot write spec_events directly. So the guardrail log is always
10
+ // local; the server-side export reads it as an ALWAYS-ON source (regardless of tier),
11
+ // alongside the Neon tables / free-tier file logs. Same `.heyspex/*-history.jsonl`
12
+ // runtime-file family as claim-history / focus-history, and like them GIT-TRACKED
13
+ // (the free tier's live event store — a clone IS the audit trail;
14
+ // [[free-tier-makes-runtime-files-non-retireable]]). The deduped, month-partitioned
15
+ // export copy lives in committed specs/events/YYYY-MM.jsonl.
16
+ //
17
+ // ROW SHAPE: already the canonical AuditEventRow
18
+ // { timestamp, event_kind:"guardrail.fired", actor, spec_id, metadata }
19
+ // so the reader (free-event-readers.ts loadGuardrailEventRows) only validates the
20
+ // timestamp and passes through. metadata carries { rule, surface, severity, decision,
21
+ // relPath?, match_context? } — the fired rule, where it fired, the disposition, and
22
+ // a bounded window of the prose that matched.
23
+ //
24
+ // FAIL-SAFE: observability must NEVER break a guardrail hook. Every error is swallowed;
25
+ // a missing .heyspex/ dir, an unwritable disk, anything → silent no-op.
26
+
27
+ import fs from 'node:fs';
28
+ import path from 'node:path';
29
+ import { fileURLToPath } from 'node:url';
30
+
31
+ /** Kept in step with _guardrail-loader.mjs; inlined so this stays dependency-free. */
32
+ const MATCH_CONTEXT_MAX_CHARS = 200;
33
+
34
+ export const DEFAULT_REPO_ROOT = path.resolve(
35
+ path.dirname(fileURLToPath(import.meta.url)),
36
+ '..',
37
+ '..',
38
+ );
39
+
40
+ function repoRootFromEnv() {
41
+ const fromEnv = process.env.CLAUDE_PROJECT_DIR?.trim();
42
+ if (fromEnv) return fromEnv.replace(/[\\/]+$/, '');
43
+ return DEFAULT_REPO_ROOT;
44
+ }
45
+
46
+ function cleanMeta(meta) {
47
+ const out = {};
48
+ for (const [k, v] of Object.entries(meta)) {
49
+ if (v !== undefined && v !== null) out[k] = v;
50
+ }
51
+ return out;
52
+ }
53
+
54
+ /**
55
+ * Append a guardrail-fired event. All fields optional except `rule`; unknown ones are
56
+ * dropped. Returns true on a successful write, false on any (swallowed) failure — so a
57
+ * caller can branch in a test, but a hook can ignore the result.
58
+ */
59
+ export function appendGuardrailEvent(
60
+ {
61
+ rule,
62
+ surface = null,
63
+ severity = null,
64
+ decision = null,
65
+ specId = null,
66
+ relPath = null,
67
+ matchContext = null,
68
+ } = {},
69
+ repoRoot = repoRootFromEnv(),
70
+ ) {
71
+ try {
72
+ if (!rule) return false;
73
+ // `match_context` is the bounded window of prose the rule matched in
74
+ // ([[HS-N71O-P3XQ-DIAG-six-new-qc-gates-fail-cold-verification]] item 10).
75
+ // Without it a row said only WHICH word fired, and a reviewer could not tell a
76
+ // false positive from a real hit — which is the evidence needed before raising
77
+ // a gate from warn to block. Collapsed to one line and hard-capped here as
78
+ // well as at the source, so an oversized value cannot bloat the log.
79
+ const context =
80
+ typeof matchContext === 'string' && matchContext.trim()
81
+ ? matchContext.replace(/\s+/g, ' ').trim().slice(0, MATCH_CONTEXT_MAX_CHARS)
82
+ : null;
83
+ const row = {
84
+ timestamp: new Date().toISOString(),
85
+ event_kind: 'guardrail.fired',
86
+ actor: process.env.HEYSPEX_GUARDRAIL_ACTOR?.trim() || 'Claude Code',
87
+ spec_id: typeof specId === 'string' ? specId : null,
88
+ metadata: cleanMeta({ rule, surface, severity, decision, relPath, match_context: context }),
89
+ };
90
+ const file = path.join(repoRoot, '.heyspex', 'guardrail-history.jsonl');
91
+ fs.appendFileSync(file, JSON.stringify(row) + '\n');
92
+ return true;
93
+ } catch {
94
+ return false; // fail-safe: never let observability break a hook
95
+ }
96
+ }
@@ -0,0 +1,494 @@
1
+ #!/usr/bin/env node
2
+ // Shared loader for the HeySpex guardrail registry (.heyspex/guardrails.json).
3
+ //
4
+ // The registry is compiled from protocol `enforcement:` frontmatter by
5
+ // scripts/build-guardrails.mjs ([[HS-EPIC-ZGWR]] phase 1, design [[HS-RES-GHKO]]
6
+ // §6/§8). This module is the runtime read side that the Claude Code hooks import
7
+ // instead of hand-coding their own rule tables — one authored source, no
8
+ // prose↔JS drift.
9
+ //
10
+ // DELIBERATELY DEPENDENCY-FREE. Hooks fire on every prompt/tool call and must run
11
+ // on a fresh clone with no `npm install` (spex-console/node_modules absent), so
12
+ // this reads the *compiled JSON* with the stdlib only — never js-yaml, never the
13
+ // protocol YAML. (build-guardrails.mjs is the one allowed to use js-yaml; it is a
14
+ // build step, not a per-prompt hook.)
15
+ //
16
+ // FAIL-SAFE CONTRACT (non-negotiable, [[HS-RES-GHKO]] §7): every function degrades
17
+ // to a safe empty/enabled result on any error. A corrupt or missing registry must
18
+ // never throw into a hook — the worst outcome is a guardrail going silent, never a
19
+ // stranded agent. Callers wrap their own bodies in try/catch and exit 0 too.
20
+
21
+ import fs from 'node:fs';
22
+ import path from 'node:path';
23
+ import { fileURLToPath } from 'node:url';
24
+
25
+ import { loadRuleTable } from '../../spexharness/src/rule-table.mjs';
26
+ import { defaultConfigRoot } from '../../spexharness/src/config-root.mjs';
27
+
28
+ // TWO ROOTS, split by what lives under `.heyspex/` at each
29
+ // ([[HS-4C76-9W86-DIAG-heyspex-config-yaml-has-no-single-home]]):
30
+ //
31
+ // - DEFAULT_REPO_ROOT — the CODE checkout (scripts/claude-hooks/ → two levels
32
+ // up). The guardrail registry (`.heyspex/guardrails.json`, compiled by
33
+ // scripts/build-guardrails.mjs) and the per-user terminology override are
34
+ // code-repo artifacts and stay here. `loadGuardrails` / `resolveVocabulary`
35
+ // default to it.
36
+ // - the CONFIG root — `.heyspex/config.yaml` is the workflow-settings file and
37
+ // per ADR-0009 lives at the VAULT root, which since the 2026-08-31 repo split
38
+ // is a separate checkout. `isConfigKeyEnabled` / `readConfigLeafRaw` /
39
+ // `resolveConfirmMode` / `isRuleEnabled` default to `defaultConfigRoot()`
40
+ // (spexharness/src/config-root.mjs → vault-resolve's `repoRootAbs`, falling
41
+ // back to the code root only if resolution throws). Resolved lazily at call
42
+ // time, never at import, so a hook's startup cost is unchanged.
43
+ //
44
+ // Every function keeps its `repoRoot` parameter: an explicit argument always
45
+ // wins over either default (tests and callers with a fixture root rely on it).
46
+ export const DEFAULT_REPO_ROOT = path.resolve(
47
+ path.dirname(fileURLToPath(import.meta.url)),
48
+ '..',
49
+ '..',
50
+ );
51
+
52
+ // Sentinels that mark harness-injected content arriving through the
53
+ // UserPromptSubmit channel (background-task notifications, system reminders, slash-
54
+ // command envelopes). They can contain a rule's trigger phrase verbatim but are NOT
55
+ // a genuine human prompt, so a rule with `ignoreInjectedContent` must not fire on
56
+ // them. Shared here so every surface uses one guard list.
57
+ export const INJECTED_CONTENT_SENTINELS = [
58
+ '<task-notification>',
59
+ '<system-reminder>',
60
+ '[SYSTEM NOTIFICATION',
61
+ '<command-message>',
62
+ '<command-name>',
63
+ '<local-command-stdout>',
64
+ ];
65
+
66
+ export function containsInjectedContent(text) {
67
+ if (typeof text !== 'string') return false;
68
+ return INJECTED_CONTENT_SENTINELS.some((s) => text.includes(s));
69
+ }
70
+
71
+ // Stricter, false-positive-safe variant: is the message a genuine harness ENVELOPE
72
+ // (a task-notification / system-reminder / slash-command envelope delivered AS the
73
+ // prompt), rather than user text that merely QUOTES a sentinel? A real envelope leads
74
+ // with its tag; a user pasting a transcript or terminal logs leads with their own
75
+ // words. `containsInjectedContent` (substring anywhere) wrongly suppresses the latter
76
+ // — which silently kills an autonomous trigger the moment the user pastes logs, the
77
+ // exact time they are offloading an idea (evidence: the HeySpex Intent Classifier
78
+ // capture no-fire, 2026-07-11). Intent-classifier surfaces should prefer THIS check.
79
+ export function startsWithInjectedContent(text) {
80
+ if (typeof text !== 'string') return false;
81
+ const head = text.trimStart();
82
+ return INJECTED_CONTENT_SENTINELS.some((s) => head.startsWith(s));
83
+ }
84
+
85
+ /**
86
+ * Load the guardrail rule table. Returns `{ rules: [...] }` — ALWAYS the full
87
+ * built-in rule set at minimum (spexharness/src/rule-table.mjs `loadRuleTable`),
88
+ * merged with whatever overrides `<repoRoot>/.heyspex/guardrails.json` supplies.
89
+ *
90
+ * This used to fail-safe to `{ rules: [] }` (every guardrail silently OFF) on a
91
+ * missing or corrupt file — that was the exact "fresh customer vaults ship with
92
+ * zero naming enforcement" gap [[HS-RY2N-91GR-DIAG-fresh-customer-vaults-ship-with-zero]]
93
+ * named. `loadRuleTable` fixes the failure mode at its source: the standard
94
+ * rules live in code, so a missing/corrupt file can no longer turn any of them
95
+ * off. This function keeps that guarantee and only adds the stderr visibility
96
+ * a present-but-broken file deserves (loadRuleTable itself is silent/pure).
97
+ */
98
+ export function loadGuardrails(repoRoot = DEFAULT_REPO_ROOT) {
99
+ const { rules, warnings } = loadRuleTable({ vaultRoot: repoRoot });
100
+
101
+ for (const warning of warnings) {
102
+ if (warning.code === 'corrupt_file') {
103
+ process.stderr.write(
104
+ `[guardrail-loader] .heyspex/guardrails.json is corrupt — falling back to the built-in rule set ` +
105
+ `(nothing is disabled). ${warning.message} Rebuild with \`node scripts/build-guardrails.mjs\`.\n`,
106
+ );
107
+ } else if (warning.code === 'unknown_rule') {
108
+ process.stderr.write(`[guardrail-loader] ${warning.message}\n`);
109
+ }
110
+ }
111
+
112
+ // HS-RY2N: a MISSING registry file used to mean every guardrail was OFF; now
113
+ // it just means the on-disk overrides are unavailable — the built-in rule set
114
+ // still runs. Still worth a loud note in a real HeySpex vault (heyspex.yaml
115
+ // present) so a missing compiled file doesn't go unnoticed, but it is no
116
+ // longer an enforcement outage.
117
+ if (warnings.length === 0 && !fs.existsSync(path.join(repoRoot, '.heyspex', 'guardrails.json'))) {
118
+ if (fs.existsSync(path.join(repoRoot, 'heyspex.yaml'))) {
119
+ process.stderr.write(
120
+ '[guardrail-loader] heyspex.yaml marks this as a HeySpex vault, but .heyspex/guardrails.json ' +
121
+ 'does not exist — running on the built-in rule set only (no on-disk severity/configKey overrides). ' +
122
+ 'Platform repo: run `node scripts/build-guardrails.mjs`. Customer vault: see ' +
123
+ '[[HS-RY2N-91GR-DIAG-fresh-customer-vaults-ship-with-zero]] / customer-vault-local-install-protocol.\n',
124
+ );
125
+ }
126
+ }
127
+
128
+ return { rules };
129
+ }
130
+
131
+ /**
132
+ * All rules whose `surfaces` include the given surface (e.g. "userpromptsubmit").
133
+ * Optionally narrow by severity. Order-preserving; never throws.
134
+ */
135
+ export function rulesForSurface(guardrails, surface, severity = null) {
136
+ const rules = guardrails && Array.isArray(guardrails.rules) ? guardrails.rules : [];
137
+ return rules.filter(
138
+ (r) =>
139
+ r &&
140
+ Array.isArray(r.surfaces) &&
141
+ r.surfaces.includes(surface) &&
142
+ (severity == null || r.severity === severity),
143
+ );
144
+ }
145
+
146
+ /**
147
+ * Compile a rule's pattern strings to case-insensitive RegExp. Any individual
148
+ * pattern that fails to compile is skipped (logged) rather than throwing — a single
149
+ * malformed pattern must not disarm the whole hook.
150
+ */
151
+ export function compilePatterns(rule) {
152
+ const patterns = rule && Array.isArray(rule.patterns) ? rule.patterns : [];
153
+ const compiled = [];
154
+ for (const p of patterns) {
155
+ try {
156
+ compiled.push(new RegExp(p, 'i'));
157
+ } catch {
158
+ process.stderr.write(
159
+ `[guardrail-loader] rule "${rule?.rule}" has an uncompilable pattern; skipping it: ${p}\n`,
160
+ );
161
+ }
162
+ }
163
+ return compiled;
164
+ }
165
+
166
+ /**
167
+ * Read a boolean leaf at a dotted `config_key` (e.g.
168
+ * "agent_behaviors.research_before_decision.enabled") from .heyspex/config.yaml,
169
+ * using a dependency-free, indentation-scoped scan (generalized from the original
170
+ * single-rule reader in the now-retired research-trigger.mjs shim, HS-IYU1). Semantics
171
+ * match the Tier-1 contract:
172
+ * the behavior is ON unless the leaf is explicitly `false`; absent / unreadable /
173
+ * malformed / no config_key all default to ON, so a rule works out of the box and
174
+ * only an explicit opt-out silences it.
175
+ *
176
+ * `repoRoot` is the directory holding `.heyspex/config.yaml`; it defaults to the
177
+ * VAULT root (see the two-roots note at the top of this file), not DEFAULT_REPO_ROOT.
178
+ */
179
+ export function isConfigKeyEnabled(configKey, repoRoot = defaultConfigRoot()) {
180
+ if (!configKey || typeof configKey !== 'string') return true; // no toggle → enabled
181
+ const segments = configKey.split('.').filter(Boolean);
182
+ if (segments.length === 0) return true;
183
+
184
+ let raw;
185
+ try {
186
+ raw = fs.readFileSync(path.join(repoRoot, '.heyspex', 'config.yaml'), 'utf8');
187
+ } catch {
188
+ return true; // no config file → default enabled
189
+ }
190
+
191
+ // Maintain the current key path via an indent stack. For each non-blank,
192
+ // non-comment `key:` line, pop stack frames whose indent is >= this line's, then
193
+ // push this key. When the path matches the target, read the leaf value.
194
+ const stack = []; // { key, indent }
195
+ for (const line of raw.split(/\r?\n/)) {
196
+ const trimmed = line.trim();
197
+ if (!trimmed || trimmed.startsWith('#')) continue;
198
+ const m = trimmed.match(/^([A-Za-z0-9_]+)\s*:(.*)$/);
199
+ if (!m) continue; // list items / flow content — irrelevant to a scalar leaf path
200
+ const indent = line.length - line.trimStart().length;
201
+ while (stack.length && stack[stack.length - 1].indent >= indent) stack.pop();
202
+ stack.push({ key: m[1], indent });
203
+ const pathHere = stack.map((f) => f.key);
204
+ if (pathHere.length === segments.length && pathHere.every((k, i) => k === segments[i])) {
205
+ // Reached the leaf — ON unless explicitly false.
206
+ return m[2].trim().toLowerCase() !== 'false';
207
+ }
208
+ }
209
+ return true; // path absent → default enabled
210
+ }
211
+
212
+ // Back-compat / ergonomic wrapper: enabled-check straight off a rule object.
213
+ // `repoRoot` defaults to the vault (config) root, same as isConfigKeyEnabled.
214
+ export function isRuleEnabled(rule, repoRoot = defaultConfigRoot()) {
215
+ return isConfigKeyEnabled(rule && rule.configKey, repoRoot);
216
+ }
217
+
218
+ /**
219
+ * Read the RAW scalar value at a dotted `configKey` from .heyspex/config.yaml,
220
+ * returning the trimmed string after the colon, or `undefined` when the path is
221
+ * absent / the file is unreadable. Unlike isConfigKeyEnabled (which collapses to a
222
+ * boolean with an enabled-by-default bias), this preserves the three-state
223
+ * distinction the confirm-mode resolver needs: explicit value vs. absent/inherit.
224
+ * Dependency-free indent-scoped scan, mirroring isConfigKeyEnabled. FAIL-SAFE.
225
+ * `repoRoot` defaults to the vault (config) root — see the two-roots note above.
226
+ */
227
+ export function readConfigLeafRaw(configKey, repoRoot = defaultConfigRoot()) {
228
+ if (!configKey || typeof configKey !== 'string') return undefined;
229
+ const segments = configKey.split('.').filter(Boolean);
230
+ if (segments.length === 0) return undefined;
231
+
232
+ let raw;
233
+ try {
234
+ raw = fs.readFileSync(path.join(repoRoot, '.heyspex', 'config.yaml'), 'utf8');
235
+ } catch {
236
+ return undefined;
237
+ }
238
+
239
+ const stack = []; // { key, indent }
240
+ for (const line of raw.split(/\r?\n/)) {
241
+ const trimmed = line.trim();
242
+ if (!trimmed || trimmed.startsWith('#')) continue;
243
+ const m = trimmed.match(/^([A-Za-z0-9_]+)\s*:(.*)$/);
244
+ if (!m) continue;
245
+ const indent = line.length - line.trimStart().length;
246
+ while (stack.length && stack[stack.length - 1].indent >= indent) stack.pop();
247
+ stack.push({ key: m[1], indent });
248
+ const pathHere = stack.map((f) => f.key);
249
+ if (pathHere.length === segments.length && pathHere.every((k, i) => k === segments[i])) {
250
+ return m[2].trim();
251
+ }
252
+ }
253
+ return undefined;
254
+ }
255
+
256
+ /**
257
+ * Resolve the CONFIRM MODE for an autonomous agent behavior — the two-layer
258
+ * permission model the owner specified (2026-07-11):
259
+ * 'auto' = act immediately, user can undo/discard afterward (forgiveness)
260
+ * 'ask' = ask the user for confirmation before acting (permission)
261
+ *
262
+ * Resolution order (per-behavior override wins over the global default):
263
+ * 1. agent_behaviors.<behaviorKey>.ask_first
264
+ * true|yes|ask → 'ask' · false|no|auto → 'auto' · null/absent → inherit
265
+ * 2. (inherit) global agent_behaviors.ask_forgiveness_not_permission
266
+ * true|yes|absent → 'auto' (forgiveness — the owner's default)
267
+ * false|no → 'ask'
268
+ *
269
+ * So EVERY behavior honors the one global "ask forgiveness, not permission" switch
270
+ * unless it opts out with its own ask_first. FAIL-SAFE: any unreadable config →
271
+ * 'auto' (the stated default). Shared here so any behavior hook resolves it identically.
272
+ * `repoRoot` defaults to the vault (config) root — see the two-roots note above.
273
+ */
274
+ export function resolveConfirmMode(behaviorKey, repoRoot = defaultConfigRoot()) {
275
+ if (behaviorKey && typeof behaviorKey === 'string') {
276
+ const per = readConfigLeafRaw(`agent_behaviors.${behaviorKey}.ask_first`, repoRoot);
277
+ if (per !== undefined) {
278
+ const v = per.toLowerCase();
279
+ if (v === 'true' || v === 'yes' || v === 'ask') return 'ask';
280
+ if (v === 'false' || v === 'no' || v === 'auto') return 'auto';
281
+ // 'null' / '' / anything else → fall through to the global default (inherit)
282
+ }
283
+ }
284
+ const global = readConfigLeafRaw('agent_behaviors.ask_forgiveness_not_permission', repoRoot);
285
+ if (global !== undefined) {
286
+ const g = global.toLowerCase();
287
+ if (g === 'false' || g === 'no') return 'ask'; // forgiveness OFF → ask first
288
+ }
289
+ return 'auto'; // forgiveness ON / absent → act, don't ask
290
+ }
291
+
292
+ /**
293
+ * Path/filename-shape evaluator for a `naming`-payload rule (the `id-suffix-shape`
294
+ * rule, lifted from enforce-vault-naming.mjs's old hand-coded validatePath). Given a
295
+ * compiled rule carrying
296
+ * naming: { managed: [{ dir, kind, pattern, example }], slugOnlyDirs: [...] }
297
+ * and a repo-relative POSIX path, returns null when the path is fine, or a violation
298
+ * descriptor { relPath, dir, kind, requiredPattern, example } when it breaks the rule.
299
+ * The caller formats the human message and decides the exit code.
300
+ *
301
+ * Lives in the loader (not the hook) so the future pre-commit / server consumers share
302
+ * one rule module — GHKO §6.2 "one rule module, many surfaces". FAIL-SAFE: a malformed
303
+ * rule, an uncompilable pattern, or any unexpected shape resolves to null (allow), never
304
+ * throws — a broken rule must never strand the agent.
305
+ */
306
+ // --- Vocabulary rules (the glossary / naughty-list family, [[HS-PROTO-terminology-governance]]) ---
307
+ //
308
+ // A `kind: vocabulary` rule carries `vocabulary: { terms: [{word, fix, nounOnly?,
309
+ // caseSensitive?}], allow: [...] }`, compiled from the doc table by build-guardrails.mjs
310
+ // (the doc IS the source of truth). The runtime merges a gitignored, per-user override
311
+ // at LOAD time (not compile time) so personal preferences never enter the committed
312
+ // registry, and the Stop hook + server gate apply the identical resolved list.
313
+
314
+ function escapeRegexLiteral(s) {
315
+ return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
316
+ }
317
+
318
+ // Strip the parts of a message that are NOT prose — code spans, fenced code blocks,
319
+ // [[wikilinks]], URLs, and blockquote lines — so a banned word quoted/defined/in-code
320
+ // is never flagged. To refer to a banned word AS a word, wrap it in backticks.
321
+ export function proseOnly(text) {
322
+ return String(text)
323
+ .replace(/```[\s\S]*?```/g, ' ') // fenced code blocks
324
+ .replace(/`[^`]*`/g, ' ') // inline code
325
+ .replace(/\[\[[^\]]*\]\]/g, ' ') // [[wikilinks]]
326
+ .replace(/https?:\/\/\S+/g, ' ') // bare URLs
327
+ .replace(/^\s{0,3}>.*$/gm, ' '); // blockquote lines
328
+ }
329
+
330
+ /**
331
+ * Resolve a vocabulary rule's effective term list by merging the compiled (vendor-
332
+ * default) terms with the per-user `.heyspex/terminology.local.json` override
333
+ * ({ add, allow, remove }). User wins: `allow`/`remove` drop a default term, `add`
334
+ * appends a personal one. FAIL-SAFE: a missing/corrupt override → defaults only.
335
+ * Returns { terms: [{word, fix, nounOnly?, caseSensitive?}], allow: Set<string> }.
336
+ */
337
+ export function resolveVocabulary(rule, repoRoot = DEFAULT_REPO_ROOT) {
338
+ const v = rule && rule.vocabulary ? rule.vocabulary : {};
339
+ let terms = Array.isArray(v.terms) ? v.terms.slice() : [];
340
+ const allow = new Set((Array.isArray(v.allow) ? v.allow : []).map((s) => String(s).toLowerCase()));
341
+
342
+ try {
343
+ const raw = fs.readFileSync(path.join(repoRoot, '.heyspex', 'terminology.local.json'), 'utf8');
344
+ const cfg = JSON.parse(raw);
345
+ for (const w of cfg?.allow ?? []) allow.add(String(w).toLowerCase());
346
+ const remove = new Set((cfg?.remove ?? []).map((w) => String(w).toLowerCase()));
347
+ if (remove.size) terms = terms.filter((t) => !remove.has(String(t.word).toLowerCase()));
348
+ for (const w of cfg?.add ?? []) {
349
+ const word = String(w).trim();
350
+ if (word && !terms.some((t) => String(t.word).toLowerCase() === word.toLowerCase())) {
351
+ terms.push({ word, fix: 'plain literal language (user-added)' });
352
+ }
353
+ }
354
+ } catch {
355
+ // no override / unreadable → defaults only
356
+ }
357
+
358
+ // `allow` (default keep-list + user allow) suppresses any matching term.
359
+ terms = terms.filter((t) => !allow.has(String(t.word).toLowerCase()));
360
+ return { terms, allow };
361
+ }
362
+
363
+ /** Longest match window carried on a hit — enough to judge, small enough to log. */
364
+ export const MATCH_CONTEXT_MAX_CHARS = 200;
365
+
366
+ /**
367
+ * A bounded window of prose around a match, for the audit row
368
+ * ([[HS-N71O-P3XQ-DIAG-six-new-qc-gates-fail-cold-verification]] item 10).
369
+ *
370
+ * Prefers the sentence the match sits in; falls back to a character window when
371
+ * the sentence is longer than the cap or has no obvious boundaries. Whitespace is
372
+ * collapsed so a row stays one line. A reviewer reading the log needs to tell a
373
+ * false positive ("ladder", the owner's own term in the confidence-ladder method)
374
+ * from a real hit, and the word alone cannot do that.
375
+ */
376
+ export function matchContextAround(prose, index, matchLength, maxChars = MATCH_CONTEXT_MAX_CHARS) {
377
+ try {
378
+ if (typeof prose !== 'string' || index < 0) return '';
379
+ // Sentence bounds around the match, then trimmed to the cap around the match
380
+ // itself so a long sentence still shows the word rather than its opening.
381
+ let start = prose.lastIndexOf('.', index) + 1;
382
+ const nextStop = prose.indexOf('.', index + matchLength);
383
+ let end = nextStop === -1 ? prose.length : nextStop + 1;
384
+ if (end - start > maxChars) {
385
+ const slack = Math.max(0, Math.floor((maxChars - matchLength) / 2));
386
+ start = Math.max(0, index - slack);
387
+ end = Math.min(prose.length, index + matchLength + slack);
388
+ }
389
+ return prose.slice(start, end).replace(/\s+/g, ' ').trim().slice(0, maxChars);
390
+ } catch {
391
+ return '';
392
+ }
393
+ }
394
+
395
+ /**
396
+ * Scan `text` for a vocabulary rule's banned terms, returning hits
397
+ * [{word, fix, context}]. `context` is a bounded window of the prose the term
398
+ * matched in — see `matchContextAround`.
399
+ * Prose-only (code/quotes excluded); word-boundary match; case-insensitive unless the
400
+ * term is `caseSensitive` (abbreviations like "AC"). FAIL-SAFE: any error → [] (allow).
401
+ */
402
+ export function vocabularyViolations(rule, text, repoRoot = DEFAULT_REPO_ROOT) {
403
+ try {
404
+ if (typeof text !== 'string' || !text) return [];
405
+ const { terms } = resolveVocabulary(rule, repoRoot);
406
+ if (!terms.length) return [];
407
+ const prose = proseOnly(text);
408
+ const hits = [];
409
+ for (const t of terms) {
410
+ const w = escapeRegexLiteral(t.word);
411
+ // (?<![\w-]) … (?![\w-]) is a word boundary that also excludes hyphenated
412
+ // compounds, so a noun-only ban on "grain" never matches "fine-grained".
413
+ const re = new RegExp(`(?<![\\w-])${w}(?![\\w-])`, t.caseSensitive ? '' : 'i');
414
+ const m = re.exec(prose);
415
+ if (m) {
416
+ hits.push({
417
+ word: t.word,
418
+ fix: t.fix,
419
+ context: matchContextAround(prose, m.index, m[0].length),
420
+ });
421
+ }
422
+ }
423
+ return hits;
424
+ } catch {
425
+ return [];
426
+ }
427
+ }
428
+
429
+ /**
430
+ * @param {object} [opts]
431
+ * @param {boolean} [opts.requireNew] Apply the managed entry's strict
432
+ * `newFilePattern` (file key REQUIRED — owner ruling 2026-07-10) instead of the
433
+ * lenient `pattern`. Callers pass true when the path is being CREATED (the
434
+ * target does not exist yet); existing/legacy files are validated leniently,
435
+ * honoring the no-backfill ruling. Entries without a `newFilePattern` fall back
436
+ * to `pattern`.
437
+ */
438
+ export function namingViolation(rule, relPath, opts = {}) {
439
+ try {
440
+ if (!rule || typeof relPath !== 'string') return null;
441
+ const naming = rule.naming;
442
+ if (!naming || !Array.isArray(naming.managed)) return null;
443
+ if (!relPath.startsWith('specs/')) return null;
444
+ // Only .md files are spec entities; .json/.yaml/etc under specs/ are not.
445
+ if (!relPath.endsWith('.md')) return null;
446
+
447
+ // POSIX dirname/basename (relPath is already POSIX-normalized by the caller).
448
+ const lastSlash = relPath.lastIndexOf('/');
449
+ const dir = lastSlash === -1 ? '' : relPath.slice(0, lastSlash);
450
+ const basename = lastSlash === -1 ? relPath : relPath.slice(lastSlash + 1);
451
+
452
+ // Slug-only zones: any kebab-case .md is allowed through.
453
+ const slugOnly = Array.isArray(naming.slugOnlyDirs) ? naming.slugOnlyDirs : [];
454
+ for (const z of slugOnly) {
455
+ if (dir === z || dir.startsWith(z + '/')) return null;
456
+ }
457
+
458
+ // Find the managed rule for this directory. A file can match BOTH a parent dir
459
+ // and a more-specific subdir (e.g. specs/agents AND specs/agents/golden-cases);
460
+ // the MOST-SPECIFIC match (longest `r.dir`) must win, else the parent rule's
461
+ // pattern shadows the child's. A plain `.find()` returns registry order, which
462
+ // mis-validates a golden-case file against the AGENT pattern when specs/agents is
463
+ // listed first.
464
+ let m = null;
465
+ for (const r of naming.managed) {
466
+ if (!r || typeof r.dir !== 'string') continue;
467
+ if (dir === r.dir || dir.startsWith(r.dir + '/')) {
468
+ if (!m || r.dir.length > m.dir.length) m = r;
469
+ }
470
+ }
471
+ if (!m) return null; // unmanaged subdir under specs/ — don't block
472
+
473
+ const strictNew = opts.requireNew === true && typeof m.newFilePattern === 'string';
474
+ const activePattern = strictNew ? m.newFilePattern : m.pattern;
475
+ let re;
476
+ try {
477
+ re = new RegExp(activePattern);
478
+ } catch {
479
+ return null; // uncompilable pattern → fail-safe allow
480
+ }
481
+ if (re.test(basename)) return null; // matches the required shape → fine
482
+
483
+ return {
484
+ relPath,
485
+ dir: m.dir,
486
+ kind: m.kind ?? null,
487
+ requiredPattern: activePattern,
488
+ example: m.example ?? null,
489
+ strictNew,
490
+ };
491
+ } catch {
492
+ return null; // any unexpected error → fail-safe allow
493
+ }
494
+ }