@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,119 @@
1
+ import { ID_RE, validateCriteria, validateNeeds } from './dsl.js';
2
+ import { v } from './refusal.js';
3
+ export const SPEC_STATUS = ['draft', 'approved', 'live'];
4
+ export const PLAN_STATUS = ['draft', 'approved', 'in-progress', 'done', 'abandoned'];
5
+ export const PRINCIPLES_STATUS = ['draft', 'approved'];
6
+ export function validateDoc(meta, body) {
7
+ const out = [];
8
+ const id = meta.id;
9
+ if (typeof id !== 'string' || !ID_RE.test(id)) {
10
+ out.push(v('id', 'id-charset', String(id ?? 'absent'), '[a-z0-9-]+'));
11
+ }
12
+ const type = meta.type;
13
+ if (type !== 'spec' && type !== 'plan' && type !== 'principles') {
14
+ out.push(v('type', 'enum', String(type ?? 'absent'), 'spec | plan | principles'));
15
+ return out;
16
+ }
17
+ if (typeof id === 'string' && (type === 'principles' ? id !== 'principles' : id === 'principles')) {
18
+ out.push(v('id', 'reserved', id, "'principles' is reserved for type: principles"));
19
+ }
20
+ if (meta.ui !== undefined && type !== 'spec') {
21
+ out.push(v('ui', 'forbidden', String(meta.ui), 'ui is a spec-only flag'));
22
+ }
23
+ if (meta.design !== undefined && type !== 'spec') {
24
+ out.push(v('design', 'forbidden', 'present', 'design is a spec-only, gate-written stamp'));
25
+ }
26
+ const depends = meta.depends ?? [];
27
+ if (!Array.isArray(depends) || depends.some((d) => typeof d !== 'string' || !ID_RE.test(d))) {
28
+ out.push(v('depends', 'shape', JSON.stringify(meta.depends), 'list of ids matching [a-z0-9-]+'));
29
+ }
30
+ out.push(...validateNeeds(meta.needs));
31
+ const statuses = type === 'spec' ? SPEC_STATUS : type === 'plan' ? PLAN_STATUS : PRINCIPLES_STATUS;
32
+ if (!statuses.includes(String(meta.status))) {
33
+ out.push(v('status', 'enum', String(meta.status ?? 'absent'), statuses.join(' | ')));
34
+ }
35
+ if (type === 'spec') {
36
+ const s = meta.summary;
37
+ if (typeof s !== 'string' || s.length === 0) {
38
+ out.push(v('summary', 'required', String(s ?? 'absent'), 'one-liner: what the slice is'));
39
+ }
40
+ else if (s.length > 120) {
41
+ out.push(v('summary', 'max-length', `${s.length} chars`, '<=120 chars'));
42
+ }
43
+ if (meta.ui !== undefined && meta.ui !== true) {
44
+ out.push(v('ui', 'shape', String(meta.ui), 'true or omitted (a ui spec earns a design stage)'));
45
+ }
46
+ if (meta.design !== undefined) {
47
+ const d = meta.design;
48
+ const hex = (x) => typeof x === 'string' && /^[0-9a-f]{64}$/.test(x);
49
+ if (typeof meta.design !== 'object' || meta.design === null || !hex(d.sha) || !hex(d.spec)) {
50
+ out.push(v('design', 'shape', JSON.stringify(meta.design), '{sha: <64hex>, spec: <64hex>} — the gate-written design stamp'));
51
+ }
52
+ }
53
+ if (typeof id === 'string')
54
+ out.push(...validateCriteria(meta.criteria, id));
55
+ if (meta.supersedes !== undefined && (typeof meta.supersedes !== 'string' || !ID_RE.test(meta.supersedes))) {
56
+ out.push(v('supersedes', 'shape', String(meta.supersedes), 'a spec id'));
57
+ }
58
+ for (const h of ['## Motivation', '## Behavior']) {
59
+ if (!new RegExp(`^${h}$`, 'm').test(body)) {
60
+ out.push(v('body', 'template', `missing ${h}`, 'spec body carries ## Motivation and ## Behavior'));
61
+ }
62
+ }
63
+ }
64
+ if (type === 'plan') {
65
+ if (typeof meta.parent !== 'string' || !ID_RE.test(meta.parent)) {
66
+ out.push(v('parent', 'required', String(meta.parent ?? 'absent'), 'parent spec id (or principles for chores)'));
67
+ }
68
+ const df = meta['derives-from'];
69
+ if (typeof df !== 'string' || !/^[0-9a-f]{64}$/.test(df)) {
70
+ out.push(v('derives-from', 'shape', String(df ?? 'absent'), '64-hex canonical content sha'));
71
+ }
72
+ if (meta['design-from'] !== undefined && (typeof meta['design-from'] !== 'string' || !/^[0-9a-f]{64}$/.test(String(meta['design-from'])))) {
73
+ out.push(v('design-from', 'shape', String(meta['design-from']), '64-hex design artifact sha'));
74
+ }
75
+ out.push(...validateSteps(meta.steps, body));
76
+ }
77
+ if (type === 'principles' && meta.criteria !== undefined) {
78
+ out.push(v('criteria', 'forbidden', 'present', 'principles carry no criteria'));
79
+ }
80
+ return out;
81
+ }
82
+ function validateSteps(raw, body) {
83
+ if (!Array.isArray(raw) || raw.length === 0) {
84
+ return [v('steps', 'required', raw === undefined ? 'absent' : 'empty', '>=1 step {id, title, criteria|scaffolding}')];
85
+ }
86
+ const out = [];
87
+ const ids = new Set();
88
+ raw.forEach((s, i) => {
89
+ const at = `steps[${i}]`;
90
+ const e = (typeof s === 'object' && s !== null ? s : {});
91
+ if (typeof e.id !== 'string' || !ID_RE.test(e.id)) {
92
+ out.push(v(`${at}.id`, 'id-charset', String(e.id ?? 'absent'), '[a-z0-9-]+'));
93
+ }
94
+ else if (ids.has(e.id)) {
95
+ out.push(v(`${at}.id`, 'id-unique', e.id, 'unique per plan'));
96
+ }
97
+ else {
98
+ ids.add(e.id);
99
+ }
100
+ if (typeof e.title !== 'string' || e.title === '') {
101
+ out.push(v(`${at}.title`, 'required', String(e.title ?? 'absent'), 'non-empty title'));
102
+ }
103
+ const hasCriteria = Array.isArray(e.criteria) && e.criteria.length > 0;
104
+ const isScaffolding = e.scaffolding === true;
105
+ if (hasCriteria === isScaffolding) {
106
+ out.push(v(at, 'mapping', hasCriteria ? 'criteria and scaffolding' : 'neither criteria nor scaffolding', 'criteria: [ids] XOR scaffolding: true'));
107
+ }
108
+ });
109
+ const found = new Set([...body.matchAll(/^## Step: (\S+)$/gm)].map((m) => m[1]));
110
+ for (const sid of ids) {
111
+ if (!found.has(sid))
112
+ out.push(v('body', 'step-section-missing', `no '## Step: ${sid}' section`, 'one section per manifest step'));
113
+ }
114
+ for (const f of found) {
115
+ if (!ids.has(f))
116
+ out.push(v('body', 'step-section-orphan', `'## Step: ${f}'`, 'every section names a manifest step'));
117
+ }
118
+ return out;
119
+ }
package/dist/sha.js ADDED
@@ -0,0 +1,27 @@
1
+ import { createHash } from 'node:crypto';
2
+ export const VOLATILE_FIELDS = ['status', 'drift', 'pr', 'design'];
3
+ export function canonicalJson(value) {
4
+ if (Array.isArray(value))
5
+ return '[' + value.map(canonicalJson).join(',') + ']';
6
+ if (value && typeof value === 'object') {
7
+ return ('{' +
8
+ Object.entries(value)
9
+ .filter(([, val]) => val !== undefined)
10
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
11
+ .map(([k, val]) => JSON.stringify(k) + ':' + canonicalJson(val))
12
+ .join(',') +
13
+ '}');
14
+ }
15
+ return JSON.stringify(value);
16
+ }
17
+ export function canonicalSha(meta, body) {
18
+ const kept = Object.fromEntries(Object.entries(meta).filter(([k]) => !VOLATILE_FIELDS.includes(k)));
19
+ return createHash('sha256')
20
+ .update(canonicalJson(kept))
21
+ .update('\0')
22
+ .update(body.replace(/\s+$/, '') + '\n')
23
+ .digest('hex');
24
+ }
25
+ export function short(sha) {
26
+ return sha.slice(0, 7);
27
+ }
package/dist/ship.js ADDED
@@ -0,0 +1,257 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { EXIT } from './cli.js';
3
+ import { loadConfig } from './config.js';
4
+ import { acquireLock } from './lock.js';
5
+ import { crashPoint, guardTxn, withTxn } from './txn.js';
6
+ import { journalRel, readStream } from './journal.js';
7
+ import { primaryRoot, stateCommit, tryGit } from './gitio.js';
8
+ import { findById, loadCanon } from './scan.js';
9
+ import { ok, refuse, renderRefusal, v } from './refusal.js';
10
+ import { kv } from './toon.js';
11
+ import { runGate } from './gate.js';
12
+ import { lastGateRun } from './rounds.js';
13
+ import { prepareStamp, writeStamp } from './stamp.js';
14
+ import { branchName, worktreePath } from './worktree.js';
15
+ import { gateSettled } from './verbs/next.js';
16
+ import { worktreeTreeSha } from './reviewed.js';
17
+ export function shipPhase(plan, entries, treeSha, baseMoved = false) {
18
+ // A PR exists, but the watch is only legitimate while the verdict still describes both
19
+ // this tree AND this base. Either one moving re-arms the gate — otherwise a rebase (or
20
+ // any post-approval edit) merges a tree no battery ever read, and re-running
21
+ // `witness ship` is a one-command bypass of every check below.
22
+ // `baseMoved` is a SEPARATE input from `treeSha` on purpose: a moved base has not yet
23
+ // touched the worktree, so the tree sha still matches. Routing on the tree alone would
24
+ // detect the moved base forever and never rebase — only the gate phase rebases, and it
25
+ // is the rebase that finally moves the tree.
26
+ if (plan.meta.pr !== undefined) {
27
+ return gateSettled(entries, 'ship', treeSha) && !baseMoved ? 'watch' : 'gate';
28
+ }
29
+ const last = lastGateRun(entries, 'ship');
30
+ if (!last)
31
+ return 'gate';
32
+ const after = entries.slice(entries.indexOf(last) + 1);
33
+ const decision = after.find((e) => e.t === 'human-decision' && e.gate === 'ship');
34
+ // awaiting-decision is deliberately blind to baseMoved: a human reading
35
+ // `decide ship --show` is reading findings pinned to a sha, and rebasing under them
36
+ // would lapse the very gate they are deciding on.
37
+ if (!decision)
38
+ return 'awaiting-decision';
39
+ if (decision.decision !== 'approve')
40
+ return 'gate';
41
+ // An approval is against a base as well as a tree — opening the PR on a base that has
42
+ // since moved is the same stale-review bug one step earlier.
43
+ return baseMoved ? 'gate' : 'pr';
44
+ }
45
+ function gh(ctx, cwd, args, timeout = 120_000) {
46
+ try {
47
+ const out = execFileSync('gh', args, {
48
+ cwd, env: ctx.env, encoding: 'utf8', timeout, maxBuffer: 16 * 1024 * 1024,
49
+ });
50
+ return { ok: true, out };
51
+ }
52
+ catch (e) {
53
+ const err = e;
54
+ return { ok: false, out: `${err.stdout ?? ''}${err.stderr ?? ''}` || String(err.message) };
55
+ }
56
+ }
57
+ export function existingPr(ctx, root, branch) {
58
+ const r = gh(ctx, root, ['pr', 'list', '--head', branch, '--json', 'number']);
59
+ if (!r.ok)
60
+ return undefined;
61
+ try {
62
+ const arr = JSON.parse(r.out);
63
+ return arr[0]?.number;
64
+ }
65
+ catch {
66
+ return undefined;
67
+ }
68
+ }
69
+ export function createPr(ctx, wt, root, plan, parentSummary) {
70
+ const branch = branchName(String(plan.meta.id));
71
+ if (!tryGit(root, 'remote', 'get-url', 'origin').ok) {
72
+ return refuse([v('origin', 'no-remote', '(none)', 'an origin remote — gh pr create needs one')]);
73
+ }
74
+ // --force-with-lease once an upstream exists: the rebase now runs in the GATE phase, so
75
+ // a re-entered pr phase pushes a rebased branch and a plain push is no longer a
76
+ // fast-forward. The lease still refuses to clobber anything pushed by someone else.
77
+ const hasUpstream = tryGit(wt, 'rev-parse', '--verify', `refs/remotes/origin/${branch}`).ok;
78
+ const push = hasUpstream
79
+ ? tryGit(wt, 'push', '--force-with-lease', '-u', 'origin', branch)
80
+ : tryGit(wt, 'push', '-u', 'origin', branch);
81
+ if (!push.ok)
82
+ return refuse([v('push', 'push-failed', push.out.slice(0, 200), 'git push -u origin to succeed')]);
83
+ const found = existingPr(ctx, root, branch);
84
+ if (found !== undefined)
85
+ return ok(found);
86
+ const created = gh(ctx, wt, ['pr', 'create', '--head', branch,
87
+ '--title', `${String(plan.meta.id)}: ${parentSummary}`,
88
+ '--body', `Realizes spec ${String(plan.meta.parent)} at ${String(plan.meta['derives-from']).slice(0, 7)} (witness).`]);
89
+ if (!created.ok) {
90
+ return refuse([v('gh', 'pr-create-failed', created.out.slice(0, 200), 'gh authenticated with push access — witness check probes gh auth status')]);
91
+ }
92
+ const m = /\/pull\/(\d+)\s*$/.exec(created.out.trim());
93
+ if (!m)
94
+ return refuse([v('gh', 'pr-url-unparseable', created.out.slice(-120), 'a …/pull/<n> URL')]);
95
+ return ok(Number(m[1]));
96
+ }
97
+ export function stampPr(ctx, root, planId, pr) {
98
+ const canon = loadCanon(root);
99
+ const plan = findById(canon, planId);
100
+ const stamp = prepareStamp(plan, String(plan.meta.status), 'ship', { pr });
101
+ // pr: is volatile — status stays in-progress; the stamp writes meta.pr and journals the entry
102
+ const lockR = acquireLock(root);
103
+ if (!lockR.ok)
104
+ return lockR;
105
+ try {
106
+ return withTxn(root, {
107
+ op: 'pr-stamp', files: [plan.rel, journalRel(planId)],
108
+ journalMulti: [{ stream: planId, line: stamp.line }],
109
+ }, () => {
110
+ writeStamp(root, { ...stamp, doc: { ...plan, meta: { ...plan.meta, pr } } });
111
+ crashPoint(ctx.env, 'pr-stamp');
112
+ return stateCommit(root, [plan.rel, journalRel(planId)], `ship(${planId}): pr #${pr}`);
113
+ });
114
+ }
115
+ finally {
116
+ lockR.value();
117
+ }
118
+ }
119
+ export function rebaseIfMoved(wt, shipBranch, remote = 'origin') {
120
+ // Resolve the base from the REMOTE tip, not the local ref: with several flows merging
121
+ // concurrently, local <shipBranch> lags every merge since the human's last pull, so a
122
+ // local ancestry check reports "clean" against a base that is provably behind and the
123
+ // conflict resurfaces on GitHub after CI already went green.
124
+ if (!tryGit(wt, 'remote', 'get-url', remote).ok) {
125
+ return refuse([v('origin', 'no-remote', '(none)', `a ${remote} remote — ship rebases on its tip`)]);
126
+ }
127
+ // A configured remote that will not fetch is NOT the offline case. Degrading to the
128
+ // local ref here would silently rebase on a stale base immediately before a merge,
129
+ // which is the exact failure this function exists to prevent.
130
+ const fetched = tryGit(wt, 'fetch', remote, shipBranch);
131
+ if (!fetched.ok) {
132
+ return refuse([v('fetch', 'fetch-failed', fetched.out.slice(0, 200), `git fetch ${remote} ${shipBranch} to succeed`)]);
133
+ }
134
+ const base = `${remote}/${shipBranch}`;
135
+ if (tryGit(wt, 'merge-base', '--is-ancestor', base, 'HEAD').ok)
136
+ return ok('clean');
137
+ // --autostash: this runs in the GATE phase, and implement deliberately leaves the
138
+ // worktree uncommitted (ship owns the sole code commit, in the pr phase below), so the
139
+ // tree is normally dirty and a bare rebase refuses with "you have unstaged changes".
140
+ // Autostash makes the rebase see a clean tree without moving witness's commit ahead of
141
+ // the human's decision. A pop that conflicts fails the rebase and refuses like any other.
142
+ const rebase = tryGit(wt, 'rebase', '--autostash', base);
143
+ if (!rebase.ok) {
144
+ tryGit(wt, 'rebase', '--abort');
145
+ return refuse([v('rebase', 'textual-conflict', `${base} moved`, 'resolve in the worktree (rebase manually), then re-run witness ship — the CLI never freehands a merge')]);
146
+ }
147
+ // Only push a branch that HAS an upstream. This now runs in the gate phase, before
148
+ // createPr has ever pushed, so on a first ship there is nothing on the remote to
149
+ // update and an unconditional push fails with "no upstream branch" — createPr does
150
+ // the initial push. Where an upstream does exist the push result is the whole point:
151
+ // reporting 'rebased' after a failed push leaves the PR head on the pre-rebase commit,
152
+ // and CI goes green on a tree nobody will merge.
153
+ if (tryGit(wt, 'rev-parse', '--verify', '--quiet', '@{upstream}').ok) {
154
+ const push = tryGit(wt, 'push', '--force-with-lease');
155
+ if (!push.ok) {
156
+ return refuse([v('push', 'push-failed', push.out.slice(0, 200), 'git push --force-with-lease to succeed')]);
157
+ }
158
+ }
159
+ return ok('rebased');
160
+ }
161
+ export async function runShip(ctx, planId) {
162
+ const rootR = primaryRoot(ctx.cwd);
163
+ if (!rootR.ok) {
164
+ renderRefusal(rootR.violations).forEach((l) => ctx.err(l));
165
+ return EXIT.REFUSED;
166
+ }
167
+ const root = rootR.value;
168
+ const blocked = guardTxn(ctx, root);
169
+ if (blocked !== undefined)
170
+ return blocked;
171
+ const cfgR = loadConfig(root);
172
+ if (!cfgR.ok) {
173
+ renderRefusal(cfgR.violations).forEach((l) => ctx.err(l));
174
+ return EXIT.REFUSED;
175
+ }
176
+ const canon = loadCanon(root);
177
+ const plan = findById(canon, planId);
178
+ if (!plan || plan.meta.type !== 'plan') {
179
+ renderRefusal([v('plan', 'unknown-plan', planId, 'a plans/ doc id')]).forEach((l) => ctx.err(l));
180
+ return EXIT.REFUSED;
181
+ }
182
+ const wt = worktreePath(root, planId);
183
+ const entries = readStream(root, planId);
184
+ const ship = (cfgR.value.raw.ship ?? {});
185
+ const shipBranch = ship.branch ?? 'main';
186
+ // One read-only fetch decides the whole routing. A failed fetch (no remote, offline)
187
+ // leaves baseMoved false and the phases behave as they did — the gate phase's own
188
+ // rebaseIfMoved is where that condition turns into a structured refusal.
189
+ const baseMoved = tryGit(wt, 'fetch', 'origin', shipBranch).ok
190
+ && !tryGit(wt, 'merge-base', '--is-ancestor', `origin/${shipBranch}`, 'HEAD').ok;
191
+ let phase = shipPhase(plan, entries, worktreeTreeSha(wt), baseMoved);
192
+ if (baseMoved)
193
+ ctx.out(kv('ship', `${shipBranch} moved — rebasing and re-reviewing before the watch`));
194
+ if (phase === 'gate') {
195
+ // Rebase BEFORE the battery, never after: the reviewers must judge the tree that will
196
+ // actually merge. Reviewing first spends a battery — and a human decision — on a tree
197
+ // without any sibling's merged work, which then lapses (gateSettled's reviewed_sha)
198
+ // the moment the rebase lands.
199
+ const rebase = rebaseIfMoved(wt, shipBranch);
200
+ if (!rebase.ok) {
201
+ renderRefusal(rebase.violations).forEach((l) => ctx.err(l));
202
+ // A textual conflict is a hand-back, not a refusal: the human resolves it in the
203
+ // worktree and re-runs. Every other rule (no-remote, fetch-failed, push-failed) is
204
+ // a genuine refusal and exits REFUSED like the rest of the CLI.
205
+ return rebase.violations.some((x) => x.rule === 'textual-conflict') ? EXIT.FINDINGS : EXIT.REFUSED;
206
+ }
207
+ if (rebase.value === 'rebased')
208
+ ctx.out(kv('rebase', `${shipBranch} moved — rebased before the battery`));
209
+ const code = await runGate(ctx, 'ship', planId, { fresh: false, manual: false });
210
+ if (code !== EXIT.FINDINGS && code !== EXIT.OK)
211
+ return code;
212
+ ctx.out(`help: witness decide ship ${planId} --approve to send the PR — ship always stops`);
213
+ return EXIT.FINDINGS;
214
+ }
215
+ if (phase === 'awaiting-decision') {
216
+ ctx.out(kv('ship', `${planId} awaits the ship decision`));
217
+ ctx.out(`help: witness decide ship ${planId} --show | --approve | --revise --note "<why>" | --stop`);
218
+ return EXIT.FINDINGS;
219
+ }
220
+ if (phase === 'pr') {
221
+ const parent = findById(canon, String(plan.meta.parent));
222
+ const parentSummary = String(parent?.meta.summary ?? 'witness change');
223
+ // implement leaves the worktree uncommitted — ship owns the sole code commit
224
+ if (tryGit(wt, 'status', '--porcelain').out !== '') {
225
+ tryGit(wt, 'add', '-A');
226
+ const committed = tryGit(wt, 'commit', '-m', `${planId}: ${parentSummary}`);
227
+ if (!committed.ok) {
228
+ renderRefusal([v('commit', 'ship-commit-failed', committed.out.slice(0, 200), 'a committable worktree — resolve and re-run witness ship')]).forEach((l) => ctx.err(l));
229
+ return EXIT.REFUSED;
230
+ }
231
+ ctx.out(kv('commit', `${planId}: ${parentSummary}`));
232
+ }
233
+ const created = createPr(ctx, wt, root, plan, parentSummary);
234
+ if (!created.ok) {
235
+ renderRefusal(created.violations).forEach((l) => ctx.err(l));
236
+ return EXIT.REFUSED;
237
+ }
238
+ const stamped = stampPr(ctx, root, planId, created.value);
239
+ if (!stamped.ok) {
240
+ renderRefusal(stamped.violations).forEach((l) => ctx.err(l));
241
+ return EXIT.REFUSED;
242
+ }
243
+ ctx.out(kv('pr', `#${created.value} created and stamped`));
244
+ phase = 'watch';
245
+ }
246
+ const pr = Number(findById(loadCanon(root), planId).meta.pr);
247
+ // watch never mutates and never rebases: reaching here means shipPhase already
248
+ // established that both the reviewed tree and the base are the ones the battery judged.
249
+ const green = gh(ctx, root, ['pr', 'checks', String(pr), '--watch'], 1_800_000);
250
+ if (!green.ok) {
251
+ ctx.out(kv('ci', `red — ${green.out.trim().slice(-200)}`));
252
+ return EXIT.FINDINGS;
253
+ }
254
+ ctx.out(kv('ci', 'green'));
255
+ ctx.out(kv('ship', `merge PR #${pr} on GitHub when ready — the lazy stamp flips plan → done, spec → live`));
256
+ return EXIT.OK;
257
+ }
package/dist/stamp.js ADDED
@@ -0,0 +1,104 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { existsSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { writeDoc } from './fm.js';
5
+ import { stateCommit } from './gitio.js';
6
+ import { appendEntry, entryLine, journalRel } from './journal.js';
7
+ import { acquireLock } from './lock.js';
8
+ import { canonicalSha } from './sha.js';
9
+ import { findById } from './scan.js';
10
+ import { crashPoint, withTxn } from './txn.js';
11
+ import { removeWorktree, worktreePath } from './worktree.js';
12
+ export function prepareStamp(doc, to, cause, extra = {}) {
13
+ const entry = {
14
+ v: 1, t: 'status', artifact: String(doc.meta.id),
15
+ from: String(doc.meta.status), to, cause, ...extra,
16
+ };
17
+ return { doc, to, entry, line: entryLine(entry), rel: doc.rel, stream: String(doc.meta.id) };
18
+ }
19
+ export function writeStamp(root, s) {
20
+ writeDoc(join(root, s.rel), { meta: { ...s.doc.meta, status: s.to }, body: s.doc.body });
21
+ appendEntry(root, s.stream, s.entry);
22
+ }
23
+ function prState(ctx, root, pr) {
24
+ try {
25
+ const out = execFileSync('gh', ['pr', 'view', String(pr), '--json', 'state'], {
26
+ cwd: root, env: ctx.env, encoding: 'utf8', timeout: 3_000,
27
+ });
28
+ return JSON.parse(out).state;
29
+ }
30
+ catch {
31
+ return undefined;
32
+ }
33
+ }
34
+ export function lazyStamp(root, ctx, canon) {
35
+ const result = { stamped: [], stale: [] };
36
+ if (ctx.env.CI)
37
+ return result; // CI never writes state
38
+ const candidates = canon.docs.filter((d) => d.meta.type === 'plan' && String(d.meta.status) === 'in-progress' && d.meta.pr !== undefined);
39
+ for (const plan of candidates) {
40
+ const planId = String(plan.meta.id);
41
+ const pr = Number(plan.meta.pr);
42
+ const state = prState(ctx, root, pr);
43
+ if (state === undefined) {
44
+ result.stale.push({ plan: planId, why: "stale, couldn't check" });
45
+ continue;
46
+ }
47
+ if (state === 'CLOSED') {
48
+ result.stale.push({ plan: planId, why: `pr #${pr} closed unmerged — witness abandon ${planId}?` });
49
+ continue;
50
+ }
51
+ if (state !== 'MERGED') {
52
+ result.stale.push({ plan: planId, why: `pr #${pr} still ${state}` });
53
+ continue;
54
+ }
55
+ const parent = findById(canon, String(plan.meta.parent));
56
+ const pinFresh = parent !== undefined &&
57
+ canonicalSha(parent.meta, parent.body) === String(plan.meta['derives-from']);
58
+ const planStamp = prepareStamp(plan, 'done', 'merge', { pr, ...(pinFresh ? {} : { note: 'stale-merge' }) });
59
+ const specStamp = parent && pinFresh && parent.meta.type === 'spec'
60
+ ? prepareStamp(parent, 'live', 'merge', { pr }) : undefined;
61
+ const files = [planStamp.rel, journalRel(planStamp.stream),
62
+ ...(specStamp ? [specStamp.rel, journalRel(specStamp.stream)] : [])];
63
+ const lockR = acquireLock(root);
64
+ if (!lockR.ok) {
65
+ result.stale.push({ plan: planId, why: 'lock held' });
66
+ continue;
67
+ }
68
+ try {
69
+ const txn = withTxn(root, {
70
+ op: 'merge-stamp', files,
71
+ journalMulti: [
72
+ { stream: planStamp.stream, line: planStamp.line },
73
+ ...(specStamp ? [{ stream: specStamp.stream, line: specStamp.line }] : []),
74
+ ],
75
+ }, () => {
76
+ writeStamp(root, planStamp);
77
+ if (specStamp)
78
+ writeStamp(root, specStamp);
79
+ crashPoint(ctx.env, 'merge-stamp');
80
+ return stateCommit(root, files, `merge(${planId}): pr #${pr}`);
81
+ });
82
+ if (txn.ok) {
83
+ removeWorktree(root, planId);
84
+ result.stamped.push({ plan: planId, ...(specStamp ? { spec: specStamp.stream } : {}), pr });
85
+ }
86
+ else {
87
+ result.stale.push({ plan: planId, why: txn.violations.map((x) => x.rule).join(' ') });
88
+ }
89
+ }
90
+ finally {
91
+ lockR.value();
92
+ }
93
+ }
94
+ // a crash between writeStamp and this point recovers generically (journal replay +
95
+ // commit only) — completeTxn has no notion of "also remove the worktree", and a plan
96
+ // already 'done' never re-enters `candidates` above, so the removal must be re-checked
97
+ // independently of whichever call actually performed the stamp.
98
+ for (const plan of canon.docs.filter((d) => d.meta.type === 'plan' && String(d.meta.status) === 'done')) {
99
+ const planId = String(plan.meta.id);
100
+ if (existsSync(worktreePath(root, planId)))
101
+ removeWorktree(root, planId);
102
+ }
103
+ return result;
104
+ }
package/dist/toon.js ADDED
@@ -0,0 +1,16 @@
1
+ function esc(value) {
2
+ const s = String(value ?? '');
3
+ return /[,"\n]|^\s|\s$/.test(s) ? `"${s.replaceAll('"', '""')}"` : s;
4
+ }
5
+ export function kv(key, value) {
6
+ return `${key}: ${esc(value)}`;
7
+ }
8
+ export function list(key, values) {
9
+ return `${key}[${values.length}]: ${values.map(esc).join(',')}`;
10
+ }
11
+ export function rows(key, fields, items) {
12
+ return [
13
+ `${key}[${items.length}]{${fields.join(',')}}:`,
14
+ ...items.map((it) => ' ' + fields.map((f) => esc(it[f])).join(',')),
15
+ ];
16
+ }
package/dist/txn.js ADDED
@@ -0,0 +1,70 @@
1
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { EXIT } from './cli.js';
4
+ import { git, stateCommit, tryGit } from './gitio.js';
5
+ import { journalRel } from './journal.js';
6
+ const markerPath = (root) => join(root, '.witness', 'txn.json');
7
+ export function pendingTxn(root) {
8
+ if (!existsSync(markerPath(root)))
9
+ return undefined;
10
+ return JSON.parse(readFileSync(markerPath(root), 'utf8'));
11
+ }
12
+ export function crashPoint(env, name) {
13
+ if (env.WITNESS_CRASH_AFTER === name)
14
+ process.exit(9);
15
+ }
16
+ export function withTxn(root, marker, fn) {
17
+ mkdirSync(join(root, '.witness'), { recursive: true });
18
+ writeFileSync(markerPath(root), JSON.stringify(marker));
19
+ let res;
20
+ try {
21
+ res = fn();
22
+ }
23
+ catch (e) {
24
+ rollbackTxn(root, marker);
25
+ throw e;
26
+ }
27
+ if (res.ok)
28
+ rmSync(markerPath(root), { force: true });
29
+ else
30
+ rollbackTxn(root, marker);
31
+ return res;
32
+ }
33
+ export function rollbackTxn(root, marker) {
34
+ for (const f of marker.files) {
35
+ const tracked = tryGit(root, 'ls-files', '--error-unmatch', '--', f).ok;
36
+ if (tracked)
37
+ git(root, 'checkout', '--', f);
38
+ else
39
+ rmSync(join(root, f), { force: true });
40
+ }
41
+ rmSync(markerPath(root), { force: true });
42
+ }
43
+ export function completeTxn(root, marker) {
44
+ const items = [...(marker.journal ? [marker.journal] : []), ...(marker.journalMulti ?? [])];
45
+ if (items.length)
46
+ mkdirSync(join(root, '.witness', 'journal'), { recursive: true });
47
+ for (const { stream, line } of items) {
48
+ const p = join(root, journalRel(stream));
49
+ const current = existsSync(p) ? readFileSync(p, 'utf8') : '';
50
+ const lastLine = current.split('\n').filter(Boolean).at(-1);
51
+ if (lastLine !== line)
52
+ appendFileSync(p, line + '\n');
53
+ }
54
+ const res = stateCommit(root, marker.files, `${marker.op} (recovered)`);
55
+ if (res.ok)
56
+ rmSync(markerPath(root), { force: true });
57
+ return res;
58
+ }
59
+ export function guardTxn(ctx, root) {
60
+ const m = pendingTxn(root);
61
+ if (!m)
62
+ return undefined;
63
+ ctx.err(`pending transaction from a crashed invocation: ${m.op}`);
64
+ // `files` is required on every marker and populated at all withTxn sites, so this
65
+ // localizes the damage everywhere — unlike an optional owner field, which would be
66
+ // absent at the longest-running write window (gate.ts) and teach a false distinction.
67
+ ctx.err(` files: ${m.files.join(', ')}`);
68
+ ctx.err('help: witness recover --complete | --rollback');
69
+ return EXIT.BLOCKED;
70
+ }