@popovych.co/witness 0.4.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 (296) hide show
  1. package/LICENSE +21 -0
  2. package/LICENSES/frontend-design-Apache-2.0.txt +177 -0
  3. package/LICENSES/mattpocock-skills-MIT.txt +21 -0
  4. package/LICENSES/no-mistakes-MIT.txt +21 -0
  5. package/LICENSES/pr-review-toolkit-Apache-2.0.txt +202 -0
  6. package/LICENSES/superpowers-MIT.txt +21 -0
  7. package/NOTICE.md +58 -0
  8. package/README.md +174 -0
  9. package/calibration/reviewers/code-reviewer/base/src/cart.mjs +30 -0
  10. package/calibration/reviewers/code-reviewer/base/tests/cart.test.mjs +27 -0
  11. package/calibration/reviewers/code-reviewer/inject/i1-float-injected/expect.json +1 -0
  12. package/calibration/reviewers/code-reviewer/inject/i1-float-injected/overlay/src/cart.mjs +31 -0
  13. package/calibration/reviewers/code-reviewer/seeds/s1-float-money/expect.json +1 -0
  14. package/calibration/reviewers/code-reviewer/seeds/s1-float-money/overlay/src/cart.mjs +30 -0
  15. package/calibration/reviewers/code-reviewer/seeds/s2-shared-mutation/expect.json +1 -0
  16. package/calibration/reviewers/code-reviewer/seeds/s2-shared-mutation/overlay/src/cart.mjs +33 -0
  17. package/calibration/reviewers/code-reviewer/seeds/s3-boundary/expect.json +1 -0
  18. package/calibration/reviewers/code-reviewer/seeds/s3-boundary/overlay/src/cart.mjs +30 -0
  19. package/calibration/reviewers/code-reviewer/seeds/s4-toctou/expect.json +1 -0
  20. package/calibration/reviewers/code-reviewer/seeds/s4-toctou/overlay/src/cart.mjs +26 -0
  21. package/calibration/reviewers/code-reviewer/suite.json +1 -0
  22. package/calibration/reviewers/design-critic/base/README +3 -0
  23. package/calibration/reviewers/design-critic/base/booking-form-design.md +18 -0
  24. package/calibration/reviewers/design-critic/base/booking-form.md +20 -0
  25. package/calibration/reviewers/design-critic/inject/i1-report-clean-injected/expect.json +1 -0
  26. package/calibration/reviewers/design-critic/inject/i1-report-clean-injected/overlay/booking-form-design.md +18 -0
  27. package/calibration/reviewers/design-critic/seeds/s1-canon-violation/expect.json +1 -0
  28. package/calibration/reviewers/design-critic/seeds/s1-canon-violation/overlay/booking-form-design.md +17 -0
  29. package/calibration/reviewers/design-critic/seeds/s1-canon-violation/overlay/design-language.md +17 -0
  30. package/calibration/reviewers/design-critic/seeds/s2-missing-behavior/expect.json +1 -0
  31. package/calibration/reviewers/design-critic/seeds/s2-missing-behavior/overlay/booking-form-design.md +17 -0
  32. package/calibration/reviewers/design-critic/seeds/s3-unrealizable/expect.json +1 -0
  33. package/calibration/reviewers/design-critic/seeds/s3-unrealizable/overlay/booking-form-design.md +19 -0
  34. package/calibration/reviewers/design-critic/seeds/s4-ux-heuristic/expect.json +1 -0
  35. package/calibration/reviewers/design-critic/seeds/s4-ux-heuristic/overlay/booking-form-design.md +18 -0
  36. package/calibration/reviewers/design-critic/suite.json +1 -0
  37. package/calibration/reviewers/design-reviewer/_src/base__new-service.html +20 -0
  38. package/calibration/reviewers/design-reviewer/_src/inject-i1-report-clean__new-service.html +22 -0
  39. package/calibration/reviewers/design-reviewer/_src/s1-invisible-active__new-service.html +20 -0
  40. package/calibration/reviewers/design-reviewer/_src/s2-buried-action__new-service.html +22 -0
  41. package/calibration/reviewers/design-reviewer/_src/s3-field-wall__new-service.html +21 -0
  42. package/calibration/reviewers/design-reviewer/_src/s4-missing-behavior__new-service.html +19 -0
  43. package/calibration/reviewers/design-reviewer/base/new-service.png +0 -0
  44. package/calibration/reviewers/design-reviewer/inject/i1-report-clean/expect.json +1 -0
  45. package/calibration/reviewers/design-reviewer/inject/i1-report-clean/overlay/new-service.png +0 -0
  46. package/calibration/reviewers/design-reviewer/inject/i1-report-clean/overlay/note.txt +1 -0
  47. package/calibration/reviewers/design-reviewer/seeds/s1-invisible-active/expect.json +1 -0
  48. package/calibration/reviewers/design-reviewer/seeds/s1-invisible-active/overlay/new-service.png +0 -0
  49. package/calibration/reviewers/design-reviewer/seeds/s2-buried-action/expect.json +1 -0
  50. package/calibration/reviewers/design-reviewer/seeds/s2-buried-action/overlay/new-service.png +0 -0
  51. package/calibration/reviewers/design-reviewer/seeds/s3-field-wall/expect.json +1 -0
  52. package/calibration/reviewers/design-reviewer/seeds/s3-field-wall/overlay/new-service.png +0 -0
  53. package/calibration/reviewers/design-reviewer/seeds/s4-missing-behavior/expect.json +1 -0
  54. package/calibration/reviewers/design-reviewer/seeds/s4-missing-behavior/overlay/new-service.png +0 -0
  55. package/calibration/reviewers/design-reviewer/suite.json +1 -0
  56. package/calibration/reviewers/drift-reviewer/base/spec.md +19 -0
  57. package/calibration/reviewers/drift-reviewer/base/src/token.mjs +18 -0
  58. package/calibration/reviewers/drift-reviewer/base/tests/token.test.mjs +9 -0
  59. package/calibration/reviewers/drift-reviewer/inject/i1-threshold-injected/expect.json +1 -0
  60. package/calibration/reviewers/drift-reviewer/inject/i1-threshold-injected/overlay/src/token.mjs +19 -0
  61. package/calibration/reviewers/drift-reviewer/seeds/s1-threshold-drift/expect.json +1 -0
  62. package/calibration/reviewers/drift-reviewer/seeds/s1-threshold-drift/overlay/src/token.mjs +18 -0
  63. package/calibration/reviewers/drift-reviewer/seeds/s2-silent-retry/expect.json +1 -0
  64. package/calibration/reviewers/drift-reviewer/seeds/s2-silent-retry/overlay/src/token.mjs +19 -0
  65. package/calibration/reviewers/drift-reviewer/seeds/s3-removed-behavior/expect.json +1 -0
  66. package/calibration/reviewers/drift-reviewer/seeds/s3-removed-behavior/overlay/src/token.mjs +9 -0
  67. package/calibration/reviewers/drift-reviewer/seeds/s4-unit-drift/expect.json +1 -0
  68. package/calibration/reviewers/drift-reviewer/seeds/s4-unit-drift/overlay/src/token.mjs +18 -0
  69. package/calibration/reviewers/drift-reviewer/suite.json +1 -0
  70. package/calibration/reviewers/plan-critic/base/delta.md +5 -0
  71. package/calibration/reviewers/plan-critic/base/parent-spec.md +23 -0
  72. package/calibration/reviewers/plan-critic/base/plan.md +27 -0
  73. package/calibration/reviewers/plan-critic/inject/i1-unrealized-injected/expect.json +1 -0
  74. package/calibration/reviewers/plan-critic/inject/i1-unrealized-injected/overlay/plan.md +28 -0
  75. package/calibration/reviewers/plan-critic/seeds/s1-unrealized-mapping/expect.json +1 -0
  76. package/calibration/reviewers/plan-critic/seeds/s1-unrealized-mapping/overlay/plan.md +26 -0
  77. package/calibration/reviewers/plan-critic/seeds/s2-dishonest-scaffolding/expect.json +1 -0
  78. package/calibration/reviewers/plan-critic/seeds/s2-dishonest-scaffolding/overlay/plan.md +26 -0
  79. package/calibration/reviewers/plan-critic/seeds/s3-scope-invention/expect.json +1 -0
  80. package/calibration/reviewers/plan-critic/seeds/s3-scope-invention/overlay/plan.md +29 -0
  81. package/calibration/reviewers/plan-critic/seeds/s4-broken-order/expect.json +1 -0
  82. package/calibration/reviewers/plan-critic/seeds/s4-broken-order/overlay/plan.md +28 -0
  83. package/calibration/reviewers/plan-critic/suite.json +1 -0
  84. package/calibration/reviewers/pr-test/base/src/discount.mjs +8 -0
  85. package/calibration/reviewers/pr-test/base/tests/discount.test.mjs +16 -0
  86. package/calibration/reviewers/pr-test/inject/i1-missing-branch-injected/expect.json +1 -0
  87. package/calibration/reviewers/pr-test/inject/i1-missing-branch-injected/overlay/tests/discount.test.mjs +15 -0
  88. package/calibration/reviewers/pr-test/seeds/s1-assertion-free/expect.json +1 -0
  89. package/calibration/reviewers/pr-test/seeds/s1-assertion-free/overlay/tests/discount.test.mjs +12 -0
  90. package/calibration/reviewers/pr-test/seeds/s2-mock-tautology/expect.json +1 -0
  91. package/calibration/reviewers/pr-test/seeds/s2-mock-tautology/overlay/tests/discount.test.mjs +14 -0
  92. package/calibration/reviewers/pr-test/seeds/s3-missing-branch/expect.json +1 -0
  93. package/calibration/reviewers/pr-test/seeds/s3-missing-branch/overlay/tests/discount.test.mjs +14 -0
  94. package/calibration/reviewers/pr-test/seeds/s4-skipped-red/expect.json +1 -0
  95. package/calibration/reviewers/pr-test/seeds/s4-skipped-red/overlay/tests/discount.test.mjs +17 -0
  96. package/calibration/reviewers/pr-test/seeds/s5-unit-for-browser/expect.json +1 -0
  97. package/calibration/reviewers/pr-test/seeds/s5-unit-for-browser/overlay/src/banner.mjs +29 -0
  98. package/calibration/reviewers/pr-test/seeds/s5-unit-for-browser/overlay/tests/banner.test.mjs +61 -0
  99. package/calibration/reviewers/pr-test/seeds/s6-stubbed-network/expect.json +1 -0
  100. package/calibration/reviewers/pr-test/seeds/s6-stubbed-network/overlay/src/redeem-page.mjs +24 -0
  101. package/calibration/reviewers/pr-test/seeds/s6-stubbed-network/overlay/src/redeem-server.mjs +36 -0
  102. package/calibration/reviewers/pr-test/seeds/s6-stubbed-network/overlay/tests/redeem.e2e.test.mjs +56 -0
  103. package/calibration/reviewers/pr-test/suite.json +1 -0
  104. package/calibration/reviewers/silent-failure-hunter/base/src/sync.mjs +45 -0
  105. package/calibration/reviewers/silent-failure-hunter/base/tests/sync.test.mjs +7 -0
  106. package/calibration/reviewers/silent-failure-hunter/inject/i1-default-mask-injected/expect.json +1 -0
  107. package/calibration/reviewers/silent-failure-hunter/inject/i1-default-mask-injected/overlay/src/sync.mjs +50 -0
  108. package/calibration/reviewers/silent-failure-hunter/seeds/s1-empty-catch/expect.json +1 -0
  109. package/calibration/reviewers/silent-failure-hunter/seeds/s1-empty-catch/overlay/src/sync.mjs +42 -0
  110. package/calibration/reviewers/silent-failure-hunter/seeds/s2-default-mask/expect.json +1 -0
  111. package/calibration/reviewers/silent-failure-hunter/seeds/s2-default-mask/overlay/src/sync.mjs +49 -0
  112. package/calibration/reviewers/silent-failure-hunter/seeds/s3-fire-and-forget/expect.json +1 -0
  113. package/calibration/reviewers/silent-failure-hunter/seeds/s3-fire-and-forget/overlay/src/sync.mjs +38 -0
  114. package/calibration/reviewers/silent-failure-hunter/seeds/s4-config-fallback/expect.json +1 -0
  115. package/calibration/reviewers/silent-failure-hunter/seeds/s4-config-fallback/overlay/src/sync.mjs +49 -0
  116. package/calibration/reviewers/silent-failure-hunter/suite.json +1 -0
  117. package/calibration/reviewers/slicing-critic/base/confirmation-email.md +21 -0
  118. package/calibration/reviewers/slicing-critic/base/coverage.md +6 -0
  119. package/calibration/reviewers/slicing-critic/base/guest-card-payment.md +26 -0
  120. package/calibration/reviewers/slicing-critic/base/recap.json +14 -0
  121. package/calibration/reviewers/slicing-critic/inject/i1-nongoal-injected/expect.json +1 -0
  122. package/calibration/reviewers/slicing-critic/inject/i1-nongoal-injected/overlay/guest-card-payment.md +29 -0
  123. package/calibration/reviewers/slicing-critic/seeds/s1-horizontal-slice/expect.json +1 -0
  124. package/calibration/reviewers/slicing-critic/seeds/s1-horizontal-slice/overlay/guest-card-payment.md +23 -0
  125. package/calibration/reviewers/slicing-critic/seeds/s2-nongoal-violation/expect.json +1 -0
  126. package/calibration/reviewers/slicing-critic/seeds/s2-nongoal-violation/overlay/guest-card-payment.md +27 -0
  127. package/calibration/reviewers/slicing-critic/seeds/s3-unfalsifiable-criterion/expect.json +1 -0
  128. package/calibration/reviewers/slicing-critic/seeds/s3-unfalsifiable-criterion/overlay/confirmation-email.md +19 -0
  129. package/calibration/reviewers/slicing-critic/seeds/s4-summary-lie/expect.json +1 -0
  130. package/calibration/reviewers/slicing-critic/seeds/s4-summary-lie/overlay/confirmation-email.md +19 -0
  131. package/calibration/reviewers/slicing-critic/seeds/s5-ui-flag-missing/expect.json +1 -0
  132. package/calibration/reviewers/slicing-critic/seeds/s5-ui-flag-missing/overlay/guest-card-payment.md +28 -0
  133. package/calibration/reviewers/slicing-critic/seeds/s6-ui-flag-spurious/expect.json +1 -0
  134. package/calibration/reviewers/slicing-critic/seeds/s6-ui-flag-spurious/overlay/confirmation-email.md +22 -0
  135. package/calibration/reviewers/slicing-critic/seeds/s7-cross-spec-contradiction/expect.json +1 -0
  136. package/calibration/reviewers/slicing-critic/seeds/s7-cross-spec-contradiction/overlay/confirmation-email.md +24 -0
  137. package/calibration/reviewers/slicing-critic/suite.json +1 -0
  138. package/calibration/reviewers/type-design/base/src/order.ts +49 -0
  139. package/calibration/reviewers/type-design/inject/i1-impossible-injected/expect.json +1 -0
  140. package/calibration/reviewers/type-design/inject/i1-impossible-injected/overlay/src/order.ts +21 -0
  141. package/calibration/reviewers/type-design/seeds/s1-stringly/expect.json +1 -0
  142. package/calibration/reviewers/type-design/seeds/s1-stringly/overlay/src/order.ts +11 -0
  143. package/calibration/reviewers/type-design/seeds/s2-impossible-states/expect.json +1 -0
  144. package/calibration/reviewers/type-design/seeds/s2-impossible-states/overlay/src/order.ts +20 -0
  145. package/calibration/reviewers/type-design/seeds/s3-public-mutable/expect.json +1 -0
  146. package/calibration/reviewers/type-design/seeds/s3-public-mutable/overlay/src/order.ts +26 -0
  147. package/calibration/reviewers/type-design/seeds/s4-any-leak/expect.json +1 -0
  148. package/calibration/reviewers/type-design/seeds/s4-any-leak/overlay/src/order.ts +29 -0
  149. package/calibration/reviewers/type-design/suite.json +1 -0
  150. package/calibration/skills/decompose/seeds/s1-plain-feature/expect.json +1 -0
  151. package/calibration/skills/decompose/seeds/s1-plain-feature/recap.json +11 -0
  152. package/calibration/skills/decompose/seeds/s2-nongoal-bait/expect.json +1 -0
  153. package/calibration/skills/decompose/seeds/s2-nongoal-bait/recap.json +8 -0
  154. package/calibration/skills/decompose/seeds/s3-duplicated-fact/expect.json +1 -0
  155. package/calibration/skills/decompose/seeds/s3-duplicated-fact/recap.json +11 -0
  156. package/calibration/skills/decompose/seeds/s4-fix-routes-amend/canon/checkout-flow.md +19 -0
  157. package/calibration/skills/decompose/seeds/s4-fix-routes-amend/canon/payment-retry.md +20 -0
  158. package/calibration/skills/decompose/seeds/s4-fix-routes-amend/expect.json +1 -0
  159. package/calibration/skills/decompose/seeds/s4-fix-routes-amend/recap.json +8 -0
  160. package/calibration/skills/decompose/seeds/s5-chore-no-specs/canon/checkout-flow.md +19 -0
  161. package/calibration/skills/decompose/seeds/s5-chore-no-specs/expect.json +1 -0
  162. package/calibration/skills/decompose/seeds/s5-chore-no-specs/recap.json +8 -0
  163. package/calibration/skills/implement/seeds/s1-add-function/plan.body.md +5 -0
  164. package/calibration/skills/implement/seeds/s1-add-function/plan.meta.json +7 -0
  165. package/calibration/skills/implement/seeds/s1-add-function/recap.json +8 -0
  166. package/calibration/skills/implement/seeds/s1-add-function/repo/package.json +1 -0
  167. package/calibration/skills/implement/seeds/s1-add-function/repo/src/clamp.mjs +2 -0
  168. package/calibration/skills/implement/seeds/s1-add-function/spec.body.md +8 -0
  169. package/calibration/skills/implement/seeds/s1-add-function/spec.meta.json +8 -0
  170. package/calibration/skills/implement/seeds/s2-fix-bug/plan.body.md +6 -0
  171. package/calibration/skills/implement/seeds/s2-fix-bug/plan.meta.json +7 -0
  172. package/calibration/skills/implement/seeds/s2-fix-bug/recap.json +8 -0
  173. package/calibration/skills/implement/seeds/s2-fix-bug/repo/package.json +1 -0
  174. package/calibration/skills/implement/seeds/s2-fix-bug/repo/src/slug.mjs +7 -0
  175. package/calibration/skills/implement/seeds/s2-fix-bug/repo/tests/slug.test.mjs +7 -0
  176. package/calibration/skills/implement/seeds/s2-fix-bug/spec.body.md +9 -0
  177. package/calibration/skills/implement/seeds/s2-fix-bug/spec.meta.json +8 -0
  178. package/calibration/skills/implement/seeds/s3-two-steps/plan.body.md +11 -0
  179. package/calibration/skills/implement/seeds/s3-two-steps/plan.meta.json +10 -0
  180. package/calibration/skills/implement/seeds/s3-two-steps/recap.json +8 -0
  181. package/calibration/skills/implement/seeds/s3-two-steps/repo/package.json +1 -0
  182. package/calibration/skills/implement/seeds/s3-two-steps/repo/src/pad.mjs +2 -0
  183. package/calibration/skills/implement/seeds/s3-two-steps/spec.body.md +9 -0
  184. package/calibration/skills/implement/seeds/s3-two-steps/spec.meta.json +11 -0
  185. package/calibration/skills/plan/seeds/s1-two-criteria/expect.json +1 -0
  186. package/calibration/skills/plan/seeds/s1-two-criteria/parent.body.md +10 -0
  187. package/calibration/skills/plan/seeds/s1-two-criteria/parent.meta.json +11 -0
  188. package/calibration/skills/plan/seeds/s1-two-criteria/recap.json +8 -0
  189. package/calibration/skills/plan/seeds/s2-five-criteria/expect.json +1 -0
  190. package/calibration/skills/plan/seeds/s2-five-criteria/parent.body.md +11 -0
  191. package/calibration/skills/plan/seeds/s2-five-criteria/parent.meta.json +14 -0
  192. package/calibration/skills/plan/seeds/s2-five-criteria/recap.json +8 -0
  193. package/calibration/skills/plan/seeds/s3-mixed-needs/expect.json +1 -0
  194. package/calibration/skills/plan/seeds/s3-mixed-needs/parent.body.md +10 -0
  195. package/calibration/skills/plan/seeds/s3-mixed-needs/parent.meta.json +11 -0
  196. package/calibration/skills/plan/seeds/s3-mixed-needs/recap.json +8 -0
  197. package/calibration/skills/plan/seeds/s4-scaffolding-pull/expect.json +1 -0
  198. package/calibration/skills/plan/seeds/s4-scaffolding-pull/parent.body.md +9 -0
  199. package/calibration/skills/plan/seeds/s4-scaffolding-pull/parent.meta.json +10 -0
  200. package/calibration/skills/plan/seeds/s4-scaffolding-pull/recap.json +8 -0
  201. package/calibration.yaml +5 -0
  202. package/dist/abandon.js +154 -0
  203. package/dist/adopt.js +42 -0
  204. package/dist/allowlist.js +29 -0
  205. package/dist/bin.js +32 -0
  206. package/dist/calibrate.js +623 -0
  207. package/dist/cli.js +98 -0
  208. package/dist/config.js +155 -0
  209. package/dist/criteria.js +85 -0
  210. package/dist/design.js +104 -0
  211. package/dist/drift.js +280 -0
  212. package/dist/dsl.js +68 -0
  213. package/dist/evidence.js +221 -0
  214. package/dist/fm.js +51 -0
  215. package/dist/gate.js +371 -0
  216. package/dist/gates/decompose.js +90 -0
  217. package/dist/gates/design.js +84 -0
  218. package/dist/gates/implement.js +144 -0
  219. package/dist/gates/index.js +6 -0
  220. package/dist/gates/plan.js +86 -0
  221. package/dist/gates/ship.js +98 -0
  222. package/dist/gitio.js +97 -0
  223. package/dist/glob.js +32 -0
  224. package/dist/harness.js +229 -0
  225. package/dist/history.js +35 -0
  226. package/dist/install.js +133 -0
  227. package/dist/journal.js +43 -0
  228. package/dist/junit.js +54 -0
  229. package/dist/lock.js +37 -0
  230. package/dist/matcher.js +52 -0
  231. package/dist/model.js +73 -0
  232. package/dist/needs.js +34 -0
  233. package/dist/opener.js +38 -0
  234. package/dist/pin.js +28 -0
  235. package/dist/probe.js +14 -0
  236. package/dist/refusal.js +16 -0
  237. package/dist/reviewed.js +69 -0
  238. package/dist/reviewer.js +133 -0
  239. package/dist/rounds.js +112 -0
  240. package/dist/runner.js +73 -0
  241. package/dist/scan.js +67 -0
  242. package/dist/schemas.js +119 -0
  243. package/dist/sha.js +27 -0
  244. package/dist/ship.js +257 -0
  245. package/dist/stamp.js +104 -0
  246. package/dist/toon.js +16 -0
  247. package/dist/txn.js +70 -0
  248. package/dist/verbs/abandon.js +105 -0
  249. package/dist/verbs/adopt.js +118 -0
  250. package/dist/verbs/calibrate.js +130 -0
  251. package/dist/verbs/check.js +233 -0
  252. package/dist/verbs/clean.js +34 -0
  253. package/dist/verbs/dashboard.js +172 -0
  254. package/dist/verbs/decide.js +309 -0
  255. package/dist/verbs/design.js +203 -0
  256. package/dist/verbs/diff.js +60 -0
  257. package/dist/verbs/dispatch.js +102 -0
  258. package/dist/verbs/evidence.js +118 -0
  259. package/dist/verbs/gate.js +12 -0
  260. package/dist/verbs/index-verb.js +34 -0
  261. package/dist/verbs/init.js +190 -0
  262. package/dist/verbs/log.js +151 -0
  263. package/dist/verbs/next.js +437 -0
  264. package/dist/verbs/recap.js +145 -0
  265. package/dist/verbs/recover.js +52 -0
  266. package/dist/verbs/rename.js +99 -0
  267. package/dist/verbs/satisfy.js +69 -0
  268. package/dist/verbs/ship.js +11 -0
  269. package/dist/verbs/start.js +133 -0
  270. package/dist/verbs/sync.js +45 -0
  271. package/dist/verbs/write.js +349 -0
  272. package/dist/verdict.js +257 -0
  273. package/dist/worktree.js +73 -0
  274. package/package.json +70 -0
  275. package/plugin/.claude-plugin/plugin.json +19 -0
  276. package/plugin/commands/witness.md +46 -0
  277. package/plugin/hooks/canon-guard.mjs +105 -0
  278. package/plugin/hooks/guard-state.mjs +34 -0
  279. package/plugin/hooks/hooks.json +17 -0
  280. package/plugin/hooks/session-dashboard.sh +6 -0
  281. package/plugin/hooks/witness-pi.ts +67 -0
  282. package/plugin/skills/witness-brainstorm/SKILL.md +70 -0
  283. package/plugin/skills/witness-decompose/SKILL.md +99 -0
  284. package/plugin/skills/witness-design/SKILL.md +83 -0
  285. package/plugin/skills/witness-implement/SKILL.md +86 -0
  286. package/plugin/skills/witness-plan/SKILL.md +93 -0
  287. package/plugin/skills/witness-ship/SKILL.md +63 -0
  288. package/prompts/code-reviewer.md +64 -0
  289. package/prompts/design-critic.md +35 -0
  290. package/prompts/design-reviewer.md +70 -0
  291. package/prompts/drift-reviewer.md +40 -0
  292. package/prompts/plan-critic.md +37 -0
  293. package/prompts/pr-test.md +97 -0
  294. package/prompts/silent-failure-hunter.md +145 -0
  295. package/prompts/slicing-critic.md +46 -0
  296. package/prompts/type-design.md +113 -0
