@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
@@ -0,0 +1,437 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { parseArgs } from 'node:util';
3
+ import { EXIT } from '../cli.js';
4
+ import { loadConfig } from '../config.js';
5
+ import { pendingTxn } from '../txn.js';
6
+ import { primaryRoot } from '../gitio.js';
7
+ import { findById, loadCanon } from '../scan.js';
8
+ import { designArtifactCurrent, designPending, designUnseen } from '../design.js';
9
+ import { effortAbandoned, effortStreams, latestRecap, readStream } from '../journal.js';
10
+ import { effortOf, effortReviewedSha, effortSpecs, effortWrites, planPairSha, worktreeTreeSha } from '../reviewed.js';
11
+ import { changedFiles, diffBase, evidenceForDiff } from '../evidence.js';
12
+ import { SESSION_DEFAULT, stagePin } from '../model.js';
13
+ import { handoffLine, relayLine, resolveHarness } from '../harness.js';
14
+ import { worktreeFlow, worktreePath } from '../worktree.js';
15
+ import { lazyStamp } from '../stamp.js';
16
+ import { ok, refuse, renderRefusal, v } from '../refusal.js';
17
+ import { kv } from '../toon.js';
18
+ import { boundReached, lastGateRun, openReopen, pendingDecision } from '../rounds.js';
19
+ // A gate is settled only if the verdict that settled it still describes the CURRENT
20
+ // content. `reviewed_sha` is the tree we actually judged; when the caller can compute
21
+ // what that sha is now and the two differ, the approval has lapsed and the gate re-arms.
22
+ // Same doctrine as designPending (design.ts:74) — approval is a fact about a sha, not a
23
+ // permanent fact. Callers that cannot cheaply compute a current sha omit it and get
24
+ // today's behavior.
25
+ export function gateSettled(entries, gate, currentSha) {
26
+ const last = lastGateRun(entries, gate);
27
+ if (!last)
28
+ return false;
29
+ // above BOTH settling branches deliberately: a human --approve --override is granted
30
+ // against a specific tree too, and lapses with it
31
+ if (currentSha !== undefined && last.reviewed_sha !== currentSha)
32
+ return false;
33
+ // an explicit reopen from another gate's --revise --upstream un-settles this one until
34
+ // it is discharged, exactly as a moved sha does — one predicate, both staleness terms
35
+ if (openReopen(entries, gate) !== undefined)
36
+ return false;
37
+ if (last.outcome === 'passed')
38
+ return true;
39
+ const after = entries.slice(entries.lastIndexOf(last) + 1);
40
+ return after.some((e) => e.t === 'human-decision' &&
41
+ e.gate === gate &&
42
+ e.decision === 'approve');
43
+ }
44
+ // `runGate` short-circuits `changed-nothing` without appending an entry (gate.ts:170)
45
+ // whenever the last run's reviewed sha still matches current content and a revise or
46
+ // reopen sits after it. Routing there asks the human to invoke a verb the CLI will
47
+ // immediately decline, and — since nothing is appended — `next` says it again next turn.
48
+ // The work owed is AUTHORING, so route to the stage. The design stage already does this
49
+ // via designArtifactCurrent; this is the same rule for decompose and plan.
50
+ function authoringOwed(entries, gate, currentSha) {
51
+ const last = lastGateRun(entries, gate);
52
+ if (!last)
53
+ return false;
54
+ if (currentSha === undefined || last.reviewed_sha !== currentSha)
55
+ return false;
56
+ if (openReopen(entries, gate) !== undefined)
57
+ return true;
58
+ const after = entries.slice(entries.lastIndexOf(last) + 1);
59
+ return after.some((e) => e.t === 'human-decision' &&
60
+ e.gate === gate &&
61
+ ['revise', 'revise-upstream'].includes(e.decision));
62
+ }
63
+ // The action for ONE flow. A flow is a plan with status `in-progress`: it begins at
64
+ // `witness start` and ends at the merge stamp. Anything else returns undefined and is
65
+ // not a flow. Exported because `--flow` and the dashboard must answer with the SAME
66
+ // derivation next uses, never a re-derived shorthand.
67
+ export function flowAction(root, cfg, plan) {
68
+ const id = String(plan.meta.id);
69
+ if (String(plan.meta.status) !== 'in-progress')
70
+ return undefined;
71
+ const entries = readStream(root, id);
72
+ const wt = worktreePath(root, id);
73
+ const pinR = stagePin(cfg, 'implement');
74
+ const pin = pinR.ok ? pinR.value : undefined;
75
+ const implementModel = pin !== undefined && pin !== SESSION_DEFAULT ? pin : undefined;
76
+ const inWorktree = { home: wt, model: implementModel };
77
+ const atRoot = { home: root };
78
+ if (!existsSync(wt))
79
+ return { line: `witness start ${id}`, target: id, note: 'worktree missing — start recreates it' };
80
+ if (plan.meta.pr !== undefined)
81
+ return { line: `witness ship ${id}`, stage: 'ship', target: id, ...atRoot };
82
+ const baseR = diffBase(wt, cfg);
83
+ const files = baseR.ok ? changedFiles(wt, baseR.value) : [];
84
+ // evidenceForDiff is vacuously "satisfied" when nothing has changed yet (an empty
85
+ // required-tags list trivially passes .every()) — a fresh worktree needs the
86
+ // implement-stage hint too, not a premature jump to "gate implement".
87
+ const satisfied = files.length > 0 && baseR.ok && evidenceForDiff(wt, root, plan, baseR.value).satisfied;
88
+ if (!satisfied)
89
+ return { line: `witness test-evidence ${id} --phase red|green`, stage: 'implement', target: id, ...inWorktree };
90
+ if (!gateSettled(entries, 'implement', worktreeTreeSha(wt)))
91
+ return { line: `witness gate implement ${id}`, target: id, ...inWorktree };
92
+ return { line: `witness ship ${id}`, stage: 'ship', target: id, ...atRoot };
93
+ }
94
+ // How far along a flow is — the drain order when several are actionable. Most-advanced
95
+ // first is WIP-limiting, not cosmetic: it pushes flows toward merge instead of fanning
96
+ // them out, which is what keeps concurrent rebases (and therefore lapsed ship gates) rare.
97
+ function flowRank(a) {
98
+ if (a.stage === 'ship')
99
+ return 3;
100
+ if (a.line.includes(' gate implement '))
101
+ return 2;
102
+ if (a.stage === 'implement')
103
+ return 1;
104
+ return 0; // worktree missing — needs recreating before it can advance
105
+ }
106
+ // A flow can be in-progress and still unable to move: its own gate is awaiting a human
107
+ // decision, or it has hit the round bound (where no pending decision can even be
108
+ // created). Such a flow is NOT tier-1 motion — offering it would loop the driving loop
109
+ // on an action that only re-reports "awaiting decision", and would starve tier 2 of the
110
+ // very decision that unblocks it.
111
+ function flowBlocked(entries) {
112
+ return ['plan', 'implement', 'ship'].some((gate) => pendingDecision(entries, gate) !== undefined ||
113
+ (boundReached(entries, gate) && !gateSettled(entries, gate)));
114
+ }
115
+ // Every human-owed decision in the repo, in the order tier 2 would surface them. One
116
+ // source for both tiers: tier 2 takes the first, tier 1 rides them all in `note:`.
117
+ function pendingDecisionsAll(root, efforts, specs, plans) {
118
+ const out = [];
119
+ for (const e of efforts) {
120
+ if (pendingDecision(e.entries, 'decompose'))
121
+ out.push({ gate: 'decompose', target: e.slug });
122
+ }
123
+ for (const plan of plans) {
124
+ const id = String(plan.meta.id);
125
+ const entries = readStream(root, id);
126
+ for (const gate of ['plan', 'implement', 'ship']) {
127
+ if (pendingDecision(entries, gate))
128
+ out.push({ gate, target: id });
129
+ }
130
+ }
131
+ for (const spec of specs) {
132
+ const id = String(spec.meta.id);
133
+ if (pendingDecision(readStream(root, id), 'design'))
134
+ out.push({ gate: 'design', target: id });
135
+ }
136
+ return out;
137
+ }
138
+ // Both notes survive: a caller's own note must not silently drop the routing note that
139
+ // explains WHY the line is a recap rather than the write the caller asked for.
140
+ function noteOf(...parts) {
141
+ const kept = parts.filter((p) => p !== undefined);
142
+ return kept.length > 0 ? { note: kept.join(' · ') } : {};
143
+ }
144
+ // Authoring a plan is booked under an effort, so `next` must name one a human can
145
+ // actually pass to `--effort`. It used to print the literal placeholder `<slug>`, which
146
+ // is unrunnable — and after `abandon <effort>` there was nothing to substitute either:
147
+ // the plan's authoring effort is terminal, so it is filtered out of the live list, while
148
+ // its parent spec stays approved with no live plan and therefore reads as planless on
149
+ // every subsequent turn. `next` repeated the same unrunnable line forever.
150
+ //
151
+ // Prefer a live effort that already wrote this plan, then one that wrote the parent spec
152
+ // — a plan is authored inside the effort that owns the spec it derives from. `effortOf`
153
+ // is deliberately not used: it answers with an ABANDONED effort whenever that is the only
154
+ // one that ever wrote the artifact, and writes must never be booked onto a terminal stream.
155
+ // With no live candidate the plan is not the next thing owed — an effort to carry it is.
156
+ function liveOwner(root, efforts, ...ids) {
157
+ return ids
158
+ .map((id) => efforts.find((e) => effortWrites(root, e.slug).has(id))?.slug)
159
+ .find((slug) => slug !== undefined);
160
+ }
161
+ // The stage rides with the line: routing to a recap is BRAINSTORM work, and a caller that
162
+ // kept its own `stage: 'plan'` would hand the plan skill a recap command to run.
163
+ function planWriteAction(root, efforts, planId, parentId, target) {
164
+ const owner = liveOwner(root, efforts, planId, parentId);
165
+ return owner === undefined
166
+ ? {
167
+ line: 'witness recap --file <recap.json>', stage: 'brainstorm',
168
+ note: `${planId} is owed, but no live effort can carry the write — open one`,
169
+ }
170
+ : {
171
+ line: `witness write ${planId} --effort ${owner} --meta m.json --body b.md`,
172
+ stage: 'plan', target,
173
+ };
174
+ }
175
+ export function computeNext(root, ctx, canon, cfg) {
176
+ if (pendingTxn(root))
177
+ return { line: 'witness recover --complete | --rollback' };
178
+ if (canon.errors.length > 0 || canon.docs.some((d) => d.violations.length > 0)) {
179
+ return { line: 'witness check' };
180
+ }
181
+ const efforts = effortStreams(root)
182
+ .map((slug) => ({ slug, entries: readStream(root, slug) }))
183
+ .filter((e) => latestRecap(root, e.slug) !== undefined && !effortAbandoned(e.entries))
184
+ .sort((a, b) => a.slug.localeCompare(b.slug));
185
+ // Hoisted above tier 1: both doc lists are read by every tier below, and they used to
186
+ // be declared interleaved between the pending-decision scans.
187
+ const plans = canon.docs.filter((d) => d.meta.type === 'plan')
188
+ .sort((a, b) => String(a.meta.id).localeCompare(String(b.meta.id)));
189
+ const specs = canon.docs.filter((d) => d.meta.type === 'spec')
190
+ .sort((a, b) => String(a.meta.id).localeCompare(String(b.meta.id)));
191
+ // TIER 1 — advance something already in flight. A flow that can move must never wait
192
+ // on a human decision belonging to a DIFFERENT flow: ship always stops (gates/ship.ts),
193
+ // so a global decision-halt freezes every other flow at the first ship gate.
194
+ const flows = plans
195
+ .filter((plan) => !flowBlocked(readStream(root, String(plan.meta.id))))
196
+ .map((plan) => flowAction(root, cfg, plan))
197
+ .filter((a) => a !== undefined)
198
+ .sort((a, b) => flowRank(b) - flowRank(a) || String(a.target).localeCompare(String(b.target)));
199
+ if (flows.length > 0) {
200
+ const waiting = pendingDecisionsAll(root, efforts, specs, plans);
201
+ return waiting.length > 0
202
+ ? { ...flows[0], note: `${waiting.length} waiting: ${waiting.map((w) => `${w.gate} ${w.target}`).join(' · ')}` }
203
+ : flows[0];
204
+ }
205
+ // TIER 2 — human-owed decisions and bound endgames. Unchanged from today's rungs; they
206
+ // simply no longer outrank in-flight motion.
207
+ const pending = pendingDecisionsAll(root, efforts, specs, plans);
208
+ if (pending.length > 0) {
209
+ const first = pending[0];
210
+ return { line: `witness decide ${first.gate} ${first.target} --show`, target: first.target };
211
+ }
212
+ // bound-stuck gates: no pending decision can ever be created (the gate
213
+ // short-circuits), so the endgame decision itself is the next action —
214
+ // decisions outrank motion, jammed targets must not be silently skipped
215
+ for (const e of efforts) {
216
+ if (boundReached(e.entries, 'decompose') && !gateSettled(e.entries, 'decompose')) {
217
+ return {
218
+ line: `witness decide decompose ${e.slug} --approve --override | --revise --upstream ${e.slug} | --stop`,
219
+ target: e.slug, note: 'round bound reached — human decision required',
220
+ };
221
+ }
222
+ }
223
+ for (const plan of plans) {
224
+ const id = String(plan.meta.id);
225
+ const entries = readStream(root, id);
226
+ for (const gate of ['plan', 'implement', 'ship']) {
227
+ if (boundReached(entries, gate) && !gateSettled(entries, gate)) {
228
+ const up = gate === 'plan' ? String(plan.meta.parent) : id;
229
+ return {
230
+ line: `witness decide ${gate} ${id} --approve --override | --revise --upstream ${up} | --stop`,
231
+ target: id, note: 'round bound reached — human decision required',
232
+ };
233
+ }
234
+ }
235
+ }
236
+ for (const spec of specs) {
237
+ const id = String(spec.meta.id);
238
+ const entries = readStream(root, id);
239
+ if (boundReached(entries, 'design') && !gateSettled(entries, 'design')) {
240
+ const eff = effortOf(root, id);
241
+ return {
242
+ line: `witness decide design ${id} --approve --override | --revise --upstream ${eff ?? '<effort>'} | --stop`,
243
+ target: id, note: 'round bound reached — human decision required',
244
+ };
245
+ }
246
+ }
247
+ if (efforts.length === 0) {
248
+ return { line: 'witness recap --file <recap.json>', stage: 'brainstorm' };
249
+ }
250
+ for (const e of efforts) {
251
+ const writes = effortWrites(root, e.slug);
252
+ // A chore is plan-level motion by definition, so the decompose stage is unsatisfiable
253
+ // in BOTH directions and must not be routed to at all: `write` refuses spec content
254
+ // from a chore (class-tripwire, write.ts) while `gate decompose` refuses an effort
255
+ // with no written specs (nothing-to-gate, gates/decompose.ts). A chore's plans are the
256
+ // gateable work, and they carry its goals — decompose is simply not owed here.
257
+ if (latestRecap(root, e.slug)?.class === 'chore') {
258
+ if (writes.size === 0) {
259
+ return {
260
+ line: `witness write <plan-id> --effort ${e.slug} --meta m.json --body b.md`,
261
+ stage: 'plan', target: e.slug,
262
+ note: 'chore: plan-level motion — a chore never writes spec content',
263
+ };
264
+ }
265
+ continue;
266
+ }
267
+ if (writes.size === 0) {
268
+ return {
269
+ line: `witness write <spec-id> --effort ${e.slug} --meta m.json --body b.md`,
270
+ stage: 'decompose', target: e.slug,
271
+ };
272
+ }
273
+ // Settled either by a real gate-run+approve, or (fixture/seed shortcuts) by every
274
+ // effort spec already being past draft — a plan can't legally target a draft parent,
275
+ // so non-draft specs are proof decompose's outcome was reached one way or another.
276
+ // The shortcut stands in for a gate-run, so an OPEN REOPEN un-discharges it too:
277
+ // otherwise it short-circuits past gateSettled and the reopen is never routed.
278
+ const specs = effortSpecs(root, canon, e.slug);
279
+ const reopened = openReopen(e.entries, 'decompose') !== undefined;
280
+ const specsApproved = !reopened && specs.length > 0 && specs.every((d) => String(d.meta.status) !== 'draft');
281
+ const effortSha = effortReviewedSha(root, canon, e.slug).sha;
282
+ if (!specsApproved && !gateSettled(e.entries, 'decompose', effortSha)) {
283
+ return authoringOwed(e.entries, 'decompose', effortSha)
284
+ ? {
285
+ line: `witness write <spec-id> --effort ${e.slug} --meta m.json --body b.md`,
286
+ stage: 'decompose', target: e.slug,
287
+ note: 'revise owed — re-author, then the gate has something new to judge',
288
+ }
289
+ : { line: `witness gate decompose --effort ${e.slug}`, target: e.slug };
290
+ }
291
+ }
292
+ for (const spec of specs) {
293
+ if (String(spec.meta.status) !== 'approved')
294
+ continue;
295
+ if (!designPending(root, spec))
296
+ continue;
297
+ const id = String(spec.meta.id);
298
+ // An artifact authored for the CURRENT spec content is awaiting its gate → gate it.
299
+ // Otherwise (no artifact, or one authored before a later amendment) → the design
300
+ // skill authors fresh or, in amend mode, decides re-design vs --reconfirm.
301
+ // A pending design DECISION was already caught by the top-of-function scan; a stale
302
+ // prior approval never routes here because designPending re-arms on stamp.spec mismatch.
303
+ if (!designArtifactCurrent(root, spec)) {
304
+ return { line: `witness design ${id} --file <html>`, stage: 'design', target: id };
305
+ }
306
+ // Registered but unshown is the normal state right after --file. Ask for the show
307
+ // step by name; routing to the gate here would refuse design-unseen every time.
308
+ return designUnseen(root, cfg.paths, id) !== undefined
309
+ ? { line: `witness design ${id} --open`, stage: 'design', target: id }
310
+ : { line: `witness gate design ${id}`, target: id };
311
+ }
312
+ const ready = (dep) => {
313
+ const doc = findById(canon, dep);
314
+ if (!doc)
315
+ return false;
316
+ const s = String(doc.meta.status);
317
+ return doc.meta.type === 'plan' ? s === 'done' : s === 'live' || doc.meta.type === 'principles';
318
+ };
319
+ const planless = canon.docs
320
+ .filter((d) => d.meta.type === 'spec' && String(d.meta.status) === 'approved')
321
+ .filter((d) => !plans.some((p) => String(p.meta.parent) === String(d.meta.id) &&
322
+ !['done', 'abandoned'].includes(String(p.meta.status))))
323
+ .filter((d) => (d.meta.depends ?? []).every(ready))
324
+ .map((d) => String(d.meta.id)).sort();
325
+ if (planless.length > 0) {
326
+ // A spec whose plan write can actually be booked outranks one that needs a new effort
327
+ // opened first — stalling the whole pipeline on a recap while runnable work sits behind
328
+ // it is a worse answer, and alphabetical order alone does not know the difference.
329
+ const spec = planless.find((s) => liveOwner(root, efforts, `${s}-plan-1`, s) !== undefined)
330
+ ?? planless[0];
331
+ const act = planWriteAction(root, efforts, `${spec}-plan-1`, spec, spec);
332
+ return {
333
+ ...act,
334
+ ...noteOf(act.note, planless.length > 1 ? `multiple ready — choose: ${planless.join(' ')}` : undefined),
335
+ };
336
+ }
337
+ // plans-first: every written plan gates before any plan starts or advances —
338
+ // an approved sibling must not short-circuit a draft out of review (stage-major order)
339
+ for (const plan of plans) {
340
+ const id = String(plan.meta.id);
341
+ if (String(plan.meta.status) !== 'draft')
342
+ continue;
343
+ const parent = findById(canon, String(plan.meta.parent));
344
+ const planSha = parent ? planPairSha(plan, parent) : undefined;
345
+ const entries = readStream(root, id);
346
+ if (gateSettled(entries, 'plan', planSha))
347
+ continue;
348
+ if (!authoringOwed(entries, 'plan', planSha))
349
+ return { line: `witness gate plan ${id}`, target: id };
350
+ const act = planWriteAction(root, efforts, id, String(plan.meta.parent), id);
351
+ return { ...act, ...noteOf(act.note, 'revise owed — rewrite the plan, then re-gate') };
352
+ }
353
+ // Starting a flow is tier 3, not tier 1: an approved-but-unstarted plan is new work,
354
+ // and every in-flight flow was already offered above.
355
+ for (const plan of plans) {
356
+ if (String(plan.meta.status) === 'approved') {
357
+ return { line: `witness start ${String(plan.meta.id)}`, target: String(plan.meta.id) };
358
+ }
359
+ }
360
+ return { line: 'witness check' };
361
+ }
362
+ function resolveFlow(canon, id) {
363
+ const doc = findById(canon, id);
364
+ if (!doc || doc.meta.type !== 'plan') {
365
+ return refuse([v('--flow', 'unknown-flow', id, 'a plans/ doc id')]);
366
+ }
367
+ const status = String(doc.meta.status);
368
+ if (status === 'done' || status === 'abandoned') {
369
+ return refuse([v('--flow', 'terminal-status', status, 'a flow that is still running')]);
370
+ }
371
+ // deliberately NOT start.ts's `not-approved`: that refuses when status is not
372
+ // `approved`, this refuses when it is not `in-progress` — an approved-but-unstarted
373
+ // plan is not yet a flow, and reusing the name would misreport that case.
374
+ if (status !== 'in-progress') {
375
+ return refuse([v('--flow', 'not-started', status, `a started plan — run witness start ${id} first`)]);
376
+ }
377
+ return ok(doc);
378
+ }
379
+ export async function run(ctx, argv) {
380
+ const rootR = primaryRoot(ctx.cwd);
381
+ if (!rootR.ok) {
382
+ renderRefusal(rootR.violations).forEach((l) => ctx.err(l));
383
+ return EXIT.REFUSED;
384
+ }
385
+ const root = rootR.value;
386
+ const cfgR = loadConfig(root);
387
+ if (!cfgR.ok) {
388
+ renderRefusal(cfgR.violations).forEach((l) => ctx.err(l));
389
+ return EXIT.REFUSED;
390
+ }
391
+ const hxR = resolveHarness(ctx.env, cfgR.value.raw);
392
+ if (!hxR.ok) {
393
+ renderRefusal(hxR.violations).forEach((l) => ctx.err(l));
394
+ return EXIT.REFUSED;
395
+ }
396
+ const harness = hxR.value.harness;
397
+ let canon = loadCanon(root);
398
+ const lazy = lazyStamp(root, ctx, canon);
399
+ if (lazy.stamped.length > 0)
400
+ canon = loadCanon(root);
401
+ const { values } = parseArgs({ args: argv, options: { flow: { type: 'string' } }, allowPositionals: true });
402
+ // Precedence: an explicit --flow is a CLAIM about a flow and refuses when false.
403
+ // A worktree cwd is AMBIENT context — it may scope a read-only question, never select
404
+ // a target for a mutation, and it degrades to the global ladder rather than refusing.
405
+ // (This is why inference lives on `next` alone. Every mutating verb takes its id
406
+ // explicitly, handed down from `target:` by the stage skills.)
407
+ let action;
408
+ if (values.flow !== undefined) {
409
+ const flowR = resolveFlow(canon, values.flow);
410
+ if (!flowR.ok) {
411
+ renderRefusal(flowR.violations).forEach((l) => ctx.err(l));
412
+ return EXIT.REFUSED;
413
+ }
414
+ action = flowAction(root, cfgR.value, flowR.value) ?? { line: 'witness check', target: values.flow };
415
+ }
416
+ else {
417
+ const inferred = worktreeFlow(ctx.cwd, root);
418
+ const ambient = inferred !== undefined ? findById(canon, inferred) : undefined;
419
+ const scoped = ambient && ambient.meta.type === 'plan'
420
+ ? flowAction(root, cfgR.value, ambient)
421
+ : undefined;
422
+ action = scoped ?? computeNext(root, ctx, canon, cfgR.value);
423
+ }
424
+ ctx.out(kv('next', action.line));
425
+ if (action.stage)
426
+ ctx.out(kv('stage', action.stage));
427
+ if (action.target)
428
+ ctx.out(kv('target', action.target));
429
+ if (action.note)
430
+ ctx.out(kv('note', action.note));
431
+ if (action.home) {
432
+ ctx.out(kv('home', action.home));
433
+ ctx.out(kv('run', handoffLine(harness, action.home, action.model)));
434
+ ctx.out(kv('relay', relayLine(harness)));
435
+ }
436
+ return EXIT.OK;
437
+ }
@@ -0,0 +1,145 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
3
+ import { parseArgs } from 'node:util';
4
+ import { EXIT } from '../cli.js';
5
+ import { loadConfig } from '../config.js';
6
+ import { ID_RE } from '../dsl.js';
7
+ import { primaryRoot, stateCommit } from '../gitio.js';
8
+ import { appendEntry, effortAbandoned, journalRel, readStream, streamExists } from '../journal.js';
9
+ import { acquireLock } from '../lock.js';
10
+ import { renderRefusal, v } from '../refusal.js';
11
+ import { kv } from '../toon.js';
12
+ import { crashPoint, guardTxn, withTxn } from '../txn.js';
13
+ function idTextList(raw, field, prefix, required) {
14
+ if (raw === undefined)
15
+ return required ? [v(field, 'required', 'absent', `>=1 {id, text} with ids ${prefix}1..`)] : [];
16
+ if (!Array.isArray(raw))
17
+ return [v(field, 'shape', typeof raw, 'list of {id, text}')];
18
+ if (required && raw.length === 0)
19
+ return [v(field, 'required', 'empty', `>=1 {id, text} with ids ${prefix}1..`)];
20
+ const out = [];
21
+ const seen = new Set();
22
+ raw.forEach((item, i) => {
23
+ const at = `${field}[${i}]`;
24
+ const e = (typeof item === 'object' && item !== null ? item : {});
25
+ if (typeof e.id !== 'string' || !new RegExp(`^${prefix}[0-9]+$`).test(e.id)) {
26
+ out.push(v(`${at}.id`, 'id-prefix', String(e.id ?? 'absent'), `${prefix}<number>`));
27
+ }
28
+ else if (seen.has(e.id)) {
29
+ out.push(v(`${at}.id`, 'id-unique', e.id, `unique within ${field}`));
30
+ }
31
+ else {
32
+ seen.add(e.id);
33
+ }
34
+ if (typeof e.text !== 'string' || e.text.trim() === '') {
35
+ out.push(v(`${at}.text`, 'required', String(e.text ?? 'absent'), 'non-empty text'));
36
+ }
37
+ });
38
+ return out;
39
+ }
40
+ export function validateRecap(raw) {
41
+ if (typeof raw !== 'object' || raw === null)
42
+ return [v('recap', 'shape', String(raw), 'JSON object')];
43
+ const e = raw;
44
+ const out = [];
45
+ if (typeof e.effort !== 'string' || !ID_RE.test(e.effort)) {
46
+ out.push(v('effort', 'id-charset', String(e.effort ?? 'absent'), '[a-z0-9-]+'));
47
+ }
48
+ if (e.class !== 'feature' && e.class !== 'fix' && e.class !== 'chore') {
49
+ out.push(v('class', 'enum', String(e.class ?? 'absent'), 'feature | fix | chore'));
50
+ }
51
+ out.push(...idTextList(e.goals, 'goals', 'g', true));
52
+ out.push(...idTextList(e.non_goals, 'non_goals', 'n', false));
53
+ out.push(...idTextList(e.constraints, 'constraints', 'c', false));
54
+ if (e.slices !== undefined && (!Array.isArray(e.slices) || e.slices.some((s) => typeof s !== 'string' || s === ''))) {
55
+ out.push(v('slices', 'shape', JSON.stringify(e.slices), 'list of non-empty strings (non-binding)'));
56
+ }
57
+ return out;
58
+ }
59
+ export async function run(ctx, argv) {
60
+ const { values } = parseArgs({ args: argv, options: { file: { type: 'string' }, amend: { type: 'boolean' } } });
61
+ if (!values.file) {
62
+ renderRefusal([v('--file', 'required', 'absent', 'path to a recap JSON file')]).forEach(ctx.err);
63
+ return EXIT.REFUSED;
64
+ }
65
+ const rootRes = primaryRoot(ctx.cwd);
66
+ if (!rootRes.ok) {
67
+ renderRefusal(rootRes.violations).forEach(ctx.err);
68
+ return EXIT.REFUSED;
69
+ }
70
+ const root = rootRes.value;
71
+ const blocked = guardTxn(ctx, root);
72
+ if (blocked !== undefined)
73
+ return blocked;
74
+ const cfg = loadConfig(root);
75
+ if (!cfg.ok) {
76
+ renderRefusal(cfg.violations).forEach(ctx.err);
77
+ return EXIT.REFUSED;
78
+ }
79
+ if (cfg.value.warning)
80
+ ctx.err(`warn: ${cfg.value.warning}`);
81
+ let parsed;
82
+ try {
83
+ // resolve, not join: an absolute --file must be honored as-is (join would
84
+ // glue it under cwd — /repo + /tmp/x → /repo/tmp/x)
85
+ parsed = JSON.parse(readFileSync(resolve(ctx.cwd, values.file), 'utf8'));
86
+ }
87
+ catch (e) {
88
+ renderRefusal([v('--file', 'input-json', String(e.message).slice(0, 120), 'readable JSON file')]).forEach(ctx.err);
89
+ return EXIT.REFUSED;
90
+ }
91
+ const violations = validateRecap(parsed);
92
+ if (violations.length) {
93
+ renderRefusal(violations).forEach(ctx.err);
94
+ return EXIT.REFUSED;
95
+ }
96
+ const recap = parsed;
97
+ recap.non_goals ??= [];
98
+ recap.constraints ??= [];
99
+ recap.slices ??= [];
100
+ const exists = streamExists(root, recap.effort);
101
+ if (!values.amend && exists) {
102
+ renderRefusal([v('effort', 'slug-reuse', `journal stream '${recap.effort}' exists (active or terminal)`, 'a new slug — histories never merge')]).forEach(ctx.err);
103
+ return EXIT.REFUSED;
104
+ }
105
+ if (values.amend && !exists) {
106
+ renderRefusal([v('effort', 'unknown-effort', recap.effort, 'an existing effort stream to amend')]).forEach(ctx.err);
107
+ return EXIT.REFUSED;
108
+ }
109
+ if (values.amend && effortAbandoned(readStream(root, recap.effort))) {
110
+ renderRefusal([v('effort', 'terminal', 'effort was abandoned', 'amend only live efforts')]).forEach(ctx.err);
111
+ return EXIT.REFUSED;
112
+ }
113
+ const lock = acquireLock(root);
114
+ if (!lock.ok) {
115
+ renderRefusal(lock.violations).forEach(ctx.err);
116
+ return EXIT.BLOCKED;
117
+ }
118
+ try {
119
+ const entry = {
120
+ t: 'recap', effort: recap.effort, class: recap.class,
121
+ goals: recap.goals, non_goals: recap.non_goals, constraints: recap.constraints, slices: recap.slices,
122
+ };
123
+ const line = JSON.stringify({ v: 1, ...entry });
124
+ const stream = journalRel(recap.effort);
125
+ const res = withTxn(root, { op: `recap(${recap.effort})`, files: [stream], journal: { stream: recap.effort, line } }, () => {
126
+ appendEntry(root, recap.effort, entry);
127
+ crashPoint(ctx.env, 'journal-append');
128
+ return stateCommit(root, [stream], `recap(${recap.effort})${values.amend ? ' --amend' : ''}: ${recap.class}`);
129
+ });
130
+ if (!res.ok) {
131
+ renderRefusal(res.violations).forEach(ctx.err);
132
+ return EXIT.REFUSED;
133
+ }
134
+ ctx.out(kv('effort', recap.effort));
135
+ ctx.out(kv('class', recap.class));
136
+ ctx.out(kv('goals', recap.goals.length));
137
+ if (values.amend)
138
+ ctx.out(kv('amended', 'latest recap wins; coverage re-validates against it'));
139
+ ctx.out(`next: witness write --effort ${recap.effort} --meta <m.json> --body <b.md> <spec-id>`);
140
+ return EXIT.OK;
141
+ }
142
+ finally {
143
+ lock.value();
144
+ }
145
+ }
@@ -0,0 +1,52 @@
1
+ import { parseArgs } from 'node:util';
2
+ import { EXIT } from '../cli.js';
3
+ import { dirtyStatePaths, primaryRoot } from '../gitio.js';
4
+ import { kv } from '../toon.js';
5
+ import { renderRefusal } from '../refusal.js';
6
+ import { completeTxn, pendingTxn, rollbackTxn } from '../txn.js';
7
+ import { rmSync } from 'node:fs';
8
+ import { join } from 'node:path';
9
+ export async function run(ctx, argv) {
10
+ const { values } = parseArgs({
11
+ args: argv,
12
+ options: { complete: { type: 'boolean' }, rollback: { type: 'boolean' } },
13
+ });
14
+ const rootRes = primaryRoot(ctx.cwd);
15
+ if (!rootRes.ok) {
16
+ renderRefusal(rootRes.violations).forEach(ctx.err);
17
+ return EXIT.REFUSED;
18
+ }
19
+ const root = rootRes.value;
20
+ const marker = pendingTxn(root);
21
+ if (!marker) {
22
+ ctx.out('recover: no pending transaction');
23
+ return EXIT.OK;
24
+ }
25
+ if (dirtyStatePaths(root).length === 0) {
26
+ rmSync(join(root, '.witness', 'txn.json'), { force: true });
27
+ ctx.out(kv('recovered', 'already-committed — marker cleared'));
28
+ return EXIT.OK;
29
+ }
30
+ let choice = values.complete ? 'complete' : values.rollback ? 'rollback' : undefined;
31
+ if (!choice && ctx.isTTY) {
32
+ const answer = await ctx.ask(`crashed mid-'${marker.op}' — [c]omplete the commit or [r]ollback?`);
33
+ choice = answer.trim().toLowerCase().startsWith('c') ? 'complete' : answer.trim().toLowerCase().startsWith('r') ? 'rollback' : undefined;
34
+ }
35
+ if (!choice) {
36
+ ctx.err(kv('pending', marker.op));
37
+ ctx.err('help: witness recover --complete | --rollback');
38
+ return EXIT.BLOCKED;
39
+ }
40
+ if (choice === 'rollback') {
41
+ rollbackTxn(root, marker);
42
+ ctx.out(kv('recovered', 'rolled-back'));
43
+ return EXIT.OK;
44
+ }
45
+ const res = completeTxn(root, marker);
46
+ if (!res.ok) {
47
+ renderRefusal(res.violations).forEach(ctx.err);
48
+ return EXIT.REFUSED;
49
+ }
50
+ ctx.out(kv('recovered', `completed as ${res.value.sha.slice(0, 7)}`));
51
+ return EXIT.OK;
52
+ }