@attalabs/vinaya 0.22.0 → 0.24.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 (194) hide show
  1. package/README.md +14 -3
  2. package/aeg-root/contracts/archivist-tranche-archivist.md +2 -2
  3. package/aeg-root/contracts/brief-developer.md +1 -0
  4. package/aeg-root/contracts/developer-reviewer.md +3 -3
  5. package/aeg-root/contracts/reviewer-archivist.md +3 -3
  6. package/aeg-root/enforcement.md +26 -20
  7. package/aeg-root/process.md +8 -4
  8. package/aeg-root/roles/archivist.md +2 -2
  9. package/aeg-root/roles/brief-author.md +9 -3
  10. package/aeg-root/roles/developer.md +63 -31
  11. package/aeg-root/roles/planner.md +1 -1
  12. package/aeg-root/roles/principal.md +14 -0
  13. package/aeg-root/roles/reviewer.md +32 -15
  14. package/aeg-root/roles/security.md +15 -5
  15. package/aeg-root/skills/aeg/SKILL.md +9 -5
  16. package/aeg-root/skills/aeg-roles/SKILL.md +2 -2
  17. package/aeg-root/skills/brief-authoring/SKILL.md +39 -27
  18. package/aeg-root/state-machine.md +9 -11
  19. package/aeg-root/templates/brief-template.md +6 -6
  20. package/aeg-root/templates/pr-report-template.md +10 -7
  21. package/aeg-root/tranche-model.md +6 -2
  22. package/dist/checks/bin/check-body-bare-digits.js +559 -140
  23. package/dist/checks/bin/check-branch-topology.js +630 -145
  24. package/dist/checks/bin/check-brief-shape.js +600 -143
  25. package/dist/checks/bin/check-changeset-coverage.js +942 -162
  26. package/dist/checks/bin/check-closes-n.js +630 -145
  27. package/dist/checks/bin/check-coherence.js +641 -148
  28. package/dist/checks/bin/check-dead-branch-push.js +559 -140
  29. package/dist/checks/bin/check-dispatch-readiness.js +639 -146
  30. package/dist/checks/bin/check-doc-coverage-push.js +696 -219
  31. package/dist/checks/bin/check-doc-coverage.js +696 -219
  32. package/dist/checks/bin/check-doctrine-no-procedures.js +4800 -0
  33. package/dist/checks/bin/check-doctrine-portability.js +691 -147
  34. package/dist/checks/bin/check-evidence-fresh.js +1048 -222
  35. package/dist/checks/bin/check-exec-bits.js +4937 -0
  36. package/dist/checks/bin/check-first-push-dispatch.js +630 -145
  37. package/dist/checks/bin/check-issue-assignment.js +630 -145
  38. package/dist/checks/bin/check-main-branch-refusal.js +565 -141
  39. package/dist/checks/bin/check-no-disk-state.js +559 -140
  40. package/dist/checks/bin/check-pr-report-density.js +4467 -0
  41. package/dist/checks/bin/check-quoted-command.js +656 -164
  42. package/dist/checks/bin/check-reader-resolvable-prose.js +652 -163
  43. package/dist/checks/bin/check-registry-gates.js +602 -145
  44. package/dist/checks/bin/check-retired-vocabulary.js +652 -163
  45. package/dist/checks/bin/check-review-gate.js +677 -142
  46. package/dist/checks/bin/check-single-plan-pr.js +559 -140
  47. package/dist/checks/bin/check-test-plan.js +560 -141
  48. package/dist/checks/bin/check-token-collection-wired.js +559 -140
  49. package/dist/checks/bin/check-token-report.js +559 -140
  50. package/dist/checks/bin/check-workspace-escape.js +992 -146
  51. package/dist/index.js +8884 -5627
  52. package/package.json +1 -1
  53. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/actions.ts +173 -0
  54. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/anchored-region.ts +118 -0
  55. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/archive-task.ts +206 -0
  56. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/baseline-capture.ts +65 -0
  57. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/blast-radius-domains.ts +198 -0
  58. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/branch-topology-gate.ts +85 -0
  59. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/brief-render.ts +462 -0
  60. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/brief-validation.ts +912 -0
  61. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/claude-code-transcript.ts +460 -0
  62. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/coherence-checks.ts +806 -0
  63. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/consumer-enumeration.ts +75 -0
  64. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/dead-branch-push-audit.ts +55 -0
  65. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/dead-branch-push-guard.ts +77 -0
  66. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/derive-section7.ts +66 -0
  67. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/derive-tranche.ts +94 -0
  68. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/diagram-model.ts +372 -0
  69. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/direct-main-push.ts +39 -0
  70. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/dispatch-gate.ts +268 -0
  71. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/doc-owners.ts +314 -0
  72. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/docs/build-doc-nav.ts +38 -0
  73. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/docs/docs-coherence.ts +136 -0
  74. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/docs/index.ts +29 -0
  75. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/docs/legacy-anchors.ts +48 -0
  76. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/docs/nav-helpers.ts +17 -0
  77. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/docs/node-route.ts +134 -0
  78. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/docs/parse-doc.ts +43 -0
  79. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/docs/published-prose.ts +306 -0
  80. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/docs/surfaced-manifest.ts +89 -0
  81. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/docs/types.ts +33 -0
  82. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/doctrine-no-procedures.ts +94 -0
  83. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/doctrine-portability.ts +293 -0
  84. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/doctrine-source.ts +23 -0
  85. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/ensure-label.ts +28 -0
  86. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/file-classify.ts +51 -0
  87. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/first-push-dispatch-gate.ts +80 -0
  88. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/gate-audience.ts +171 -0
  89. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/index.ts +323 -0
  90. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/issue-assignment.ts +102 -0
  91. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/issue-validation.ts +746 -0
  92. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/leftover-detection.ts +68 -0
  93. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/local-anchor-coverage.ts +245 -0
  94. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/main-branch-refusal.ts +64 -0
  95. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/manifest-validity.ts +81 -0
  96. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/markdown-table.ts +76 -0
  97. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/metering-io-guard.ts +119 -0
  98. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/milestone-validation.ts +240 -0
  99. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/no-disk-state.ts +50 -0
  100. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/parse-ledger.ts +77 -0
  101. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/parse-registry.ts +105 -0
  102. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/parse-token-report.ts +177 -0
  103. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/parse-tranche.ts +183 -0
  104. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/pr-report-density.ts +116 -0
  105. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/pr-tier.ts +81 -0
  106. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/premise-check.ts +160 -0
  107. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/quoted-command.ts +275 -0
  108. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/reader-resolvable-prose.ts +320 -0
  109. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/registry-checks.ts +255 -0
  110. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/registry-parse.ts +164 -0
  111. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/registry-scaffold.ts +216 -0
  112. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/report-tokens.ts +261 -0
  113. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/retired-vocabulary.ts +163 -0
  114. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/review-gate.ts +378 -0
  115. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/review-status.ts +200 -0
  116. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/single-plan-pr.ts +73 -0
  117. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/state-machine-model.ts +269 -0
  118. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/state-source.ts +14 -0
  119. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/status-block.ts +8 -0
  120. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/sum-ledger.ts +26 -0
  121. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/symbol-collisions.ts +78 -0
  122. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/test-plan-gate.ts +117 -0
  123. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/test-plan-section.ts +65 -0
  124. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/types.ts +174 -0
  125. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/verdict-extraction.ts +199 -0
  126. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/vocabulary-citation.ts +114 -0
  127. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/waiver-label.ts +52 -0
  128. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/workspace-escape.ts +172 -0
  129. package/studio-standalone/apps/vinaya-studio/web/.next/BUILD_ID +1 -1
  130. package/studio-standalone/apps/vinaya-studio/web/.next/build-manifest.json +3 -3
  131. package/studio-standalone/apps/vinaya-studio/web/.next/prerender-manifest.json +3 -3
  132. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_global-error.html +1 -1
  133. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_global-error.rsc +1 -1
  134. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
  135. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  136. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  137. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  138. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  139. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_not-found/page/server-reference-manifest.json +2 -2
  140. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_not-found/page.js.nft.json +1 -1
  141. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  142. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/api/coherence/route.js.nft.json +1 -1
  143. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/page/server-reference-manifest.json +2 -2
  144. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/page.js.nft.json +1 -1
  145. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/page_client-reference-manifest.js +1 -1
  146. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/backlog/page/server-reference-manifest.json +2 -2
  147. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/backlog/page.js.nft.json +1 -1
  148. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/backlog/page_client-reference-manifest.js +1 -1
  149. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/page/server-reference-manifest.json +2 -2
  150. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/page.js.nft.json +1 -1
  151. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/page_client-reference-manifest.js +1 -1
  152. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/page/server-reference-manifest.json +2 -2
  153. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/page.js.nft.json +1 -1
  154. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/page_client-reference-manifest.js +1 -1
  155. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/tranches/[slug]/page/server-reference-manifest.json +2 -2
  156. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/tranches/[slug]/page.js.nft.json +1 -1
  157. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/tranches/[slug]/page_client-reference-manifest.js +1 -1
  158. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/tranches/[slug]/tasks/[taskId]/page/server-reference-manifest.json +2 -2
  159. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/tranches/[slug]/tasks/[taskId]/page.js.nft.json +1 -1
  160. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/tranches/[slug]/tasks/[taskId]/page_client-reference-manifest.js +1 -1
  161. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/page/server-reference-manifest.json +2 -2
  162. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/page.js.nft.json +1 -1
  163. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/page_client-reference-manifest.js +1 -1
  164. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/tranches/page/server-reference-manifest.json +2 -2
  165. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/tranches/page.js.nft.json +1 -1
  166. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/tranches/page_client-reference-manifest.js +1 -1
  167. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/[root-of-the-server]__02a-3g8._.js +1 -1
  168. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/1q96_modules_@clerk_nextjs_dist_esm_app-router_client_keyless-creator-reader_0lom2js.js +1 -1
  169. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/[root-of-the-server]__0053k9k._.js +1 -1
  170. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/[root-of-the-server]__0112h-k._.js +1 -1
  171. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/[root-of-the-server]__0o771t1._.js +1 -1
  172. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/[root-of-the-server]__0puovz5._.js +1 -1
  173. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/[root-of-the-server]__1hs0dcu._.js +1 -1
  174. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/{[root-of-the-server]__069-_41._.js → [root-of-the-server]__1pndh3_._.js} +1 -1
  175. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/[root-of-the-server]__1wc4-ip._.js +1 -1
  176. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/_03x_w6q._.js +1 -1
  177. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/_0gvm3og._.js +1 -1
  178. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/_0lwxg63._.js +1 -1
  179. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/_1fqw88f._.js +2 -2
  180. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/_1n0cnq-._.js +1 -1
  181. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/apps_vinaya-studio_web_src_app_studio_projects_[name]_tranches_[slug]_05ufo8i._.js +3 -3
  182. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/node_modules_1vo08dj._.js +2 -2
  183. package/studio-standalone/apps/vinaya-studio/web/.next/server/middleware-build-manifest.js +3 -3
  184. package/studio-standalone/apps/vinaya-studio/web/.next/server/pages/500.html +1 -1
  185. package/studio-standalone/apps/vinaya-studio/web/.next/server/server-reference-manifest.js +1 -1
  186. package/studio-standalone/apps/vinaya-studio/web/.next/server/server-reference-manifest.json +3 -3
  187. package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/{37hnluhzj9bei.js → 0zebsmmk1bvnb.js} +1 -1
  188. package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/{267o-tsrnuwnv.js → 1uogpj5w2n5ju.js} +1 -1
  189. package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/{1hufpuody6vjv.js → 3m1kgax7j2vgs.js} +1 -1
  190. package/studio-standalone/apps/vinaya-studio/web/package.json +2 -2
  191. /package/studio-standalone/_node_modules/@attalabs/vinaya/studio-standalone/_node_modules/@attalabs/{aeg-core → vinaya/studio-standalone/_node_modules/@attalabs/vinaya/studio-standalone/_node_modules/@attalabs/aeg-core}/bin/verify-coherence.ts +0 -0
  192. /package/studio-standalone/apps/vinaya-studio/web/.next/static/{Fmyc42qzLwFcWWHLLNy-G → QY2GhiI47-765D9DHGy1I}/_buildManifest.js +0 -0
  193. /package/studio-standalone/apps/vinaya-studio/web/.next/static/{Fmyc42qzLwFcWWHLLNy-G → QY2GhiI47-765D9DHGy1I}/_clientMiddlewareManifest.js +0 -0
  194. /package/studio-standalone/apps/vinaya-studio/web/.next/static/{Fmyc42qzLwFcWWHLLNy-G → QY2GhiI47-765D9DHGy1I}/_ssgManifest.js +0 -0
