@attalabs/vinaya 0.22.0 → 0.23.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 (188) hide show
  1. package/README.md +13 -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 +552 -139
  23. package/dist/checks/bin/check-branch-topology.js +603 -144
  24. package/dist/checks/bin/check-brief-shape.js +593 -142
  25. package/dist/checks/bin/check-changeset-coverage.js +935 -161
  26. package/dist/checks/bin/check-closes-n.js +603 -144
  27. package/dist/checks/bin/check-coherence.js +614 -147
  28. package/dist/checks/bin/check-dead-branch-push.js +552 -139
  29. package/dist/checks/bin/check-dispatch-readiness.js +612 -145
  30. package/dist/checks/bin/check-doc-coverage-push.js +689 -218
  31. package/dist/checks/bin/check-doc-coverage.js +689 -218
  32. package/dist/checks/bin/check-doctrine-no-procedures.js +4794 -0
  33. package/dist/checks/bin/check-doctrine-portability.js +684 -146
  34. package/dist/checks/bin/check-evidence-fresh.js +994 -174
  35. package/dist/checks/bin/check-exec-bits.js +4931 -0
  36. package/dist/checks/bin/check-first-push-dispatch.js +603 -144
  37. package/dist/checks/bin/check-issue-assignment.js +603 -144
  38. package/dist/checks/bin/check-main-branch-refusal.js +552 -139
  39. package/dist/checks/bin/check-no-disk-state.js +552 -139
  40. package/dist/checks/bin/check-pr-report-density.js +4461 -0
  41. package/dist/checks/bin/check-quoted-command.js +621 -155
  42. package/dist/checks/bin/check-reader-resolvable-prose.js +617 -154
  43. package/dist/checks/bin/check-registry-gates.js +595 -144
  44. package/dist/checks/bin/check-retired-vocabulary.js +617 -154
  45. package/dist/checks/bin/check-review-gate.js +670 -141
  46. package/dist/checks/bin/check-single-plan-pr.js +552 -139
  47. package/dist/checks/bin/check-test-plan.js +553 -140
  48. package/dist/checks/bin/check-token-collection-wired.js +552 -139
  49. package/dist/checks/bin/check-token-report.js +552 -139
  50. package/dist/checks/bin/check-workspace-escape.js +985 -145
  51. package/dist/index.js +8500 -5518
  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 +113 -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 +192 -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-validation.ts +511 -0
  60. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/claude-code-transcript.ts +460 -0
  61. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/coherence-checks.ts +787 -0
  62. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/dead-branch-push-audit.ts +55 -0
  63. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/dead-branch-push-guard.ts +77 -0
  64. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/derive-section7.ts +66 -0
  65. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/derive-tranche.ts +94 -0
  66. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/diagram-model.ts +372 -0
  67. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/direct-main-push.ts +39 -0
  68. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/dispatch-gate.ts +224 -0
  69. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/doc-owners.ts +314 -0
  70. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/docs/build-doc-nav.ts +38 -0
  71. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/docs/docs-coherence.ts +136 -0
  72. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/docs/index.ts +29 -0
  73. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/docs/legacy-anchors.ts +48 -0
  74. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/docs/nav-helpers.ts +17 -0
  75. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/docs/node-route.ts +134 -0
  76. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/docs/parse-doc.ts +43 -0
  77. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/docs/published-prose.ts +306 -0
  78. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/docs/surfaced-manifest.ts +89 -0
  79. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/docs/types.ts +33 -0
  80. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/doctrine-portability.ts +170 -0
  81. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/doctrine-source.ts +23 -0
  82. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/ensure-label.ts +28 -0
  83. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/file-classify.ts +51 -0
  84. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/first-push-dispatch-gate.ts +80 -0
  85. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/gate-audience.ts +168 -0
  86. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/index.ts +301 -0
  87. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/issue-assignment.ts +102 -0
  88. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/issue-validation.ts +746 -0
  89. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/leftover-detection.ts +68 -0
  90. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/local-anchor-coverage.ts +245 -0
  91. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/main-branch-refusal.ts +64 -0
  92. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/manifest-validity.ts +81 -0
  93. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/markdown-table.ts +76 -0
  94. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/metering-io-guard.ts +119 -0
  95. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/milestone-validation.ts +240 -0
  96. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/no-disk-state.ts +50 -0
  97. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/parse-ledger.ts +77 -0
  98. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/parse-registry.ts +105 -0
  99. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/parse-token-report.ts +165 -0
  100. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/parse-tranche.ts +183 -0
  101. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/pr-tier.ts +81 -0
  102. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/premise-check.ts +125 -0
  103. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/quoted-command.ts +275 -0
  104. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/reader-resolvable-prose.ts +320 -0
  105. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/registry-checks.ts +255 -0
  106. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/registry-parse.ts +164 -0
  107. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/registry-scaffold.ts +216 -0
  108. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/report-tokens.ts +261 -0
  109. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/retired-vocabulary.ts +163 -0
  110. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/review-gate.ts +269 -0
  111. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/single-plan-pr.ts +73 -0
  112. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/state-machine-model.ts +269 -0
  113. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/state-source.ts +14 -0
  114. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/status-block.ts +8 -0
  115. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/sum-ledger.ts +26 -0
  116. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/symbol-collisions.ts +78 -0
  117. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/test-plan-gate.ts +104 -0
  118. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/test-plan-section.ts +65 -0
  119. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/types.ts +174 -0
  120. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/verdict-extraction.ts +140 -0
  121. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/vocabulary-citation.ts +114 -0
  122. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/waiver-label.ts +52 -0
  123. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/workspace-escape.ts +172 -0
  124. package/studio-standalone/apps/vinaya-studio/web/.next/BUILD_ID +1 -1
  125. package/studio-standalone/apps/vinaya-studio/web/.next/build-manifest.json +3 -3
  126. package/studio-standalone/apps/vinaya-studio/web/.next/prerender-manifest.json +3 -3
  127. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_global-error.html +1 -1
  128. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_global-error.rsc +1 -1
  129. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
  130. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  131. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  132. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  133. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  134. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_not-found/page/server-reference-manifest.json +2 -2
  135. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_not-found/page.js.nft.json +1 -1
  136. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  137. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/api/coherence/route.js.nft.json +1 -1
  138. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/page/server-reference-manifest.json +2 -2
  139. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/page.js.nft.json +1 -1
  140. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/page_client-reference-manifest.js +1 -1
  141. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/backlog/page/server-reference-manifest.json +2 -2
  142. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/backlog/page.js.nft.json +1 -1
  143. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/backlog/page_client-reference-manifest.js +1 -1
  144. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/page/server-reference-manifest.json +2 -2
  145. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/page.js.nft.json +1 -1
  146. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/page_client-reference-manifest.js +1 -1
  147. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/page/server-reference-manifest.json +2 -2
  148. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/page.js.nft.json +1 -1
  149. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/page_client-reference-manifest.js +1 -1
  150. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/tranches/[slug]/page/server-reference-manifest.json +2 -2
  151. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/tranches/[slug]/page.js.nft.json +1 -1
  152. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/tranches/[slug]/page_client-reference-manifest.js +1 -1
  153. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/tranches/[slug]/tasks/[taskId]/page/server-reference-manifest.json +2 -2
  154. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/tranches/[slug]/tasks/[taskId]/page.js.nft.json +1 -1
  155. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/tranches/[slug]/tasks/[taskId]/page_client-reference-manifest.js +1 -1
  156. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/page/server-reference-manifest.json +2 -2
  157. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/page.js.nft.json +1 -1
  158. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/page_client-reference-manifest.js +1 -1
  159. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/tranches/page/server-reference-manifest.json +2 -2
  160. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/tranches/page.js.nft.json +1 -1
  161. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/tranches/page_client-reference-manifest.js +1 -1
  162. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/[root-of-the-server]__02a-3g8._.js +1 -1
  163. 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
  164. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/[root-of-the-server]__0053k9k._.js +1 -1
  165. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/[root-of-the-server]__0112h-k._.js +1 -1
  166. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/[root-of-the-server]__0o771t1._.js +1 -1
  167. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/[root-of-the-server]__0puovz5._.js +1 -1
  168. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/[root-of-the-server]__1hs0dcu._.js +1 -1
  169. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/{[root-of-the-server]__069-_41._.js → [root-of-the-server]__1mcitst._.js} +1 -1
  170. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/[root-of-the-server]__1wc4-ip._.js +1 -1
  171. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/_03x_w6q._.js +1 -1
  172. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/_0gvm3og._.js +1 -1
  173. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/_0lwxg63._.js +1 -1
  174. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/_1fqw88f._.js +1 -1
  175. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/_1n0cnq-._.js +1 -1
  176. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/node_modules_1vo08dj._.js +2 -2
  177. package/studio-standalone/apps/vinaya-studio/web/.next/server/middleware-build-manifest.js +3 -3
  178. package/studio-standalone/apps/vinaya-studio/web/.next/server/pages/500.html +1 -1
  179. package/studio-standalone/apps/vinaya-studio/web/.next/server/server-reference-manifest.js +1 -1
  180. package/studio-standalone/apps/vinaya-studio/web/.next/server/server-reference-manifest.json +3 -3
  181. package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/{37hnluhzj9bei.js → 0jer_drkl1e4r.js} +4 -4
  182. package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/{267o-tsrnuwnv.js → 20x-t0a0v129z.js} +1 -1
  183. package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/{1hufpuody6vjv.js → 2ou_81ahqw31e.js} +1 -1
  184. package/studio-standalone/apps/vinaya-studio/web/package.json +2 -2
  185. /package/studio-standalone/_node_modules/@attalabs/vinaya/studio-standalone/_node_modules/@attalabs/{aeg-core → vinaya/studio-standalone/_node_modules/@attalabs/aeg-core}/bin/verify-coherence.ts +0 -0
  186. /package/studio-standalone/apps/vinaya-studio/web/.next/static/{Fmyc42qzLwFcWWHLLNy-G → 5xy_GA7G5KRuE3HDumIiw}/_buildManifest.js +0 -0
  187. /package/studio-standalone/apps/vinaya-studio/web/.next/static/{Fmyc42qzLwFcWWHLLNy-G → 5xy_GA7G5KRuE3HDumIiw}/_clientMiddlewareManifest.js +0 -0
  188. /package/studio-standalone/apps/vinaya-studio/web/.next/static/{Fmyc42qzLwFcWWHLLNy-G → 5xy_GA7G5KRuE3HDumIiw}/_ssgManifest.js +0 -0