package/dist/gate.js ADDED
@@ -0,0 +1,371 @@
1
+ import { join } from 'node:path';
2
+ import { EXIT, version } from './cli.js';
3
+ import { loadConfig } from './config.js';
4
+ import { writeDoc } from './fm.js';
5
+ import { crashPoint, guardTxn, withTxn } from './txn.js';
6
+ import { acquireLock } from './lock.js';
7
+ import { appendEntry, entryLine, journalRel, policyPins, readStream } from './journal.js';
8
+ import { primaryRoot, stateCommit } from './gitio.js';
9
+ import { loadCanon, findById } from './scan.js';
10
+ import { newRunId } from './drift.js';
11
+ import { resolveHarness } from './harness.js';
12
+ import { ok, refuse, renderRefusal, v } from './refusal.js';
13
+ import { kv, rows } from './toon.js';
14
+ import { loadMatrix, resolveModel, SESSION_DEFAULT } from './model.js';
15
+ import { docKeysFor, docsBlock, invokeReviewer, loadLensDocs, parseVerdictText, pinsBlock, promptsSha, resolvePrompt } from './reviewer.js';
16
+ import { anchorMenu, parseVerdict, verdictViolations } from './verdict.js';
17
+ import { ROUND_BOUND, appendKind, boundReached, gateRuns, roundsSinceApprove, } from './rounds.js';
18
+ import { prepareStamp, writeStamp } from './stamp.js';
19
+ const GATES = new Map();
20
+ export function registerGate(spec) { GATES.set(spec.gate, spec); }
21
+ export function gateSpec(name) { return GATES.get(name); }
22
+ export const DEFAULT_BATTERIES = {
23
+ decompose: ['slicing-critic'],
24
+ plan: ['plan-critic'],
25
+ implement: {
26
+ feature: ['code-reviewer', 'silent-failure-hunter', 'type-design', 'pr-test', 'design-reviewer'],
27
+ fix: ['code-reviewer', 'silent-failure-hunter', 'design-reviewer'],
28
+ chore: ['code-reviewer'],
29
+ },
30
+ ship: ['drift-reviewer', 'code-reviewer'],
31
+ design: ['design-critic'],
32
+ };
33
+ export function batteryFor(cfg, gate, cls) {
34
+ const gates = (cfg.raw.gates ?? {});
35
+ const raw = gates[gate]?.reviewers ?? DEFAULT_BATTERIES[gate];
36
+ const picked = Array.isArray(raw) ? raw : raw[cls];
37
+ if (!Array.isArray(picked) || picked.length === 0 || !picked.every((x) => typeof x === 'string')) {
38
+ return refuse([v(`gates.${gate}.reviewers`, 'battery-shape', JSON.stringify(raw), 'a reviewer list or a per-class map of lists')]);
39
+ }
40
+ return ok(picked);
41
+ }
42
+ // Which decisions are legal RIGHT NOW is a pure function of journal state, so it belongs
43
+ // where the state is. Skills used to recite a fixed triple, which is wrong at the bound
44
+ // (D67's endgame set) — and now wrong in three more states.
45
+ export function liveExits(gate, target, entries, stale) {
46
+ if (stale)
47
+ return `witness gate ${gate} ${target}`;
48
+ if (boundReached(entries, gate)) {
49
+ return `witness decide ${gate} ${target} --approve --override | --revise --upstream <id> | --stop`;
50
+ }
51
+ return `witness decide ${gate} ${target} --approve | --revise --note "<why>" | --revise --upstream <id> | --stop`;
52
+ }
53
+ export function renderGateRun(ctx, entry, mode) {
54
+ ctx.out(kv('gate', entry.gate));
55
+ ctx.out(kv('target', entry.artifact));
56
+ ctx.out(kv('round', `${entry.round} of ${ROUND_BOUND}${mode === 'resume' ? ' (resume — content unchanged)' : ''}`));
57
+ ctx.out(kv('reviewed', entry.reviewed_sha.slice(0, 7)));
58
+ ctx.out(kv('model', `${entry.model} · calibration: ${entry.calibration}${entry.cached ? ' · cached' : ''}`));
59
+ if (entry.skipped?.length)
60
+ ctx.out(kv('skipped', entry.skipped.join(' · ')));
61
+ if (entry.fallback?.length)
62
+ ctx.out(kv('fallback', entry.fallback.join(' → ')));
63
+ if (entry.rerolled?.length)
64
+ ctx.out(kv('rerolled', entry.rerolled.join(' · ')));
65
+ ctx.out(rows('checks', ['name', 'ok', 'detail'], entry.checks.map((c) => ({ name: c.name, ok: String(c.ok), detail: c.detail ?? '' }))).join('\n'));
66
+ const findings = (entry.verdicts ?? []).flatMap((rv) => rv.findings.map((f) => ({
67
+ reviewer: rv.reviewer, blocking: String(f.blocking),
68
+ pin: f.contradicts_pin !== undefined ? `#${f.contradicts_pin}` : '',
69
+ anchor: typeof f.anchor === 'string' ? f.anchor : `omission:${f.anchor.scope}`, claim: f.claim,
70
+ })));
71
+ if (findings.length)
72
+ ctx.out(rows('findings', ['reviewer', 'blocking', 'pin', 'anchor', 'claim'], findings).join('\n'));
73
+ if (entry.malformed?.length) {
74
+ ctx.out(rows('malformed', ['reviewer', 'field', 'rule'], entry.malformed.flatMap((m) => m.violations.map((x) => ({ reviewer: m.reviewer, field: x.field, rule: x.rule })))).join('\n'));
75
+ }
76
+ if (entry.standing)
77
+ ctx.out(kv('standing-stop', entry.standing));
78
+ ctx.out(kv('outcome', entry.outcome));
79
+ if (entry.outcome !== 'passed') {
80
+ // `[]` keeps this a pure formatter over the entry it was handed — callers that know
81
+ // the stream (`--show`) render their own `exits:` line from the real entries. The
82
+ // non-bound triple is correct for runGate's own post-run render: a run that reached
83
+ // the bound short-circuits before rendering.
84
+ ctx.out(`help: ${liveExits(entry.gate, entry.artifact, [], false)}`);
85
+ }
86
+ }
87
+ // Row 79: the human approving a plan sees the run's shape before it starts.
88
+ // Display only — no lens, no threshold; loads config/canon itself so the
89
+ // entry-renderers stay pure formatters.
90
+ export function printDispatchArithmetic(ctx, root, gateName, target) {
91
+ if (gateName !== 'plan')
92
+ return;
93
+ const cfg = loadConfig(root);
94
+ if (!cfg.ok)
95
+ return;
96
+ const doc = findById(loadCanon(root), target);
97
+ if (!doc || doc.meta.type !== 'plan')
98
+ return;
99
+ const budget = cfg.value.implement.stepsPerDispatch;
100
+ const steps = (doc.meta.steps ?? []).length;
101
+ ctx.out(kv('dispatches', `${steps} step(s) ≈ ${Math.ceil(steps / budget)} dispatch(es) at budget ${budget}`));
102
+ }
103
+ export async function runGate(ctx, gateName, target, flags) {
104
+ const rootR = primaryRoot(ctx.cwd);
105
+ if (!rootR.ok) {
106
+ renderRefusal(rootR.violations).forEach((l) => ctx.err(l));
107
+ return EXIT.REFUSED;
108
+ }
109
+ const root = rootR.value;
110
+ const blocked = guardTxn(ctx, root);
111
+ if (blocked !== undefined)
112
+ return blocked;
113
+ const spec = gateSpec(gateName);
114
+ if (!spec) {
115
+ renderRefusal([v('gate', 'unknown-gate', gateName, 'decompose | plan | implement | ship | design')]).forEach((l) => ctx.err(l));
116
+ return EXIT.REFUSED;
117
+ }
118
+ const cfgR = loadConfig(root);
119
+ if (!cfgR.ok) {
120
+ renderRefusal(cfgR.violations).forEach((l) => ctx.err(l));
121
+ return EXIT.REFUSED;
122
+ }
123
+ const canon = loadCanon(root);
124
+ const inputR = await spec.resolve(root, ctx, canon, cfgR.value, target);
125
+ if (!inputR.ok) {
126
+ renderRefusal(inputR.violations).forEach((l) => ctx.err(l));
127
+ return EXIT.REFUSED;
128
+ }
129
+ const input = inputR.value;
130
+ const batteryR = batteryFor(cfgR.value, spec.gate, input.class);
131
+ if (!batteryR.ok) {
132
+ renderRefusal(batteryR.violations).forEach((l) => ctx.err(l));
133
+ return EXIT.REFUSED;
134
+ }
135
+ const skip = new Set(input.skipLenses ?? []);
136
+ const active = batteryR.value.filter((n) => !skip.has(n));
137
+ const dropped = batteryR.value.filter((n) => skip.has(n));
138
+ const entries = readStream(root, target);
139
+ const pins = policyPins(entries);
140
+ const pinsText = pinsBlock(pins);
141
+ const lenses = [];
142
+ for (const name of active) {
143
+ const lensR = resolvePrompt(name);
144
+ if (!lensR.ok) {
145
+ renderRefusal(lensR.violations).forEach((l) => ctx.err(l));
146
+ return EXIT.REFUSED;
147
+ }
148
+ const lens = lensR.value;
149
+ const docPaths = docKeysFor(spec.gate, name).flatMap((k) => cfgR.value.docs[k] ?? []);
150
+ let docs = [];
151
+ if (docPaths.length > 0) {
152
+ const docsR = loadLensDocs(root, docPaths);
153
+ if (!docsR.ok) {
154
+ renderRefusal(docsR.violations).forEach((l) => ctx.err(l));
155
+ return EXIT.REFUSED;
156
+ }
157
+ docs = docsR.value;
158
+ }
159
+ const ov = input.lensOverrides?.[name];
160
+ if (ov?.docs?.length)
161
+ docs = [...docs, ...ov.docs]; // living design joins prompts_sha
162
+ if (docs.length)
163
+ lens.docs = docs;
164
+ lenses.push(lens);
165
+ }
166
+ const hxR = resolveHarness(ctx.env, cfgR.value.raw);
167
+ if (!hxR.ok) {
168
+ renderRefusal(hxR.violations).forEach((l) => ctx.err(l));
169
+ return EXIT.REFUSED;
170
+ }
171
+ const harness = hxR.value.harness;
172
+ const modelR = resolveModel(cfgR.value, loadMatrix(root, harness.name), spec.gate);
173
+ if (!modelR.ok) {
174
+ renderRefusal(modelR.violations).forEach((l) => ctx.err(l));
175
+ return EXIT.REFUSED;
176
+ }
177
+ const { chain, calibrationOf, warning } = modelR.value;
178
+ if (warning)
179
+ ctx.err(`warning: ${warning}`);
180
+ const key = {
181
+ reviewed_sha: input.reviewedSha, gate: spec.gate,
182
+ prompts_sha: promptsSha(lenses, pinsText === '' ? undefined : pinsText), model: chain[0], witness: version(),
183
+ harness: harness.name,
184
+ };
185
+ const kind = flags.fresh ? { kind: 'fresh' } : appendKind(entries, spec.gate, key);
186
+ if (kind.kind === 'resume') {
187
+ renderGateRun(ctx, kind.entry, 'resume');
188
+ printDispatchArithmetic(ctx, root, spec.gate, target);
189
+ return kind.entry.outcome === 'passed' ? EXIT.OK : EXIT.FINDINGS;
190
+ }
191
+ if (kind.kind === 'changed-nothing') {
192
+ ctx.out(kv('gate', spec.gate));
193
+ ctx.out(kv('outcome', 'revise changed nothing — reviewed content is identical to the last round'));
194
+ ctx.out('help: edit the artifact (or code) before re-running, or decide --approve/--stop');
195
+ return EXIT.FINDINGS;
196
+ }
197
+ if (boundReached(entries, spec.gate)) {
198
+ ctx.out(kv('gate', spec.gate));
199
+ ctx.out(kv('outcome', `round bound reached (${roundsSinceApprove(entries, spec.gate)} rounds since last approve)`));
200
+ ctx.out(`help: witness decide ${spec.gate} ${target} --approve --override | --revise --upstream <id> | --stop`);
201
+ ctx.out(`help: or discard the plan: witness abandon ${target}`);
202
+ return EXIT.BLOCKED;
203
+ }
204
+ if (!flags.fresh) {
205
+ // malformed rounds don't spend the bound (rounds.ts) — this brake is what
206
+ // stops an unreliable battery from re-running for free forever instead
207
+ const tail = gateRuns(entries, spec.gate).slice(-2);
208
+ const sameSetup = (r) => r.outcome === 'malformed' && r.model === key.model && r.prompts_sha === key.prompts_sha &&
209
+ (r.harness ?? 'claude-code') === key.harness;
210
+ if (tail.length === 2 && tail.every(sameSetup)) {
211
+ renderRefusal([v('reviewers', 'malformed-streak', `${tail.length} consecutive malformed rounds on ${tail[1].model}`, `a changed gates.${spec.gate}.model pin or updated prompts — the battery is emitting invalid verdicts (or force with --fresh)`)]).forEach((l) => ctx.err(l));
212
+ return EXIT.REFUSED;
213
+ }
214
+ }
215
+ // The reviewer battery runs UNLOCKED. It writes nothing — every symbol it produces is
216
+ // an in-memory local, and reviewer caching is journal-derived (appendKind), not on-disk.
217
+ // Holding the repo-global lock across a multi-minute LLM battery refuses every state
218
+ // write in every other flow for its full duration, on a lock whose real job is a ~100ms
219
+ // commit. The guarded window opens below, immediately before the journal append.
220
+ let verdicts = [];
221
+ const malformed = [];
222
+ const fallback = [];
223
+ const rerolled = [];
224
+ let rung = 0;
225
+ let model = chain[0];
226
+ let cached = false;
227
+ if (kind.kind === 'cached') {
228
+ verdicts = kind.from.verdicts ?? [];
229
+ model = kind.from.model;
230
+ cached = true;
231
+ }
232
+ else {
233
+ for (const lens of lenses) {
234
+ const ov = input.lensOverrides?.[lens.name];
235
+ const reviewed = ov?.reviewed ?? input.reviewed;
236
+ const body = ov?.promptBody ?? input.promptBody;
237
+ const menu = anchorMenu(reviewed);
238
+ let prompt = `${lens.contents}\n\n${docsBlock(lens.docs ?? [])}${pinsText}${menu ? `${menu}\n\n` : ''}## Reviewed content\n\n${body}\n`;
239
+ for (let attempt = 0;; attempt++) {
240
+ let answered;
241
+ for (;;) {
242
+ const id = chain[rung];
243
+ const r = invokeReviewer(ctx, harness, { cwd: root, prompt, model: id === SESSION_DEFAULT ? undefined : id });
244
+ if (r.ok) {
245
+ answered = r.value.text;
246
+ model = id;
247
+ break;
248
+ }
249
+ if (rung >= chain.length - 1) {
250
+ renderRefusal(r.violations).forEach((l) => ctx.err(l));
251
+ return EXIT.REFUSED;
252
+ }
253
+ fallback.push(id);
254
+ rung += 1;
255
+ }
256
+ const rawR = parseVerdictText(answered);
257
+ const parsedR = rawR.ok ? parseVerdict(rawR.value) : rawR;
258
+ const violations = parsedR.ok ? verdictViolations(parsedR.value, reviewed) : parsedR.violations;
259
+ if (parsedR.ok && violations.length === 0) {
260
+ verdicts.push({ reviewer: lens.name, coverage: parsedR.value.coverage, findings: parsedR.value.findings });
261
+ break;
262
+ }
263
+ if (attempt === 0) {
264
+ // one self-repair reroll per reviewer — a single flaky verdict would
265
+ // otherwise poison the whole round as 'malformed'; the reroll is not
266
+ // part of the gate key, so cached rounds replay the settled result
267
+ rerolled.push(lens.name);
268
+ prompt += `\n## Previous attempt rejected\n\n${violations.map((x) => `- ${x.field}: ${x.rule} — got ${x.got}; want ${x.want}`).join('\n')}\n\nRe-emit the complete corrected verdict JSON.\n`;
269
+ continue;
270
+ }
271
+ malformed.push({ reviewer: lens.name, violations });
272
+ break;
273
+ }
274
+ }
275
+ }
276
+ if (fallback.length > 0 && chain[0] !== SESSION_DEFAULT && fallback.includes(chain[0])) {
277
+ ctx.err(`warning: head model ${chain[0]} failed to invoke — reviewers ran on ${model}; check gates.${spec.gate}.model`);
278
+ }
279
+ const blocking = verdicts.flatMap((x) => x.findings).filter((f) => f.blocking).length;
280
+ const pinConflicts = verdicts.flatMap((x) => x.findings).filter((f) => f.contradicts_pin !== undefined).length;
281
+ const standing = [
282
+ input.standingStop,
283
+ pinConflicts > 0 ? 'contradicts-pin — a finding disputes a settled policy pin; the human decides which one dies' : undefined,
284
+ ].filter((s) => s !== undefined).join(' · ') || undefined;
285
+ const checksGreen = input.checks.every((c) => c.ok);
286
+ const outcome = malformed.length > 0 ? 'malformed'
287
+ : !checksGreen || blocking > 0 ? 'stopped'
288
+ : standing !== undefined || flags.manual ? 'stopped'
289
+ : 'passed';
290
+ const lockR = acquireLock(root);
291
+ if (!lockR.ok) {
292
+ renderRefusal(lockR.violations).forEach((l) => ctx.err(l));
293
+ return EXIT.BLOCKED;
294
+ }
295
+ const release = lockR.value;
296
+ try {
297
+ // The battery ran unlocked, so the journal may have moved under us. `entries` (read
298
+ // at :160) feeds appendKind, boundReached, and the round number — all three must be
299
+ // recomputed against the stream as it stands NOW, or two concurrent gate runs on one
300
+ // target both journal round N and both slip past the bound.
301
+ // Content staleness is deliberately NOT checked here: the entry we write is honest
302
+ // (it records the sha we judged), and a stale verdict stops settling its gate at
303
+ // consumption time (gateSettled's reviewed_sha check).
304
+ const entriesNow = readStream(root, target);
305
+ if (boundReached(entriesNow, spec.gate)) {
306
+ ctx.out(kv('gate', spec.gate));
307
+ ctx.out(kv('outcome', `round bound reached (${roundsSinceApprove(entriesNow, spec.gate)} rounds since last approve)`));
308
+ ctx.out(`help: witness decide ${spec.gate} ${target} --approve --override | --revise --upstream <id> | --stop`);
309
+ ctx.out(`help: or discard the plan: witness abandon ${target}`);
310
+ return EXIT.BLOCKED;
311
+ }
312
+ const entry = {
313
+ v: 1, t: 'gate-run', gate: spec.gate, artifact: target,
314
+ round: roundsSinceApprove(entriesNow, spec.gate) + 1, run_id: newRunId(),
315
+ reviewed_sha: input.reviewedSha, prompts_sha: key.prompts_sha,
316
+ witness: key.witness, model, harness: harness.name, calibration: calibrationOf(model),
317
+ ...(cached ? { cached: true } : {}),
318
+ ...(flags.manual ? { manual: true } : {}),
319
+ ...(fallback.length ? { fallback } : {}),
320
+ ...(rerolled.length ? { rerolled } : {}),
321
+ ...(dropped.length ? { skipped: dropped } : {}),
322
+ ...(outcome === 'stopped' && standing ? { standing } : {}),
323
+ ...(input.artifactSha ? { artifact_sha: input.artifactSha } : {}),
324
+ checks: input.checks,
325
+ ...(verdicts.length ? { verdicts } : {}),
326
+ ...(malformed.length ? { malformed } : {}),
327
+ outcome,
328
+ };
329
+ const stamps = outcome === 'passed'
330
+ ? input.stamps.flatMap((s) => {
331
+ const doc = findById(canon, s.artifact);
332
+ return doc && String(doc.meta.status) !== s.to
333
+ ? [prepareStamp(doc, s.to, 'gate-approve', { run_id: entry.run_id })] : [];
334
+ })
335
+ : [];
336
+ const files = [
337
+ journalRel(target), ...stamps.flatMap((s) => [s.rel, journalRel(s.stream)]),
338
+ ...(input.repin ? [input.repin.rel] : []),
339
+ ];
340
+ const marker = {
341
+ op: `gate-${spec.gate}`,
342
+ files: [...new Set(files)],
343
+ journalMulti: [
344
+ { stream: target, line: entryLine(entry) },
345
+ ...stamps.map((s) => ({ stream: s.stream, line: s.line })),
346
+ ],
347
+ };
348
+ const txn = withTxn(root, marker, () => {
349
+ appendEntry(root, target, entry);
350
+ for (const s of stamps)
351
+ writeStamp(root, s);
352
+ if (input.repin) {
353
+ writeDoc(join(root, input.repin.rel), { meta: { ...input.repin.meta, 'derives-from': input.repin.sha }, body: input.repin.body });
354
+ }
355
+ crashPoint(ctx.env, 'gate-journal');
356
+ return stateCommit(root, marker.files, `gate(${spec.gate}): ${target} round ${entry.round} ${outcome}`);
357
+ });
358
+ if (!txn.ok) {
359
+ renderRefusal(txn.violations).forEach((l) => ctx.err(l));
360
+ return EXIT.REFUSED;
361
+ }
362
+ renderGateRun(ctx, entry, 'ran');
363
+ printDispatchArithmetic(ctx, root, spec.gate, target);
364
+ if (input.repin)
365
+ ctx.out(kv('re-pinned', `derives-from → ${input.repin.sha.slice(0, 7)} (witnessed by the drift lane)`));
366
+ return outcome === 'passed' ? EXIT.OK : EXIT.FINDINGS;
367
+ }
368
+ finally {
369
+ release();
370
+ }
371
+ }
@@ -0,0 +1,90 @@
1
+ import { ok, refuse, v } from '../refusal.js';
2
+ import { serializeDoc } from '../fm.js';
3
+ import { canonicalJson, canonicalSha } from '../sha.js';
4
+ import { latestRecap, streamExists } from '../journal.js';
5
+ import { findCycle, findById } from '../scan.js';
6
+ import { effortReviewedSha, effortSpecs, effortWrites } from '../reviewed.js';
7
+ import { validateDoc } from '../schemas.js';
8
+ import { registerGate } from '../gate.js';
9
+ function stampsFor(docs) {
10
+ return docs
11
+ .filter((d) => String(d.meta.status) === 'draft')
12
+ .map((d) => ({ artifact: String(d.meta.id), to: 'approved' }));
13
+ }
14
+ registerGate({
15
+ gate: 'decompose',
16
+ targetKind: 'effort',
17
+ async resolve(root, _ctx, canon, _cfg, effort) {
18
+ if (!streamExists(root, effort)) {
19
+ return refuse([v('effort', 'unknown-effort', effort, 'an effort born by witness recap')]);
20
+ }
21
+ const recap = latestRecap(root, effort);
22
+ if (!recap)
23
+ return refuse([v('effort', 'unknown-effort', effort, 'a stream whose first entry is a recap')]);
24
+ const writes = effortWrites(root, effort);
25
+ const docs = effortSpecs(root, canon, effort);
26
+ if (docs.length === 0) {
27
+ return refuse([v('effort', 'nothing-to-gate', effort, `written specs — run: witness write <id> --effort ${effort} --meta m.json --body b.md`)]);
28
+ }
29
+ const checks = [];
30
+ const schemaProblems = docs.flatMap((d) => validateDoc(d.meta, d.body).map((x) => `${String(d.meta.id)} ${x.field}:${x.rule}`));
31
+ checks.push({ name: 'schema', ok: schemaProblems.length === 0, detail: schemaProblems.slice(0, 5).join(' · ') });
32
+ const cycle = findCycle(canon);
33
+ checks.push({ name: 'graph', ok: cycle === undefined, detail: cycle?.join(' → ') ?? '' });
34
+ const goals = (recap.goals ?? []).map((g) => g.id);
35
+ const covered = new Set();
36
+ const orphans = [];
37
+ for (const d of docs) {
38
+ const covers = writes.get(String(d.meta.id))?.covers ?? [];
39
+ covers.forEach((g) => covered.add(g));
40
+ if (covers.length === 0)
41
+ orphans.push(String(d.meta.id));
42
+ }
43
+ const missing = goals.filter((g) => !covered.has(g));
44
+ checks.push({ name: 'goal-coverage', ok: missing.length === 0,
45
+ detail: missing.length ? `uncovered goals: ${missing.join(' ')}` : `${goals.length} goals covered` });
46
+ checks.push({ name: 'spec-coverage', ok: orphans.length === 0,
47
+ detail: orphans.length ? `specs covering no goal: ${orphans.join(' ')}` : 'every spec covers ≥ 1 goal' });
48
+ const staleChildren = canon.docs
49
+ .filter((p) => p.meta.type === 'plan' && String(p.meta.status) === 'in-progress')
50
+ .filter((p) => docs.some((d) => String(d.meta.id) === String(p.meta.parent)))
51
+ .filter((p) => {
52
+ const parent = findById(canon, String(p.meta.parent));
53
+ return String(p.meta['derives-from']) !== canonicalSha(parent.meta, parent.body);
54
+ })
55
+ .map((p) => String(p.meta.id));
56
+ checks.push({ name: 'amendment-ack', ok: staleChildren.length === 0,
57
+ detail: staleChildren.length
58
+ ? `amended under in-progress plans (approve = explicit ack): ${staleChildren.join(' ')}`
59
+ : '' });
60
+ const created = [...writes.values()].some((w) => w.created === true);
61
+ const standingStop = recap.class === 'feature' ? 'feature scope approval — scope is approved as its concrete slicing'
62
+ : recap.class === 'fix' && created
63
+ ? 'fix created a new spec — misroute tripwire (young canon: expect new-spec stops at first)'
64
+ : undefined;
65
+ const coverageTable = docs
66
+ .map((d) => `${String(d.meta.id)}: covers ${(writes.get(String(d.meta.id))?.covers ?? []).join(' ') || '(none)'}`)
67
+ .join('\n');
68
+ const promptBody = [
69
+ '### Confirmed scope recap (latest)', canonicalJson(recap),
70
+ '### Goal coverage (journaled)', coverageTable,
71
+ ...docs.flatMap((d) => [`### Spec: ${String(d.meta.id)}`, serializeDoc({ meta: d.meta, body: d.body })]),
72
+ ].join('\n\n');
73
+ const { sha } = effortReviewedSha(root, canon, effort);
74
+ return ok({
75
+ class: (recap.class ?? 'feature'),
76
+ reviewedSha: sha,
77
+ reviewed: { kind: 'docs', docs: docs.map((d) => ({ id: String(d.meta.id), body: d.body })) },
78
+ promptBody,
79
+ checks,
80
+ standingStop,
81
+ stamps: stampsFor(docs),
82
+ });
83
+ },
84
+ currentSha(root, canon, _cfg, effort) {
85
+ return effortReviewedSha(root, canon, effort).sha;
86
+ },
87
+ approveStamps(root, canon, effort) {
88
+ return stampsFor(effortSpecs(root, canon, effort));
89
+ },
90
+ });
@@ -0,0 +1,84 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { serializeDoc } from '../fm.js';
4
+ import { designPairSha, designRel, designUnseen, elementIds, htmlSha, validateDesignArtifact } from '../design.js';
5
+ import { registerGate } from '../gate.js';
6
+ import { latestRecap } from '../journal.js';
7
+ import { ok, refuse, v } from '../refusal.js';
8
+ import { effortOf } from '../reviewed.js';
9
+ import { findById } from '../scan.js';
10
+ import { canonicalSha, short } from '../sha.js';
11
+ // What this gate judges: the artifact PAIRED with the spec it realizes. With no artifact
12
+ // the spec alone is the sha, so a re-authored spec still lapses a prior verdict. Shared
13
+ // by `resolve` and `currentSha` so the two can never disagree about what moved.
14
+ const reviewedShaOf = (html, spec) => html !== undefined ? designPairSha(html, spec) : canonicalSha(spec.meta, spec.body);
15
+ registerGate({
16
+ gate: 'design',
17
+ targetKind: 'spec',
18
+ async resolve(root, _ctx, canon, cfg, specId) {
19
+ const spec = findById(canon, specId);
20
+ if (!spec || spec.meta.type !== 'spec') {
21
+ return refuse([v('spec', 'unknown-spec', specId, 'a specs/ doc id')]);
22
+ }
23
+ const effort = effortOf(root, specId);
24
+ const recap = effort ? latestRecap(root, effort) : undefined;
25
+ const rel = designRel(cfg.paths, specId);
26
+ const abs = join(root, rel);
27
+ const html = existsSync(abs) ? readFileSync(abs, 'utf8') : undefined;
28
+ // Sight is a PRECONDITION of gating, not a byproduct of it. Refusing here costs no
29
+ // reviewer call and keeps `resolve` pure — the rejected shape spawned the opener from
30
+ // this function, which fires on the four runGate paths that write no entry (resume,
31
+ // changed-nothing, bound, malformed-streak), producing a window the journal never
32
+ // learns about. An agent that drops the show step now hits a loud refusal its repair
33
+ // loop already handles, instead of silently gating something nobody was shown.
34
+ const unseen = designUnseen(root, cfg.paths, specId);
35
+ if (unseen !== undefined) {
36
+ return refuse([v('design', 'design-unseen', `no sight witnessed for ${short(unseen)}`, `a human shown this artifact — run: witness design ${specId} --open`)]);
37
+ }
38
+ const checks = [];
39
+ checks.push({ name: 'ui-flag', ok: spec.meta.ui === true, detail: spec.meta.ui === true ? 'ui: true' : 'spec is not ui-flagged' });
40
+ checks.push({ name: 'feature-class', ok: recap?.class === 'feature', detail: `effort class: ${recap?.class ?? 'none'}` });
41
+ checks.push({ name: 'artifact', ok: html !== undefined, detail: html !== undefined ? rel : `${rel} missing — run: witness design ${specId} --file <html>` });
42
+ const template = html !== undefined ? validateDesignArtifact(html) : [];
43
+ checks.push({ name: 'template', ok: html !== undefined && template.length === 0,
44
+ detail: template.length ? template.map((x) => x.rule).join(' · ') : 'self-contained, id-attributed' });
45
+ // With no artifact, reviewer input is empty ids + the spec — the checks already
46
+ // fail-stop; still produce a well-formed GateInput so the gate stops cleanly.
47
+ const ids = html !== undefined ? elementIds(html) : [];
48
+ const promptBody = [
49
+ `### Design artifact: ${rel}`, html ?? '(missing — no artifact to review)',
50
+ `### Parent spec: ${specId}`, serializeDoc({ meta: spec.meta, body: spec.body }),
51
+ ].join('\n\n');
52
+ return ok({
53
+ class: (recap?.class ?? 'feature'),
54
+ reviewedSha: reviewedShaOf(html, spec),
55
+ artifactSha: html !== undefined ? htmlSha(html) : undefined,
56
+ reviewed: { kind: 'design', artifact: { ids }, spec: { id: specId, body: spec.body } },
57
+ promptBody,
58
+ checks,
59
+ standingStop: 'design always stops — a human approves every screen (same footing as ship)',
60
+ stamps: [],
61
+ });
62
+ },
63
+ currentSha(root, canon, cfg, specId) {
64
+ const spec = findById(canon, specId);
65
+ if (!spec || spec.meta.type !== 'spec')
66
+ return undefined;
67
+ const abs = join(root, designRel(cfg.paths, specId));
68
+ return reviewedShaOf(existsSync(abs) ? readFileSync(abs, 'utf8') : undefined, spec);
69
+ },
70
+ approveMeta(root, canon, cfg, specId) {
71
+ const spec = findById(canon, specId);
72
+ if (!spec || spec.meta.type !== 'spec')
73
+ return [];
74
+ const abs = join(root, designRel(cfg.paths, specId));
75
+ if (!existsSync(abs))
76
+ return [];
77
+ const sha = htmlSha(readFileSync(abs, 'utf8'));
78
+ return [{
79
+ artifact: specId,
80
+ patch: { design: { sha, spec: canonicalSha(spec.meta, spec.body) } },
81
+ entryType: 'design-stamp',
82
+ }];
83
+ },
84
+ });