@@ -0,0 +1,378 @@
1
+ /**
2
+ * Required pre-merge review gate (aeg-review-gate-v1 task 1, #474). Blocks a
3
+ * PR from merging unless a clean code-reviewer `APPROVE` verdict
4
+ * AND a clean security-review `PASS` verdict both exist on the PR — the same
5
+ * `extractCodeReviewVerdict`/`extractSecurityReviewVerdict` detection
6
+ * (`verdict-extraction.ts`) the post-merge Archivist automation already runs,
7
+ * now gated pre-merge and blocking instead of post-merge and advisory-only.
8
+ *
9
+ * Verdict comments are ONLY counted when their author is on the same
10
+ * `PRINCIPAL_ALLOWLIST` the waiver actor-check trusts (security finding,
11
+ * PR #806): body-shape alone is never sufficient on a public repo. Unverified
12
+ * comments are ignored, not fatal.
13
+ *
14
+ * A verified `vinaya/waiver:review` label (the exact actor-verification pattern,
15
+ * `isWaiverLabelActorVerified` reused directly and parameterized by label —
16
+ * see `waiver-label.ts`) lets a principal explicitly skip the requirement for
17
+ * one PR. Label presence alone is never sufficient — only an actor-verified
18
+ * label waives the gate, mirroring exactly.
19
+ *
20
+ * Reviewed-commit binding (#73, a duplicate of #71 closes this one). A clean
21
+ * verdict is no longer sufficient on its own — it must also cover the PR's
22
+ * CURRENT head. Design record (the four questions this fix had to answer,
23
+ * argued in full in the closing PR's body):
24
+ *
25
+ * 1. Commit sha, not tree hash. `headSha` is `gh pr view --json headRefOid`
26
+ * verbatim — one API call, no second fetch of a commit object's tree.
27
+ * Matches the Issue's own proposed fix shape and the hand-typed
28
+ * convention reviewers were already using ("Judged head: <sha>", never a
29
+ * tree hash). Cost accepted: a rebase or an empty amend that leaves the
30
+ * tree byte-identical still invalidates every verdict — no free pass for
31
+ * "the code didn't really change." That is the explicit trade for a
32
+ * single mental model ("a verdict covers an exact commit") over a cheaper
33
+ * but subtler one (two different code states could share a tree).
34
+ * 2. Every push that changes `headRefOid` invalidates unconditionally — a
35
+ * rebase with an identical tree, a merge commit resolving a conflict
36
+ * elsewhere, and a genuine content-changing amend all produce a new sha
37
+ * and therefore a new required verdict. Only a push that does NOT change
38
+ * `headRefOid` at all (a body-only PR-description edit; a force-push that
39
+ * reproduces the exact same commit object) leaves an existing binding
40
+ * intact, because there is nothing for it to have gone stale against.
41
+ * 3. `headSha` is REQUIRED on `ReviewGateInput`, not optional. An optional
42
+ * field that silently skips the binding check when absent is fail-open —
43
+ * exactly the defect class this task exists to close. Every caller
44
+ * (`bin/verify-review-gate.ts`, `apps/cli/src/checks/bin/check-review-gate.ts`)
45
+ * must supply it; the type system enforces that, not a runtime default.
46
+ * 4. Fail-closed for the transition. The moment this merges, every verdict
47
+ * already posted on an open PR is unbound (it carries no `Judged head:`
48
+ * line) and the gate stops honouring it — expensive, and correct: an
49
+ * unbound verdict is exactly the property this task closes. The sanctioned
50
+ * escape for a PR already far along in review is the existing
51
+ * `vinaya/waiver:review` actor-verified label, applied by a principal, the
52
+ * same mechanism that already exists for any other one-off skip.
53
+ *
54
+ * Pure — no `fs`, no `fetch`, no `process.env`. The CLI shim
55
+ * (`bin/verify-review-gate.ts`) resolves the PR's comments/labels/label-actor/
56
+ * head sha via `gh` and calls `checkReviewGate`.
57
+ */
58
+
59
+ import { isPrincipal, isWaiverLabelActorVerified, PRINCIPAL_ALLOWLIST, WAIVER_LABEL_REVIEW } from './waiver-label'
60
+ import { extractCodeReviewVerdict, extractSecurityReviewVerdict } from './verdict-extraction'
61
+
62
+ export type ReviewGateVerdict = 'pass' | 'fail'
63
+
64
+ export type ReviewGateResult = {
65
+ verdict: ReviewGateVerdict
66
+ reason: string
67
+ waived: boolean
68
+ }
69
+
70
+ export type ReviewGateComment = {
71
+ body: string
72
+ /** The comment author's GitHub login, or `null` when the caller could not resolve one. */
73
+ author: string | null
74
+ }
75
+
76
+ export type MechanicalCheckStatus = {
77
+ /** The check-run's display name, as GitHub reports it (`gh pr checks`' own `name` field). */
78
+ name: string
79
+ /** GitHub's own coalesced status vocabulary for this check-run — forwarded verbatim from `gh pr checks --json name,bucket`'s `bucket` field (e.g. "pass", "fail", "pending", "skipping", "cancel"). Not re-mapped to a smaller enum here — that would be a second copy of a vocabulary `gh` already owns. */
80
+ bucket: string
81
+ }
82
+
83
+ export type ReviewGateInput = {
84
+ /** Every comment on the PR, with its author. */
85
+ comments: ReviewGateComment[]
86
+ /** Every label currently applied to the PR. */
87
+ labels: string[]
88
+ /** Actor of the most recent `vinaya/waiver:review` labeling timeline event, or `null` when none exists. */
89
+ waiverLabelActor: string | null
90
+ /**
91
+ * The PR's current head commit sha (`gh pr view --json headRefOid`),
92
+ * resolved from GitHub — never from local git, an env var, or the PR's own
93
+ * checkout, all three of which a `pull_request`-triggered workflow's
94
+ * PR-editable YAML could steer (#73). REQUIRED, not optional: an omitted
95
+ * head would have to mean either skip-the-binding-check (fail-open, the
96
+ * exact defect this field exists to close) or unconditional-fail, and a
97
+ * required field makes that choice a compile error instead of a runtime
98
+ * default. Every verdict must cover this value to count as clean.
99
+ */
100
+ headSha: string
101
+ /**
102
+ * Every check-run reported for the PR's current head, EXCLUDING this
103
+ * repo's own review-gate check-run (the caller filters that out before
104
+ * calling in — see check-review-gate.ts's own comment for why the
105
+ * exclusion must not live here). An empty array means no mechanical
106
+ * check-run has reported yet, which does NOT count as clean — there is
107
+ * no proof to point to, not an implicit pass.
108
+ */
109
+ mechanicalChecks: MechanicalCheckStatus[]
110
+ /**
111
+ * Overrides `PRINCIPAL_ALLOWLIST` for this evaluation when provided — an
112
+ * adopter repo's own `vinaya.config.json` `principals` field, resolved by
113
+ * the CLI bin before calling in (never read from here; this stays pure).
114
+ * Defaults to `PRINCIPAL_ALLOWLIST` when omitted, so every existing caller
115
+ * (this repo's own `bin/verify-review-gate.ts` included) is unaffected.
116
+ * `PRINCIPAL_ALLOWLIST` hardcoding this repo's own principal made the gate
117
+ * structurally unpassable on any adopter repo — found live on a real
118
+ * client repo's first dispatched task, the reviewer/security verdicts it
119
+ * already had counted for nobody.
120
+ */
121
+ principalAllowlist?: string[]
122
+ /**
123
+ * The PATCH IDENTITY of a commit — `git diff <base>...<sha> | git patch-id
124
+ * --stable`, truncated — or `null` when git cannot answer for that sha
125
+ * (an unreachable commit after a force-push, a shallow clone, no git at
126
+ * all). Supplied by the caller because this module is pure; omitted
127
+ * entirely, the gate behaves exactly as before.
128
+ *
129
+ * Why a second binding at all: a verdict was judged, the branch merged
130
+ * `origin/main` to clear a stale base, and the verdict died — for a merge
131
+ * commit that changed not one line of the PR's own patch. Re-reviewing
132
+ * an identical patch is a round spent proving nothing. Patch identity is
133
+ * what a reviewer actually judged; the head sha is only its address.
134
+ *
135
+ * Two known limits, stated rather than papered over.
136
+ *
137
+ * A base that moved under an identical patch can carry a semantic conflict
138
+ * the earlier review could not have seen, and this binding will still
139
+ * hold. That is the same limit GitHub's own stale-review rule has, and CI
140
+ * at the new head — which this gate already requires green — is the guard
141
+ * for it.
142
+ *
143
+ * `git patch-id --stable` ignores whitespace, so a push that changes only
144
+ * whitespace produces the same patch identity and KEEPS the verdict. That
145
+ * is deliberate for reformatting, but it is not free: whitespace is
146
+ * semantic in some languages and some string literals, so a push that is
147
+ * whitespace-only to git can still change behaviour. Any change to
148
+ * non-whitespace content produces a different identity and correctly drops
149
+ * the verdict; only the whitespace-only case survives unreviewed.
150
+ */
151
+ patchIdOf?: (sha: string) => string | null
152
+ }
153
+
154
+ /**
155
+ * Branch names are contributor-controlled metadata, so none can exempt an
156
+ * authority check. The former `plan/*` exemption assumed those branches
157
+ * contained topology docs only; a contributor could put code on a branch
158
+ * with that prefix and make the adapter exit before it fetched the PR. Keep
159
+ * this exported predicate for API compatibility, but fail closed for every
160
+ * branch. A future plan-only exemption would need server-derived changed-file
161
+ * validation, not a name prefix.
162
+ */
163
+ export function isReviewGateExemptBranch(_branch: string): boolean {
164
+ return false
165
+ }
166
+
167
+ /**
168
+ * True when `extraction.headSha` covers `headSha` — an exact match, or
169
+ * `headSha` starting with `extraction.headSha` (the abbreviated-sha case:
170
+ * a verdict may bind against a 7-char prefix, and `headSha` itself is always
171
+ * the full 40-char form GitHub's API returns). `false` when the extraction
172
+ * carries no `headSha` at all (no `Judged head:` line was found) — an
173
+ * unbound verdict never counts as covering anything.
174
+ */
175
+ function isBoundToHead(extraction: { headSha: string | null }, headSha: string): boolean {
176
+ if (!extraction.headSha) return false
177
+ return headSha.toLowerCase().startsWith(extraction.headSha.toLowerCase())
178
+ }
179
+
180
+ /**
181
+ * True when the judged head and the current head carry the SAME patch — the
182
+ * verdict was cast on this exact set of changes, whatever sha now addresses
183
+ * it. Composed BESIDE `isBoundToHead`, never in place of it: sha binding
184
+ * still counts on its own, and this only widens what else counts.
185
+ *
186
+ * Fails closed on every uncertainty. `null` on either side is "git could not
187
+ * answer", not "they match" — a force-push that makes the judged head
188
+ * unreachable resolves to `null` and the verdict correctly stops counting.
189
+ */
190
+ function isBoundByPatchIdentity(
191
+ extraction: { headSha: string | null },
192
+ headSha: string,
193
+ patchIdOf?: (sha: string) => string | null
194
+ ): boolean {
195
+ if (patchIdOf === undefined || !extraction.headSha) return false
196
+ const judged = patchIdOf(extraction.headSha)
197
+ const current = patchIdOf(headSha)
198
+ if (judged === null || current === null) return false
199
+ return judged === current
200
+ }
201
+
202
+ /** A verdict covers the current head when its sha binds it, or its patch identity does. */
203
+ function isBoundToPatch(
204
+ extraction: { headSha: string | null },
205
+ headSha: string,
206
+ patchIdOf?: (sha: string) => string | null
207
+ ): boolean {
208
+ return isBoundToHead(extraction, headSha) || isBoundByPatchIdentity(extraction, headSha, patchIdOf)
209
+ }
210
+
211
+ /**
212
+ * `pass` when either (a) `vinaya/waiver:review` is present and actor-verified against
213
+ * `PRINCIPAL_ALLOWLIST`, or (b) both verdicts are clean AND bound — code-reviewer
214
+ * `APPROVE` (not `REQUEST_CHANGES`, not missing, not unclear) covering the PR's
215
+ * current `headSha` — by that sha, or by an equal patch identity when
216
+ * `patchIdOf` is supplied — and security-review `PASS` (not `FAIL`, not
217
+ * missing, not unclear) covering it too — AND every reported mechanical check-run for that
218
+ * same head is green, a `skipping`/`neutral` entry (a job whose own `if:`
219
+ * was false for this event) filtered out first as absent rather than
220
+ * counted either way (`mechanicalChecks`, after that filter, non-empty and
221
+ * every remaining entry's `bucket` is `"pass"`). `fail` otherwise, naming
222
+ * exactly which verdict(s) are not clean, not bound to the current head,
223
+ * which mechanical check(s) are not green, or that none have reported at
224
+ * all.
225
+ */
226
+ export function checkReviewGate(input: ReviewGateInput): ReviewGateResult {
227
+ const principalAllowlist = input.principalAllowlist ?? PRINCIPAL_ALLOWLIST
228
+ const waived = isWaiverLabelActorVerified({
229
+ label: WAIVER_LABEL_REVIEW,
230
+ labels: input.labels,
231
+ labelActor: input.waiverLabelActor,
232
+ principalAllowlist
233
+ })
234
+ if (waived) {
235
+ return {
236
+ verdict: 'pass',
237
+ reason: `\`${WAIVER_LABEL_REVIEW}\` label is actor-verified — review requirement waived for this PR.`,
238
+ waived: true
239
+ }
240
+ }
241
+
242
+ // A `skipping` (GitHub `conclusion: "skipped"` or `"neutral"`) check-run is
243
+ // ABSENT, never a failure (Issue #402 O4): a job whose own `if:` is false
244
+ // for this event still reports a check-run — `vinaya-review.yml`'s
245
+ // `retrigger-on-ci-green` job reports `skipped` on every ordinary
246
+ // `pull_request_target` run — and counting that as "not green" blocked
247
+ // every PR (first seen on PR #401: "vinaya review gate (retrigger on CI
248
+ // green) (skipping)"). Filtered out before both the emptiness check and
249
+ // the clean-check, so a head reporting only skipped/neutral runs reads as
250
+ // "nothing has reported yet", not as a false pass.
251
+ const reportedMechanicalChecks = input.mechanicalChecks.filter(
252
+ (c) => c.bucket !== 'skipping' && c.bucket !== 'neutral'
253
+ )
254
+ const mechanicalChecksClean =
255
+ reportedMechanicalChecks.length > 0 && reportedMechanicalChecks.every((c) => c.bucket === 'pass')
256
+
257
+ // Verdict-AUTHOR verification (security finding on PR #806): on a public
258
+ // repo any GitHub account can post a `VERDICT: APPROVE`-shaped comment, and
259
+ // most-recent-clear-hit-wins extraction would let a forged later APPROVE
260
+ // override a real earlier REQUEST CHANGES. Only comments whose author is on
261
+ // the same `PRINCIPAL_ALLOWLIST` the waiver's actor check already trusts
262
+ // participate in verdict extraction; everything else — unknown authors and
263
+ // unresolvable (`null`) ones alike — is IGNORED, never fatal, so a drive-by
264
+ // comment cannot brick evaluation, only fail to count. Dispatched reviewer
265
+ // agents post under the principal's own `gh` identity, so the legitimate
266
+ // flow is unchanged.
267
+ const verified = input.comments.filter((c) => isPrincipal(c.author, principalAllowlist))
268
+ // Count only VERDICT-shaped ignored comments — deployment bots and ordinary
269
+ // chat are also non-allowlisted, and counting them would imply forgery
270
+ // where there is only noise (review finding, PR #806).
271
+ const ignoredCount = input.comments.filter(
272
+ (c) => !isPrincipal(c.author, principalAllowlist) && c.body.includes('VERDICT')
273
+ ).length
274
+ const verifiedBodies = verified.map((c) => c.body)
275
+
276
+ const codeReview = extractCodeReviewVerdict(verifiedBodies)
277
+ const security = extractSecurityReviewVerdict(verifiedBodies)
278
+ const codeReviewClean = codeReview.value === 'APPROVE'
279
+ const securityClean = security.value === 'PASS'
280
+ const codeReviewBound = isBoundToPatch(codeReview, input.headSha, input.patchIdOf)
281
+ const securityBound = isBoundToPatch(security, input.headSha, input.patchIdOf)
282
+
283
+ if (codeReviewClean && codeReviewBound && securityClean && securityBound && mechanicalChecksClean) {
284
+ return {
285
+ verdict: 'pass',
286
+ reason: `code-reviewer verdict is a clean APPROVE and security-review verdict is a clean PASS, both covering head ${input.headSha}, and every reported mechanical check is green.`,
287
+ waived: false
288
+ }
289
+ }
290
+
291
+ const problems: string[] = []
292
+ if (!codeReviewClean) {
293
+ problems.push(`code-reviewer verdict is not a clean APPROVE (found: ${codeReview.value})`)
294
+ } else if (!codeReviewBound) {
295
+ problems.push(
296
+ `the newest code-review verdict covers ${codeReview.headSha ?? 'no recorded commit'}, head is ${input.headSha}`
297
+ )
298
+ }
299
+ if (!securityClean) {
300
+ problems.push(`security-review verdict is not a clean PASS (found: ${security.value})`)
301
+ } else if (!securityBound) {
302
+ problems.push(
303
+ `the newest security-review verdict covers ${security.headSha ?? 'no recorded commit'}, head is ${input.headSha}`
304
+ )
305
+ }
306
+ if (!mechanicalChecksClean) {
307
+ problems.push(
308
+ reportedMechanicalChecks.length === 0
309
+ ? 'no mechanical checks have reported for this head yet'
310
+ : `mechanical check(s) not green: ${reportedMechanicalChecks
311
+ .filter((c) => c.bucket !== 'pass')
312
+ .map((c) => `${c.name} (${c.bucket})`)
313
+ .join(', ')}`
314
+ )
315
+ }
316
+ const ignoredNote =
317
+ ignoredCount > 0
318
+ ? ` ${ignoredCount} verdict-shaped comment(s) from authors outside the principal allowlist were ignored.`
319
+ : ''
320
+
321
+ return {
322
+ verdict: 'fail',
323
+ reason: `${problems.join('; ')}. A principal can apply an actor-verified \`${WAIVER_LABEL_REVIEW}\` label to skip this requirement, or post the missing/clean verdict comment(s).${ignoredNote}`,
324
+ waived: false
325
+ }
326
+ }
327
+
328
+ /**
329
+ * Exported so a caller can cheaply pre-check the branch BEFORE paying for
330
+ * whatever it takes to resolve `expectedAuthor` — resolving that value is a
331
+ * network round-trip (`resolveReleaseActor(loadTrustAnchorConfig())`) that
332
+ * must not run on every ordinary PR just because it is one of three
333
+ * arguments to `isChangesetsReleasePr`. Found live (code review, PR #169):
334
+ * evaluating it inline as a function argument runs it unconditionally,
335
+ * regardless of branch, since JS evaluates arguments eagerly.
336
+ */
337
+ export const CHANGESET_RELEASE_BRANCH = 'changeset-release/main'
338
+
339
+ /**
340
+ * The stock Changesets flow's default identity — a release PR opened by
341
+ * `changesets/action` using the ambient `GITHUB_TOKEN` shows this as its
342
+ * author. Only a SANE DEFAULT for adopters who haven't configured
343
+ * `releaseActor` — never assume it matches any specific repo's real setup.
344
+ * A repo that opens release PRs with a custom PAT (this repo's own
345
+ * `RELEASE_TOKEN`, `.github/workflows/release.yml`) has a DIFFERENT real PR
346
+ * author (the token's owner) and must set `releaseActor` in its
347
+ * `vinaya.config.json`.
348
+ */
349
+ export const DEFAULT_RELEASE_ACTOR = 'github-actions[bot]'
350
+
351
+ /**
352
+ * True only for the Changesets release PR. `branch` and `author` must both
353
+ * match — `branch` alone is not a trust boundary, an attacker can push a
354
+ * branch literally called `changeset-release/main`. `expectedAuthor` is
355
+ * caller-resolved, never hardcoded here — this ships in the published
356
+ * `@attalabs/vinaya` package, and hardcoding any one identity would be
357
+ * correct for at most one adopter's release-token setup. Callers resolve it
358
+ * via `resolveReleaseActor(loadTrustAnchorConfig())`, falling back to
359
+ * `DEFAULT_RELEASE_ACTOR`.
360
+ *
361
+ * This predicate is pure and trust-agnostic about ITS OWN inputs — the
362
+ * caller is entirely responsible for where `branch`/`author` come from.
363
+ * `check-body-bare-digits.ts` is the one caller, and only reachable in
364
+ * production from `vinaya-body-checks.yml`'s `pull_request_target` job:
365
+ * `branch`/`author` there come from a live `gh pr view <PR_NUMBER>` fetch,
366
+ * with `PR_NUMBER` itself sourced from `github.event.pull_request.number` —
367
+ * an expression evaluated from the workflow file on the DEFAULT BRANCH, a
368
+ * pull request cannot edit that file to substitute a different literal
369
+ * (the same `pull_request_target` boundary `vinaya-review.yml` already
370
+ * uses). Found live (round 5, PR #165): the identical exemption on a plain
371
+ * `pull_request` trigger let an attacker redirect `PR_NUMBER`/`BRANCH` to
372
+ * any already-approved PR by the configured release actor — verified no
373
+ * env-var or git-state signal inside that trigger type is a safe anchor,
374
+ * which is why this predicate never runs there again.
375
+ */
376
+ export function isChangesetsReleasePr(branch: string, author: string | null, expectedAuthor: string): boolean {
377
+ return branch === CHANGESET_RELEASE_BRANCH && author === expectedAuthor
378
+ }
@@ -0,0 +1,200 @@
1
+ /**
2
+ * The review loop's own state, derived (review-convergence-v1 task 8, #381).
3
+ * Pure — no `fs`, no `fetch`, no `process.env`; the CLI shim
4
+ * (`apps/cli/src/commands/review-status.ts`) fetches the PR's comments, head
5
+ * and base through `gh` and hands them here.
6
+ *
7
+ * Why this exists: two PRs of this tranche took six and four review rounds,
8
+ * and every extra round traced to a sentence somebody wrote instead of a
9
+ * command somebody ran. "Is this loop still converging?" was one of those
10
+ * sentences. It is a function now: the four ways a loop stops converging are
11
+ * each a named `PAUSE` reason with the round that produced it, read off the
12
+ * PR's own comments.
13
+ *
14
+ * Every fact here is read through a parser that already exists. Verdict
15
+ * comments and their `Judged head:` binding come from `verdict-extraction.ts`
16
+ * — the same extractors `review-gate.ts` blocks merges with — so this command
17
+ * can never disagree with the gate about which comment cast a verdict or what
18
+ * head it judged. There is no second `Judged head:` regex in this file.
19
+ */
20
+
21
+ import { extractCodeReviewVerdict, extractSecurityReviewVerdict } from './verdict-extraction'
22
+ import { isPrincipal } from './waiver-label'
23
+
24
+ /**
25
+ * The Developer's per-round comment marker. `roles/developer.md`'s post-open
26
+ * sequence renders it into the one comment headed `Head: <sha>`; every gate
27
+ * that needs to know a Developer round happened reads THIS marker at a fixed
28
+ * position rather than scanning the comment's free text for a phrase.
29
+ */
30
+ const DEVELOPER_ROUND_MARKER = /<!--\s*aeg:developer:round-(\d+)\s*-->/i
31
+
32
+ /**
33
+ * A rendered finding line as `review-post.ts`'s `renderFindingsSection`
34
+ * writes it — `<n>. [SEVERITY] <location> — F<id> <class> <state>: <text>` —
35
+ * read for its id and its re-review state together. The id half deliberately
36
+ * mirrors that renderer's own output rather than inventing a second finding
37
+ * grammar; the state half is what round-over-round convergence is measured
38
+ * from, and no exported parser carries it.
39
+ */
40
+ const FINDING_LINE =
41
+ /^\d+\.\s+\[[A-Z]+\]\s+\S+\s+—\s+F(\d+)(?:\s+\S+)?(?:\s+(open|fix-claimed|reproduced|resolved))?:/gm
42
+
43
+ export type ReviewStatus =
44
+ | { state: 'CONTINUE' }
45
+ | { state: 'PAUSE'; reason: 'reappearance' | 'zero-deaths' | 'stale' | 'max-rounds'; id?: string; round: number }
46
+
47
+ export type ReviewStatusInput = {
48
+ comments: { body: string; author: string | null }[]
49
+ headSha: string
50
+ principalAllowlist: string[]
51
+ maxRounds: number
52
+ }
53
+
54
+ /**
55
+ * The round number a Developer round comment declares, or `null` when the
56
+ * body carries no marker at all. Fixed-position read: the marker is an HTML
57
+ * comment this repo's own doctrine renders, never a phrase inferred from the
58
+ * comment's prose.
59
+ */
60
+ export function parseDeveloperRoundMarker(body: string): number | null {
61
+ const m = body.match(DEVELOPER_ROUND_MARKER)
62
+ if (!m) return null
63
+ const n = Number(m[1])
64
+ return Number.isFinite(n) ? n : null
65
+ }
66
+
67
+ type VerdictComment = { judgedHead: string | null; ids: Map<string, string | null> }
68
+
69
+ function findingStates(body: string): Map<string, string | null> {
70
+ const out = new Map<string, string | null>()
71
+ for (const m of body.matchAll(FINDING_LINE)) {
72
+ const id = `F${m[1]}`
73
+ const state = m[2] ?? null
74
+ // A round that lists the same id twice keeps the first state it declared;
75
+ // a later id-less repeat must never erase a real `resolved`/`reproduced`.
76
+ if (!out.has(id) || out.get(id) === null) out.set(id, state)
77
+ }
78
+ return out
79
+ }
80
+
81
+ /**
82
+ * A comment counts as a verdict comment when either extractor parses it
83
+ * clean — the same "did this cast a verdict" test the merge gate applies,
84
+ * one body at a time so this function also learns WHICH body won.
85
+ */
86
+ function asVerdictComment(body: string): VerdictComment | null {
87
+ const code = extractCodeReviewVerdict([body])
88
+ const security = extractSecurityReviewVerdict([body])
89
+ if (code.danglingNote !== null && security.danglingNote !== null) return null
90
+ return { judgedHead: code.headSha ?? security.headSha, ids: findingStates(body) }
91
+ }
92
+
93
+ type Round = { judgedHead: string | null; ids: Map<string, string | null> }
94
+
95
+ /**
96
+ * One round per `Judged head:` value, in the order that head was first
97
+ * judged. A code-review verdict and a security verdict cast on the same head
98
+ * are one round, not two — which is why the grouping key is the judged head
99
+ * and not the comment count.
100
+ */
101
+ function groupRounds(verdicts: VerdictComment[]): Round[] {
102
+ const rounds: Round[] = []
103
+ for (const v of verdicts) {
104
+ const existing = rounds.find((r) => r.judgedHead === v.judgedHead)
105
+ const target = existing ?? { judgedHead: v.judgedHead, ids: new Map<string, string | null>() }
106
+ if (!existing) rounds.push(target)
107
+ for (const [id, state] of v.ids) {
108
+ if (!target.ids.has(id) || target.ids.get(id) === null) target.ids.set(id, state)
109
+ }
110
+ }
111
+ return rounds
112
+ }
113
+
114
+ /**
115
+ * `CONTINUE` means the loop is still converging; a `PAUSE` names the one
116
+ * reason it is not and the round that produced it. The four reasons are
117
+ * evaluated in the order they are declared on `ReviewStatus`, most specific
118
+ * first: a reappearance names an individual finding, zero-deaths names a
119
+ * round, staleness names the loop's own inactivity, and max-rounds is the
120
+ * count alone. The first that holds is reported — a paused loop needs one
121
+ * actionable reason, not a list.
122
+ */
123
+ export function deriveReviewStatus(input: ReviewStatusInput): ReviewStatus {
124
+ const verdicts: VerdictComment[] = []
125
+ let lastVerdictIndex = -1
126
+ input.comments.forEach((c, i) => {
127
+ if (!isPrincipal(c.author, input.principalAllowlist)) return
128
+ const v = asVerdictComment(c.body)
129
+ if (v === null) return
130
+ verdicts.push(v)
131
+ lastVerdictIndex = i
132
+ })
133
+ const rounds = groupRounds(verdicts)
134
+ if (rounds.length === 0) return { state: 'CONTINUE' }
135
+
136
+ // reappearance — an id this loop already called `resolved` comes back
137
+ // `reproduced` in the very next round. The loop is re-litigating, not
138
+ // converging.
139
+ for (let i = 1; i < rounds.length; i++) {
140
+ const prior = rounds[i - 1] as Round
141
+ const current = rounds[i] as Round
142
+ for (const [id, state] of current.ids) {
143
+ if (state === 'reproduced' && prior.ids.get(id) === 'resolved') {
144
+ return { state: 'PAUSE', reason: 'reappearance', id, round: i + 1 }
145
+ }
146
+ }
147
+ }
148
+
149
+ // zero-deaths — a round that kills nothing it inherited and still raises
150
+ // something new. Round one is excluded structurally: it inherits no ids, so
151
+ // "resolved none of them" is vacuously true there and would pause every
152
+ // loop at its first verdict.
153
+ for (let i = 1; i < rounds.length; i++) {
154
+ const current = rounds[i] as Round
155
+ const priorIds = new Set<string>()
156
+ for (let j = 0; j < i; j++) for (const id of (rounds[j] as Round).ids.keys()) priorIds.add(id)
157
+ if (priorIds.size === 0) continue
158
+ const killedOne = [...current.ids].some(([id, state]) => priorIds.has(id) && state === 'resolved')
159
+ const raisedNew = [...current.ids.keys()].some((id) => !priorIds.has(id))
160
+ if (!killedOne && raisedNew) return { state: 'PAUSE', reason: 'zero-deaths', round: i + 1 }
161
+ }
162
+
163
+ // stale — the newest verdict judged a head that is no longer the PR's, and
164
+ // no Developer round comment has landed since it. Nothing is moving: the
165
+ // reviewer is judging a superseded head and the Developer has not answered.
166
+ const newest = rounds[rounds.length - 1] as Round
167
+ const judged = newest.judgedHead
168
+ const boundToHead = judged !== null && (input.headSha.startsWith(judged) || judged.startsWith(input.headSha))
169
+ // Allowlist-filtered, exactly as the verdict scan above is: a round marker
170
+ // is an HTML comment any commenter can paste, and an unfiltered read would
171
+ // let a stranger's comment clear a `stale` pause the Developer never
172
+ // answered.
173
+ const developerAnswered = input.comments
174
+ .slice(lastVerdictIndex + 1)
175
+ .some((c) => isPrincipal(c.author, input.principalAllowlist) && parseDeveloperRoundMarker(c.body) !== null)
176
+ if (!boundToHead && !developerAnswered) {
177
+ return { state: 'PAUSE', reason: 'stale', round: rounds.length }
178
+ }
179
+
180
+ // max-rounds — the count alone. The loop may still be converging; it has
181
+ // simply run long enough that the Principal decides whether it continues.
182
+ if (rounds.length >= input.maxRounds) return { state: 'PAUSE', reason: 'max-rounds', round: rounds.length }
183
+
184
+ return { state: 'CONTINUE' }
185
+ }
186
+
187
+ /**
188
+ * The one-line rendering the CLI prints: `CONTINUE`, or `PAUSE: <reason>[ <id>]`
189
+ * — except `stale`, whose condition (the newest verdict's judged head is no
190
+ * longer the PR's, and no Developer round comment has answered it since) is
191
+ * exactly "a commit landed after the newest verdict, unacknowledged". Verdicts
192
+ * are the last forge event before merge (`roles/developer.md`), so that
193
+ * condition is rendered as the actionable fact it names rather than the bare
194
+ * reason word: `push after verdict — re-review required`.
195
+ */
196
+ export function renderReviewStatus(status: ReviewStatus): string {
197
+ if (status.state === 'CONTINUE') return 'CONTINUE'
198
+ if (status.reason === 'stale') return 'push after verdict — re-review required'
199
+ return status.id !== undefined ? `PAUSE: ${status.reason} ${status.id}` : `PAUSE: ${status.reason}`
200
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Single-plan-PR guard predicate (task 19 / #336). Extracted from
3
+ * `bin/open-pr.ts` (aeg-governance-hardening task 24, #364, Part 1) so the
4
+ * exact same implementation can be consumed by both the ring-0 local wrapper
5
+ * (`open-pr.ts`, prevention) and a ring-1 CI check (detection, for PRs opened
6
+ * via the web UI that bypass the wrapper entirely) — one implementation per
7
+ * fact (§11 constraint), never a second copy of this predicate.
8
+ *
9
+ * Pure — no `fs`, no `gh`/`git` I/O. Callers gather the facts (this branch's
10
+ * touched files, every other open PR's touched files) and pass them in.
11
+ */
12
+
13
+ /**
14
+ * Parses a tranche slug from a touched file path, when that path is an
15
+ * active (non-`completed/`) tranche topology file. Returns `null` for
16
+ * everything else — including `README.md` and `*.tokens.md`, neither of
17
+ * which is a topology file the single-plan-PR guard (below) cares about.
18
+ */
19
+ export function trancheSlugFromTopologyPath(path: string): string | null {
20
+ const m = path.match(/^aeg-root\/tranches\/([^/]+)\.md$/)
21
+ if (!m) return null
22
+ const slug = m[1] as string
23
+ if (slug === 'README' || slug.endsWith('.tokens')) return null
24
+ return slug
25
+ }
26
+
27
+ export type OpenPrFiles = { number: number; files: string[] }
28
+
29
+ /**
30
+ * Single-plan-PR guard (task 19 / #336): refuses a plan-branch diff
31
+ * that touches a tranche's topology file when another OPEN PR's diff
32
+ * already touches that SAME tranche's topology file. Ends the plan-PR
33
+ * race that produced two concurrent plan PRs for `aeg-governance-hardening`
34
+ * itself (#352/#354) — each cut from `origin/main` unaware of the other's
35
+ * newly-cut Issue.
36
+ *
37
+ * `branchFiles` is this branch's diff vs `origin/main` (or vs the PR's
38
+ * base); `otherOpenPrs` is every other currently-open PR's touched files
39
+ * (the caller excludes this PR's own number when editing). An ordinary
40
+ * Dormant where plans are forge objects: with a tranche held as a
41
+ * Milestone plus labeled Issues, no plan diff touches a topology file and
42
+ * this predicate has nothing to compare. It stays because it is still the
43
+ * right guard for a repo that keeps plans as files — a dormant check that
44
+ * says so is honest; one that reads as live protection is not.
45
+ *
46
+ * task-branch PR touches no topology file at all, so `branchFiles` yields
47
+ * no slugs and this passes trivially without even needing `otherOpenPrs`.
48
+ */
49
+ export function checkSinglePlanPr(
50
+ branchFiles: string[],
51
+ otherOpenPrs: OpenPrFiles[]
52
+ ): { ok: boolean; message?: string } {
53
+ const touchedSlugs = new Set(branchFiles.map(trancheSlugFromTopologyPath).filter((s): s is string => s !== null))
54
+ if (touchedSlugs.size === 0) return { ok: true }
55
+
56
+ for (const pr of otherOpenPrs) {
57
+ const otherSlugs = new Set(pr.files.map(trancheSlugFromTopologyPath).filter((s): s is string => s !== null))
58
+ for (const slug of touchedSlugs) {
59
+ if (otherSlugs.has(slug)) {
60
+ return {
61
+ ok: false,
62
+ message: `single-plan-pr: another open PR (#${pr.number}) already touches tranche "${slug}"'s topology file. Only one open plan PR per tranche is allowed at a time — wait for #${pr.number} to merge or close, or coordinate with its author.`
63
+ }
64
+ }
65
+ }
66
+ }
67
+ return { ok: true }
68
+ }
69
+
70
+ /** True when any of `files` touches an active tranche's topology file — i.e. this diff is a plan-PR diff. */
71
+ export function touchesAnyTopology(files: string[]): boolean {
72
+ return files.some((f) => trancheSlugFromTopologyPath(f) !== null)
73
+ }