@attalabs/vinaya 0.22.0 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (194) hide show
  1. package/README.md +14 -3
  2. package/aeg-root/contracts/archivist-tranche-archivist.md +2 -2
  3. package/aeg-root/contracts/brief-developer.md +1 -0
  4. package/aeg-root/contracts/developer-reviewer.md +3 -3
  5. package/aeg-root/contracts/reviewer-archivist.md +3 -3
  6. package/aeg-root/enforcement.md +26 -20
  7. package/aeg-root/process.md +8 -4
  8. package/aeg-root/roles/archivist.md +2 -2
  9. package/aeg-root/roles/brief-author.md +9 -3
  10. package/aeg-root/roles/developer.md +63 -31
  11. package/aeg-root/roles/planner.md +1 -1
  12. package/aeg-root/roles/principal.md +14 -0
  13. package/aeg-root/roles/reviewer.md +32 -15
  14. package/aeg-root/roles/security.md +15 -5
  15. package/aeg-root/skills/aeg/SKILL.md +9 -5
  16. package/aeg-root/skills/aeg-roles/SKILL.md +2 -2
  17. package/aeg-root/skills/brief-authoring/SKILL.md +39 -27
  18. package/aeg-root/state-machine.md +9 -11
  19. package/aeg-root/templates/brief-template.md +6 -6
  20. package/aeg-root/templates/pr-report-template.md +10 -7
  21. package/aeg-root/tranche-model.md +6 -2
  22. package/dist/checks/bin/check-body-bare-digits.js +559 -140
  23. package/dist/checks/bin/check-branch-topology.js +630 -145
  24. package/dist/checks/bin/check-brief-shape.js +600 -143
  25. package/dist/checks/bin/check-changeset-coverage.js +942 -162
  26. package/dist/checks/bin/check-closes-n.js +630 -145
  27. package/dist/checks/bin/check-coherence.js +641 -148
  28. package/dist/checks/bin/check-dead-branch-push.js +559 -140
  29. package/dist/checks/bin/check-dispatch-readiness.js +639 -146
  30. package/dist/checks/bin/check-doc-coverage-push.js +696 -219
  31. package/dist/checks/bin/check-doc-coverage.js +696 -219
  32. package/dist/checks/bin/check-doctrine-no-procedures.js +4800 -0
  33. package/dist/checks/bin/check-doctrine-portability.js +691 -147
  34. package/dist/checks/bin/check-evidence-fresh.js +1048 -222
  35. package/dist/checks/bin/check-exec-bits.js +4937 -0
  36. package/dist/checks/bin/check-first-push-dispatch.js +630 -145
  37. package/dist/checks/bin/check-issue-assignment.js +630 -145
  38. package/dist/checks/bin/check-main-branch-refusal.js +565 -141
  39. package/dist/checks/bin/check-no-disk-state.js +559 -140
  40. package/dist/checks/bin/check-pr-report-density.js +4467 -0
  41. package/dist/checks/bin/check-quoted-command.js +656 -164
  42. package/dist/checks/bin/check-reader-resolvable-prose.js +652 -163
  43. package/dist/checks/bin/check-registry-gates.js +602 -145
  44. package/dist/checks/bin/check-retired-vocabulary.js +652 -163
  45. package/dist/checks/bin/check-review-gate.js +677 -142
  46. package/dist/checks/bin/check-single-plan-pr.js +559 -140
  47. package/dist/checks/bin/check-test-plan.js +560 -141
  48. package/dist/checks/bin/check-token-collection-wired.js +559 -140
  49. package/dist/checks/bin/check-token-report.js +559 -140
  50. package/dist/checks/bin/check-workspace-escape.js +992 -146
  51. package/dist/index.js +8884 -5627
  52. package/package.json +1 -1
  53. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/actions.ts +173 -0
  54. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/anchored-region.ts +118 -0
  55. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/archive-task.ts +206 -0
  56. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/baseline-capture.ts +65 -0
  57. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/blast-radius-domains.ts +198 -0
  58. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/branch-topology-gate.ts +85 -0
  59. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/brief-render.ts +462 -0
  60. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/brief-validation.ts +912 -0
  61. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/claude-code-transcript.ts +460 -0
  62. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/coherence-checks.ts +806 -0
  63. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/consumer-enumeration.ts +75 -0
  64. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/dead-branch-push-audit.ts +55 -0
  65. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/dead-branch-push-guard.ts +77 -0
  66. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/derive-section7.ts +66 -0
  67. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/derive-tranche.ts +94 -0
  68. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/diagram-model.ts +372 -0
  69. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/direct-main-push.ts +39 -0
  70. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/dispatch-gate.ts +268 -0
  71. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/doc-owners.ts +314 -0
  72. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/docs/build-doc-nav.ts +38 -0
  73. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/docs/docs-coherence.ts +136 -0
  74. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/docs/index.ts +29 -0
  75. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/docs/legacy-anchors.ts +48 -0
  76. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/docs/nav-helpers.ts +17 -0
  77. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/docs/node-route.ts +134 -0
  78. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/docs/parse-doc.ts +43 -0
  79. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/docs/published-prose.ts +306 -0
  80. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/docs/surfaced-manifest.ts +89 -0
  81. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/docs/types.ts +33 -0
  82. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/doctrine-no-procedures.ts +94 -0
  83. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/doctrine-portability.ts +293 -0
  84. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/doctrine-source.ts +23 -0
  85. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/ensure-label.ts +28 -0
  86. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/file-classify.ts +51 -0
  87. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/first-push-dispatch-gate.ts +80 -0
  88. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/gate-audience.ts +171 -0
  89. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/index.ts +323 -0
  90. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/issue-assignment.ts +102 -0
  91. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/issue-validation.ts +746 -0
  92. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/leftover-detection.ts +68 -0
  93. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/local-anchor-coverage.ts +245 -0
  94. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/main-branch-refusal.ts +64 -0
  95. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/manifest-validity.ts +81 -0
  96. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/markdown-table.ts +76 -0
  97. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/metering-io-guard.ts +119 -0
  98. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/milestone-validation.ts +240 -0
  99. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/no-disk-state.ts +50 -0
  100. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/parse-ledger.ts +77 -0
  101. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/parse-registry.ts +105 -0
  102. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/parse-token-report.ts +177 -0
  103. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/parse-tranche.ts +183 -0
  104. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/pr-report-density.ts +116 -0
  105. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/pr-tier.ts +81 -0
  106. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/premise-check.ts +160 -0
  107. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/quoted-command.ts +275 -0
  108. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/reader-resolvable-prose.ts +320 -0
  109. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/registry-checks.ts +255 -0
  110. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/registry-parse.ts +164 -0
  111. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/registry-scaffold.ts +216 -0
  112. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/report-tokens.ts +261 -0
  113. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/retired-vocabulary.ts +163 -0
  114. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/review-gate.ts +378 -0
  115. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/review-status.ts +200 -0
  116. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/single-plan-pr.ts +73 -0
  117. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/state-machine-model.ts +269 -0
  118. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/state-source.ts +14 -0
  119. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/status-block.ts +8 -0
  120. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/sum-ledger.ts +26 -0
  121. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/symbol-collisions.ts +78 -0
  122. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/test-plan-gate.ts +117 -0
  123. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/test-plan-section.ts +65 -0
  124. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/types.ts +174 -0
  125. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/verdict-extraction.ts +199 -0
  126. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/vocabulary-citation.ts +114 -0
  127. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/waiver-label.ts +52 -0
  128. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/workspace-escape.ts +172 -0
  129. package/studio-standalone/apps/vinaya-studio/web/.next/BUILD_ID +1 -1
  130. package/studio-standalone/apps/vinaya-studio/web/.next/build-manifest.json +3 -3
  131. package/studio-standalone/apps/vinaya-studio/web/.next/prerender-manifest.json +3 -3
  132. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_global-error.html +1 -1
  133. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_global-error.rsc +1 -1
  134. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
  135. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  136. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  137. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  138. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  139. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_not-found/page/server-reference-manifest.json +2 -2
  140. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_not-found/page.js.nft.json +1 -1
  141. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  142. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/api/coherence/route.js.nft.json +1 -1
  143. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/page/server-reference-manifest.json +2 -2
  144. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/page.js.nft.json +1 -1
  145. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/page_client-reference-manifest.js +1 -1
  146. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/backlog/page/server-reference-manifest.json +2 -2
  147. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/backlog/page.js.nft.json +1 -1
  148. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/backlog/page_client-reference-manifest.js +1 -1
  149. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/page/server-reference-manifest.json +2 -2
  150. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/page.js.nft.json +1 -1
  151. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/page_client-reference-manifest.js +1 -1
  152. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/page/server-reference-manifest.json +2 -2
  153. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/page.js.nft.json +1 -1
  154. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/page_client-reference-manifest.js +1 -1
  155. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/tranches/[slug]/page/server-reference-manifest.json +2 -2
  156. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/tranches/[slug]/page.js.nft.json +1 -1
  157. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/tranches/[slug]/page_client-reference-manifest.js +1 -1
  158. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/tranches/[slug]/tasks/[taskId]/page/server-reference-manifest.json +2 -2
  159. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/tranches/[slug]/tasks/[taskId]/page.js.nft.json +1 -1
  160. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/tranches/[slug]/tasks/[taskId]/page_client-reference-manifest.js +1 -1
  161. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/page/server-reference-manifest.json +2 -2
  162. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/page.js.nft.json +1 -1
  163. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/page_client-reference-manifest.js +1 -1
  164. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/tranches/page/server-reference-manifest.json +2 -2
  165. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/tranches/page.js.nft.json +1 -1
  166. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/tranches/page_client-reference-manifest.js +1 -1
  167. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/[root-of-the-server]__02a-3g8._.js +1 -1
  168. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/1q96_modules_@clerk_nextjs_dist_esm_app-router_client_keyless-creator-reader_0lom2js.js +1 -1
  169. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/[root-of-the-server]__0053k9k._.js +1 -1
  170. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/[root-of-the-server]__0112h-k._.js +1 -1
  171. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/[root-of-the-server]__0o771t1._.js +1 -1
  172. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/[root-of-the-server]__0puovz5._.js +1 -1
  173. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/[root-of-the-server]__1hs0dcu._.js +1 -1
  174. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/{[root-of-the-server]__069-_41._.js → [root-of-the-server]__1pndh3_._.js} +1 -1
  175. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/[root-of-the-server]__1wc4-ip._.js +1 -1
  176. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/_03x_w6q._.js +1 -1
  177. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/_0gvm3og._.js +1 -1
  178. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/_0lwxg63._.js +1 -1
  179. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/_1fqw88f._.js +2 -2
  180. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/_1n0cnq-._.js +1 -1
  181. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/apps_vinaya-studio_web_src_app_studio_projects_[name]_tranches_[slug]_05ufo8i._.js +3 -3
  182. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/node_modules_1vo08dj._.js +2 -2
  183. package/studio-standalone/apps/vinaya-studio/web/.next/server/middleware-build-manifest.js +3 -3
  184. package/studio-standalone/apps/vinaya-studio/web/.next/server/pages/500.html +1 -1
  185. package/studio-standalone/apps/vinaya-studio/web/.next/server/server-reference-manifest.js +1 -1
  186. package/studio-standalone/apps/vinaya-studio/web/.next/server/server-reference-manifest.json +3 -3
  187. package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/{37hnluhzj9bei.js → 0zebsmmk1bvnb.js} +1 -1
  188. package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/{267o-tsrnuwnv.js → 1uogpj5w2n5ju.js} +1 -1
  189. package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/{1hufpuody6vjv.js → 3m1kgax7j2vgs.js} +1 -1
  190. package/studio-standalone/apps/vinaya-studio/web/package.json +2 -2
  191. /package/studio-standalone/_node_modules/@attalabs/vinaya/studio-standalone/_node_modules/@attalabs/{aeg-core → vinaya/studio-standalone/_node_modules/@attalabs/vinaya/studio-standalone/_node_modules/@attalabs/aeg-core}/bin/verify-coherence.ts +0 -0
  192. /package/studio-standalone/apps/vinaya-studio/web/.next/static/{Fmyc42qzLwFcWWHLLNy-G → QY2GhiI47-765D9DHGy1I}/_buildManifest.js +0 -0
  193. /package/studio-standalone/apps/vinaya-studio/web/.next/static/{Fmyc42qzLwFcWWHLLNy-G → QY2GhiI47-765D9DHGy1I}/_clientMiddlewareManifest.js +0 -0
  194. /package/studio-standalone/apps/vinaya-studio/web/.next/static/{Fmyc42qzLwFcWWHLLNy-G → QY2GhiI47-765D9DHGy1I}/_ssgManifest.js +0 -0