@@ -0,0 +1,787 @@
1
+ /**
2
+ * verify-coherence pure check evaluators — deterministic plan↔forge coherence
3
+ * oracle logic (A1/A2/A3, T1/T2/T3, D1, L1/L2/L3/L4, closes-N)..
4
+ *
5
+ * Pure — no `fs`, no `fetch`, no `process.env`. All forge facts and tranche
6
+ * topology are injected by the caller (`bin/verify-coherence.ts`, the I/O shim).
7
+ */
8
+
9
+ import { trancheLabel, label } from '@attalabs/aeg-forge-state'
10
+ import { anchoredRegion, stripCode } from './anchored-region'
11
+ import { checkIssueRationale, checkProjectsRegistered, isTaskIssueLabelSet } from './issue-validation'
12
+ import { isPrincipal, PRINCIPAL_ALLOWLIST } from './waiver-label'
13
+ import type { ForgeIssue, TaskIssueRef } from '@attalabs/aeg-types'
14
+ import type { ForgeFacts, Tranche, Task } from './types'
15
+
16
+ // ---------- grandfather cutoff -----------------------------------------------
17
+
18
+ /**
19
+ * Incoherences whose terminal forge event predates this date are grandfathered:
20
+ * emitted as `status: "info"` (visible in the report) rather than `"fail"`
21
+ * (which blocks CI). Applies to A1/A2/A3/T3.
22
+ *
23
+ * Rationale: pre-existing repo-wide debt from tranches that predate the
24
+ * cutoff can't be retro-fixed, so a hard gate on those findings would make
25
+ * every new PR un-mergeable.
26
+ */
27
+ export const COHERENCE_ENFORCED_FROM = '2026-07-01'
28
+
29
+ /** True when `isoDate` is an ISO string whose date portion is strictly before `COHERENCE_ENFORCED_FROM`. */
30
+ export function isGrandfathered(isoDate: string | null | undefined): boolean {
31
+ if (!isoDate) return false
32
+ return isoDate.slice(0, 10) < COHERENCE_ENFORCED_FROM
33
+ }
34
+
35
+ /**
36
+ * R1 grandfather — explicit, data-declared Issue numbers whose body predates
37
+ * the rationale grammar (or predates R1 enforcement) and is therefore
38
+ * exempted from blocking. Unlike A1/A2/A3/T3's date-based cutoff, an Issue
39
+ * body carries no reliable "authored under which grammar" timestamp, so this
40
+ * is an explicit number set rather than a date proxy — populated once, at
41
+ * `aeg-governance-hardening` task 1 implementation time, with exactly the
42
+ * active-tranche task Issues that failed `checkIssueRationale` against the
43
+ * live forge (see the task's PR body for the list + counts).
44
+ *
45
+ * New/edited task Issues are already gated at ring 0 (`bin/open-issue.ts`,
46
+ *) — this list is visible debt for the pre-gate stock, not a standing
47
+ * exemption mechanism. Do not add to it going forward; fix the Issue body
48
+ * instead (the planner-brief rationale contract).
49
+ */
50
+ export const R1_GRANDFATHERED_ISSUES: ReadonlySet<number> = new Set([279, 280, 281, 282])
51
+
52
+ // ---------- types -------------------------------------------------------------
53
+
54
+ export type CheckFailure = {
55
+ issue?: number | null
56
+ tranche: string
57
+ task?: string
58
+ reason: string
59
+ /** True when the terminal event predates `COHERENCE_ENFORCED_FROM` — finding is info, not fail. */
60
+ grandfathered?: boolean
61
+ }
62
+
63
+ export type CheckResult = {
64
+ check: string
65
+ status: 'pass' | 'fail' | 'info'
66
+ failures: CheckFailure[]
67
+ note?: string
68
+ }
69
+
70
+ export type TaskEntry = {
71
+ trancheSlug: string
72
+ archived: boolean
73
+ task: Task
74
+ /** `undefined` when the forge was unavailable or the issue didn't exist. */
75
+ facts: ForgeFacts | undefined
76
+ }
77
+
78
+ export type TrancheFile = {
79
+ slug: string
80
+ archived: boolean
81
+ tranche: Tranche
82
+ }
83
+
84
+ // ---------- pure check evaluators --------------------------------------------
85
+
86
+ /**
87
+ * A1: Every closed task-Issue has a merged closing PR — OR was hand-closed
88
+ * directly by a recognized Principal (task `vinaya-engine-v1` 21, #99): a
89
+ * second, narrower "done" path for a dependency Issue whose technical
90
+ * premise dissolved, closed with a stated `COMPLETED` reason rather than via
91
+ * a merge. Every condition is a real forge fact (who performed the
92
+ * CLOSED_EVENT, GitHub's own close reason), never a prose claim in a
93
+ * comment — the trap this task exists to avoid is "any closed Issue with a
94
+ * comment counts."
95
+ * Fail class: `closed-without-merge`
96
+ * Terminal event date: `issueClosedAt` — grandfathered when before `COHERENCE_ENFORCED_FROM`.
97
+ *
98
+ * Excludes `stateReason: 'not_planned'`: a task closed that way with no merged
99
+ * PR is `dropped` — legitimately abandoned, never done, never `todo` — a valid
100
+ * terminal state, not a coherence failure. `stateReason: 'completed'` (or
101
+ * `null`) with no merged PR and no recognized hand-close stays flagged: that
102
+ * is done-but-unprovable, or a broken close, exactly the class this check
103
+ * exists to catch.
104
+ */
105
+ export function checkA1(entries: TaskEntry[], principalAllowlist: string[] = PRINCIPAL_ALLOWLIST): CheckResult {
106
+ const failures: CheckFailure[] = []
107
+ for (const e of entries) {
108
+ if (!e.facts) continue
109
+ if (e.facts.stateReason === 'not_planned') continue
110
+ if (e.facts.issueState === 'closed' && e.facts.prState !== 'merged') {
111
+ const handClosed = e.facts.stateReason === 'completed' && isPrincipal(e.facts.closedByActor, principalAllowlist)
112
+ if (handClosed) continue
113
+ failures.push({
114
+ issue: e.task.issue,
115
+ tranche: e.trancheSlug,
116
+ task: e.task.id,
117
+ reason: `Issue closed but closing PR is not merged (prState: ${e.facts.prState})`,
118
+ grandfathered: isGrandfathered(e.facts.closedAt)
119
+ })
120
+ }
121
+ }
122
+ const activeFails = failures.filter((f) => !f.grandfathered)
123
+ const status = activeFails.length > 0 ? 'fail' : failures.length > 0 ? 'info' : 'pass'
124
+ return {
125
+ check: 'A1',
126
+ status,
127
+ failures,
128
+ note: status === 'info' ? `${failures.length} grandfathered (pre-${COHERENCE_ENFORCED_FROM})` : undefined
129
+ }
130
+ }
131
+
132
+ /**
133
+ * A2: The closing PR of each closed task-Issue carries an Archivist provenance block.
134
+ * Fail class: `archived-without-provenance`
135
+ * Terminal event date: `prMergedAt` — grandfathered when before `COHERENCE_ENFORCED_FROM`.
136
+ *
137
+ * `hasProvenanceByKey`: Map keyed by `${trancheSlug}/${taskId}` → true when the
138
+ * closing PR has a comment containing `### AEG provenance`.
139
+ */
140
+ export function checkA2(entries: TaskEntry[], hasProvenanceByKey: Map<string, boolean>): CheckResult {
141
+ const failures: CheckFailure[] = []
142
+ for (const e of entries) {
143
+ if (!e.facts) continue
144
+ // Only check tasks whose issue is closed AND whose closing PR merged.
145
+ if (e.facts.issueState !== 'closed' || e.facts.prState !== 'merged') continue
146
+ const key = `${e.trancheSlug}/${e.task.id}`
147
+ const hasProvenance = hasProvenanceByKey.get(key)
148
+ // If key is absent from the map we were unable to fetch (forge error); skip.
149
+ if (hasProvenance === undefined) continue
150
+ if (!hasProvenance) {
151
+ failures.push({
152
+ issue: e.task.issue,
153
+ tranche: e.trancheSlug,
154
+ task: e.task.id,
155
+ reason: 'Closing PR has no `### AEG provenance` comment (Archivist close-out missing)',
156
+ grandfathered: isGrandfathered(e.facts.mergedAt)
157
+ })
158
+ }
159
+ }
160
+ const activeFails = failures.filter((f) => !f.grandfathered)
161
+ const status = activeFails.length > 0 ? 'fail' : failures.length > 0 ? 'info' : 'pass'
162
+ return {
163
+ check: 'A2',
164
+ status,
165
+ failures,
166
+ note: status === 'info' ? `${failures.length} grandfathered (pre-${COHERENCE_ENFORCED_FROM})` : undefined
167
+ }
168
+ }
169
+
170
+ /**
171
+ * A3: Every Issue whose closing PR merged is itself closed.
172
+ * Fail class: `auto-close-misfire` — the headline check (#174 class).
173
+ * Terminal event date: `prMergedAt` — grandfathered when before `COHERENCE_ENFORCED_FROM`.
174
+ */
175
+ export function checkA3(entries: TaskEntry[]): CheckResult {
176
+ const failures: CheckFailure[] = []
177
+ for (const e of entries) {
178
+ if (!e.facts) continue
179
+ if (e.facts.prState === 'merged' && e.facts.issueState !== 'closed') {
180
+ failures.push({
181
+ issue: e.task.issue,
182
+ tranche: e.trancheSlug,
183
+ task: e.task.id,
184
+ reason: 'Closing PR is merged but Issue is still open (GitHub auto-close misfire)',
185
+ grandfathered: isGrandfathered(e.facts.mergedAt)
186
+ })
187
+ }
188
+ }
189
+ const activeFails = failures.filter((f) => !f.grandfathered)
190
+ const status = activeFails.length > 0 ? 'fail' : failures.length > 0 ? 'info' : 'pass'
191
+ return {
192
+ check: 'A3',
193
+ status,
194
+ failures,
195
+ note: status === 'info' ? `${failures.length} grandfathered (pre-${COHERENCE_ENFORCED_FROM})` : undefined
196
+ }
197
+ }
198
+
199
+ /**
200
+ * T1: Every topology row's Issue ref resolves to a real Issue.
201
+ * Fail class: `phantom-issue-ref`
202
+ *
203
+ * A task has a non-null issue number in the topology but is absent from the
204
+ * forge facts map → the issue doesn't exist on GitHub.
205
+ */
206
+ export function checkT1(entries: TaskEntry[]): CheckResult {
207
+ const failures: CheckFailure[] = []
208
+ for (const e of entries) {
209
+ // Skip: issue was null in topology (TBD or empty); T3 handles that.
210
+ if (e.task.issue === null) continue
211
+ // facts === undefined and issue !== null → forge query returned nothing for this issue
212
+ if (e.facts === undefined) {
213
+ failures.push({
214
+ issue: e.task.issue,
215
+ tranche: e.trancheSlug,
216
+ task: e.task.id,
217
+ reason: `Issue #${e.task.issue} in topology does not resolve to a real GitHub Issue`
218
+ })
219
+ }
220
+ }
221
+ return { check: 'T1', status: failures.length > 0 ? 'fail' : 'pass', failures }
222
+ }
223
+
224
+ /**
225
+ * T2: Every open Issue labeled `vinaya/tranche:X` appears in X's topology file.
226
+ * Fail class: `orphan-task`
227
+ *
228
+ * `openIssuesBySlug`: Map from active tranche slug → list of open issue
229
+ * numbers fetched from the forge with that `vinaya/tranche:` label.
230
+ * `topologyIssuesBySlug`: Map from slug → Set of issue numbers in the topology.
231
+ *
232
+ * `ciTrancheSlug`: when set (parsed from `BRANCH`/`GITHUB_HEAD_REF` env),
233
+ * only the tranche matching that slug is checked — prevents a coherence
234
+ * gap in one tranche's topology (e.g. a Planner plan-PR mid-flight) from
235
+ * blocking CI on an unrelated PR against a different tranche. Mirrors
236
+ * `checkT3`'s `ciTrancheSlug` parameter exactly.
237
+ */
238
+ export function checkT2(
239
+ openIssuesBySlug: Map<string, number[]>,
240
+ topologyIssuesBySlug: Map<string, Set<number>>,
241
+ ciTrancheSlug?: string | null
242
+ ): CheckResult {
243
+ const failures: CheckFailure[] = []
244
+ for (const [slug, openNums] of openIssuesBySlug) {
245
+ if (ciTrancheSlug && slug !== ciTrancheSlug) continue
246
+
247
+ const topologySet = topologyIssuesBySlug.get(slug) ?? new Set<number>()
248
+ for (const num of openNums) {
249
+ if (!topologySet.has(num)) {
250
+ failures.push({
251
+ issue: num,
252
+ tranche: slug,
253
+ reason: `Issue #${num} is open and labeled ${trancheLabel(slug)} but does not appear in the topology file`
254
+ })
255
+ }
256
+ }
257
+ }
258
+ return { check: 'T2', status: failures.length > 0 ? 'fail' : 'pass', failures }
259
+ }
260
+
261
+ /**
262
+ * T2 point-of-power relocation (aeg-governance-hardening task 24, #364,
263
+ * Part 2; supersedes half of task 19's T2-in-task-PR-CI placement).
264
+ * A gate may only red a PR that could cause or cure the violation it
265
+ * reports — live incident #363 (2026-07-04): registering Issues #364/#365
266
+ * correctly reddened same-tranche task PR #363's CI, which could neither
267
+ * have caused nor fixed the topology gap. `checkT2`'s own assertion logic
268
+ * (above) is untouched; this only demotes its CI-blocking status when the
269
+ * current PR is NOT a plan PR (i.e. its diff doesn't touch a tranche
270
+ * topology file) — the only PR kind that can actually close a T2 gap. The
271
+ * underlying findings stay visible (`status: 'info'`, never omitted) for
272
+ * every other context: task-PR CI, `--json`/audit mode, and daily-drift.
273
+ */
274
+ export function scopeT2ToPlanPr(result: CheckResult, isPlanPr: boolean): CheckResult {
275
+ if (isPlanPr || result.status !== 'fail') return result
276
+ return {
277
+ ...result,
278
+ status: 'info',
279
+ note:
280
+ result.note ??
281
+ 'T2 findings are non-blocking outside plan PRs (point-of-power principle) — see aeg-root/enforcement.md.'
282
+ }
283
+ }
284
+
285
+ /**
286
+ * T3: No `#TBD` rows in an active tranche.
287
+ * Fail class: `tbd-in-active-tranche`
288
+ *
289
+ * A task in an active tranche has a null issue ref (empty / `—` / `#TBD`).
290
+ *
291
+ * `ciTrancheSlug`: when set (parsed from `BRANCH`/`GITHUB_HEAD_REF` env),
292
+ * only tasks in THAT tranche are checked — prevents legacy `#TBD` rows in
293
+ * an unrelated tranche from blocking this PR.
294
+ *
295
+ * `enrichedEntries`: when provided (post-forge-fetch), used to determine if a
296
+ * tranche predates `COHERENCE_ENFORCED_FROM` by proxy: if the tranche has
297
+ * any task whose `closedAt` or `mergedAt` is pre-cutoff, its #TBD rows are
298
+ * grandfathered as `info`.
299
+ *
300
+ * `forgeUnavailableSlugs`: tranche slugs whose forge snapshot fetch failed
301
+ * entirely (the caller couldn't fetch `closedAt`/`mergedAt` for ANY task in
302
+ * that tranche). A `#TBD` row in one of these tranches cannot be evaluated
303
+ * against the grandfather proxy at all — treating it as `grandfathered: false`
304
+ * would silently fail it purely because of a forge outage, not because it's
305
+ * genuinely un-grandfathered. Such rows are reported `grandfathered: true`
306
+ * with a distinct reason so they never produce a `fail`, but remain visible.
307
+ */
308
+ export function checkT3(
309
+ entries: TaskEntry[],
310
+ ciTrancheSlug?: string | null,
311
+ enrichedEntries?: TaskEntry[],
312
+ forgeUnavailableSlugs?: Set<string>
313
+ ): CheckResult {
314
+ // Build set of tranches that are pre-enforcement (by proxy: any task with a pre-cutoff date).
315
+ const preEnforcement = new Set<string>()
316
+ if (enrichedEntries) {
317
+ for (const e of enrichedEntries) {
318
+ if (isGrandfathered(e.facts?.closedAt) || isGrandfathered(e.facts?.mergedAt)) {
319
+ preEnforcement.add(e.trancheSlug)
320
+ }
321
+ }
322
+ }
323
+
324
+ const failures: CheckFailure[] = []
325
+ for (const e of entries) {
326
+ if (!e.archived && e.task.issue === null) {
327
+ // Branch-scope: in CI for a specific tranche, only check that tranche.
328
+ if (ciTrancheSlug && e.trancheSlug !== ciTrancheSlug) continue
329
+
330
+ if (forgeUnavailableSlugs?.has(e.trancheSlug)) {
331
+ failures.push({
332
+ issue: null,
333
+ tranche: e.trancheSlug,
334
+ task: e.task.id,
335
+ reason: `Task ${e.task.id} in active tranche has no Issue ref (#TBD or empty), but forge data for tranche "${e.trancheSlug}" was unavailable — cannot evaluate grandfather status, not silently failed`,
336
+ grandfathered: true
337
+ })
338
+ continue
339
+ }
340
+
341
+ failures.push({
342
+ issue: null,
343
+ tranche: e.trancheSlug,
344
+ task: e.task.id,
345
+ reason: `Task ${e.task.id} in active tranche has no Issue ref (#TBD or empty) — the model requires all active tasks to have Issue numbers`,
346
+ grandfathered: preEnforcement.has(e.trancheSlug)
347
+ })
348
+ }
349
+ }
350
+ const activeFails = failures.filter((f) => !f.grandfathered)
351
+ const status = activeFails.length > 0 ? 'fail' : failures.length > 0 ? 'info' : 'pass'
352
+ return {
353
+ check: 'T3',
354
+ status,
355
+ failures,
356
+ note: status === 'info' ? `${failures.length} grandfathered (pre-${COHERENCE_ENFORCED_FROM})` : undefined
357
+ }
358
+ }
359
+
360
+ /**
361
+ * D1: A task with an open PR has all `depends-on` Issues closed.
362
+ * Fail class: `dispatched-on-unmet-deps`
363
+ *
364
+ * `issueToEntry`: Map from issue number → TaskEntry, for resolving `#NNN`
365
+ * style depends-on refs in addition to task-ID style refs.
366
+ * `taskToEntry`: Map from `${slug}/${taskId}` → TaskEntry for same-tranche refs.
367
+ */
368
+ export function checkD1(
369
+ entries: TaskEntry[],
370
+ issueToEntry: Map<number, TaskEntry>,
371
+ taskToEntry: Map<string, TaskEntry>
372
+ ): CheckResult {
373
+ const failures: CheckFailure[] = []
374
+ for (const e of entries) {
375
+ if (!e.facts) continue
376
+ if (e.facts.prState !== 'open') continue
377
+
378
+ for (const dep of e.task.dependsOn) {
379
+ const depEntry = resolveDepEntry(dep, e.trancheSlug, issueToEntry, taskToEntry)
380
+ if (!depEntry) continue // unknown dep — not a D1 concern
381
+
382
+ const depFacts = depEntry.facts
383
+ const depClosed = depFacts?.issueState === 'closed'
384
+ if (!depClosed) {
385
+ failures.push({
386
+ issue: e.task.issue,
387
+ tranche: e.trancheSlug,
388
+ task: e.task.id,
389
+ reason: `Task has open PR but depends-on ${dep} (issue #${depEntry.task.issue ?? '?'}) is not closed`
390
+ })
391
+ }
392
+ }
393
+ }
394
+ return { check: 'D1', status: failures.length > 0 ? 'fail' : 'pass', failures }
395
+ }
396
+
397
+ function resolveDepEntry(
398
+ dep: string,
399
+ trancheSlug: string,
400
+ issueToEntry: Map<number, TaskEntry>,
401
+ taskToEntry: Map<string, TaskEntry>
402
+ ): TaskEntry | undefined {
403
+ // `#NNN` style — resolve by issue number
404
+ const issueMatch = dep.match(/^#(\d+)$/)
405
+ if (issueMatch?.[1]) return issueToEntry.get(Number(issueMatch[1]))
406
+ // Task-ID style — resolve within same tranche first, then globally
407
+ return taskToEntry.get(`${trancheSlug}/${dep}`) ?? taskToEntry.get(dep)
408
+ }
409
+
410
+ /**
411
+ * `ForgeIssue` lives in `@attalabs/aeg-types` (aeg-core-purity fix, #521) —
412
+ * re-exported here since every existing call site imports it from
413
+ * `@attalabs/aeg-core`.
414
+ */
415
+ export type { ForgeIssue }
416
+
417
+ /**
418
+ * R1: Every active-tranche task Issue's body carries the full eight-field
419
+ * Planner's rationale (`aeg-root/contracts/planner-brief.md`), and every
420
+ * project its `Project:` field names has a `.vinaya/projects.md` row.
421
+ * Fail class: `missing-rationale-field`
422
+ *
423
+ * Presence-only — delegates entirely to `checkIssueRationale` and
424
+ * `checkProjectsRegistered` (the same evaluators `bin/open-issue.ts` enforces
425
+ * at ring 0 on new/edited Issues). This is the ring-1/2 half: continuous
426
+ * re-checking of the stock, which is what catches an Issue edited by an
427
+ * ungated writer (the GitHub web UI, a raw API call) or one that predates the
428
+ * gate. One grammar, one parser — this function re-implements neither.
429
+ *
430
+ * `issuesBySlug`: open Issues per active tranche slug, from the same
431
+ * batched label-scoped query T2 uses (`fetchOpenIssuesByLabel`), extended to
432
+ * carry `body` + `labels`.
433
+ * `grandfatheredIssues`: `R1_GRANDFATHERED_ISSUES` — pre- stock,
434
+ * reported as `info`, never `fail`.
435
+ * `registeredNames`: the registry's project names, read by the caller
436
+ * (aeg-core is pure). Defaults to `[]`, which leaves the registry half
437
+ * dormant — so a caller that has no registry to hand keeps R1's prior
438
+ * behaviour exactly.
439
+ */
440
+ export function checkR1(
441
+ issuesBySlug: Map<string, ForgeIssue[]>,
442
+ grandfatheredIssues: ReadonlySet<number>,
443
+ registeredNames: string[] = []
444
+ ): CheckResult {
445
+ const failures: CheckFailure[] = []
446
+ for (const [slug, issues] of issuesBySlug) {
447
+ for (const issue of issues) {
448
+ if (!isTaskIssueLabelSet(issue.labels)) continue
449
+ const errors = [
450
+ ...checkIssueRationale(issue.body).errors,
451
+ ...checkProjectsRegistered(issue.body, issue.labels, registeredNames).errors
452
+ ]
453
+ if (errors.length === 0) continue
454
+ failures.push({
455
+ issue: issue.number,
456
+ tranche: slug,
457
+ reason: `Issue #${issue.number} fails the rationale gate: ${errors.join(' | ')}`,
458
+ grandfathered: grandfatheredIssues.has(issue.number)
459
+ })
460
+ }
461
+ }
462
+ const activeFails = failures.filter((f) => !f.grandfathered)
463
+ const status = activeFails.length > 0 ? 'fail' : failures.length > 0 ? 'info' : 'pass'
464
+ return {
465
+ check: 'R1',
466
+ status,
467
+ failures,
468
+ note:
469
+ status === 'info'
470
+ ? `${failures.length} grandfathered task Issue(s) predate this check's grammar (rationale fields and/or the project registry)`
471
+ : undefined
472
+ }
473
+ }
474
+
475
+ /**
476
+ * L1: Active tranche with zero open task-Issues → should be archived.
477
+ * **Advisory (info-only)** per `state-machine.md` §12 (L1/L2 are lifecycle-hygiene
478
+ * signals, not the done-lifecycle gate). Findings are surfaced for a human to
479
+ * investigate; they never fail CI. Only A1/A2/A3/M1/M3/L5 block (L5 promoted, vinaya-milestone-model-v1 task 1).
480
+ *
481
+ * An active tranche (file not in completed/) where every task with a
482
+ * known issue has `issueState === 'closed'`.
483
+ */
484
+ export function checkL1(files: TrancheFile[], entriesBySlug: Map<string, TaskEntry[]>): CheckResult {
485
+ const failures: CheckFailure[] = []
486
+ for (const f of files) {
487
+ if (f.archived) continue
488
+ const entries = entriesBySlug.get(f.slug) ?? []
489
+ const withFacts = entries.filter((e) => e.facts !== undefined)
490
+ if (withFacts.length === 0) continue // forge unavailable or no tasks with issues
491
+ const allClosed = withFacts.every((e) => e.facts?.issueState === 'closed')
492
+ if (allClosed) {
493
+ failures.push({
494
+ tranche: f.slug,
495
+ reason: 'Active tranche has no open task-Issues — consider archiving to completed/'
496
+ })
497
+ }
498
+ }
499
+ return {
500
+ check: 'L1',
501
+ status: 'info',
502
+ failures,
503
+ note:
504
+ failures.length > 0
505
+ ? `${failures.length} active tranche(s) with no open task-Issues — consider archiving (advisory)`
506
+ : undefined
507
+ }
508
+ }
509
+
510
+ /**
511
+ * L2: Archived tranche with any open task-Issue → premature archive.
512
+ * **Advisory (info-only)** per `state-machine.md` §12 (L1/L2 are lifecycle-hygiene
513
+ * signals, not the done-lifecycle gate). Findings are surfaced for a human to
514
+ * investigate; they never fail CI. Only A1/A2/A3/M1/M3/L5 block (L5 promoted, vinaya-milestone-model-v1 task 1).
515
+ */
516
+ export function checkL2(files: TrancheFile[], entriesBySlug: Map<string, TaskEntry[]>): CheckResult {
517
+ const failures: CheckFailure[] = []
518
+ for (const f of files) {
519
+ if (!f.archived) continue
520
+ const entries = entriesBySlug.get(f.slug) ?? []
521
+ for (const e of entries) {
522
+ if (!e.facts) continue
523
+ if (e.facts.issueState === 'open') {
524
+ failures.push({
525
+ issue: e.task.issue,
526
+ tranche: f.slug,
527
+ task: e.task.id,
528
+ reason: `Archived tranche has open task-Issue #${e.task.issue ?? '?'} (premature archive)`
529
+ })
530
+ }
531
+ }
532
+ }
533
+ return {
534
+ check: 'L2',
535
+ status: 'info',
536
+ failures,
537
+ note:
538
+ failures.length > 0
539
+ ? `${failures.length} archived tranche(s) with an open task-Issue — investigate (advisory)`
540
+ : undefined
541
+ }
542
+ }
543
+
544
+ /**
545
+ * L3: Count of active tranches — informational only, does not affect exit code.
546
+ */
547
+ export function checkL3(files: TrancheFile[]): CheckResult {
548
+ const active = files.filter((f) => !f.archived)
549
+ return {
550
+ check: 'L3',
551
+ status: 'info',
552
+ failures: [],
553
+ note: `${active.length} active tranche(s): ${active.map((f) => f.slug).join(', ') || '(none)'}`
554
+ }
555
+ }
556
+
557
+ /**
558
+ * L4: Issue-level Milestone-attachment drift (aeg-review-gate-v1 task 1
559
+ * follow-up). An open task-Issue carrying `vinaya/tranche:<slug>` for an ACTIVE
560
+ * tranche (open Milestone titled the slug) whose GitHub-native
561
+ * `milestone` field doesn't match that same Milestone.
562
+ *
563
+ * **Scope changed (vinaya-milestone-model-v1 task 1): the exact-title-match
564
+ * invariant this check evaluates is only meaningful for LEGACY tranches** —
565
+ * a Milestone titled exactly the tranche slug, the 1:1 regime that predates
566
+ * the label model. Once one Milestone can legitimately hold several
567
+ * tranches, an Issue attached to a shared Milestone will never have a
568
+ * `milestone.title` equal to any one of the several slugs it might carry,
569
+ * so flagging that as drift would be noise on every label-only tranche. The
570
+ * restriction to legacy slugs is applied by the CALLER
571
+ * (`verify-coherence.ts`, via `TrancheMilestoneIndex.legacySlugs`) — this
572
+ * function's own logic is unchanged and still trusts whatever
573
+ * `activeTrancheSlugs` it is handed.
574
+ *
575
+ * **Advisory (info-only)**, same framing as L1/L2 (`state-machine.md` §12):
576
+ * confirmed NOT functionally load-bearing — `deriveTrancheFromForge`/
577
+ * `listActiveTrancheSlugs` never read an Issue's milestone field, only the
578
+ * `vinaya/tranche:<slug>` label, which remains the sole, sufficient membership
579
+ * signal. This is real drift between GitHub's own Milestone view (e.g.
580
+ * `open_issues`/`closed_issues` counts) and reality — cosmetic, not a gate,
581
+ * surfaced so it doesn't silently accumulate rather than because anything
582
+ * downstream currently breaks. `open-issue.ts` prevents new drift at
583
+ * creation time (ring 0); this is the ring-1/2 detection half for whatever
584
+ * predates that gate or was created outside it.
585
+ */
586
+ export function checkL4(
587
+ activeTrancheSlugs: string[],
588
+ issueMilestones: Array<{ tranche: string; issue: number; milestoneTitle: string | null }>
589
+ ): CheckResult {
590
+ const activeSet = new Set(activeTrancheSlugs)
591
+ const failures: CheckFailure[] = []
592
+ for (const f of issueMilestones) {
593
+ if (!activeSet.has(f.tranche)) continue
594
+ if (f.milestoneTitle === f.tranche) continue
595
+ failures.push({
596
+ issue: f.issue,
597
+ tranche: f.tranche,
598
+ reason:
599
+ f.milestoneTitle === null
600
+ ? `Issue #${f.issue} carries ${trancheLabel(f.tranche)} (active) but has no GitHub-native milestone attached`
601
+ : `Issue #${f.issue} carries ${trancheLabel(f.tranche)} (active) but is attached to Milestone "${f.milestoneTitle}" instead`
602
+ })
603
+ }
604
+ return {
605
+ check: 'L4',
606
+ status: 'info',
607
+ failures,
608
+ note:
609
+ failures.length > 0
610
+ ? `${failures.length} open task-Issue(s) in an active tranche whose GitHub-native milestone doesn't match their ${label('tranche')}<slug> label (cosmetic drift, advisory)`
611
+ : undefined
612
+ }
613
+ }
614
+
615
+ /**
616
+ * L5: an active tranche whose every task Issue is closed → the tranche is
617
+ * effectively complete but was never archived (Issue #481, drift class #2;
618
+ * 1 live incident this session — `aeg-forge-state-v1`'s Milestone left open
619
+ * after full archive).
620
+ *
621
+ * This is the FORGE-NATIVE analogue of file-based L1: L1 reads `TrancheFile[]`
622
+ * (a `!f.archived` file location), but post-cutover most tranches have no
623
+ * topology file at all, so their Milestone-object drift is invisible to L1.
624
+ * L5 keys off `activeTrancheSlugs` (the derived-active population — the
625
+ * authority) instead, so it sees exactly the tranches L1 no longer can.
626
+ *
627
+ * **Promoted from advisory to authoritative (vinaya-milestone-model-v1 task
628
+ * 1): `status` is now `'fail'`, joining A1/A2/A3/M1/M3 as CI-blocking.**
629
+ * Before this task, "the tranche is complete" meant "its Milestone is
630
+ * closed" — a fact a Milestone shared by several tranches can no longer
631
+ * carry reliably (closing it would close every tranche it holds, not just
632
+ * the one that finished). This check's underlying signal was ALREADY the
633
+ * correct one even before this task — per-task-Issue state from
634
+ * `entriesBySlug`, never the Milestone's own open/closed field — so
635
+ * promoting it is a severity change, not a logic change: the signal is now
636
+ * trustworthy enough to be the completeness answer other consumers should
637
+ * defer to, rather than a maybe-stale hint. Applies uniformly to legacy and
638
+ * label-only tranches alike; unlike L4, no legacy restriction is needed here,
639
+ * since Issue state (not Milestone attachment) is what this check reads.
640
+ *
641
+ * Slugs whose facts are unavailable (forge outage) are skipped, mirroring
642
+ * L1's `withFacts.length === 0` guard — an outage is not a finding.
643
+ */
644
+ export function checkL5(activeTrancheSlugs: string[], entriesBySlug: Map<string, TaskEntry[]>): CheckResult {
645
+ const failures: CheckFailure[] = []
646
+ for (const slug of activeTrancheSlugs) {
647
+ const entries = entriesBySlug.get(slug) ?? []
648
+ const withFacts = entries.filter((e) => e.facts !== undefined)
649
+ if (withFacts.length === 0) continue // forge unavailable or no tasks with issues
650
+ const allClosed = withFacts.every((e) => e.facts?.issueState === 'closed')
651
+ if (allClosed) {
652
+ failures.push({
653
+ tranche: slug,
654
+ reason: 'Every task Issue is closed but the tranche is not recorded as complete — archive it'
655
+ })
656
+ }
657
+ }
658
+ return {
659
+ check: 'L5',
660
+ status: failures.length > 0 ? 'fail' : 'pass',
661
+ failures,
662
+ note:
663
+ failures.length > 0
664
+ ? `${failures.length} tranche(s) whose task Issues are all closed but were never archived`
665
+ : undefined
666
+ }
667
+ }
668
+
669
+ /** Extracts the set of Issue numbers a PR body's `Closes #N` (and Fixes/
670
+ * Resolves synonyms) references. Shared by `checkClosesN`'s forward and
671
+ * reverse directions, and by the CI wiring script that must resolve each
672
+ * referenced Issue's task identity *before* calling `checkClosesN` — one
673
+ * grammar, not a second copy of the pattern (discipline). Honors the
674
+ * AEG:CLOSES anchor pair (`anchored-region.ts`, task 30) when present: only
675
+ * references inside the pair count, so a Closes-shaped line in a pasted
676
+ * reference brief elsewhere in the PR body isn't picked up. The searched region
677
+ * is additionally `stripCode`d before matching, for parity with GitHub's
678
+ * auto-close parser (which ignores `Closes #N` inside code) — a backticked-only
679
+ * reference resolves to no Issue here exactly as it does on merge, so this
680
+ * repo-wide check and the pre-merge `checkClosesN` agree with GitHub.
681
+ *
682
+ * The separator groups are bounded (`\s{0,8}`) for the same reason, and to the
683
+ * same width, as `checkClosesN`'s — see the ReDoS note there. The two patterns
684
+ * must stay byte-identical apart from the capture group; a divergence here is
685
+ * a gate-disagreement bug, not a style difference. */
686
+ export function extractClosesReferences(prBody: string): Set<number> {
687
+ const closesPattern = /(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s{0,8}:?\s{0,8}#(\d+)/gi
688
+ // Strip the WHOLE body, then slice the anchor region out of the stripped
689
+ // text — never strip a sliced region. A region has lost the block context
690
+ // the strip's rules read (list vs indented-code, fence pairing), so an
691
+ // anchor indented inside a list item was blanked as if it were code, and
692
+ // this parser silently disagreed with the whole-body path in the same call
693
+ // (PR #617 review MAJOR). See `archive-task.ts`'s `extractIssue`.
694
+ const stripped = stripCode(prBody)
695
+ const searchIn = anchoredRegion(stripped, 'CLOSES') ?? stripped
696
+ const referenced = new Set<number>()
697
+ for (const hit of searchIn.matchAll(closesPattern)) {
698
+ referenced.add(Number(hit[1]))
699
+ }
700
+ return referenced
701
+ }
702
+
703
+ /**
704
+ * Closes #N gate — Layer 1 of forge-lifecycle enforcement.
705
+ *
706
+ * Forward direction: a task PR (branch `task/<tranche>/<n>`) must carry
707
+ * `Closes #<its-issue>` in the body. Non-task branches are silently
708
+ * bypassed for this direction (returns ok:true).
709
+ *
710
+ * Reverse direction (added: a branch NOT named `task/<tranche>/<n>` that
711
+ * nonetheless closes a real AEG task Issue must be named after that task —
712
+ * the gap that let `feat/vinaya-landing-v3` implement Issue #509 with zero
713
+ * forge-visible status. Runs for ANY branch, gated on `taskIssueRefs` being
714
+ * supplied: each `Closes #N` the body references is looked up in the map;
715
+ * an entry resolving to a task's `{trancheSlug, taskId}` requires
716
+ * `branch === "task/<trancheSlug>/<taskId>"`. A missing map entry (issue not
717
+ * resolved, e.g. no forge token) or an ordinary non-task Issue (`null` in
718
+ * the map) skips the check for that reference — this direction only ever
719
+ * *adds* a failure, never silently passes something the forward direction
720
+ * would have caught.
721
+ *
722
+ * Pure function; reads from injected parameters. The CLI entry-point wires
723
+ * in BRANCH + PR_BODY env vars (forward) and a batched forge lookup
724
+ * (reverse, see `@attalabs/aeg-forge-state`'s `fetchTaskIssueRefs`).
725
+ */
726
+ export function checkClosesN(
727
+ branch: string,
728
+ prBody: string,
729
+ trancheFiles: TrancheFile[],
730
+ taskIssueRefs?: Map<number, TaskIssueRef | null>
731
+ ): { ok: boolean; message?: string; expectedIssue?: number } {
732
+ const referenced = extractClosesReferences(prBody)
733
+
734
+ if (taskIssueRefs) {
735
+ for (const n of referenced) {
736
+ const ref = taskIssueRefs.get(n)
737
+ if (!ref) continue
738
+ const expectedBranch = `task/${ref.trancheSlug}/${ref.taskId}`
739
+ if (branch !== expectedBranch) {
740
+ return {
741
+ ok: false,
742
+ message: `closes-n-reverse: branch "${branch}" closes #${n} (task ${ref.taskId} of tranche "${ref.trancheSlug}") but is not named "${expectedBranch}" — rename the branch and re-push, or if this work is intentionally outside AEG's dispatch flow, remove the Closes reference.`
743
+ }
744
+ }
745
+ }
746
+ }
747
+
748
+ const m = branch.match(/^task\/([^/]+)\/([^/]+)$/)
749
+ if (!m) return { ok: true } // non-task branch — forward direction bypass
750
+
751
+ const trancheSlug = m[1] as string
752
+ const taskId = m[2] as string
753
+
754
+ const trancheFile = trancheFiles.find((f) => f.slug === trancheSlug)
755
+ if (!trancheFile) {
756
+ return {
757
+ ok: false,
758
+ message: `closes-n: branch "${branch}" references tranche "${trancheSlug}" but no topology file found at aeg-root/tranches/${trancheSlug}.md. Ensure the tranche file exists before opening the PR.`
759
+ }
760
+ }
761
+
762
+ const task = trancheFile.tranche.tasks.find((t) => t.id === taskId)
763
+ if (!task) {
764
+ return {
765
+ ok: false,
766
+ message: `closes-n: branch "${branch}" references task "${taskId}" not found in ${trancheSlug} topology. Verify the task ID matches the tranche file.`
767
+ }
768
+ }
769
+
770
+ if (task.issue === null) {
771
+ return {
772
+ ok: false,
773
+ message: `closes-n: task "${taskId}" in "${trancheSlug}" has no Issue number (#TBD). The Planner must cut the Issue before this PR can be validated.`
774
+ }
775
+ }
776
+
777
+ const expectedIssue = task.issue
778
+ if (!referenced.has(expectedIssue)) {
779
+ return {
780
+ ok: false,
781
+ expectedIssue,
782
+ message: `closes-n: PR body does not contain \`Closes #${expectedIssue}\` (required for task "${taskId}" in tranche "${trancheSlug}"). Add it to the PR body Summary section.`
783
+ }
784
+ }
785
+
786
+ return { ok: true, expectedIssue }
787
+ }