@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@attalabs/vinaya",
3
- "version": "0.22.0",
3
+ "version": "0.24.0",
4
4
  "description": "Vinaya — Agentic Engineering Harness. Deterministic checks every AI coding agent must satisfy before merge.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -0,0 +1,173 @@
1
+ /**
2
+ * actions.ts — the canonical, aeg-core-owned enumeration of every AEG action
3
+ * that either crosses into GitHub or hands work across a role-seam contract.
4
+ * Pure data, zero I/O, zero imports beyond the type itself — same discipline
5
+ * as `waiver-label.ts`.
6
+ *
7
+ * Two consumers read this one list (Issue #505): G3's "no seventh way into
8
+ * GitHub" completeness story (`registry-checks.ts`) targets the
9
+ * `crosses: 'into-github'` subset against `enforcement.md`'s Ring-0 gate rows;
10
+ * task 506's DiagramModel places each action both inside a ring-0 gate (as a
11
+ * guarded crossing) and on the edge between role/contract nodes (as a seam
12
+ * hand-off). One list, so an `ACTIONS.length`-driven diagram edge count and
13
+ * G3's crossing list can never drift apart.
14
+ *
15
+ * The set is 11 distinct actions: the 6 crossings named in Issue #505 plus 5
16
+ * seam-only actions (4 original, plus `create-the-milestone`,
17
+ * vinaya-milestone-model-v1 task 2). Two of the six contract seams are already
18
+ * accomplished by a GitHub crossing (the Planner→Brief seam's carrier is the
19
+ * Issue body, created by `create-a-task-issue`; the Developer→Reviewer seam's
20
+ * carrier is the PR, created by `open-a-pull-request`), so they get no
21
+ * duplicate id — inventing one would give two ids for one real act and break
22
+ * any `ACTIONS.length`-driven edge count.
23
+ *
24
+ * `crosses` is factually per-action, not a mirror of Issue #505's prose:
25
+ * `commit-the-work` is `'none'` because `git commit` never leaves the local
26
+ * machine — it is `git push` (`publish-the-branch`) that reaches the
27
+ * GitHub-hosted remote. `enforcement.md`'s own Ring-0 table lists them as
28
+ * separate rows (`.husky/pre-commit` vs `.husky/pre-push`) for exactly this
29
+ * reason..
30
+ *
31
+ * The only valid `performedBy` values are the 9 `role_id`s under
32
+ * `aeg-root/roles/*.md` (task 2, #522) — asserted by the real-file test.
33
+ */
34
+
35
+ export type ActionCrossing = 'into-github' | 'none'
36
+
37
+ export type Action = {
38
+ id: string
39
+ label: string
40
+ crosses: ActionCrossing
41
+ performedBy: string[] // role_id[]
42
+ /** The rhetorical question — what a reader recognises. */
43
+ summary: string
44
+ /** What the action actually is, plainly. The counterpart to `summary`:
45
+ * the question earns attention, this answers it. Required, so a new action
46
+ * cannot be added without one — the same bar `roles/*.md` and
47
+ * `contracts/*.md` hold via their `description:` frontmatter. */
48
+ description: string
49
+ }
50
+
51
+ export const ACTIONS: Action[] = [
52
+ {
53
+ id: 'publish-the-branch',
54
+ label: 'publish the branch',
55
+ crosses: 'into-github',
56
+ performedBy: ['developer'],
57
+ summary: 'Ever had a branch pushed straight to main by mistake?',
58
+ description: 'Pushing local commits up to GitHub, where the rest of the mechanism can finally see them.'
59
+ },
60
+ {
61
+ id: 'create-a-task-issue',
62
+ label: 'create a task issue',
63
+ crosses: 'into-github',
64
+ performedBy: ['planner'],
65
+ summary: 'Ever opened a ticket that turned out to have zero plan behind it?',
66
+ description:
67
+ 'Opening the Issue that a task exists as — its scope, its reasoning and its dependencies, written down before anyone starts.'
68
+ },
69
+ {
70
+ id: 'open-a-pull-request',
71
+ label: 'open a pull request',
72
+ crosses: 'into-github',
73
+ performedBy: ['developer'],
74
+ summary: 'Ever gotten a PR with no explanation of what it does or why?',
75
+ description:
76
+ 'Proposing finished work for review, carrying the account of what changed and which intent it came from.'
77
+ },
78
+ {
79
+ id: 'revise-a-pull-request',
80
+ label: 'revise a pull request',
81
+ crosses: 'into-github',
82
+ performedBy: ['developer', 'planner'],
83
+ summary: 'Ever seen a PR quietly gutted after it already passed review?',
84
+ description:
85
+ 'Editing a pull request after it exists — its code, its title or its description, whether or not review already happened.'
86
+ },
87
+ {
88
+ id: 'grant-a-waiver',
89
+ label: 'grant a waiver',
90
+ crosses: 'into-github',
91
+ performedBy: ['principal'],
92
+ summary: 'Ever had someone bypass a rule just by typing the right words?',
93
+ description:
94
+ 'Deliberately excusing a rule for one case — an authority the mechanism grants to a person, never to an agent.'
95
+ },
96
+ {
97
+ id: 'commit-the-work',
98
+ label: 'commit the work',
99
+ crosses: 'none',
100
+ performedBy: ['developer'],
101
+ summary: "Ever had a commit land that plain doesn't build?",
102
+ description: 'Recording a change locally — the last moment it costs nothing to catch a mistake.'
103
+ },
104
+ {
105
+ id: 'author-the-brief',
106
+ label: 'author the brief',
107
+ crosses: 'none',
108
+ performedBy: ['brief-author'],
109
+ summary: 'Ever started a task with no clear idea of what "done" means?',
110
+ description:
111
+ 'Turning one intent into instructions someone can execute: what to build, what is out of scope, and what done means.'
112
+ },
113
+ {
114
+ id: 'produce-the-verdict',
115
+ label: 'produce the verdict',
116
+ crosses: 'none',
117
+ performedBy: ['reviewer', 'security'],
118
+ summary: 'Ever gotten a review that was really just a rubber stamp?',
119
+ description: 'Judging finished work against the brief it came from, and saying plainly whether it passes.'
120
+ },
121
+ {
122
+ id: 'post-provenance-comment',
123
+ label: 'post the provenance comment',
124
+ crosses: 'none',
125
+ performedBy: ['archivist'],
126
+ summary: 'Ever needed to know exactly what shipped, and why, six months later?',
127
+ description: 'Writing the permanent record of a merged task — what shipped, from what intent, checked by whom.'
128
+ },
129
+ {
130
+ id: 'write-the-retrospective',
131
+ label: 'write the retrospective',
132
+ crosses: 'none',
133
+ performedBy: ['tranche-archivist'],
134
+ summary: 'Ever finished a big chunk of work with nobody writing down what was learned?',
135
+ description: 'Closing out a finished phase of work by recording what actually happened and what it taught.'
136
+ },
137
+ {
138
+ id: 'create-the-milestone',
139
+ label: 'create the milestone',
140
+ // Not into-github: `crosses` marks the specific Ring-0 local-git-hook
141
+ // crossing class G3 polices (raw `gh issue create`/`gh pr create`/`git
142
+ // push` bypass), not "touches the GitHub API" generally — the same
143
+ // reason `post-provenance-comment`/`produce-the-verdict` are 'none'
144
+ // despite writing PR comments via the API. `vinaya milestone create`
145
+ // is a manually-invoked CLI command with no Ring-0 hook intercepting a
146
+ // raw bypass, so it stays seam-only here (vinaya-milestone-model-v1
147
+ // task 2).
148
+ crosses: 'none',
149
+ performedBy: ['architect'],
150
+ summary: 'Ever had a Milestone whose title quietly became the only place a version lived?',
151
+ description:
152
+ "Declaring a product goal as a Milestone — free-text title, prose goal, an optional Release: field as the version's sole authority, and an optional list of which tranches serve it."
153
+ }
154
+ ]
155
+
156
+ // A stable keyword per into-github action that must appear (case-insensitive)
157
+ // in some real Ring-0 gate row's action text — the "feeds G3" tripwire from
158
+ // Issue #505. If enforcement.md ever drops the gate row backing a crossing,
159
+ // the corresponding assertion fails. grant-a-waiver's keyword is
160
+ // `pull request`: granting a waiver is an act on a pull request (labeling it),
161
+ // honored at the Ring-0 PR-creation/editing gate and refused-from-agents at
162
+ // the raw-API gate — both reference pull requests. See PR body.
163
+ //
164
+ // Consumed by both `actions.test.ts` (the crossing tripwire) and
165
+ // `diagram-model.ts` (the `guards` gate→action edges), so the one keyword map
166
+ // can never drift between them (same no-drift principle as ACTIONS).
167
+ export const CROSSING_KEYWORDS: Record<string, string> = {
168
+ 'publish-the-branch': 'git push',
169
+ 'create-a-task-issue': 'task issue',
170
+ 'open-a-pull-request': 'pull request',
171
+ 'revise-a-pull-request': 'pull request',
172
+ 'grant-a-waiver': 'pull request'
173
+ }
@@ -0,0 +1,118 @@
1
+ /**
2
+ * AEG anchored-region grammar (aeg-governance-hardening task 30, #393). Pure —
3
+ * no `fs`, no `gh`/`git`.
4
+ *
5
+ * One anchor syntax for every gate-read field: an HTML comment pair
6
+ * `<!-- AEG:<FIELD>:START -->` … `<!-- AEG:<FIELD>:END -->` delimiting the
7
+ * field's one canonical home inside a PR/Issue body. HTML comments render
8
+ * invisibly on the forge, survive inside `<details>` blocks, and can never be
9
+ * produced accidentally by freeform prose — which is exactly the failure class
10
+ * this closes (PR #392's pasted reference brief duplicated its own Test Plan
11
+ * inside the PR body; #363/#377 were the same shape as real bugs).
12
+ *
13
+ * Recognition semantics, shared by every consumer
14
+ * (`pr-tier.ts`, `test-plan-section.ts`, `premise-check.ts`,
15
+ * `brief-validation.ts`/`archive-task.ts`'s Project read, and the `Closes #N`
16
+ * reads in `coherence-checks.ts`/`archive-task.ts`):
17
+ *
18
+ * - **Anchors are additive, never required.** A body with no anchor pair for
19
+ * a field parses byte-identically to the pre-anchor behavior — every
20
+ * already-merged PR/Issue keeps reading exactly as today.
21
+ * - **When a pair is present, it is authoritative.** The consumer reads the
22
+ * field exclusively from inside the pair and ignores identical-looking
23
+ * text anywhere else in the body — there is deliberately no fallback to
24
+ * body-wide search, since falling back would resurrect the decoy problem
25
+ * the anchor exists to solve.
26
+ * - **First pair wins** when a field is (incorrectly) anchored twice —
27
+ * mirroring the first-match-wins philosophy of the existing prose parsers.
28
+ * - **A `START` with no following `END` is treated as no anchor at all**
29
+ * (prose fallback) — a malformed half-pair must not be able to hide a
30
+ * field from the gates.
31
+ * - **Markers inside fenced code blocks or inline code spans do not count**
32
+ * — example/quoted anchor syntax in documentation or evidence output is
33
+ * never mistaken for a real anchor (same code-stripping philosophy as
34
+ * `archive-task.ts`'s `stripCode`, #311 regression).
35
+ *
36
+ * The field line/block goes on its own line(s) INSIDE the pair — e.g.
37
+ *
38
+ * <!-- AEG:TIER:START -->
39
+ * **Tier:** 1
40
+ * <!-- AEG:TIER:END -->
41
+ *
42
+ * Deliberately minimal: seven field names, one shape. This is a delimiting
43
+ * convention, not a metadata DSL; resist adding structure to it.
44
+ *
45
+ * `EVIDENCE` (fix/pr-report-emitter) differs from the other six in one way:
46
+ * it is never hand-typed. `vinaya pr report --write` is the only writer, and
47
+ * `check-evidence-fresh` is the only reader — no prose-fallback recognition
48
+ * exists or is planned for this field, unlike the anchor-optional grammar
49
+ * described above for the other five.
50
+ */
51
+
52
+ import { maskCode } from '@attalabs/aeg-forge-state/strip-code'
53
+
54
+ /**
55
+ * The seven gate-read fields with an anchored home. `TOKENS` (task 5, #378)
56
+ * joined here rather than through a second, parallel field list —
57
+ * `pr-report.ts`'s own `AEG:TOKENS:START`/`:END` markers already use this
58
+ * exact grammar, so the registry is the fix, not a workaround beside it.
59
+ */
60
+ export const ANCHOR_FIELDS = ['CLOSES', 'PROJECT', 'TIER', 'PREMISE', 'TEST-PLAN', 'EVIDENCE', 'TOKENS'] as const
61
+
62
+ export type AnchorField = (typeof ANCHOR_FIELDS)[number]
63
+
64
+ /**
65
+ * The code-recognition grammar — `stripCode`, `maskCode` and their fence /
66
+ * indented-block / inline-span scanners — moved down to
67
+ * `@attalabs/aeg-forge-state` (`strip-code.ts`) so `projectFieldFromBody` can
68
+ * read a body "minus its examples" through the SAME implementation rather than
69
+ * a second copy of it. `aeg-core` already depends on `aeg-forge-state`, so the
70
+ * grammar could only be shared by moving it down; importing upward would close
71
+ * a cycle. It is re-exported here because `anchored-region` remains this
72
+ * package's documented home for body-parsing primitives and every existing
73
+ * `import { stripCode } from './anchored-region'` call site is unchanged.
74
+ */
75
+ export { stripCode } from '@attalabs/aeg-forge-state'
76
+ export type { StripCodeOptions } from '@attalabs/aeg-forge-state'
77
+
78
+ /**
79
+ * The START/END markers' positions in `body`, found the same code-blind way
80
+ * `anchoredRegion` finds its content — `null` under the identical conditions
81
+ * `anchoredRegion` returns `null`. `outerStart`/`outerEnd` span the markers
82
+ * themselves; `innerStart`/`innerEnd` span the content between them (what
83
+ * `anchoredRegion` returns as a string).
84
+ *
85
+ * Exists so a WRITER (`vinaya pr report --write`'s `replaceEvidenceBlock`)
86
+ * can target the same real, non-fenced pair a reader would — a naive
87
+ * `body.indexOf(START)` finds whichever copy comes first in raw text, fenced
88
+ * decoy included, and a body that quotes a worked example of its own anchor
89
+ * (exactly what this task's own PR body does, in its Test Plan evidence)
90
+ * would get ITS OWN replacement written into the quoted example instead of
91
+ * the real field. One masked search, shared by the reader and the writer,
92
+ * closes that by construction rather than by convention.
93
+ */
94
+ export function anchoredRegionBounds(
95
+ body: string,
96
+ field: AnchorField
97
+ ): { outerStart: number; outerEnd: number; innerStart: number; innerEnd: number } | null {
98
+ const masked = maskCode(body)
99
+ const start = new RegExp(`<!--\\s*AEG:${field}:START\\s*-->`).exec(masked)
100
+ if (!start) return null
101
+ const innerStart = start.index + start[0].length
102
+ const end = new RegExp(`<!--\\s*AEG:${field}:END\\s*-->`).exec(masked.slice(innerStart))
103
+ if (!end) return null
104
+ const innerEnd = innerStart + end.index
105
+ return { outerStart: start.index, outerEnd: innerEnd + end[0].length, innerStart, innerEnd }
106
+ }
107
+
108
+ /**
109
+ * The text between the first `<!-- AEG:<field>:START -->` and the first
110
+ * `<!-- AEG:<field>:END -->` after it, or `null` when the body carries no
111
+ * (well-formed, non-code) pair for this field. `null` is the signal for
112
+ * consumers to run their unchanged prose/heading recognition.
113
+ */
114
+ export function anchoredRegion(body: string, field: AnchorField): string | null {
115
+ const bounds = anchoredRegionBounds(body, field)
116
+ if (!bounds) return null
117
+ return body.slice(bounds.innerStart, bounds.innerEnd)
118
+ }
@@ -0,0 +1,206 @@
1
+ /**
2
+ * Post-merge Archivist provenance assembly (aeg-governance-hardening
3
+ * task 5d, #309). Pure — no `fs`, no `fetch`, no `process.env`. The CLI shim
4
+ * (`bin/archive-task.ts`) resolves the merged PR via `gh`, gathers
5
+ * `MergedPrFacts`, and calls these functions.
6
+ *
7
+ * The cardinal constraint (`aeg-root/roles/archivist.md`): ASSEMBLE, never
8
+ * author. Every provenance field is copied from a fact the merge already
9
+ * froze — the PR body, PR metadata, or PR comments. A field whose source
10
+ * fact is absent becomes a DANGLING entry, never an inferred or defaulted
11
+ * value. Field/heading shapes below mirror the live provenance comments
12
+ * posted by hand on PRs #302/#305/#306.
13
+ */
14
+
15
+ import { hasLabel } from '@attalabs/aeg-forge-state'
16
+ import { type AnchorField, anchoredRegion, stripCode } from './anchored-region'
17
+ import { headerRegion } from './brief-validation'
18
+ import { readTierFromPrBody } from './pr-tier'
19
+ import { extractCodeReviewVerdict, extractSecurityReviewVerdict } from './verdict-extraction'
20
+
21
+ export type MergedPrFacts = {
22
+ number: number
23
+ headRefName: string
24
+ body: string
25
+ mergedAt: string
26
+ mergeSha: string
27
+ comments: string[]
28
+ }
29
+
30
+ const TASK_BRANCH_PATTERN = /^task\/([^/]+)\/([^/]+)$/
31
+
32
+ /**
33
+ * null when headRefName is not task/<tranche>/<taskId>.
34
+ *
35
+ * This is ONE of two independent eligibility signals for provenance —
36
+ * the branch-name pattern — not the only one. A PR that closes a
37
+ * tranche-labeled Issue from a non-`task/*` branch (e.g. a small ad-hoc
38
+ * `fix/*` cleanup that finally attaches `Closes #N` to a task whose real
39
+ * work already shipped elsewhere) is EQUALLY eligible: the CLI shim
40
+ * (`bin/archive-task.ts`) additionally checks the closed Issue's own
41
+ * `vinaya/tranche:*` label via `gh issue view --json labels` and proceeds if
42
+ * either signal is present. `buildProvenanceBlock` already tolerates a
43
+ * `null` ref (falls back to a branch-name task label), so this function's
44
+ * only remaining job is the branch-name half of that OR — it is no longer
45
+ * the sole gate. (Confirmed gap: a task whose Issue carried a
46
+ * `vinaya/tranche:*` label was closed by a `fix/*`-branch PR;
47
+ * branch-name-only detection silently skipped provenance forever.)
48
+ */
49
+ export function taskRefFromBranch(branch: string): { tranche: string; taskId: string } | null {
50
+ const m = branch.match(TASK_BRANCH_PATTERN)
51
+ if (!m) return null
52
+ return { tranche: m[1] as string, taskId: m[2] as string }
53
+ }
54
+
55
+ /**
56
+ * The eligibility decision itself, pulled out as a pure function so it's
57
+ * testable without mocking `gh` — the CLI shim (`bin/archive-task.ts`) does
58
+ * only the I/O (resolve `ref`, fetch the closed Issue's labels) and hands
59
+ * both facts here. True when EITHER signal holds: a real task-branch `ref`,
60
+ * or the closed Issue's own labels carry an `vinaya/tranche:*` tag.
61
+ */
62
+ export function isEligibleForProvenance(
63
+ ref: { tranche: string; taskId: string } | null,
64
+ issueLabels: string[]
65
+ ): boolean {
66
+ if (ref !== null) return true
67
+ return hasLabel('tranche', issueLabels)
68
+ }
69
+
70
+ const PROVENANCE_HEADING = '### AEG provenance'
71
+
72
+ /** true when any comment already carries the provenance heading (idempotency, scoped to this PR). */
73
+ export function hasProvenance(comments: string[]): boolean {
74
+ return comments.some((c) => c.includes(PROVENANCE_HEADING))
75
+ }
76
+
77
+ /**
78
+ * Tolerant field reader: accepts `Field:` and `**Field:**`, line-anchored,
79
+ * searched ONLY in the header region (shared with `brief-validation.ts`'s
80
+ * gate — gate and archivist read the same region, so a body that passes the
81
+ * gate can't produce a DANGLING field here). Whole-body scanning is the
82
+ * regression from #311's first live run, where a prose sentence *about* the
83
+ * `Ticket:` field in a later section was extracted as the field's value.
84
+ *
85
+ * When `anchor` is given and the body carries that anchor pair
86
+ * (`anchored-region.ts`, task 30), the pair replaces the header region as the
87
+ * one place the field is read from — same recognition the gate side
88
+ * (`checkProjectField`) uses, preserving gate/archivist parity.
89
+ */
90
+ function extractField(body: string, labelPattern: string, anchor?: AnchorField): string | null {
91
+ const anchored = anchor !== undefined ? anchoredRegion(body, anchor) : null
92
+ const region = anchored ?? headerRegion(body)
93
+ const re = new RegExp(`^(?:\\*\\*)?\\s*${labelPattern}\\s*(?:\\*\\*)?\\s*:\\s*(?:\\*\\*)?\\s*([^\\n·]+)`, 'im')
94
+ const m = region.match(re)
95
+ if (!m) return null
96
+ const value = (m[1] as string).trim()
97
+ return value.length > 0 ? value : null
98
+ }
99
+
100
+ function closesRefs(text: string): number[] {
101
+ return [...new Set([...text.matchAll(/Closes #(\d+)/gi)].map((m) => Number(m[1] as string)))]
102
+ }
103
+
104
+ /**
105
+ * Primary Issue = first `Closes #N` in the header block (canonical placement),
106
+ * falling back to the first anywhere in the body (flagged, not lost). Extras
107
+ * are scanned body-wide so a real second closing reference in prose is still
108
+ * flagged — but always fence-stripped, so example text like a Test Plan's
109
+ * `Closes #123` fixture never counts (#311 regression).
110
+ *
111
+ * When the body carries an `AEG:CLOSES` anchor pair (`anchored-region.ts`,
112
+ * task 30), the pair replaces the header block as the canonical placement:
113
+ * the primary Issue is the first `Closes #N` inside the pair (never flagged
114
+ * `outsideHeader` — anchored IS canonical), and extras are still scanned
115
+ * body-wide so stray closing references stay flagged. Bodies without the
116
+ * pair parse exactly as before.
117
+ */
118
+ export function extractIssue(body: string): { issue: number | null; extraIssues: number[]; outsideHeader: boolean } {
119
+ // Strip ONCE over the whole body, then slice — never strip a slice. The
120
+ // strip's block rules read context a fragment no longer carries: an anchor
121
+ // indented inside a list item is list content in the full body (kept, and
122
+ // GitHub *does* auto-close it) but looks like a bare 4-space indented code
123
+ // block once sliced, so stripping the region blanked the reference and
124
+ // returned `issue: null` — the Issue then goes unclosed on merge, the exact
125
+ // stranding this PR exists to eliminate, reintroduced along the over-strip
126
+ // axis (PR #617 review MAJOR). Stripping first also subsumes the decoy
127
+ // protection rather than trading against it: `AEG:*` markers are HTML
128
+ // comments and survive the strip, while a decoy anchor inside code does not
129
+ // survive to be sliced in the first place.
130
+ const stripped = stripCode(body)
131
+ const bodyNums = closesRefs(stripped)
132
+ const anchored = anchoredRegion(stripped, 'CLOSES')
133
+ if (anchored !== null) {
134
+ const anchorNums = closesRefs(anchored)
135
+ if (anchorNums.length === 0) return { issue: null, extraIssues: bodyNums, outsideHeader: false }
136
+ const issue = anchorNums[0] as number
137
+ return { issue, extraIssues: bodyNums.filter((n) => n !== issue), outsideHeader: false }
138
+ }
139
+ const headerNums = closesRefs(headerRegion(stripped))
140
+ if (bodyNums.length === 0) return { issue: null, extraIssues: [], outsideHeader: false }
141
+ const issue = headerNums.length > 0 ? (headerNums[0] as number) : (bodyNums[0] as number)
142
+ return { issue, extraIssues: bodyNums.filter((n) => n !== issue), outsideHeader: headerNums.length === 0 }
143
+ }
144
+
145
+ /** Assemble the block purely from frozen facts; absent facts become DANGLING entries. */
146
+ export function buildProvenanceBlock(facts: MergedPrFacts): {
147
+ block: string
148
+ issue: number | null
149
+ dangling: string[]
150
+ } {
151
+ const ref = taskRefFromBranch(facts.headRefName)
152
+ const dangling: string[] = []
153
+
154
+ const { issue, extraIssues, outsideHeader } = extractIssue(facts.body)
155
+ if (issue === null) {
156
+ dangling.push('no `Closes #N` found in PR body — Issue field is DANGLING, no Issue will be closed')
157
+ }
158
+ if (outsideHeader) {
159
+ dangling.push(
160
+ `\`Closes #${issue}\` was found outside the PR body's header block — the canonical form places it before the first \`##\` heading`
161
+ )
162
+ }
163
+ if (extraIssues.length > 0) {
164
+ dangling.push(
165
+ `PR body references additional Issues (${extraIssues.map((n) => `#${n}`).join(', ')}) beyond the first — closing only #${issue}, the rest are flagged, not closed`
166
+ )
167
+ }
168
+
169
+ const tier = readTierFromPrBody(facts.body)
170
+ if (tier === null) dangling.push('no `Tier:` field found in PR body — Tier field is DANGLING')
171
+
172
+ const project = extractField(facts.body, 'Project(?:\\(s\\))?', 'PROJECT')
173
+ if (!project) dangling.push('no `Project:` field found in PR body — Project(s) field is DANGLING')
174
+
175
+ const forField = extractField(facts.body, 'For')
176
+ if (!forField) dangling.push('no `For:` field found in PR body — Model/agent field is DANGLING')
177
+
178
+ const codeReview = extractCodeReviewVerdict(facts.comments)
179
+ if (codeReview.danglingNote) dangling.push(codeReview.danglingNote)
180
+
181
+ const security = extractSecurityReviewVerdict(facts.comments)
182
+ if (security.danglingNote) dangling.push(security.danglingNote)
183
+
184
+ const ticket = extractField(facts.body, 'Ticket') ?? 'none'
185
+
186
+ const taskLabel = ref ? `task ${ref.taskId} (tranche ${ref.tranche})` : `task (branch ${facts.headRefName})`
187
+
188
+ const lines = [
189
+ `${PROVENANCE_HEADING} — ${taskLabel}`,
190
+ `- Issue: ${issue !== null ? `#${issue} (closed by merge)` : 'DANGLING — no Closes #N in PR body'}`,
191
+ `- Tier: ${tier !== null ? tier : 'DANGLING — no Tier field in PR body'}`,
192
+ '- Brief: in this PR body (the frozen intent)',
193
+ `- Project(s): ${project ?? 'DANGLING — no Project field in PR body'}`,
194
+ `- Model/agent: ${forField ?? 'DANGLING — no For field in PR body'}`,
195
+ `- Code review: ${codeReview.value}`,
196
+ `- Security: ${security.value}`,
197
+ `- Ticket: ${ticket}`,
198
+ `- Merged: ${facts.mergeSha} at ${facts.mergedAt}`
199
+ ]
200
+
201
+ if (dangling.length > 0) {
202
+ lines.push('', `DANGLING: ${dangling.join('; ')}`)
203
+ }
204
+
205
+ return { block: lines.join('\n'), issue, dangling }
206
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Finding-count baseline capture + comparison (aeg-governance-hardening task
3
+ * 11, #324). Pure — does not run `verify-docs.ts`/`verify-coherence.ts`
4
+ * itself and never calls `Date.now()` (a pure `src/` module cannot — see
5
+ * `packages/aeg-core/CLAUDE.md`-equivalent constraint, this repo's is the
6
+ * Workflow-script rule of the same shape). The CLI shim runs those tools,
7
+ * counts their findings, and passes the counts plus a caller-supplied
8
+ * timestamp in here.
9
+ *
10
+ * Exists to make the dispatch-readiness bar honestly "no new findings versus
11
+ * the captured baseline" rather than "zero findings" — live-fire #2
12
+ * (aeg-governance-hardening's own dispatch wave) asserted "verify-docs full
13
+ * mode must be green" as a pre-flight precondition without ever running it;
14
+ * full mode had 44 pre-existing, unrelated findings and was never green.
15
+ * Baseline data is capture-at-run only, never a committed file.
16
+ */
17
+
18
+ export type BaselineEntry = {
19
+ tool: string
20
+ findingCount: number
21
+ /** ISO timestamp supplied by the caller — never generated inside this module. */
22
+ capturedAt: string
23
+ }
24
+
25
+ /** Stamps a set of tool→findingCount pairs into baseline records, using the caller-supplied `capturedAt`. */
26
+ export function captureBaseline(
27
+ counts: Array<{ tool: string; findingCount: number }>,
28
+ capturedAt: string
29
+ ): BaselineEntry[] {
30
+ return counts.map((c) => ({ tool: c.tool, findingCount: c.findingCount, capturedAt }))
31
+ }
32
+
33
+ export type BaselineToolComparison = { tool: string; baseline: number; current: number; delta: number }
34
+
35
+ export type BaselineComparison = {
36
+ /** True iff no tool's current finding count exceeds its baseline. */
37
+ withinBudget: boolean
38
+ /** Sum of every tool's delta (positive = net new findings across all tools). */
39
+ delta: number
40
+ perTool: BaselineToolComparison[]
41
+ }
42
+
43
+ /**
44
+ * Compare current finding counts against a captured baseline, per tool.
45
+ * A tool absent from the baseline (never captured before) has nothing to
46
+ * regress against — its delta reports as `0` and its current count becomes
47
+ * the effective baseline going forward, rather than failing budget purely
48
+ * because no prior baseline was ever recorded for it.
49
+ */
50
+ export function compareToBaseline(
51
+ current: Array<{ tool: string; findingCount: number }>,
52
+ baseline: BaselineEntry[]
53
+ ): BaselineComparison {
54
+ const perTool: BaselineToolComparison[] = current.map((c) => {
55
+ const baselineEntry = baseline.find((b) => b.tool === c.tool)
56
+ const baselineCount = baselineEntry ? baselineEntry.findingCount : c.findingCount
57
+ return { tool: c.tool, baseline: baselineCount, current: c.findingCount, delta: c.findingCount - baselineCount }
58
+ })
59
+
60
+ return {
61
+ withinBudget: perTool.every((t) => t.delta <= 0),
62
+ delta: perTool.reduce((sum, t) => sum + t.delta, 0),
63
+ perTool
64
+ }
65
+ }