@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,55 @@
1
+ /**
2
+ * Dead-branch-push AUDIT (aeg-governance-hardening task 24, #364, Part 4 —
3
+ * item 2). Distinct from the ring-0 `dead-branch-push-guard.ts` (task 18):
4
+ * that predicate answers "should THIS push be allowed right now?" at push
5
+ * time; this one answers "did commits land on a branch AFTER its PR already
6
+ * resolved?" retrospectively, across every remote `task/*` branch — the
7
+ * ring-2 backstop for the class the ring-0 hook is fail-open/hook-dependent
8
+ * against (a writer without the hook installed, or a hook bypass). Pure —
9
+ * no `fs`, no `gh`/`git` shell-outs. The CLI shim (`bin/dead-branch-audit.ts`,
10
+ * joining task 23's `daily-drift` job) gathers each branch's most recent
11
+ * PR (any state) plus its resolution/latest-commit timestamps and passes
12
+ * them in here.
13
+ *
14
+ * Exists to catch the "six topology rows landed on a merged plan PR's
15
+ * branch" incident class (2026-07-03) server-side, within a day, instead of
16
+ * never — never-red discipline: `daily-drift` (the CI job that calls this)
17
+ * must never fail red.
18
+ */
19
+
20
+ export type DeadBranchFact = {
21
+ branch: string
22
+ prNumber: number
23
+ prState: 'MERGED' | 'CLOSED'
24
+ /** ISO timestamp the branch's PR was merged (MERGED) or closed (CLOSED). */
25
+ resolvedAt: string
26
+ /** ISO commit date of the branch's current remote tip. */
27
+ latestCommitAt: string
28
+ }
29
+
30
+ export type DeadBranchPush = {
31
+ branch: string
32
+ prNumber: number
33
+ prState: 'MERGED' | 'CLOSED'
34
+ resolvedAt: string
35
+ latestCommitAt: string
36
+ }
37
+
38
+ /**
39
+ * Flags a branch whose current tip commit is strictly newer than its own
40
+ * PR's resolution time — i.e. commits kept landing on a branch after the
41
+ * forge already considered its work done. A branch whose tip predates (or
42
+ * exactly matches) its PR's resolution is normal: the branch simply hasn't
43
+ * been deleted yet, which is not itself a violation.
44
+ */
45
+ export function findDeadBranchPushes(facts: DeadBranchFact[]): DeadBranchPush[] {
46
+ return facts
47
+ .filter((f) => new Date(f.latestCommitAt).getTime() > new Date(f.resolvedAt).getTime())
48
+ .map(({ branch, prNumber, prState, resolvedAt, latestCommitAt }) => ({
49
+ branch,
50
+ prNumber,
51
+ prState,
52
+ resolvedAt,
53
+ latestCommitAt
54
+ }))
55
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Dead-branch push guard (aeg-governance-hardening task 18, #335). Pure — no
3
+ * `fs`, no `gh`/`git` shell-outs. The CLI shim (`bin/check-push-target.ts`)
4
+ * makes the one batched forge call (`gh pr list --head <branch> --state all
5
+ * --json number,state --limit 1`), maps its result to a `PrStateFact`, and
6
+ * passes it in here.
7
+ *
8
+ * Exists to answer, deterministically, "is it safe to push more commits to
9
+ * this `task/<tranche>/<n>` branch?" — closing the live-fire incident
10
+ * class where stale local work landed on a branch whose PR had already
11
+ * merged, silently reopening/confusing forge state with no push-time signal.
12
+ *
13
+ * `UNKNOWN` (forge unreachable — network flake, auth issue, rate limit) maps
14
+ * to `allow`, the same as `NONE` (no PR exists yet — a brand-new branch
15
+ * legitimately has none). This is a deliberate fail-open choice: a guard
16
+ * that can block *all* pushes on a transient forge-reachability issue is
17
+ * worse than the bug it fixes. See `aeg-root/enforcement.md`.
18
+ */
19
+
20
+ export type PrStateFact = 'MERGED' | 'CLOSED' | 'OPEN' | 'NONE' | 'UNKNOWN'
21
+
22
+ export type DeadBranchPushInput = {
23
+ /** The `task/<tranche>/<n>` branch being pushed. */
24
+ branch: string
25
+ /** State of the branch's most recent PR (any state), or the fact that none/forge-unreachable applies. */
26
+ prState: PrStateFact
27
+ /** The PR number that `prState` refers to, or `null` when no PR exists / could not be determined. */
28
+ prNumber: number | null
29
+ }
30
+
31
+ export type DeadBranchPushVerdict = 'allow' | 'refuse'
32
+
33
+ export type DeadBranchPushResult = { verdict: DeadBranchPushVerdict; reason: string }
34
+
35
+ /**
36
+ * `allow` — `OPEN`/`NONE`/`UNKNOWN`: an in-flight PR, no PR yet, or the
37
+ * forge couldn't be reached — all legitimate reasons to push.
38
+ * `refuse` — `MERGED`/`CLOSED`: the branch's most recent PR is already
39
+ * resolved. Pushing here would silently resurrect resolved forge
40
+ * state instead of starting a fresh branch.
41
+ */
42
+ export function checkDeadBranchPush(input: DeadBranchPushInput): DeadBranchPushResult {
43
+ const { branch, prState, prNumber } = input
44
+
45
+ if (prState === 'MERGED') {
46
+ return {
47
+ verdict: 'refuse',
48
+ reason: `Branch \`${branch}\` already has a MERGED PR (#${prNumber}) — this branch's work is done. Push a new task branch instead of pushing onto an already-resolved one.`
49
+ }
50
+ }
51
+
52
+ if (prState === 'CLOSED') {
53
+ return {
54
+ verdict: 'refuse',
55
+ reason: `Branch \`${branch}\` has a CLOSED PR (#${prNumber}) that was never merged — pushing here would resurrect abandoned/rejected work. Open a new task branch instead.`
56
+ }
57
+ }
58
+
59
+ if (prState === 'OPEN') {
60
+ return {
61
+ verdict: 'allow',
62
+ reason: `Branch \`${branch}\` has an OPEN PR (#${prNumber}) — pushing more commits to it is normal.`
63
+ }
64
+ }
65
+
66
+ if (prState === 'NONE') {
67
+ return {
68
+ verdict: 'allow',
69
+ reason: `Branch \`${branch}\` has no PR yet — a brand-new task branch legitimately has none.`
70
+ }
71
+ }
72
+
73
+ return {
74
+ verdict: 'allow',
75
+ reason: `Could not determine \`${branch}\`'s PR state (forge unreachable) — failing open rather than blocking the push on a transient issue.`
76
+ }
77
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Derive the floor of a task's §7 doc-update list by matching its intended
3
+ * surface globs against `.vinaya/doc-owners` bindings (state-machine.md
4
+ * Section 15). Pure — parses doc-owners content via `parseDocOwners` (no
5
+ * second parser) and does segment-wise glob overlap between each intended
6
+ * surface and each binding's code glob. A Planner/Brief-Author aid only —
7
+ * not wired into any CI gate.
8
+ */
9
+
10
+ import { parseDocOwners } from './doc-owners'
11
+
12
+ export type Section7Match = { surface: string; glob: string; pointer: string; lineNum: number }
13
+
14
+ export function deriveSection7(
15
+ intendedSurfaces: string[],
16
+ docOwnersContent: string
17
+ ): { pointers: string[]; matches: Section7Match[]; errors: string[] } {
18
+ const { bindings, errors } = parseDocOwners(docOwnersContent)
19
+ const matches: Section7Match[] = []
20
+ for (const surface of intendedSurfaces) {
21
+ for (const binding of bindings) {
22
+ if (globsOverlap(binding.glob, surface)) {
23
+ matches.push({ surface, glob: binding.glob, pointer: binding.pointer, lineNum: binding.lineNum })
24
+ }
25
+ }
26
+ }
27
+
28
+ const pointers: string[] = []
29
+ for (const m of matches) {
30
+ if (!pointers.includes(m.pointer)) pointers.push(m.pointer)
31
+ }
32
+
33
+ return { pointers, matches, errors }
34
+ }
35
+
36
+ /**
37
+ * True if two doc-owners-grammar globs can match at least one common path,
38
+ * compared segment-by-segment (split on `/`). `**` absorbs zero or more
39
+ * remaining segments on either side — checked at each step, even once one
40
+ * side has run out of segments, so a trailing `**` still absorbs. `*`
41
+ * matches exactly one segment regardless of the other side's literal/`*`.
42
+ * Two literal segments must be byte-equal. If one side runs out of segments
43
+ * before the other and the next pending segment on either side isn't `**`,
44
+ * there is no overlap.
45
+ */
46
+ export function globsOverlap(a: string, b: string): boolean {
47
+ const segsA = a.split('/')
48
+ const segsB = b.split('/')
49
+ let i = 0
50
+ let j = 0
51
+ while (i < segsA.length || j < segsB.length) {
52
+ const sa = i < segsA.length ? segsA[i] : undefined
53
+ const sb = j < segsB.length ? segsB[j] : undefined
54
+ if (sa === '**' || sb === '**') return true
55
+ if (sa === undefined || sb === undefined) return false
56
+ if (sa === '*' || sb === '*') {
57
+ i++
58
+ j++
59
+ continue
60
+ }
61
+ if (sa !== sb) return false
62
+ i++
63
+ j++
64
+ }
65
+ return true
66
+ }
@@ -0,0 +1,94 @@
1
+ import { deriveStatusFromModel } from './state-machine-model'
2
+ import type { DerivedTranche, DerivedStatus, DerivedTask, ForgeFacts, Tranche, Task, UnknownEdge } from './types'
3
+
4
+ /**
5
+ * Compute each task's derived status, the resolved depends-on / conflicts-with
6
+ * graph, and dispatch-eligibility per `tranche-model.md` §3 (status table)
7
+ * and §8 (dispatch gates).
8
+ *
9
+ * The function is pure: `tranche` + `forgeFacts` in, `DerivedTranche` out.
10
+ * Edges referencing task ids not present in this tranche's table are
11
+ * reported via `unknownEdges` rather than throwing — see §4 traps in the brief
12
+ * (real files reference dropped/prose-only ids in narrative).
13
+ */
14
+ export function deriveTranche(tranche: Tranche, forgeFacts: Map<string, ForgeFacts>): DerivedTranche {
15
+ const byId = new Map<string, Task>()
16
+ for (const task of tranche.tasks) byId.set(task.id, task)
17
+
18
+ // First pass: derive each task's own status.
19
+ const statuses = new Map<string, DerivedStatus>()
20
+ for (const task of tranche.tasks) {
21
+ statuses.set(task.id, deriveStatus(forgeFacts.get(task.id)))
22
+ }
23
+
24
+ // Second pass: dispatch eligibility, which references siblings' derived
25
+ // statuses (so it must come after the first pass).
26
+ const unknownEdges: UnknownEdge[] = []
27
+ const derived: DerivedTask[] = []
28
+ for (const task of tranche.tasks) {
29
+ const status = statuses.get(task.id) ?? 'todo'
30
+ const dependsOnNotMerged: string[] = []
31
+ for (const dep of task.dependsOn) {
32
+ if (!byId.has(dep)) {
33
+ unknownEdges.push({ from: task.id, to: dep, kind: 'depends-on' })
34
+ continue
35
+ }
36
+ if (statuses.get(dep) !== 'merged') dependsOnNotMerged.push(dep)
37
+ }
38
+ const conflictsWithOpenOrInFlight: string[] = []
39
+ for (const sib of task.conflictsWith) {
40
+ if (!byId.has(sib)) {
41
+ unknownEdges.push({ from: task.id, to: sib, kind: 'conflicts-with' })
42
+ continue
43
+ }
44
+ const sibStatus = statuses.get(sib)
45
+ if (sibStatus === 'in-flight' || sibStatus === 'in-review' || sibStatus === 'changes-requested') {
46
+ conflictsWithOpenOrInFlight.push(sib)
47
+ }
48
+ }
49
+ derived.push({
50
+ task,
51
+ status,
52
+ dispatchable: dependsOnNotMerged.length === 0 && conflictsWithOpenOrInFlight.length === 0,
53
+ blockers: { dependsOnNotMerged, conflictsWithOpenOrInFlight }
54
+ })
55
+ }
56
+
57
+ return { tranche, tasks: derived, unknownEdges }
58
+ }
59
+
60
+ /**
61
+ * Map a single task's forge facts to its derived status. Mirrors the §3 table
62
+ *: `blocked` wins over everything else, then `merged`, then
63
+ * `changes-requested`, then `in-review`, then `in-flight`, then `todo` for the
64
+ * open-issue cases (assigned or not — both are `todo` inside a tranche per
65
+ *), then the honest terminal cases for a closed-without-merge Issue.
66
+ *
67
+ * Reopened-after-merge exception: a reopened Issue can carry a stale
68
+ * `prState: 'merged'` fact from a closing PR that predates the reopen —
69
+ * `issueState` and `prState` are independently sourced, and GitHub does not
70
+ * retract a PR's merge history just because the Issue reopened. Without this
71
+ * check, such a task would permanently display `merged` even though the work
72
+ * it names was never actually done. Checked before the `prState === 'merged'`
73
+ * branch below so a currently-open Issue always wins over a historical merge.
74
+ *
75
+ * Honest terminal derivation: a closed Issue with no merged PR must
76
+ * never resolve to `todo` (which would imply not-started — a lie). It reads
77
+ * GitHub's native `stateReason`: closed `NOT_PLANNED` → `dropped` (legitimately
78
+ * abandoned); anything else (closed `COMPLETED` or no reason recorded) →
79
+ * `incoherent` (genuinely done but unprovable, or a broken close that the
80
+ * `Closes #N` law was built to surface).
81
+ *
82
+ * A missing `forgeFacts` entry → `todo`. Tranche tasks are committed work;
83
+ * `backlog` is a project-level concept only and is never emitted here.
84
+ *
85
+ * The rules themselves are no longer written here. They live as an ordered,
86
+ * pure-data list in `state-machine-model.ts` (the `actions.ts`
87
+ * discipline): one list, executed by this function and rendered by the docs,
88
+ * so the two cannot drift. Each rule carries the prose and the reason its
89
+ * position matters — change the order there, and this function's behavior
90
+ * changes with it.
91
+ */
92
+ function deriveStatus(facts: ForgeFacts | undefined): DerivedStatus {
93
+ return deriveStatusFromModel(facts)
94
+ }
@@ -0,0 +1,372 @@
1
+ /**
2
+ * diagram-model.ts — the pure derivation at the heart of one library
3
+ * turns doctrine (`enforcement.md` rings/gates/checks + role/contract
4
+ * frontmatter + the canonical `ACTIONS` set) plus declarative config plus
5
+ * live status into a renderer-agnostic `DiagramModel`. Every diagram consumer
6
+ * (Studio, the Vinaya portal, a CLI visualizer) reads THIS model; none
7
+ * re-implements the governance logic. Zero I/O, zero `fs`/`node:`
8
+ * imports — doctrine arrives already-read through `DoctrineContent`;
9
+ * the file reads live behind `DoctrineSource` in `@attalabs/vinaya-sources`.
10
+ *
11
+ * What is NOT here: geometry. Radius, angle, colour, coordinates are the
12
+ * renderer's job (a ring index and a render-state are structural facts; a
13
+ * pixel is not). Render-state is DERIVED, never authored: `disabled` when
14
+ * config disables it, else `active`.
15
+ */
16
+
17
+ import matter from 'gray-matter'
18
+ import { ACTIONS, CROSSING_KEYWORDS } from './actions'
19
+ import type { DoctrineContent } from './doctrine-source'
20
+ import { findHeadingLine, findTable } from './markdown-table'
21
+ import { parseEnforcementRegistry } from './registry-parse'
22
+ import type { Tranche } from './types'
23
+
24
+ export type DiagramNodeKind = 'ring' | 'gate' | 'check' | 'action' | 'role' | 'contract'
25
+ export type RenderState = 'active' | 'disabled'
26
+
27
+ export type DiagramNode = {
28
+ id: string
29
+ kind: DiagramNodeKind
30
+ label: string
31
+ ringIndex?: 0 | 1 | 2
32
+ renderState: RenderState
33
+ sourceLine?: number
34
+ summary?: string
35
+ /** What this node actually does, in one plain sentence — every kind carries
36
+ * one, each from its own source's `description`: the enforcement table's
37
+ * Description column for `gate`/`check`, `description:` frontmatter for
38
+ * `role`/`contract`, the `ACTIONS` entry's field for `action`. One register
39
+ * for every kind, because a reader cannot see what kind of node they
40
+ * clicked. Never `GateRow.spec` — that column is written to enforce, not to
41
+ * read (38% of rows cite a decision id, an issue number or a file path;
42
+ * longest is 2650 chars), and rendering it here is the bug this field
43
+ * exists to prevent. `summary` asks the question, this answers it, and
44
+ * "Read more" carries the spec. Optional because a source may omit it,
45
+ * never because a kind structurally lacks one. */
46
+ detail?: string
47
+ category?: 'ci' | 'hook' | 'event'
48
+ actorType?: 'agent' | 'human' | 'either'
49
+ /** Whether this action reaches GitHub — `action` nodes only, straight from
50
+ * its `ACTIONS` entry (the canonical set). On the node because a
51
+ * renderer cannot get it any other way: `ACTIONS` is a value export, and
52
+ * importing it into a client component drags `@attalabs/aeg-forge-state`'s
53
+ * `node:child_process` into the browser bundle. The distinction is
54
+ * doctrine-load-bearing — ring-0 gates guard exactly the crossings, and
55
+ * G3 exists to prove there is no unguarded one — so it must survive to the
56
+ * render rather than being implied by which ring a node was filed under. */
57
+ crosses?: 'into-github' | 'none'
58
+ /** The node's name **for a reader**, when its `label` is an identifier rather
59
+ * than words. `role`/`contract` nodes label themselves with their doctrine id
60
+ * (`brief-author`, `archivist-tranche-archivist`) because that id is the
61
+ * node's identity — routes, doc paths and edge endpoints all key off it, so
62
+ * it cannot be prettified in place. This carries the doc's own `title:`
63
+ * frontmatter instead, and every renderer showing a name to a person prefers
64
+ * it, falling back to `label` when absent.
65
+ *
66
+ * Deliberately NOT `sidebar_title`: that field is the docs nav's own caption,
67
+ * and sourcing a doctrine node's name from a nav hint would let a caption
68
+ * edit silently rename the node. `title:` is the document's own name, which
69
+ * is what a display name should be. */
70
+ displayLabel?: string
71
+ /** Where this node sits in the process, from its doc's `order:` frontmatter
72
+ * — `role`/`contract` nodes only. Doctrine files are read in filename order,
73
+ * which is alphabetical and therefore meaningless to a reader; the process
74
+ * runs plan → brief → build → review → verify → merge → archive, and that is
75
+ * the order every surface listing these nodes should present them in. Kept on
76
+ * the node rather than re-derived per renderer so the docs nav and the map
77
+ * cannot disagree about the sequence. Absent means unordered — a renderer
78
+ * sorting by it must treat that as "last", never as zero. */
79
+ order?: number
80
+ }
81
+
82
+ export type DiagramEdge = {
83
+ id: string
84
+ kind: 'guards' | 'performs' | 'produces' | 'consumes'
85
+ from: string
86
+ to: string
87
+ }
88
+
89
+ export type DiagramFinding = { configKey?: string; reason: string }
90
+
91
+ export type DiagramConfig = {
92
+ rings?: { ring1_forgeWriteInterception?: boolean; ring2_asyncAudits?: boolean }
93
+ gates?: Record<string, boolean>
94
+ }
95
+
96
+ export type DiagramModel = {
97
+ nodes: DiagramNode[]
98
+ edges: DiagramEdge[]
99
+ findings: DiagramFinding[]
100
+ tranche: Tranche | null
101
+ }
102
+
103
+ /** Deterministic slug from a table row's first cell: lowercase, drop
104
+ * backticks/bold/markdown-link chrome, collapse non-alphanumerics to `-`. */
105
+ function slugify(cell: string): string {
106
+ return cell
107
+ .toLowerCase()
108
+ .replace(/`/g, '')
109
+ .replace(/\*\*/g, '')
110
+ .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
111
+ .replace(/[^a-z0-9]+/g, '-')
112
+ .replace(/^-+|-+$/g, '')
113
+ }
114
+
115
+ /** The three ring summary labels, keyed by ring index, from `enforcement.md`'s
116
+ * `## The model:` summary table (the same table `loadRings` reads). Throws on a
117
+ * malformed doctrine — same contract as `parseEnforcementRegistry`.
118
+ *
119
+ * Keyed by row POSITION (row 0 = ring 0, row 1 = ring 1, row 2 = ring 2),
120
+ * not by a "0 —"/"1 —"/"2 —" text prefix on the cell — the labels
121
+ * themselves carry no numeric prefix (round-3 fix: dropped per Issue #508),
122
+ * so a prefix match would no longer find anything. The 3-row length check
123
+ * above is what actually guarantees the ring-0/1/2 order; position is a
124
+ * reliable key once that's enforced. */
125
+ function ringLabels(enforcement: string): Record<0 | 1 | 2, string> {
126
+ const lines = enforcement.split('\n')
127
+ const summaryHeadingLine = findHeadingLine(lines, /^##\s*The model:/)
128
+ if (!summaryHeadingLine) throw new Error('enforcement.md: could not find "## The model:" heading')
129
+ const summaryTable = findTable(lines, summaryHeadingLine, false)
130
+ if (summaryTable?.rows.length !== 3) {
131
+ throw new Error('enforcement.md: summary table does not have exactly 3 rows (Ring 0/1/2)')
132
+ }
133
+ const byRow = summaryTable.rows.map((row) => (row.cells[0] ?? '').replace(/\*\*/g, '').trim())
134
+ return { 0: byRow[0] ?? '', 1: byRow[1] ?? '', 2: byRow[2] ?? '' }
135
+ }
136
+
137
+ type RoleFrontmatter = {
138
+ roleId: string
139
+ title?: string
140
+ order?: number
141
+ description?: string
142
+ summary?: string
143
+ actorType?: 'agent' | 'human' | 'either'
144
+ }
145
+
146
+ function extractRole(content: string): RoleFrontmatter | null {
147
+ const { data } = matter(content)
148
+ if (typeof data.role_id !== 'string') return null
149
+ return {
150
+ roleId: data.role_id,
151
+ title: typeof data.title === 'string' ? data.title : undefined,
152
+ order: typeof data.order === 'number' ? data.order : undefined,
153
+ description: typeof data.description === 'string' ? data.description : undefined,
154
+ summary: typeof data.summary === 'string' ? data.summary : undefined,
155
+ actorType: data.actor === 'agent' || data.actor === 'human' || data.actor === 'either' ? data.actor : undefined
156
+ }
157
+ }
158
+
159
+ type ContractFrontmatter = {
160
+ contractId: string
161
+ title?: string
162
+ order?: number
163
+ description?: string
164
+ producer?: string
165
+ consumer?: string
166
+ summary?: string
167
+ }
168
+
169
+ function extractContract(content: string): ContractFrontmatter | null {
170
+ const { data } = matter(content)
171
+ if (typeof data.contract_id !== 'string') return null
172
+ return {
173
+ contractId: data.contract_id,
174
+ title: typeof data.title === 'string' ? data.title : undefined,
175
+ order: typeof data.order === 'number' ? data.order : undefined,
176
+ description: typeof data.description === 'string' ? data.description : undefined,
177
+ producer: typeof data.producer === 'string' ? data.producer : undefined,
178
+ consumer: typeof data.consumer === 'string' ? data.consumer : undefined,
179
+ summary: typeof data.summary === 'string' ? data.summary : undefined
180
+ }
181
+ }
182
+
183
+ /**
184
+ * The pure derivation. `doctrine` is already-read raw markdown; `config` is the
185
+ * declarative enable/disable input (null = nothing disabled); `tranche` is
186
+ * StateSource live context, passed through verbatim and never used to compute
187
+ * render-state.
188
+ */
189
+ export function deriveDiagramModel(
190
+ doctrine: DoctrineContent,
191
+ config: DiagramConfig | null,
192
+ tranche: Tranche | null
193
+ ): DiagramModel {
194
+ const nodes: DiagramNode[] = []
195
+ const edges: DiagramEdge[] = []
196
+ const findings: DiagramFinding[] = []
197
+
198
+ const labels = ringLabels(doctrine.enforcement)
199
+ const ring1Disabled = config?.rings?.ring1_forgeWriteInterception === false
200
+ const ring2Disabled = config?.rings?.ring2_asyncAudits === false
201
+
202
+ // --- Ring nodes (Ring 0 has no ring-level switch) ---
203
+ nodes.push({ id: 'ring:0', kind: 'ring', label: labels[0], ringIndex: 0, renderState: 'active' })
204
+ nodes.push({
205
+ id: 'ring:1',
206
+ kind: 'ring',
207
+ label: labels[1],
208
+ ringIndex: 1,
209
+ renderState: ring1Disabled ? 'disabled' : 'active'
210
+ })
211
+ nodes.push({
212
+ id: 'ring:2',
213
+ kind: 'ring',
214
+ label: labels[2],
215
+ ringIndex: 2,
216
+ renderState: ring2Disabled ? 'disabled' : 'active'
217
+ })
218
+
219
+ // --- Gate (Ring 0) and check (Ring 1/2) nodes from the detail tables ---
220
+ const gateRows = parseEnforcementRegistry(doctrine.enforcement)
221
+ const usedSlugs = new Set<string>()
222
+ const gateCheckSlugs = new Set<string>()
223
+ // Track ring-0 gate nodes (label + id) for the `guards` keyword match.
224
+ const ring0Gates: Array<{ id: string; label: string }> = []
225
+
226
+ for (const row of gateRows) {
227
+ const ringIndex: 0 | 1 | 2 = row.ring === 'ring0' ? 0 : row.ring === 'ring1' ? 1 : 2
228
+ const kind: DiagramNodeKind = ringIndex === 0 ? 'gate' : 'check'
229
+ const base = slugify(row.action)
230
+ const slug = usedSlugs.has(base) ? `${base}-${row.line}` : base
231
+ usedSlugs.add(slug)
232
+ gateCheckSlugs.add(slug)
233
+ const id = `${kind}:${slug}`
234
+
235
+ let renderState: RenderState
236
+ if (config?.gates?.[slug] === false) {
237
+ renderState = 'disabled'
238
+ } else if (ringIndex === 1 && ring1Disabled) {
239
+ renderState = 'disabled'
240
+ } else if (ringIndex === 2 && ring2Disabled) {
241
+ renderState = 'disabled'
242
+ } else {
243
+ renderState = 'active'
244
+ }
245
+
246
+ nodes.push({
247
+ id,
248
+ kind,
249
+ label: row.action,
250
+ ringIndex,
251
+ renderState,
252
+ sourceLine: row.line,
253
+ summary: row.summary,
254
+ detail: row.description,
255
+ category: row.category
256
+ })
257
+ if (ringIndex === 0) ring0Gates.push({ id, label: row.action })
258
+ }
259
+
260
+ // --- Action nodes (always active in v1) ---
261
+ for (const action of ACTIONS) {
262
+ nodes.push({
263
+ id: `action:${action.id}`,
264
+ kind: 'action',
265
+ label: action.label,
266
+ renderState: 'active',
267
+ summary: action.summary,
268
+ detail: action.description,
269
+ crosses: action.crosses
270
+ })
271
+ }
272
+
273
+ // --- Role nodes from roles/*.md frontmatter (skip files without role_id) ---
274
+ const roleIds = new Set<string>()
275
+ for (const file of doctrine.roles) {
276
+ const role = extractRole(file.content)
277
+ if (role === null) continue
278
+ if (roleIds.has(role.roleId)) continue
279
+ roleIds.add(role.roleId)
280
+ nodes.push({
281
+ id: `role:${role.roleId}`,
282
+ kind: 'role',
283
+ label: role.roleId,
284
+ displayLabel: role.title,
285
+ order: role.order,
286
+ renderState: 'active',
287
+ summary: role.summary,
288
+ detail: role.description,
289
+ actorType: role.actorType
290
+ })
291
+ }
292
+
293
+ // --- Contract nodes from contracts/*.md frontmatter (skip without id) ---
294
+ const contracts: ContractFrontmatter[] = []
295
+ for (const file of doctrine.contracts) {
296
+ const contract = extractContract(file.content)
297
+ if (contract === null) continue
298
+ contracts.push(contract)
299
+ nodes.push({
300
+ id: `contract:${contract.contractId}`,
301
+ kind: 'contract',
302
+ label: contract.contractId,
303
+ displayLabel: contract.title,
304
+ order: contract.order,
305
+ renderState: 'active',
306
+ summary: contract.summary,
307
+ detail: contract.description
308
+ })
309
+ }
310
+
311
+ // --- Edges ---
312
+ // guards: ring-0 gate → into-github action, matched by CROSSING_KEYWORDS
313
+ // (case-insensitive substring on the gate row label; one edge per match).
314
+ for (const action of ACTIONS) {
315
+ if (action.crosses !== 'into-github') continue
316
+ const keyword = (CROSSING_KEYWORDS[action.id] ?? '').toLowerCase()
317
+ if (keyword === '') continue
318
+ for (const gate of ring0Gates) {
319
+ if (!gate.label.toLowerCase().includes(keyword)) continue
320
+ edges.push({
321
+ id: `guards:${gate.id}:action:${action.id}`,
322
+ kind: 'guards',
323
+ from: gate.id,
324
+ to: `action:${action.id}`
325
+ })
326
+ }
327
+ }
328
+
329
+ // performs: role → action for every ACTIONS.performedBy entry (the canonical
330
+ // single source — the role's own `performs` frontmatter is intentionally not
331
+ // re-read, so the two lists can never drift).
332
+ for (const action of ACTIONS) {
333
+ for (const roleId of action.performedBy) {
334
+ edges.push({
335
+ id: `performs:role:${roleId}:action:${action.id}`,
336
+ kind: 'performs',
337
+ from: `role:${roleId}`,
338
+ to: `action:${action.id}`
339
+ })
340
+ }
341
+ }
342
+
343
+ // produces / consumes: role → contract from each contract's producer/consumer.
344
+ for (const contract of contracts) {
345
+ if (contract.producer !== undefined) {
346
+ edges.push({
347
+ id: `produces:role:${contract.producer}:contract:${contract.contractId}`,
348
+ kind: 'produces',
349
+ from: `role:${contract.producer}`,
350
+ to: `contract:${contract.contractId}`
351
+ })
352
+ }
353
+ if (contract.consumer !== undefined) {
354
+ edges.push({
355
+ id: `consumes:role:${contract.consumer}:contract:${contract.contractId}`,
356
+ kind: 'consumes',
357
+ from: `role:${contract.consumer}`,
358
+ to: `contract:${contract.contractId}`
359
+ })
360
+ }
361
+ }
362
+
363
+ // --- Findings: config keys that reference no real gate/check slug. Config
364
+ // references, never invents — surface the drift, don't silently drop it. ---
365
+ for (const key of Object.keys(config?.gates ?? {})) {
366
+ if (!gateCheckSlugs.has(key)) {
367
+ findings.push({ configKey: key, reason: `config gates["${key}"] references no gate or check in doctrine` })
368
+ }
369
+ }
370
+
371
+ return { nodes, edges, findings, tranche }
372
+ }