@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,912 @@
1
+ /**
2
+ * Brief→Developer brief-validation grammar (aeg-governance-hardening
3
+ * task 2). Pure — no `fs`, no `fetch`, no `process.env`. The CLI shim
4
+ * (`bin/verify-brief.ts`) reads `PR_BODY`, then
5
+ * calls `checkBriefSections`.
6
+ *
7
+ * Scope is presence-only (per the Planner's trap): this gate cannot judge
8
+ * whether a Test Plan item is truly scriptable, whether `unit-tests-only` is
9
+ * justified by the surface map, or whether a doc-update entry is factually
10
+ * correct — those remain Reviewer + Verification judgment. It only confirms each
11
+ * required brief section exists in the PR body, in the shape the
12
+ * `brief-authoring` skill and `brief-developer` contract define.
13
+ */
14
+
15
+ import { type AnchorField, anchoredRegion, stripCode } from './anchored-region'
16
+ import { parsePremiseBlock, premiseBlockText } from './premise-check'
17
+ import { locateTestPlanSection } from './test-plan-section'
18
+
19
+ export type BriefSectionResult = { status: 'pass' | 'fail'; errors: string[] }
20
+
21
+ /**
22
+ * The PR body's header block: everything before the first h2+ heading. The
23
+ * canonical PR-body form (`roles/developer.md`) puts the metadata fields
24
+ * (Tier, For, Project, Closes) at the top, before `## Summary`.
25
+ * Anchoring field extraction here — shared with `archive-task.ts` — is what
26
+ * stops prose in later sections that merely *mentions* a field name (e.g. a
27
+ * "Decisions made" paragraph discussing the `Ticket:` field) from being
28
+ * parsed as the field itself. Regression from #311's first live archivist
29
+ * run, where exactly that happened.
30
+ */
31
+ export function headerRegion(prBody: string): string {
32
+ const m = prBody.match(/^##\s/m)
33
+ return m?.index !== undefined ? prBody.slice(0, m.index) : prBody
34
+ }
35
+
36
+ /**
37
+ * Tolerant header-field reader: accepts `Field: value` and `**Field:** value`,
38
+ * line-anchored, searched only in the header region. Stops at line end or a
39
+ * `·` metadata separator.
40
+ *
41
+ * When `anchor` is given and the body carries that anchor pair
42
+ * (`anchored-region.ts`, task 30), the pair replaces the header region as the
43
+ * one place the field is read from — a field-shaped line anywhere else in the
44
+ * body is ignored. Bodies without the pair parse exactly as before.
45
+ */
46
+ function headerField(prBody: string, labelPattern: string, anchor?: AnchorField): string | null {
47
+ const anchored = anchor !== undefined ? anchoredRegion(prBody, anchor) : null
48
+ const region = anchored ?? headerRegion(prBody)
49
+ const re = new RegExp(`^(?:\\*\\*)?\\s*${labelPattern}\\s*(?:\\*\\*)?\\s*:\\s*(?:\\*\\*)?\\s*([^\\n·]+)`, 'im')
50
+ const m = region.match(re)
51
+ if (!m) return null
52
+ const value = (m[1] as string).trim()
53
+ return value.length > 0 ? value : null
54
+ }
55
+
56
+ /** Strips markdown emphasis markers and collapses whitespace, for tolerant phrase matching. */
57
+ function normalize(text: string): string {
58
+ return text.replace(/[*_]/g, '').replace(/\s+/g, ' ')
59
+ }
60
+
61
+ function headingCheck(prBody: string, keywordPattern: string, sectionName: string): BriefSectionResult {
62
+ const re = new RegExp(`^#{1,4}\\s*(?:\\*\\*)?(?:\\d+[a-z]?\\.\\s*)?[^\\n]*${keywordPattern}`, 'im')
63
+ if (re.test(prBody)) return { status: 'pass', errors: [] }
64
+ return {
65
+ status: 'fail',
66
+ errors: [`brief-validation ${sectionName}: no "${sectionName}" section found in the PR body.`]
67
+ }
68
+ }
69
+
70
+ /** Tier field — reuses the canonical `@attalabs/aeg-core` parser (do not re-implement). */
71
+ export function checkTierField(prBody: string, readTier: (body: string) => 0 | 1 | 3 | null): BriefSectionResult {
72
+ if (readTier(prBody) !== null) return { status: 'pass', errors: [] }
73
+ return {
74
+ status: 'fail',
75
+ errors: [
76
+ 'brief-validation tier: no `Tier:` field found in the PR body (expected `Tier: 0|1|3` or `**Tier:** 0|1|3`).'
77
+ ]
78
+ }
79
+ }
80
+
81
+ /**
82
+ * The `unit-tests-only` sentinel, tolerating the markdown emphasis every other
83
+ * brief field carries (`**Tier:**`, `**Project:**`, …). All three of
84
+ * `Test Plan: unit-tests-only`, `**Test Plan:** unit-tests-only` and
85
+ * `**Test Plan**: unit-tests-only` match.
86
+ *
87
+ * The bold forms used to fail: the pattern was `/Test Plan\s*:\s*unit-tests-only/i`,
88
+ * and `\s*` cannot cross the `**` sitting between the colon and the value — so
89
+ * `brief-authoring/SKILL.md` §9's own canonical example (`**Test Plan:** unit-tests-only`)
90
+ * was rejected by the gate that documents it, and any brief copying the skill
91
+ * verbatim bounced. Two checks read this sentinel and both were wrong in
92
+ * different directions: `checkTestPlan` false-FAILED a well-formed brief, while
93
+ * `checkTestPlanExclusivity` silently false-PASSED a self-contradictory one (a
94
+ * bolded `unit-tests-only` **plus** tagged checkboxes never tripped it, because
95
+ * its own guard clause never matched either) — the exact combination that guard
96
+ * was built for in #340. Fixing the doc instead of the pattern was rejected:
97
+ * bold is the house convention for brief fields, so an unbolded Test Plan is the
98
+ * odd one out and drifts back the moment someone tidies it.
99
+ */
100
+ const TEST_PLAN_UNIT_TESTS_ONLY_RE = /(?:\*\*)?Test Plan(?:\*\*)?\s*:\s*(?:\*\*)?\s*unit-tests-only/i
101
+
102
+ /**
103
+ * The Test Plan section text these three checks scan — never the raw body.
104
+ * Delegates to `locateTestPlanSection` (the same locator `test-plan.ts`'s
105
+ * merge-gate check uses) so an `AEG:TEST-PLAN` anchor pair is authoritative
106
+ * here exactly as it already is there. Without this, a body that anchors its
107
+ * real Test Plan AND carries a pasted verbatim reference copy of the brief
108
+ * (the template's own instruction) has its checkbox lines double-counted:
109
+ * `checkTestPlanExclusivity`/`checkPrincipalPlaceholder` scanned the whole
110
+ * body and fired on checkboxes living only in the quoted reference copy,
111
+ * disagreeing with `test-plan.ts` about which bytes are the real field
112
+ * (found live: a well-formed anchored brief failed `brief-shape` while
113
+ * `test-plan` passed the same body). Falls back to the full body when no
114
+ * section is locatable, matching every other anchor consumer's "anchors are
115
+ * additive, never required" contract.
116
+ */
117
+ function testPlanRegion(prBody: string): string {
118
+ const located = locateTestPlanSection(prBody)
119
+ return located.found ? located.section : prBody
120
+ }
121
+
122
+ /**
123
+ * Test Plan — pass iff the body contains the `Test Plan: unit-tests-only`
124
+ * sentinel (bolded or not — see `TEST_PLAN_UNIT_TESTS_ONLY_RE`), OR at least one
125
+ * `**[agent]**`/`**[principal]**`-tagged checklist line. Presence-only: does not
126
+ * judge whether tags are correctly assigned or whether `unit-tests-only` is
127
+ * justified by the surface map.
128
+ */
129
+ /**
130
+ * Presence-only Test Plan detector — three acceptable shapes since task 12
131
+ * (#387) rendered the `[agent]` half of a Test Plan as a fenced command list
132
+ * rather than a checkbox: the `unit-tests-only` sentinel; a
133
+ * `**[agent]**`/`**[principal]**`-tagged checkbox line (the pre-#387 shape,
134
+ * still valid on a PR below `AGENT_BOXES_REFUSED_SINCE_PR` and for
135
+ * `[principal]` items on every PR); or a fenced code block anywhere in the
136
+ * Test Plan region (the fenced-command-list shape). This gate does not judge
137
+ * WHICH shape a given PR number must use — `checkNoAgentBoxes`, below, is the
138
+ * rule that refuses a checkbox `[agent]` item on a PR at or above the rollout
139
+ * constant; this one only asks "is there a Test Plan at all."
140
+ */
141
+ export function checkTestPlan(prBody: string): BriefSectionResult {
142
+ const located = locateTestPlanSection(prBody)
143
+ const region = located.found ? located.section : prBody
144
+ if (TEST_PLAN_UNIT_TESTS_ONLY_RE.test(region)) return { status: 'pass', errors: [] }
145
+ if (/\*\*\[(?:agent|principal)\]\*\*/.test(region)) return { status: 'pass', errors: [] }
146
+ // Fenced-block detection only counts when the section was genuinely
147
+ // LOCATED, never on the whole-body fallback: `testPlanRegion`'s fallback
148
+ // exists so the sentinel/tag checks above still work on a body with no
149
+ // recognizable heading, but a fence can appear anywhere in a brief (Step
150
+ // 0's own worktree command, a Part's own command block) — trusting ANY
151
+ // fence in the whole body would pass a brief whose Test Plan section was
152
+ // deleted outright, as long as it kept some unrelated fence elsewhere
153
+ // (found live, fixing this same rule: `checkBriefSections`'s own "missing
154
+ // Test Plan" regression fixture stopped failing until this guard landed).
155
+ if (located.found && extractFencedBlocks(region).length > 0) return { status: 'pass', errors: [] }
156
+ return {
157
+ status: 'fail',
158
+ errors: [
159
+ 'brief-validation Test Plan: no Test Plan section found — expected `Test Plan: unit-tests-only`, a fenced `[agent]` command list, or at least one `**[agent]**`/`**[principal]**`-tagged checklist item.'
160
+ ]
161
+ }
162
+ }
163
+
164
+ /**
165
+ * Test Plan shape guard (aeg-governance-hardening task 20 follow-up, #340) —
166
+ * `Test Plan: unit-tests-only` and a tagged checkbox item are mutually
167
+ * exclusive per brief-authoring §9 ("The two fields are coupled; Brief
168
+ * Validation cross-checks them"): `unit-tests-only` declares there is no
169
+ * checklist because there is nothing runtime to check off, so a body
170
+ * carrying both is self-contradictory. Regression source: PR #363's own
171
+ * original brief declared `unit-tests-only` while its Test Plan also listed
172
+ * `- [x]`/`- [ ]` `[agent]`/`[principal]` items — a combination this gate
173
+ * previously let through.
174
+ */
175
+ export function checkTestPlanExclusivity(prBody: string): BriefSectionResult {
176
+ const region = testPlanRegion(prBody)
177
+ if (!TEST_PLAN_UNIT_TESTS_ONLY_RE.test(region)) return { status: 'pass', errors: [] }
178
+ if (/^-\s*\[[ xX]\]\s*\*{2}\[(?:agent|principal)\]\*{2}/im.test(region)) {
179
+ return {
180
+ status: 'fail',
181
+ errors: [
182
+ 'brief-validation Test Plan shape: `Test Plan: unit-tests-only` and a `- [ ]`/`- [x]` tagged checkbox item are mutually exclusive (brief-authoring §9) — declare one form, not both.'
183
+ ]
184
+ }
185
+ }
186
+ return { status: 'pass', errors: [] }
187
+ }
188
+
189
+ /**
190
+ * Principal-placeholder guard (aeg-governance-hardening task 20 follow-up,
191
+ * #340) — a `**[principal]**` checkbox item whose content is a
192
+ * none-placeholder ("None — …") is untickable by construction: nobody can
193
+ * check a box asserting there is nothing to verify, so it blocks the merge
194
+ * gate forever. If a brief genuinely has no principal-runnable surface, the
195
+ * item must be omitted entirely, not declared and left permanently unticked.
196
+ */
197
+ export function checkPrincipalPlaceholder(prBody: string): BriefSectionResult {
198
+ const region = testPlanRegion(prBody)
199
+ const lineRe = /^-\s*\[[ xX]\]\s*\*{2}\[principal\]\*{2}(.*)$/gim
200
+ for (const m of region.matchAll(lineRe)) {
201
+ const content = m[1] ?? ''
202
+ if (/^\s*None\b/i.test(content)) {
203
+ return {
204
+ status: 'fail',
205
+ errors: [
206
+ 'brief-validation Test Plan shape: a `**[principal]**` checkbox item is a "None" placeholder — if there is no principal-runnable surface, omit the item entirely; an untickable placeholder box blocks the merge gate forever.'
207
+ ]
208
+ }
209
+ }
210
+ }
211
+ return { status: 'pass', errors: [] }
212
+ }
213
+
214
+ /**
215
+ * Premise coverage (this task, aeg-governance-hardening 11, #324) — pass iff
216
+ * either (a) at least one `Premise:` assertion's path matches a file in
217
+ * `surfaceFiles` (the §4 surface map's file list — in practice, the PR's
218
+ * actual changed-file list, the same diff-derived-truth philosophy
219
+ * `deriveTierFromDiff` already uses), or (b) `surfaceFiles` is empty (a
220
+ * Tier 0 brief with zero runtime/code surface has nothing to pin — mirrors
221
+ * `checkTestPlan`'s `unit-tests-only` exemption). Presence-only: does not
222
+ * judge whether the pinned premise is the *right* one to have pinned.
223
+ */
224
+ export function checkPremiseCoverage(prBody: string, surfaceFiles: string[]): BriefSectionResult {
225
+ if (surfaceFiles.length === 0) return { status: 'pass', errors: [] }
226
+ const assertions = parsePremiseBlock(prBody)
227
+ if (assertions.some((a) => surfaceFiles.includes(a.path))) return { status: 'pass', errors: [] }
228
+ return {
229
+ status: 'fail',
230
+ errors: [
231
+ 'brief-validation Premise: no `Premise:` assertion found whose path matches a file in the surface map — a brief with a real code surface must pin at least one premise (aeg-governance-hardening task 11).'
232
+ ]
233
+ }
234
+ }
235
+
236
+ export function checkSurfaceMap(prBody: string): BriefSectionResult {
237
+ return headingCheck(prBody, '(?:technical\\s+)?surface map', 'Technical surface map')
238
+ }
239
+
240
+ export function checkDocUpdateList(prBody: string): BriefSectionResult {
241
+ return headingCheck(prBody, '(?:documentation|doc)[- ]update(?:\\s+list)?', 'Documentation-update list')
242
+ }
243
+
244
+ export function checkWorktreeStep0(prBody: string): BriefSectionResult {
245
+ if (/git worktree add/.test(prBody)) return { status: 'pass', errors: [] }
246
+ return {
247
+ status: 'fail',
248
+ errors: ['brief-validation worktree Step 0: no `git worktree add` command found in the PR body.']
249
+ }
250
+ }
251
+
252
+ export function checkStopConditions(prBody: string): BriefSectionResult {
253
+ return headingCheck(prBody, 'stop conditions', 'Stop conditions')
254
+ }
255
+
256
+ /**
257
+ * Autonomy clause — matches the standing clause's core phrase, case-insensitively,
258
+ * tolerant of whitespace and emphasis markup (`**Autonomy:**` vs `Autonomy:`).
259
+ * Per the Planner's trap, this is deliberately verbatim-ish rather than a loose
260
+ * "mentions autonomy somewhere" match — the phrase is the actual invariant.
261
+ */
262
+ export function checkAutonomyClause(prBody: string): BriefSectionResult {
263
+ const normalized = normalize(prBody).toLowerCase()
264
+ if (/do not stop to ask clarifying questions/.test(normalized)) return { status: 'pass', errors: [] }
265
+ return {
266
+ status: 'fail',
267
+ errors: [
268
+ 'brief-validation autonomy clause: the standing autonomy clause ("Do not stop to ask clarifying questions...") was not found in the PR body.'
269
+ ]
270
+ }
271
+ }
272
+
273
+ /**
274
+ * Project field — required in a multi-project repo (`brief-authoring` skill,
275
+ * `planner-brief` contract: the field routes the Developer to the right specs
276
+ * and tells the Reviewer whose behavior to verify). Must appear in the header
277
+ * block, where the Archivist's provenance assembly also reads it — gate and
278
+ * archivist share `headerRegion`, so a body that passes this gate can never
279
+ * produce a DANGLING Project field in provenance. Added after #311 merged
280
+ * without it: the Developer satisfied exactly the sections this gate checked
281
+ * and dropped everything it didn't — whatever the gate doesn't enforce, agents
282
+ * will eventually omit (contract-gate parity is the fix, not discipline).
283
+ */
284
+ export function checkProjectField(prBody: string): BriefSectionResult {
285
+ if (headerField(prBody, 'Project(?:\\(s\\))?', 'PROJECT') !== null) return { status: 'pass', errors: [] }
286
+ return {
287
+ status: 'fail',
288
+ errors: [
289
+ 'brief-validation Project: no `Project:` field found in the PR body header block (before the first `##` heading). Required per the brief-developer contract — expected `Project: <name>[, <name>]` or `**Project:** …`.'
290
+ ]
291
+ }
292
+ }
293
+
294
+ /**
295
+ * For/model field — the brief's mandatory `For:` header (`brief-authoring`
296
+ * skill: "the `For:` + `Reason:` lines are mandatory"). AEG forbids
297
+ * commit-trailer attribution, so this line is the provenance block's only
298
+ * source for Model/agent — same headerRegion parity rationale as
299
+ * `checkProjectField`.
300
+ */
301
+ export function checkForField(prBody: string): BriefSectionResult {
302
+ if (headerField(prBody, 'For') !== null) return { status: 'pass', errors: [] }
303
+ return {
304
+ status: 'fail',
305
+ errors: [
306
+ 'brief-validation For: no `For:` field found in the PR body header block (before the first `##` heading). Required per the brief-authoring skill — expected `For: <model + environment>` or `**For:** …`.'
307
+ ]
308
+ }
309
+ }
310
+
311
+ /**
312
+ * `Closes #N` gate — the match runs on **code-stripped** body text (fenced
313
+ * blocks + inline spans removed via `stripCode`), so it agrees byte-for-byte
314
+ * with GitHub's own auto-close parser, which also ignores `Closes #N` inside
315
+ * code. Without the strip, a body whose only closing reference is backticked
316
+ * (`` `Closes #600` ``) passed this gate green yet merged **without** closing
317
+ * its Issue — stranding #600 (PR #608) and #601 (PR #611) and reddening every
318
+ * open PR via the A3 `auto-close-misfire` oracle. "verify-docs green" must
319
+ * imply "GitHub will auto-close"; stripping code here is what makes it so.
320
+ *
321
+ * The separator groups are **bounded** (`\s{0,8}`, not `\s*`). Two adjacent
322
+ * unbounded `\s*` around an optional `:` backtrack quadratically on a body of
323
+ * the shape `closes` + long whitespace + no `#` — ~2.65 s at GitHub's
324
+ * 65,536-char body cap, and this function runs the pattern twice on the fail
325
+ * path (PR #617 security pass). A constant bound makes the work per start
326
+ * position constant, so the scan is linear in body length. Eight is far past
327
+ * any real separator; a body needing more is malformed by the brief's own
328
+ * convention (a bare ref inside the `AEG:CLOSES` anchor) and fails with an
329
+ * actionable message rather than silently stranding its Issue.
330
+ */
331
+ export function checkClosesN(prBody: string): BriefSectionResult {
332
+ const closesPattern = /(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s{0,8}:?\s{0,8}#\d+/i
333
+ if (closesPattern.test(stripCode(prBody))) {
334
+ return { status: 'pass', errors: [] }
335
+ }
336
+ // Distinguish "only inside code" (actionable — move it out) from "absent entirely".
337
+ if (closesPattern.test(prBody)) {
338
+ return {
339
+ status: 'fail',
340
+ errors: [
341
+ "brief-validation Closes #N: `Closes #N` found only inside a code span — GitHub won't auto-close it. Put a bare `Closes #N` on its own line inside the `AEG:CLOSES` anchor."
342
+ ]
343
+ }
344
+ }
345
+ return {
346
+ status: 'fail',
347
+ errors: ['brief-validation Closes #N: no `Closes #<N>` (or Fixes/Resolves) reference found in the PR body.']
348
+ }
349
+ }
350
+
351
+ /**
352
+ * The commit-type vocabulary this repo enforces — the commitlint type set
353
+ * plus `Plan` (plan PRs; deliberately excluded from the separate
354
+ * `vinaya/type:*` label axis, since a commit type and a task type are
355
+ * different things — see `packages/aeg-forge-state/src/labels.ts`). The one
356
+ * list: `COMMIT_TYPE_STYLE` below is derived from it, and the `commit-msg`
357
+ * hook (`apps/cli/src/commands/commit-msg.ts`) reads `COMMIT_TYPES` for its
358
+ * own error text — neither carries a second, independently-typed copy.
359
+ */
360
+ export const COMMIT_TYPES = [
361
+ 'Build',
362
+ 'Chore',
363
+ 'Docs',
364
+ 'Feat',
365
+ 'Fix',
366
+ 'Perf',
367
+ 'Plan',
368
+ 'Refactor',
369
+ 'Revert',
370
+ 'Style',
371
+ 'Test'
372
+ ] as const
373
+
374
+ export const COMMIT_TYPE_STYLE = new RegExp(`^(${COMMIT_TYPES.join('|')})(\\([a-z0-9-]+\\))?: \\S`)
375
+
376
+ /**
377
+ * Forge-title grammar — the two title forms this repo actually uses:
378
+ * 1. Commit-style: `Type: description` or `Type(scope): description`, with
379
+ * the commitlint type set plus `Plan` (plan PRs).
380
+ * 2. Task-style: `[tranche] id — description` (task Issues and task PRs).
381
+ * Anything else is refused: titles ride into merge commits and the forge's
382
+ * derived views, so they carry the same grammar obligation as commit messages
383
+ * (husky/commitlint parity, applied at the wrapper).
384
+ */
385
+ export function checkForgeTitle(title: string): BriefSectionResult {
386
+ const taskStyle = /^\[[a-z0-9._-]+\] \S+ — \S/
387
+ if (COMMIT_TYPE_STYLE.test(title) || taskStyle.test(title)) return { status: 'pass', errors: [] }
388
+ return {
389
+ status: 'fail',
390
+ errors: [
391
+ `brief-validation title: "${title}" matches neither title grammar — expected \`Type: description\` / \`Type(scope): description\` (commitlint types + Plan) or \`[tranche] id — description\` (task form).`
392
+ ]
393
+ }
394
+ }
395
+
396
+ /**
397
+ * Plan-PR Closes guard — a `plan/*` PR body must never carry a
398
+ * `Closes #N` reference. Three confirmed live incidents (#294→#293,
399
+ * #298→#297, #288→#287) show a plan PR's `Closes #N` prematurely closing
400
+ * the task Issue when the *plan* merged — before the task itself ever ran.
401
+ * `roles/planner.md`'s Plan-PR close-out section already forbids this in
402
+ * prose ("a plan PR creates Issues; it does not resolve one"); this is the
403
+ * mechanical gate. Separate from `checkBriefSections` (it needs the branch,
404
+ * not just the body, and it's a guard against a forbidden shape, not a
405
+ * presence check) — the shim runs it before the non-task-branch bypass, since
406
+ * a `plan/*` branch would otherwise never reach a brief-shape check at all.
407
+ * Plan branches without `Closes` continue to bypass, as today.
408
+ */
409
+ export function checkPlanPrNoCloses(branch: string, prBody: string): BriefSectionResult {
410
+ if (!branch.startsWith('plan/')) return { status: 'pass', errors: [] }
411
+ if (!/\bcloses\s+#\d+/i.test(prBody)) return { status: 'pass', errors: [] }
412
+ return {
413
+ status: 'fail',
414
+ errors: [
415
+ 'brief-validation plan-PR guard: a `plan/*` PR must not carry `Closes #N` — a plan PR creates Issues, it does not resolve one (roles/planner.md). Remove the `Closes #N` reference; the task Issue closes from the task PR that does the work.'
416
+ ]
417
+ }
418
+ }
419
+
420
+ /**
421
+ * The four markers that make a body *a brief* rather than an ordinary PR
422
+ * description. Each is an existing section detector, reused unchanged — so a
423
+ * body that trips this detector is, by construction, a body the section checks
424
+ * were written to grade.
425
+ *
426
+ * Test Plan is deliberately **not** a marker even though it is a required brief
427
+ * section: the canonical PR-body template gives every AEG PR a Test plan with
428
+ * `**[agent]**` tags, so it is the one section an ordinary non-brief PR
429
+ * plausibly carries. The four kept here are brief grammar and nothing else — a
430
+ * dependency bump has none of them, and needing *two* means one stray phrase
431
+ * can never force a brief onto a PR that was never meant to carry one.
432
+ */
433
+ const BRIEF_SHAPE_MARKERS = [checkSurfaceMap, checkDocUpdateList, checkStopConditions, checkAutonomyClause] as const
434
+
435
+ /**
436
+ * A task branch, per the topology naming convention (`task/<tranche>/<n>`).
437
+ * The one shared copy — `bin/verify-brief.ts`, `test-plan-gate.ts`, and
438
+ * `archive-task.ts` each still define this pattern locally (out of this
439
+ * export's blast radius; not deduped onto it here), but a new consumer
440
+ * (`check-brief-shape.ts`, #870) reuses this one rather than adding a
441
+ * fourth copy.
442
+ */
443
+ const TASK_BRANCH_PATTERN = /^task\/[^/]+\/[^/]+$/
444
+
445
+ export function isTaskBranch(branch: string): boolean {
446
+ return TASK_BRANCH_PATTERN.test(branch)
447
+ }
448
+
449
+ /**
450
+ * Is this PR body a brief? — the predicate that replaces the branch name as
451
+ * `verify-brief`'s trigger for running `checkBriefSections`.
452
+ *
453
+ * The old rule was "validate iff the branch is `task/<tranche>/<n>`", which meant a
454
+ * standalone `fix/*` brief bypassed **every** section check. Confirmed live: a
455
+ * fix brief on `fix/studio-tranche-href` shipped with no §7
456
+ * documentation-update list, and `checkDocUpdateList` — the checker that exists
457
+ * for exactly that — never ran, because the branch wasn't a task branch. The
458
+ * exemption still has to exist (an ordinary one-line dependency-bump PR has no
459
+ * brief and must not be forced to grow one), so the rule becomes: *if a body is
460
+ * a brief, it must be a complete brief, whatever the branch is called.*
461
+ *
462
+ * Detection runs on `stripCode(prBody)` (the single shared stripper from
463
+ * `anchored-region`, per #617's "one stripper, never a duplicated regex"). A PR
464
+ * that *quotes* a brief inside a fence — "here's a sample brief: ``` …Technical
465
+ * surface map… ```" — is discussing a brief, not carrying one, and must stay
466
+ * exempt; matching on raw text would force-validate it. Note this also rules out
467
+ * the worktree `git worktree add … -b` line as a marker: Step 0 lives inside a
468
+ * fence in every real brief, so it never survives the strip.
469
+ *
470
+ * Threshold is ≥2 of four rather than any-one so that a brief missing one
471
+ * section is still detected as a brief — the failure mode this gate exists to
472
+ * catch. The `fix/studio-tranche-href` body trips three with §7 absent.
473
+ */
474
+ export function isBriefShaped(prBody: string): boolean {
475
+ const stripped = stripCode(prBody)
476
+ return BRIEF_SHAPE_MARKERS.filter((check) => check(stripped).status === 'pass').length >= 2
477
+ }
478
+
479
+ /**
480
+ * The branch a brief declares for itself, read from its Step 0 command. At
481
+ * authoring time there is no `BRANCH` env var and no branch yet — but every
482
+ * brief carries `git worktree add <path> -b <branch> origin/main`, so the brief
483
+ * states what it is going to be, and `verify-brief --body-file` can grade it as
484
+ * that. Matched on the RAW body, not `stripCode`'s output: Step 0 lives inside a
485
+ * fence in every real brief, so the stripped text never contains it.
486
+ */
487
+ export function inferBranchFromBody(prBody: string): string {
488
+ const m = prBody.match(/git worktree add\s+\S+\s+-b\s+(\S+)/)
489
+ return m?.[1] ?? ''
490
+ }
491
+
492
+ /**
493
+ * The shell-command-word vocabulary this task's two prose-shape rules share
494
+ * (`checkCommandsCarryOutput` and `doctrine-no-procedures.ts`'s sweep) — one
495
+ * list, so the two rules can never silently diverge on what counts as "a
496
+ * command". Exported for that reuse, not for callers to extend at runtime.
497
+ */
498
+ export const COMMAND_WORDS = ['export', 'bun', 'gh', 'git', 'grep', 'sed', 'cat', 'diff', 'vinaya'] as const
499
+
500
+ /**
501
+ * Rollout PR number for the four brief-shape rules this task added
502
+ * (`checkNoUnpinnedCodeClaims`, `checkCommandsCarryOutput`,
503
+ * `checkConsumerTests`, `checkDefeatCases`). A PR numbered below this is
504
+ * grandfathered: the CI shim (`check-brief-shape.ts`) reports a finding from
505
+ * one of these four rules as informational, never a failure. A PR at or
506
+ * above it is held to the rule for real. `verify-brief.ts` (authoring time,
507
+ * pre-dispatch) has no PR number and applies all four unconditionally —
508
+ * grandfathering is a CI-only rollout concern, not a grammar relaxation
509
+ * (round-2 ruling addendum 1).
510
+ */
511
+ export const BRIEF_RULES_SINCE_PR = 394
512
+
513
+ /**
514
+ * Rollout PR number for `checkNoAgentBoxes`, below — a second, distinct
515
+ * cutover from `BRIEF_RULES_SINCE_PR` above, per the Principal's ruling
516
+ * (2026-09-03, after PR #395): an agent never ticks a box or edits a PR
517
+ * body, so the `[agent]` half of a Test Plan stops being checkboxes and
518
+ * becomes a fenced command list (task 12, #387). A PR numbered below this
519
+ * is grandfathered — its checkbox `[agent]` items were written before the
520
+ * ruling and are reported informationally, never a failure. `verify-brief.ts`
521
+ * (authoring time, pre-dispatch) has no PR number and applies the rule
522
+ * unconditionally, same grandfathering-is-CI-only posture as
523
+ * `BRIEF_RULES_SINCE_PR`.
524
+ */
525
+ export const AGENT_BOXES_REFUSED_SINCE_PR = 396
526
+
527
+ const AGENT_BOX_LINE_RE = /^-\s*\[[ xX]\]\s*\*{2}\[agent\]\*{2}/im
528
+
529
+ /**
530
+ * Refuses a Test Plan whose `[agent]` half is still a checkbox item — the
531
+ * shape the fenced-command-list rule (task 12, #387) replaces. Presence-only
532
+ * within the Test Plan region (`testPlanRegion`), like every sibling check
533
+ * in this file: whether the fenced list a body carries instead is any GOOD
534
+ * is a Reviewer/Verification judgment, not this gate's.
535
+ */
536
+ export function checkNoAgentBoxes(prBody: string): BriefSectionResult {
537
+ const region = testPlanRegion(prBody)
538
+ if (!AGENT_BOX_LINE_RE.test(region)) return { status: 'pass', errors: [] }
539
+ return {
540
+ status: 'fail',
541
+ errors: [
542
+ 'brief-validation no agent boxes: the Test Plan carries a checkbox `[agent]` item — an agent never ticks a box or edits a PR body (Principal ruling, 2026-09-03, after PR #395). Render the `[agent]` half as a fenced command list instead; `vinaya pr report` runs each command from the PR head and writes its actual output into the AEG:EVIDENCE block. `[principal]` checkboxes are unaffected.'
543
+ ]
544
+ }
545
+ }
546
+
547
+ /**
548
+ * True for an error message produced by one of the five grandfatherable
549
+ * rules below (the original four, plus `checkNoAgentBoxes`) — classified by
550
+ * each rule's own distinct message prefix, since `checkBriefSections`
551
+ * aggregates every sub-check's errors into one flat `string[]` and this is
552
+ * the only reader that ever needs to tell them apart from the rest.
553
+ */
554
+ export function isGrandfatherableBriefRuleError(message: string): boolean {
555
+ return rolloutThresholdFor(message) !== null
556
+ }
557
+
558
+ /**
559
+ * The rollout constant a given error message is grandfathered against, or
560
+ * `null` for a message this rollout scheme does not cover (always blocking,
561
+ * on every PR). Two thresholds exist today (`BRIEF_RULES_SINCE_PR`,
562
+ * `AGENT_BOXES_REFUSED_SINCE_PR`) — a message is matched to whichever rule
563
+ * produced it, never a single global cutover, so a later third threshold
564
+ * can be added here without touching either existing one's grandfather
565
+ * window.
566
+ */
567
+ function rolloutThresholdFor(message: string): number | null {
568
+ if (
569
+ message.startsWith('brief-validation unpinned code claim:') ||
570
+ message.startsWith('brief-validation commands carry output:') ||
571
+ message.startsWith('brief-validation consumer tests:') ||
572
+ message.startsWith('brief-validation defeat cases:')
573
+ ) {
574
+ return BRIEF_RULES_SINCE_PR
575
+ }
576
+ if (message.startsWith('brief-validation no agent boxes:')) return AGENT_BOXES_REFUSED_SINCE_PR
577
+ return null
578
+ }
579
+
580
+ /**
581
+ * Splits `checkBriefSections`'s flat error list into `blocking` (fails the
582
+ * check) and `info` (printed, never a failure) — the CI shim
583
+ * (`check-brief-shape.ts`) is the only caller, but the split is a pure
584
+ * function of `(errors, prNumber)` so it is unit-testable directly.
585
+ *
586
+ * `prNumber === null` (no `PR_NUMBER`, or an unparseable one) is NOT
587
+ * grandfathered — only a real, parsed number below a message's own
588
+ * threshold (`rolloutThresholdFor`) is. Fail-closed: a check that can't tell
589
+ * which PR it's grading must not quietly waive rules it has no number to
590
+ * check against.
591
+ */
592
+ export function partitionBriefErrorsByRollout(
593
+ errors: string[],
594
+ prNumber: number | null
595
+ ): { blocking: string[]; info: string[] } {
596
+ const blocking: string[] = []
597
+ const info: string[] = []
598
+ for (const e of errors) {
599
+ const threshold = rolloutThresholdFor(e)
600
+ const grandfathered = threshold !== null && prNumber !== null && prNumber < threshold
601
+ ;(grandfathered ? info : blocking).push(e)
602
+ }
603
+ return { blocking, info }
604
+ }
605
+
606
+ /**
607
+ * One fenced block's raw span, content, and language tag — `start`/`end` are
608
+ * char offsets into the original text. `lang` is the fence's info-string,
609
+ * lowercased and trimmed to its first word (` ```ts ` → `'ts'`, ` ``` ` →
610
+ * `''`) — `doctrine-no-procedures.ts` uses it to exempt a non-shell block
611
+ * (e.g. a TypeScript illustration whose two `export` lines are not a
612
+ * command sequence, task 10 round-2 ruling item 2).
613
+ */
614
+ export type FencedBlock = { start: number; end: number; lang: string; content: string }
615
+
616
+ /**
617
+ * Every fenced (``` or ~~~) code block in `text`, in document order, with its
618
+ * raw content, language tag, and char-offset span. Tolerant of the 3-space
619
+ * list-item indentation every brief's own numbered steps use (` \`\`\``) —
620
+ * the fence marker need not sit at column 0. Not a full CommonMark
621
+ * implementation (no nested-fence-length edge cases beyond "the closer
622
+ * repeats the opener's exact run"), which this repo's own doctrine/brief
623
+ * prose never exercises.
624
+ *
625
+ * Shared by `checkCommandsCarryOutput` (below) and `doctrine-no-procedures.ts`
626
+ * — one fence scanner, never a second copy of this pattern.
627
+ */
628
+ export function extractFencedBlocks(text: string): FencedBlock[] {
629
+ const re = /^[ \t]*(`{3,}|~{3,})([^\n]*)\n([\s\S]*?)^[ \t]*\1[ \t]*$/gm
630
+ const blocks: FencedBlock[] = []
631
+ let m: RegExpExecArray | null = re.exec(text)
632
+ while (m !== null) {
633
+ const lang = (m[2] as string).trim().toLowerCase().split(/\s+/)[0] ?? ''
634
+ blocks.push({ start: m.index, end: m.index + m[0].length, lang, content: m[3] as string })
635
+ m = re.exec(text)
636
+ }
637
+ return blocks
638
+ }
639
+
640
+ function firstNonBlankLine(text: string): string {
641
+ const line = text.split('\n').find((l) => l.trim().length > 0)
642
+ return line?.trim() ?? ''
643
+ }
644
+
645
+ /**
646
+ * A file-and-line reference — `<path>.<ext>:<digits>` — the shape a brief
647
+ * states a code fact by pointer instead of by an executed command or a
648
+ * `Premise:` pin. Extension list is this repo's own doctrine/code file kinds;
649
+ * deliberately not "any word after a dot" (that would also catch a semver
650
+ * string or a decimal figure with a trailing count).
651
+ */
652
+ const FILE_LINE_RE = /\b[\w./-]+\.(?:tsx?|jsx?|mjs|cjs|md|mdx|json|ya?ml|sh|py|toml):\d+\b/g
653
+
654
+ /**
655
+ * Rule (i) (task 10, Issue #385) — a brief states no code fact as a bare
656
+ * `file:line` prose pointer; it either pins the fact in `Premise:` (re-
657
+ * asserted at dispatch, `verify-dispatch --premise`) or shows the executed
658
+ * command whose output names the line. A pointer that survives outside both
659
+ * homes is a claim nobody re-checks — exactly the class task 9's rule
660
+ * forbids in prose (PR #382's `security-archivist.md:88` mention, backticked
661
+ * but never pinned nor shown as command output).
662
+ *
663
+ * Scans `stripCode(…, { inlineSpans: 'keep' })`: fenced/indented **blocks**
664
+ * are removed (a worked example's own line numbers are not a live claim),
665
+ * but a single-backtick inline span is left as literal text — the PR #382
666
+ * sentence was exactly an inline span, and this rule must still catch it.
667
+ * This is why `maskCode` (which blanks inline spans too) is the wrong tool
668
+ * here, unlike `checkClosesN`/`isBriefShaped` elsewhere in this file.
669
+ */
670
+ export function checkNoUnpinnedCodeClaims(prBody: string): BriefSectionResult {
671
+ const premiseText = premiseBlockText(prBody)
672
+ const withoutPremise = premiseText ? prBody.replace(premiseText, '') : prBody
673
+ const scanned = stripCode(withoutPremise, { inlineSpans: 'keep' })
674
+ const matches = scanned.match(FILE_LINE_RE) ?? []
675
+ if (matches.length === 0) return { status: 'pass', errors: [] }
676
+ return {
677
+ status: 'fail',
678
+ errors: matches.map(
679
+ (m) =>
680
+ `brief-validation unpinned code claim: "${m}" states a code fact by file-and-line reference outside a \`Premise:\` pin and outside a fenced code block — pin it in \`Premise:\`, or show the executed command and its output instead.`
681
+ )
682
+ }
683
+ }
684
+
685
+ /**
686
+ * Section `num`'s own text (the heading line's content, ending at the next
687
+ * heading of any level 1-4) — `§4`/`§5`/`§6` share this numbered-heading
688
+ * shape with `headingCheck`'s keyword form, so this mirrors that pattern
689
+ * rather than inventing a second heading grammar. `null` when no such
690
+ * section exists (the composer's other checks already judge whether a
691
+ * required section is missing; these two rules apply only when it is
692
+ * present).
693
+ */
694
+ function extractNumberedSection(prBody: string, num: number): string | null {
695
+ const startRe = new RegExp(`^#{1,4}\\s*(?:\\*\\*)?${num}[a-z]?\\.\\s`, 'im')
696
+ const start = startRe.exec(prBody)
697
+ if (!start) return null
698
+ const afterStart = start.index + start[0].length
699
+ const rest = prBody.slice(afterStart)
700
+ const next = /^#{1,4}\s/m.exec(rest)
701
+ return next ? rest.slice(0, next.index) : rest
702
+ }
703
+
704
+ function commandWordOf(line: string): string {
705
+ return line.trim().split(/\s+/)[0] ?? ''
706
+ }
707
+
708
+ function isCommandBlock(content: string): boolean {
709
+ return (COMMAND_WORDS as readonly string[]).includes(commandWordOf(firstNonBlankLine(content)))
710
+ }
711
+
712
+ /** The Step 0 exemption — `git worktree add …` is a command with no separate output block by convention. */
713
+ function isStep0Block(content: string): boolean {
714
+ return firstNonBlankLine(content).startsWith('git worktree add')
715
+ }
716
+
717
+ /**
718
+ * Rule (ii) (task 10, Issue #385; tightened by the round-2 ruling item 1) —
719
+ * in a brief's `§5` (Pre-flight checks) or `§6` (Numbered parts), a fenced
720
+ * block that opens with a shell command must be immediately followed by
721
+ * another fenced block that does NOT itself open with a shell command — its
722
+ * output. The Step `0` `git worktree add` block is exempt: it is a setup
723
+ * command with no output to show, by the convention every brief's own
724
+ * pre-flight step already follows.
725
+ *
726
+ * **The immediately-following fence, never merely "a later one".** The first
727
+ * cut of this rule accepted `blocks[i + 1]` existing at all, which let three
728
+ * undocumented command blocks in a row pass as long as a single trailing
729
+ * output fence sat after the last of them — each of the first two command
730
+ * blocks' own "next fence" was itself another un-followed command, and the
731
+ * rule never noticed (round-2 ruling, found live reviewing this PR's own
732
+ * diff). A command block's output claim is only satisfied by the very next
733
+ * fence, and only if that fence is not itself a command.
734
+ *
735
+ * Deliberately loose about what sits *between* the two fences (the prose
736
+ * separating them, never counted) — this brief's own pre-flight steps
737
+ * (`§5` items 4-9) interleave a sentence of prose between a command fence
738
+ * and its output fence ("Output the Brief Author obtained at authoring
739
+ * time…"), and the rule must not fail the brief that documents it.
740
+ */
741
+ export function checkCommandsCarryOutput(prBody: string): BriefSectionResult {
742
+ const errors: string[] = []
743
+ for (const [label, num] of [
744
+ ['5', 5],
745
+ ['6', 6]
746
+ ] as const) {
747
+ const section = extractNumberedSection(prBody, num)
748
+ if (!section) continue
749
+ const blocks = extractFencedBlocks(section)
750
+ for (let i = 0; i < blocks.length; i++) {
751
+ const block = blocks[i] as FencedBlock
752
+ if (isStep0Block(block.content) || !isCommandBlock(block.content)) continue
753
+ const next = blocks[i + 1]
754
+ if (!next || isCommandBlock(next.content)) {
755
+ errors.push(
756
+ `brief-validation commands carry output: §${label} has a command block ("${firstNonBlankLine(block.content)}") not immediately followed by a non-command output block — the very next fenced block must hold that command's actual output, not another command.`
757
+ )
758
+ }
759
+ }
760
+ }
761
+ return errors.length === 0 ? { status: 'pass', errors: [] } : { status: 'fail', errors }
762
+ }
763
+
764
+ const CONSUMER_TESTS_SENTINEL_RE = /consumer-tests\s*:\s*none\s*[-—–]\s*\S/i
765
+
766
+ /** Every distinct `packages/<pkg>/` reference in `text` — the packages a §4 surface map names. */
767
+ function packagesNamedIn(text: string): string[] {
768
+ const re = /\bpackages\/([A-Za-z0-9_-]+)\//g
769
+ const pkgs = new Set<string>()
770
+ let m: RegExpExecArray | null = re.exec(text)
771
+ while (m !== null) {
772
+ pkgs.add(m[1] as string)
773
+ m = re.exec(text)
774
+ }
775
+ return [...pkgs]
776
+ }
777
+
778
+ /** Whether `text` names a test-shaped path (`*.test.<ext>`) under workspace directory `consumerDir` (e.g. `apps/cli`, `packages/sources`). */
779
+ function hasTestPathForConsumer(text: string, consumerDir: string): boolean {
780
+ const escaped = consumerDir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
781
+ const re = new RegExp(`${escaped}\\/[\\w./-]*\\.test\\.[A-Za-z0-9]+`)
782
+ return re.test(text)
783
+ }
784
+
785
+ /**
786
+ * Rule (iii) (task 10, Issue #385) — a brief whose `§4` names a path under a
787
+ * shared `packages/<pkg>/` must also name, for every workspace package that
788
+ * depends on `@attalabs/<pkg>`, a test path proving that consumer still
789
+ * works, or the sentinel `consumer-tests: none — <reason>` opting out with a
790
+ * stated reason. A shared-package edit with no consumer awareness at all is
791
+ * exactly the gap task 9's rule targets alongside the unpinned-claim and
792
+ * missing-output shapes.
793
+ *
794
+ * `consumersOf` is injected (never `fs`/`package.json` reads here) — the CLI
795
+ * shim (`check-brief-shape.ts`) builds it once from the real workspace
796
+ * dependency graph. Both the trigger and its satisfying test path/sentinel
797
+ * are read from `§4` alone, never the whole body — scanning the whole body
798
+ * let a brief's own §2 prose, quoting the sentinel grammar as an example of
799
+ * the rule (exactly this task's own dispatched brief), silently satisfy the
800
+ * rule it was merely describing. `§4` is where a surface map states its
801
+ * blast radius; that is also where this rule's own answer belongs.
802
+ */
803
+ export function checkConsumerTests(prBody: string, consumersOf: (pkg: string) => string[]): BriefSectionResult {
804
+ const section4 = extractNumberedSection(prBody, 4)
805
+ if (!section4) return { status: 'pass', errors: [] }
806
+ if (CONSUMER_TESTS_SENTINEL_RE.test(section4)) return { status: 'pass', errors: [] }
807
+
808
+ const errors: string[] = []
809
+ for (const pkg of packagesNamedIn(section4)) {
810
+ for (const consumerDir of consumersOf(pkg)) {
811
+ if (hasTestPathForConsumer(section4, consumerDir)) continue
812
+ errors.push(
813
+ `brief-validation consumer tests: §4 names a path under packages/${pkg}/, and ${consumerDir} depends on @attalabs/${pkg}, but no test path under ${consumerDir} is named in §4 — name one, or add \`consumer-tests: none — <reason>\`.`
814
+ )
815
+ }
816
+ }
817
+ return errors.length === 0 ? { status: 'pass', errors: [] } : { status: 'fail', errors }
818
+ }
819
+
820
+ /** A check bin filename (`check-<slug>.ts`) — the shape both a §4 "Create"/"Modify" entry and a registry entry use for a check's own name. */
821
+ const CHECK_NAME_RE = /\bcheck-[a-z0-9-]+(?:\.ts)?\b/
822
+
823
+ /** A command that writes to the forge — as opposed to a read-only query — the class this rule wants a `Defeat cases:` line for. */
824
+ const FORGE_WRITE_COMMAND_RE =
825
+ /\bgh\s+(?:pr|issue)\s+(?:create|merge|close|comment|edit|review)\b|\bgit\s+push\b|\bvinaya\s+pr\s+(?:create|report\s+--write)\b/i
826
+
827
+ const DEFEAT_CASES_RE = /defeat cases\s*:/i
828
+
829
+ /**
830
+ * Rule (v) — Principal ruling amending Issue #385: a brief whose `§4`
831
+ * names a check (a `check-<slug>.ts` bin, or its registry entry) or a
832
+ * forge-writing command (`gh pr create`, `git push`, `vinaya pr create`, …)
833
+ * must carry a `Defeat cases:` line in `§6` — the inputs that would defeat
834
+ * the check, or the ones a forge-writing command must not accidentally
835
+ * trigger on. A check or a write path shipped with no stated defeat case is
836
+ * exactly the kind of untested edge this task's own sibling rules exist to
837
+ * close for a brief's prose claims; this one closes it for the check/command
838
+ * itself.
839
+ */
840
+ export function checkDefeatCases(prBody: string): BriefSectionResult {
841
+ const section4 = extractNumberedSection(prBody, 4)
842
+ if (!section4) return { status: 'pass', errors: [] }
843
+ if (!CHECK_NAME_RE.test(section4) && !FORGE_WRITE_COMMAND_RE.test(section4)) return { status: 'pass', errors: [] }
844
+
845
+ const section6 = extractNumberedSection(prBody, 6) ?? ''
846
+ if (DEFEAT_CASES_RE.test(section6)) return { status: 'pass', errors: [] }
847
+
848
+ return {
849
+ status: 'fail',
850
+ errors: [
851
+ 'brief-validation defeat cases: §4 names a check or a forge-writing command, but §6 carries no `Defeat cases:` line — name the inputs that would defeat this check, or that this command must not accidentally trigger on.'
852
+ ]
853
+ }
854
+ }
855
+
856
+ /** Composition knobs for `checkBriefSections` — see each field. */
857
+ export type BriefSectionsOptions = {
858
+ /**
859
+ * Whether a `Closes #N` reference is required. Defaults to `true` (every
860
+ * existing caller keeps today's behavior).
861
+ *
862
+ * A task PR must close its Issue, so `verify-brief` leaves this on for
863
+ * `task/<tranche>/<n>`. A brief-shaped body on a non-task branch must not: a
864
+ * standalone fix brief has no task Issue to close, and a `plan/*` PR is
865
+ * *forbidden* to carry `Closes #N` by `checkPlanPrNoCloses` — so
866
+ * requiring it there would make the two gates jointly unsatisfiable. Issue
867
+ * linkage is a task-branch obligation; brief completeness is not.
868
+ */
869
+ requireClosesN?: boolean
870
+ /**
871
+ * Consumer enumeration for `checkConsumerTests` (task 10) — workspace
872
+ * directories (e.g. `apps/cli`) whose `package.json` depends on
873
+ * `@attalabs/<pkg>`. Defaults to `() => []`, which makes rule (iii) a
874
+ * no-op — the callers that don't wire a real dependency graph (tests,
875
+ * `verify-brief.ts`) keep today's behavior rather than silently failing
876
+ * on an empty consumer list.
877
+ */
878
+ consumersOf?: (pkg: string) => string[]
879
+ }
880
+
881
+ /**
882
+ * Aggregates every section detector into one combined result — one error
883
+ * line per failing section, mirroring `doc-owners.ts`'s `parseDocOwners`
884
+ * error-message style.
885
+ */
886
+ export function checkBriefSections(
887
+ prBody: string,
888
+ readTier: (body: string) => 0 | 1 | 3 | null,
889
+ options: BriefSectionsOptions = {}
890
+ ): { errors: string[] } {
891
+ const { requireClosesN = true, consumersOf = () => [] } = options
892
+ const results = [
893
+ checkTierField(prBody, readTier),
894
+ checkTestPlan(prBody),
895
+ checkTestPlanExclusivity(prBody),
896
+ checkPrincipalPlaceholder(prBody),
897
+ checkSurfaceMap(prBody),
898
+ checkDocUpdateList(prBody),
899
+ checkWorktreeStep0(prBody),
900
+ checkStopConditions(prBody),
901
+ checkAutonomyClause(prBody),
902
+ checkProjectField(prBody),
903
+ checkForField(prBody),
904
+ checkNoUnpinnedCodeClaims(prBody),
905
+ checkNoAgentBoxes(prBody),
906
+ checkCommandsCarryOutput(prBody),
907
+ checkConsumerTests(prBody, consumersOf),
908
+ checkDefeatCases(prBody),
909
+ ...(requireClosesN ? [checkClosesN(prBody)] : [])
910
+ ]
911
+ return { errors: results.flatMap((r) => r.errors) }
912
+ }