@@ -0,0 +1,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
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Direct-main-push detection (aeg-governance-hardening task 24, #364,
3
+ * Part 3 — item 1). Pure — no `fs`, no `gh`/`git` shell-outs. The CLI shim
4
+ * (`bin/check-direct-main-push.ts`) makes the one forge call (the
5
+ * commits→pulls association API: `GET /repos/{owner}/{repo}/commits/{sha}/pulls`,
6
+ * which GitHub documents as returning "the merged Pull Request that
7
+ * introduced the commit to the repository" for a commit on the default
8
+ * branch) and passes the result in here.
9
+ *
10
+ * Detection only, never prevention here: prevention lives at
11
+ * `.husky/pre-push` (ring 0) and the repository ruleset (ring 1). This
12
+ * predicate only decides whether the pushed commit is
13
+ * legitimate (associated with a merged PR) — the CLI shim opens an
14
+ * incident Issue and fails the run loudly on a violation; it never reverts
15
+ * or mutates anything (Principal decision, brief §11).
16
+ */
17
+
18
+ export type DirectMainPushFact = {
19
+ sha: string
20
+ /** PR numbers the commits→pulls association API reports as MERGED for this commit. Empty when the commit has no associated merged PR — i.e. it was pushed directly. */
21
+ associatedMergedPrNumbers: number[]
22
+ }
23
+
24
+ export type DirectMainPushResult =
25
+ | { verdict: 'legitimate'; mergedPrNumber: number }
26
+ | { verdict: 'direct-push'; sha: string }
27
+
28
+ /**
29
+ * `legitimate` — at least one merged PR is associated with this commit (the
30
+ * normal case: a squash-merge or merge commit that landed via a PR).
31
+ * `direct-push` — no associated merged PR at all. This is the violation:
32
+ * either a genuine direct push, or (equivalently suspicious) a commit the
33
+ * association API cannot explain.
34
+ */
35
+ export function checkDirectMainPush(fact: DirectMainPushFact): DirectMainPushResult {
36
+ const [first] = fact.associatedMergedPrNumbers
37
+ if (first !== undefined) return { verdict: 'legitimate', mergedPrNumber: first }
38
+ return { verdict: 'direct-push', sha: fact.sha }
39
+ }
@@ -0,0 +1,268 @@
1
+ /**
2
+ * Dispatch-readiness composition (aeg-governance-hardening task 11, #324).
3
+ * Pure — no `fs`, no `fetch`. Composes the forge/rationale/provenance/
4
+ * archival facts the CLI shim (`bin/verify-dispatch.ts`) gathers (reusing
5
+ * `parseTranche`, `hasProvenance`, `taskRefFromBranch`,
6
+ * `checkIssueRationale`, `fetchProvenance` — never re-implementing any of
7
+ * them) into one `{ ready, blockers }` verdict, one blocker string per
8
+ * failing predicate.
9
+ *
10
+ * Every predicate here mechanizes a prose precondition that `roles/
11
+ * developer.md`'s entry gate and `contracts/brief-developer.md` currently
12
+ * state as something the Developer must re-derive by hand — the exact gap
13
+ * this task exists to close: four Developer agents independently re-derived
14
+ * (and stopped on) the same archival fact from scratch, at real token cost,
15
+ * hours after it first became true (aeg-governance-hardening's 2026-07-02/03
16
+ * live-fire wave).
17
+ *
18
+ * Message style mirrors `coherence-checks.ts`'s A1/A2/T2/etc. family: one
19
+ * line per failure, naming the exact task/Issue/PR involved.
20
+ */
21
+
22
+ import type { Task } from './types'
23
+ import { isPrincipal, PRINCIPAL_ALLOWLIST } from './waiver-label'
24
+
25
+ export type DispatchIssueFact = { number: number; state: 'open' | 'closed' } | null
26
+
27
+ export type DispatchEdgeFact = {
28
+ /** The edge's target task id, as written in the topology's depends-on/conflicts-with cell. */
29
+ id: string
30
+ /** The target task's Issue number, if known — for message readability only. */
31
+ issue: number | null
32
+ }
33
+
34
+ export type DispatchDependsOnFact = DispatchEdgeFact & {
35
+ merged: boolean
36
+ /**
37
+ * `false` when the resolver could not find any tranche/task/Issue matching
38
+ * this edge at all — a fact distinct from `merged: false`, which means the
39
+ * edge resolved to a real target that just hasn't merged yet. Conflating
40
+ * the two produced a false "not merged yet" claim for #193's
41
+ * `vinaya-milestone-model-v1 2` edge, which had genuinely already merged
42
+ * (#196) — the message this distinction exists to correct. Absent or
43
+ * `true` for every edge the resolver actually matched to a target,
44
+ * including one whose target lookup itself then failed (an outage), which
45
+ * stays under the existing conservative `merged: false` default rather
46
+ * than this one.
47
+ */
48
+ resolved?: boolean
49
+ /**
50
+ * Hand-close recognition facts (task `vinaya-engine-v1` 21, #99) — a
51
+ * second, narrower path alongside `merged` for a dependency Issue closed
52
+ * directly by a recognized Principal, with a stated `COMPLETED` reason,
53
+ * rather than via a merged PR. All three are `null`/absent when the edge
54
+ * is unresolvable or the target issue is open/has no close event — in
55
+ * which case this path never fires and only `merged` matters, same as
56
+ * before this task.
57
+ */
58
+ issueState?: 'open' | 'closed' | null
59
+ stateReason?: 'completed' | 'not_planned' | null
60
+ closedByActor?: string | null
61
+ }
62
+
63
+ /**
64
+ * True when a dependency Issue was closed directly by a recognized Principal
65
+ * with a stated `COMPLETED` reason — the second, narrower "done" path this
66
+ * task adds alongside `merged`. Deliberately conjunctive and centralized
67
+ * here (not duplicated per caller): the trap this task exists to avoid is
68
+ * "any closed Issue with a comment counts," so every condition below is a
69
+ * real forge fact, not a prose claim —
70
+ * - `issueState === 'closed'`: the Issue is actually closed.
71
+ * - `stateReason === 'completed'`: GitHub's own close reason says resolved,
72
+ * not `not_planned` (abandoned — the opposite of resolved).
73
+ * - `closedByActor` is the GitHub login that performed the CLOSED_EVENT
74
+ * (not a claim in a comment body) and is a member of the recognized
75
+ * Principal allowlist.
76
+ */
77
+ function isHandClosedByRecognizedPrincipal(
78
+ dep: Pick<DispatchDependsOnFact, 'issueState' | 'stateReason' | 'closedByActor'>,
79
+ principalAllowlist: string[]
80
+ ): boolean {
81
+ return (
82
+ dep.issueState === 'closed' &&
83
+ dep.stateReason === 'completed' &&
84
+ isPrincipal(dep.closedByActor ?? null, principalAllowlist)
85
+ )
86
+ }
87
+
88
+ export type DispatchConflictsWithFact = DispatchEdgeFact & { openOrInFlight: boolean }
89
+
90
+ /**
91
+ * Removed the predicate that consumed this fact
92
+ * (`checkDispatchReadiness` no longer blocks on it) — automated the
93
+ * signal (per-task provenance posting) this predicate existed to protect,
94
+ * making the row-adjacency gate itself the stale part, not the automation.
95
+ * Kept, dormant, rather than deleted: several callers (`bin/verify-dispatch.ts`,
96
+ * both Studio/Vinaya `map-dispatch-input.ts` mappers) still assemble this
97
+ * fact as harmless dead plumbing, and `DispatchGateInput.priorTask` below
98
+ * remains the exact shape needed to test the predicate's absence.
99
+ */
100
+ export type DispatchPriorTaskFact = {
101
+ id: string
102
+ issue: number | null
103
+ issueClosed: boolean
104
+ prMerged: boolean
105
+ hasProvenance: boolean
106
+ }
107
+
108
+ export type DispatchPriorTrancheFact = {
109
+ project: string
110
+ /** null when this project has no prior tranche at all — the gate passes trivially for it. */
111
+ priorTrancheSlug: string | null
112
+ archived: boolean
113
+ }
114
+
115
+ export type DispatchGateInput = {
116
+ trancheSlug: string
117
+ /** The topology row for this task, as parsed by `parseTranche`. */
118
+ task: Task
119
+ /** null when the task has no Issue (#TBD/blank) OR the Issue number doesn't resolve on the forge (phantom ref). */
120
+ issue: DispatchIssueFact
121
+ /** `checkIssueRationale` result for this task's Issue — irrelevant (treated as passing) when `issue` is null. */
122
+ issueRationalePass: boolean
123
+ dependsOn: DispatchDependsOnFact[]
124
+ conflictsWith: DispatchConflictsWithFact[]
125
+ /**
126
+ * The immediately-prior task in this same tranche's topology, or null
127
+ * when this is the first task. no longer read by
128
+ * `checkDispatchReadiness` — dormant field, kept for caller compatibility.
129
+ */
130
+ priorTask: DispatchPriorTaskFact | null
131
+ /** One entry per project named in `task.projects`. */
132
+ priorTrancheArchival: DispatchPriorTrancheFact[]
133
+ /**
134
+ * Overrides `PRINCIPAL_ALLOWLIST` for hand-close recognition when
135
+ * provided — an adopter repo's own `vinaya.config.json` `principals`
136
+ * field, resolved by the CLI bin before calling in (never read from here;
137
+ * this stays pure). Defaults to `PRINCIPAL_ALLOWLIST` when omitted, same
138
+ * pattern as `checkReviewGate`'s `principalAllowlist` (`review-gate.ts`) —
139
+ * hardcoding this repo's own principal made that gate unpassable on any
140
+ * adopter repo; the same mistake here would silently do the same to the
141
+ * hand-close path.
142
+ */
143
+ principalAllowlist?: string[]
144
+ }
145
+
146
+ export type DispatchResult = { ready: boolean; blockers: string[] }
147
+
148
+ /**
149
+ * True when a parsed `depends-on` edge points back at its own host task.
150
+ *
151
+ * A self-dependency is unsatisfiable by construction, so it is never a real
152
+ * gate state — its presence is proof of a defect in the edge text or in its
153
+ * resolution. `parseRationaleDeps` (`@attalabs/aeg-forge-state`) reads only
154
+ * a labeled `Depends-on:`/`Conflicts-with:` field's own comma-separated,
155
+ * id-shaped tokens; since Issue #347 an unlabeled/bare span elsewhere in the
156
+ * "Dependency rationale" section is never read as an edge, whatever its
157
+ * shape (`parse-rationale-deps.ts`'s own module comment states the rule). A
158
+ * slug-qualified token in that list (`aeg-governance-hardening #368`) still
159
+ * resolves its trailing bare number against the NAMED tranche, not the host
160
+ * — a resolver bug there is what would land a task depending on itself, on
161
+ * that tranche's own task `1`.
162
+ *
163
+ * Reported as an INTERNAL error rather than through the ordinary
164
+ * "not merged yet" branch below, because that message reads as a legitimate
165
+ * serialization and invites the reader to route around it: two Developer
166
+ * agents faced with exactly this gate concluded it was a false positive and
167
+ * committed with `--no-verify` to get past their hooks. Naming it a tool bug
168
+ * converts "agent improvises around a nonsense gate" into "agent escalates,"
169
+ * which is the behavior the gate architecture assumes.
170
+ *
171
+ * Two arms, because the edge can carry either shape: a resolved Issue number
172
+ * equal to this task's own, or — the live case — a bare same-tranche id equal
173
+ * to this task's own id.
174
+ */
175
+ function isSelfDependency(dep: DispatchDependsOnFact, task: Task, issue: DispatchIssueFact): boolean {
176
+ if (dep.issue !== null && issue !== null && dep.issue === issue.number) return true
177
+ const bare = dep.id.trim().replace(/^#/, '')
178
+ return bare.toLowerCase() === task.id.trim().toLowerCase()
179
+ }
180
+
181
+ export function checkDispatchReadiness(input: DispatchGateInput): DispatchResult {
182
+ const { trancheSlug, task } = input
183
+ const taskLabel = `task ${task.id} (tranche ${trancheSlug})`
184
+ const principalAllowlist = input.principalAllowlist ?? PRINCIPAL_ALLOWLIST
185
+ const blockers: string[] = []
186
+
187
+ // Issue-existence — the topology row itself has no Issue number.
188
+ if (task.issue === null) {
189
+ blockers.push(
190
+ `dispatch-gate issue-existence: ${taskLabel} has no Issue (#TBD or blank) in the topology — not dispatchable until the Planner cuts the Issue.`
191
+ )
192
+ } else if (input.issue === null) {
193
+ // Row names an Issue number, but it doesn't resolve on the forge — phantom ref (T1's fail class).
194
+ blockers.push(
195
+ `dispatch-gate issue-existence: ${taskLabel} names Issue #${task.issue}, but it does not resolve to a real GitHub Issue (phantom reference).`
196
+ )
197
+ }
198
+
199
+ // Planner-rationale completeness — only evaluable when the Issue itself resolved.
200
+ if (input.issue !== null && !input.issueRationalePass) {
201
+ blockers.push(
202
+ `dispatch-gate rationale: Issue #${input.issue.number} for ${taskLabel} fails the rationale gate (checkIssueRationale) — the Planner must complete the eight-field rationale before this task is dispatchable.`
203
+ )
204
+ }
205
+
206
+ // Depends-on merged — OR hand-closed by a recognized Principal (task
207
+ // `vinaya-engine-v1` 21, #99): a second, narrower path for a dependency
208
+ // Issue closed directly rather than via a merged PR.
209
+ for (const dep of input.dependsOn) {
210
+ // Evaluated BEFORE the unresolvable branch: a self-reference that also
211
+ // failed to resolve is still a parser bug, and reporting it as an
212
+ // unresolvable edge would send the reader to correct edge text that is
213
+ // not actually the fault.
214
+ if (isSelfDependency(dep, task, input.issue)) {
215
+ const issueStr = input.issue !== null ? `#${input.issue.number}` : '?'
216
+ blockers.push(
217
+ `dispatch-gate INTERNAL: parsed a self-dependency for task ${task.id} (${issueStr}) — this is a parser bug in parseRationaleDeps, not a real dependency. Please report it upstream. Re-run once the rationale is corrected or the fix ships.`
218
+ )
219
+ continue
220
+ }
221
+ if (dep.resolved === false) {
222
+ // Distinct from the "not merged yet" branch below (#196): this edge
223
+ // never resolved to any tranche/task/Issue at all, so a "not merged"
224
+ // claim would misattribute the failure to the forge rather than to
225
+ // the edge text. Still blocks — the conservative default is correct
226
+ // for a genuinely unresolvable edge — but says so honestly.
227
+ blockers.push(
228
+ `dispatch-gate depends-on: ${taskLabel} depends on "${dep.id}", which is UNRESOLVABLE — the resolver could not find a matching tranche/task/Issue for this edge (not a claim about merge status). Not dispatchable until the edge is corrected.`
229
+ )
230
+ continue
231
+ }
232
+ if (!dep.merged && !isHandClosedByRecognizedPrincipal(dep, principalAllowlist)) {
233
+ const issueStr = dep.issue !== null ? ` (#${dep.issue})` : ''
234
+ blockers.push(
235
+ `dispatch-gate depends-on: ${taskLabel} depends on ${dep.id}${issueStr}, whose PR is not merged yet — not dispatchable, it serializes behind it.`
236
+ )
237
+ }
238
+ }
239
+
240
+ // Conflicts-with not open/in-flight.
241
+ for (const c of input.conflictsWith) {
242
+ if (c.openOrInFlight) {
243
+ const issueStr = c.issue !== null ? ` (#${c.issue})` : ''
244
+ blockers.push(
245
+ `dispatch-gate conflicts-with: ${taskLabel} conflicts with ${c.id}${issueStr}, whose PR is open or in-flight — not dispatchable until it merges.`
246
+ )
247
+ }
248
+ }
249
+
250
+ // Prior-task archival / row-adjacency predicate REMOVED (2026-07-13).
251
+ // The retired predicate required every earlier table row's full archival (Issue
252
+ // closed, PR merged, provenance posted) regardless of whether that row was
253
+ // a declared dependency automated the provenance-posting signal
254
+ // this existed to protect, so the blanket row-order block outlived its
255
+ // justification. `input.priorTask` is still accepted (dormant) for caller
256
+ // compatibility; see the type's doc comment above.
257
+
258
+ // Prior-tranche archival, per project named in Project(s).
259
+ for (const proj of input.priorTrancheArchival) {
260
+ if (proj.priorTrancheSlug !== null && !proj.archived) {
261
+ blockers.push(
262
+ `dispatch-gate prior-tranche-archival: project \`${proj.project}\`'s previous tranche \`${proj.priorTrancheSlug}\` is not archived — the Tranche Archivist must run before new work on this product.`
263
+ )
264
+ }
265
+ }
266
+
267
+ return { ready: blockers.length === 0, blockers }
268
+ }