@attalabs/vinaya 0.21.0 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (211) hide show
  1. package/README.md +15 -4
  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 +28 -21
  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 +2 -2
  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 +12 -7
  21. package/aeg-root/tranche-model.md +9 -5
  22. package/dist/checks/bin/check-body-bare-digits.js +667 -148
  23. package/dist/checks/bin/check-branch-topology.js +714 -149
  24. package/dist/checks/bin/check-brief-shape.js +704 -147
  25. package/dist/checks/bin/check-changeset-coverage.js +1046 -166
  26. package/dist/checks/bin/check-closes-n.js +714 -149
  27. package/dist/checks/bin/check-coherence.js +729 -156
  28. package/dist/checks/bin/check-dead-branch-push.js +663 -144
  29. package/dist/checks/bin/check-dispatch-readiness.js +728 -155
  30. package/dist/checks/bin/check-doc-coverage-push.js +838 -261
  31. package/dist/checks/bin/check-doc-coverage.js +838 -261
  32. package/dist/checks/bin/check-doctrine-no-procedures.js +4794 -0
  33. package/dist/checks/bin/check-doctrine-portability.js +799 -155
  34. package/dist/checks/bin/check-evidence-fresh.js +1132 -206
  35. package/dist/checks/bin/check-exec-bits.js +4931 -0
  36. package/dist/checks/bin/check-first-push-dispatch.js +716 -151
  37. package/dist/checks/bin/check-issue-assignment.js +714 -149
  38. package/dist/checks/bin/check-main-branch-refusal.js +663 -144
  39. package/dist/checks/bin/check-no-disk-state.js +663 -144
  40. package/dist/checks/bin/check-pr-report-density.js +4461 -0
  41. package/dist/checks/bin/check-quoted-command.js +741 -166
  42. package/dist/checks/bin/check-reader-resolvable-prose.js +768 -173
  43. package/dist/checks/bin/check-registry-gates.js +726 -154
  44. package/dist/checks/bin/check-retired-vocabulary.js +760 -165
  45. package/dist/checks/bin/check-review-gate.js +783 -148
  46. package/dist/checks/bin/check-single-plan-pr.js +663 -144
  47. package/dist/checks/bin/check-test-plan.js +664 -145
  48. package/dist/checks/bin/check-token-collection-wired.js +665 -146
  49. package/dist/checks/bin/check-token-report.js +4509 -0
  50. package/dist/checks/bin/check-workspace-escape.js +1129 -183
  51. package/dist/index.js +8458 -5403
  52. package/package.json +1 -1
  53. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/actions.ts +173 -0
  54. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/anchored-region.ts +113 -0
  55. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/archive-task.ts +206 -0
  56. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/baseline-capture.ts +65 -0
  57. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/blast-radius-domains.ts +192 -0
  58. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/branch-topology-gate.ts +85 -0
  59. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/brief-validation.ts +511 -0
  60. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/claude-code-transcript.ts +460 -0
  61. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/coherence-checks.ts +787 -0
  62. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/dead-branch-push-audit.ts +55 -0
  63. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/dead-branch-push-guard.ts +77 -0
  64. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/derive-section7.ts +66 -0
  65. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/derive-tranche.ts +94 -0
  66. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/diagram-model.ts +372 -0
  67. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/direct-main-push.ts +39 -0
  68. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/dispatch-gate.ts +224 -0
  69. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/doc-owners.ts +314 -0
  70. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/docs/build-doc-nav.ts +38 -0
  71. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/docs/docs-coherence.ts +136 -0
  72. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/docs/index.ts +29 -0
  73. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/docs/legacy-anchors.ts +48 -0
  74. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/docs/nav-helpers.ts +17 -0
  75. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/docs/node-route.ts +134 -0
  76. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/docs/parse-doc.ts +43 -0
  77. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/docs/published-prose.ts +306 -0
  78. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/docs/surfaced-manifest.ts +89 -0
  79. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/docs/types.ts +33 -0
  80. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/doctrine-portability.ts +170 -0
  81. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/doctrine-source.ts +23 -0
  82. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/ensure-label.ts +28 -0
  83. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/file-classify.ts +51 -0
  84. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/first-push-dispatch-gate.ts +80 -0
  85. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/gate-audience.ts +168 -0
  86. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/index.ts +301 -0
  87. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/issue-assignment.ts +102 -0
  88. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/issue-validation.ts +746 -0
  89. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/leftover-detection.ts +68 -0
  90. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/local-anchor-coverage.ts +245 -0
  91. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/main-branch-refusal.ts +64 -0
  92. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/manifest-validity.ts +81 -0
  93. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/markdown-table.ts +76 -0
  94. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/metering-io-guard.ts +119 -0
  95. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/milestone-validation.ts +240 -0
  96. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/no-disk-state.ts +50 -0
  97. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/parse-ledger.ts +77 -0
  98. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/parse-registry.ts +105 -0
  99. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/parse-token-report.ts +165 -0
  100. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/parse-tranche.ts +183 -0
  101. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/pr-tier.ts +81 -0
  102. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/premise-check.ts +125 -0
  103. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/quoted-command.ts +275 -0
  104. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/reader-resolvable-prose.ts +320 -0
  105. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/registry-checks.ts +255 -0
  106. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/registry-parse.ts +164 -0
  107. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/registry-scaffold.ts +216 -0
  108. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/report-tokens.ts +261 -0
  109. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/retired-vocabulary.ts +163 -0
  110. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/review-gate.ts +269 -0
  111. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/single-plan-pr.ts +73 -0
  112. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/state-machine-model.ts +269 -0
  113. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/state-source.ts +14 -0
  114. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/status-block.ts +8 -0
  115. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/sum-ledger.ts +26 -0
  116. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/symbol-collisions.ts +78 -0
  117. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/test-plan-gate.ts +104 -0
  118. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/test-plan-section.ts +65 -0
  119. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/types.ts +174 -0
  120. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/verdict-extraction.ts +140 -0
  121. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/vocabulary-citation.ts +114 -0
  122. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/waiver-label.ts +52 -0
  123. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/workspace-escape.ts +172 -0
  124. package/studio-standalone/apps/vinaya-studio/web/.next/BUILD_ID +1 -1
  125. package/studio-standalone/apps/vinaya-studio/web/.next/build-manifest.json +3 -3
  126. package/studio-standalone/apps/vinaya-studio/web/.next/prerender-manifest.json +3 -3
  127. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_global-error.html +1 -1
  128. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_global-error.rsc +1 -1
  129. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
  130. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  131. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  132. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  133. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  134. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_not-found/page/server-reference-manifest.json +2 -2
  135. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_not-found/page.js.nft.json +1 -1
  136. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  137. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/api/coherence/route.js.nft.json +1 -1
  138. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/page/server-reference-manifest.json +2 -2
  139. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/page.js.nft.json +1 -1
  140. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/page_client-reference-manifest.js +1 -1
  141. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/backlog/page/server-reference-manifest.json +2 -2
  142. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/backlog/page.js.nft.json +1 -1
  143. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/backlog/page_client-reference-manifest.js +1 -1
  144. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/page/server-reference-manifest.json +2 -2
  145. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/page.js.nft.json +1 -1
  146. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/page_client-reference-manifest.js +1 -1
  147. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/page/server-reference-manifest.json +2 -2
  148. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/page.js.nft.json +1 -1
  149. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/page_client-reference-manifest.js +1 -1
  150. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/tranches/[slug]/page/server-reference-manifest.json +2 -2
  151. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/tranches/[slug]/page.js.nft.json +1 -1
  152. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/tranches/[slug]/page_client-reference-manifest.js +1 -1
  153. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/tranches/[slug]/tasks/[taskId]/page/server-reference-manifest.json +2 -2
  154. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/tranches/[slug]/tasks/[taskId]/page.js.nft.json +1 -1
  155. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/tranches/[slug]/tasks/[taskId]/page_client-reference-manifest.js +1 -1
  156. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/page/server-reference-manifest.json +2 -2
  157. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/page.js.nft.json +1 -1
  158. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/page_client-reference-manifest.js +1 -1
  159. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/tranches/page/server-reference-manifest.json +2 -2
  160. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/tranches/page.js.nft.json +1 -1
  161. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/tranches/page_client-reference-manifest.js +1 -1
  162. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/[root-of-the-server]__02a-3g8._.js +1 -1
  163. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/1q96_modules_@clerk_nextjs_dist_esm_app-router_client_keyless-creator-reader_0lom2js.js +1 -1
  164. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/[root-of-the-server]__0053k9k._.js +1 -1
  165. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/[root-of-the-server]__0112h-k._.js +1 -1
  166. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/[root-of-the-server]__02c71kv._.js +1 -1
  167. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/[root-of-the-server]__0o771t1._.js +1 -1
  168. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/[root-of-the-server]__0puovz5._.js +1 -1
  169. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/[root-of-the-server]__1hs0dcu._.js +1 -1
  170. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/{[root-of-the-server]__0ozbaq0._.js → [root-of-the-server]__1mcitst._.js} +1 -1
  171. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/[root-of-the-server]__1wc4-ip._.js +1 -1
  172. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/_03x_w6q._.js +1 -1
  173. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/_0gvm3og._.js +1 -1
  174. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/_0lwxg63._.js +1 -1
  175. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/_0o54suy._.js +1 -1
  176. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/_1-6rzip._.js +1 -1
  177. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/_1fqw88f._.js +1 -1
  178. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/_1mrtj0f._.js +1 -1
  179. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/_1n0cnq-._.js +1 -1
  180. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/apps_vinaya-studio_web_src_app_studio_06it6s-._.js +1 -1
  181. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/node_modules_1vo08dj._.js +2 -2
  182. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/packages_ui_libraries_animate_components_index_ts_186fje1._.js +1 -1
  183. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/packages_ui_libraries_brutal_components_index_ts_0n_lcc4._.js +1 -1
  184. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/packages_ui_libraries_retro_components_index_ts_0wvpm_z._.js +1 -1
  185. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/packages_ui_libraries_retro_components_index_ts_1_cfi48._.js +1 -1
  186. package/studio-standalone/apps/vinaya-studio/web/.next/server/middleware-build-manifest.js +3 -3
  187. package/studio-standalone/apps/vinaya-studio/web/.next/server/pages/500.html +1 -1
  188. package/studio-standalone/apps/vinaya-studio/web/.next/server/server-reference-manifest.js +1 -1
  189. package/studio-standalone/apps/vinaya-studio/web/.next/server/server-reference-manifest.json +3 -3
  190. package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/00pi8vhg8w6gi.js +1 -0
  191. package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/{0r4i_ks9z02bw.js → 03q0q9n6-xah8.js} +1 -1
  192. package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/{2_k6j1o9q5nn4.js → 0jer_drkl1e4r.js} +4 -4
  193. package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/0vc2u252q5q6o.js +1 -0
  194. package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/{1k916ih3p4nsg.js → 13g16cu00g570.js} +1 -1
  195. package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/{2hmannd4bjhzy.js → 1sfxfunirkcr6.js} +1 -1
  196. package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/{1t2kft7bxzuxn.js → 206ajg8f1a-_h.js} +1 -1
  197. package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/{1hfhxjy577l5d.js → 20x-t0a0v129z.js} +1 -1
  198. package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/{25kbt5k-xb2a5.js → 279qq4twinc97.js} +1 -1
  199. package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/{112g08xpx3kro.js → 2ka8dg687auhj.js} +1 -1
  200. package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/2ou_81ahqw31e.js +1 -0
  201. package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/{0up5x3jg-cwov.js → 2rn89uwtf2lwc.js} +1 -1
  202. package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/{3mqu_uhluy19i.css → 3qc67qhcvbg0o.css} +1 -1
  203. package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/{1yqs2zgvwa4p1.js → 3zugx9esh2x51.js} +1 -1
  204. package/studio-standalone/apps/vinaya-studio/web/package.json +2 -2
  205. package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/0_jnwbarv-cqk.js +0 -1
  206. package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/1ybrihl5_aocy.js +0 -1
  207. package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/3r5efmleneaco.js +0 -1
  208. /package/studio-standalone/_node_modules/@attalabs/vinaya/studio-standalone/_node_modules/@attalabs/{aeg-core → vinaya/studio-standalone/_node_modules/@attalabs/aeg-core}/bin/verify-coherence.ts +0 -0
  209. /package/studio-standalone/apps/vinaya-studio/web/.next/static/{crNCHQ9uAgKOYC6DKkZma → 5xy_GA7G5KRuE3HDumIiw}/_buildManifest.js +0 -0
  210. /package/studio-standalone/apps/vinaya-studio/web/.next/static/{crNCHQ9uAgKOYC6DKkZma → 5xy_GA7G5KRuE3HDumIiw}/_clientMiddlewareManifest.js +0 -0
  211. /package/studio-standalone/apps/vinaya-studio/web/.next/static/{crNCHQ9uAgKOYC6DKkZma → 5xy_GA7G5KRuE3HDumIiw}/_ssgManifest.js +0 -0
@@ -0,0 +1,746 @@
1
+ /**
2
+ * Planner→Brief Issue-rationale grammar. Pure — no `fs`, no `fetch`,
3
+ * no `process.env`. The tool-layer gate (`bin/open-issue.ts`, invoked because
4
+ * the `check-forge-gates.sh` hook denies raw `gh issue create`) calls
5
+ * `checkIssueRationale` before any task Issue can reach the forge.
6
+ *
7
+ * A task Issue's body must carry every producer field of the
8
+ * `aeg-root/contracts/planner-brief.md` contract — the eight Planner's
9
+ * rationale fields. makes cutting the Issue with its rationale the
10
+ * canonical plan act; an Issue without the full rationale forces the Brief
11
+ * Author to re-derive the Planner's dig cold, the exact loss the contract
12
+ * exists to prevent. Presence-only, like `brief-validation.ts`: content
13
+ * quality stays a judgment call; existence does not.
14
+ *
15
+ * Applies to task Issues only (label `vinaya/tranche:<slug>`) — the caller decides
16
+ * applicability from the labels; this module only checks the body.
17
+ */
18
+
19
+ import { hasLabel, LABELS, projectFieldFromBody, projectsFromBody, SECTION_HEADER } from '@attalabs/aeg-forge-state'
20
+ import { stripCode } from './anchored-region'
21
+
22
+ export type IssueSectionResult = { status: 'pass' | 'fail'; errors: string[] }
23
+
24
+ /**
25
+ * Tolerant field detector: accepts the two live rationale styles —
26
+ * `**Field** — …` bold-inline (e.g. Issue #309) and `### Field` headings
27
+ * (e.g. Issue #219). Case-insensitive.
28
+ */
29
+ function hasRationaleField(body: string, labelPattern: string): boolean {
30
+ // `labelPattern` is grouped. Ungrouped, its own top-level `|` (present in
31
+ // several RATIONALE_FIELDS entries, e.g. `Dependency rationale|Depends[- ]on`)
32
+ // splits the WHOLE alternation instead of just the label — turning the
33
+ // second half into a bare, unanchored match with no prefix requirement at
34
+ // all. Found live: "Depends on" matching mid-sentence prose with no `**`/
35
+ // heading marker anywhere near it, misreporting old Issues that never had
36
+ // this field as "malformed" instead of "missing".
37
+ const re = new RegExp(`(?:\\*\\*|^#{1,4}\\s+)\\s*(?:${labelPattern})`, 'im')
38
+ return re.test(body)
39
+ }
40
+
41
+ /** The eight producer fields of the planner-brief contract, with tolerant label patterns. */
42
+ const RATIONALE_FIELDS: Array<{ name: string; pattern: string }> = [
43
+ { name: 'Boundary', pattern: 'Boundary' },
44
+ { name: 'Sizing', pattern: 'Sizing' },
45
+ { name: 'Project(s) + blast radius', pattern: 'Project\\(s\\)|Project(?:s)?\\s*\\+|blast radius' },
46
+ { name: 'Dependency rationale', pattern: 'Dependency rationale|Depends[- ]on' },
47
+ { name: 'Traps to avoid', pattern: 'Traps' },
48
+ { name: 'Suggested agent-class', pattern: '(?:Suggested\\s+)?agent-class' },
49
+ { name: 'Stop-and-escalate', pattern: 'Stop-and-escalate' },
50
+ { name: 'Docs to keep coherent', pattern: 'Docs to keep coherent|§7' }
51
+ ]
52
+
53
+ const DEPENDENCY_RATIONALE_FIELD_NAME = 'Dependency rationale'
54
+
55
+ /**
56
+ * Every one of the eight Planner's-rationale fields must be present in a task
57
+ * Issue's body. One error line per missing field, mirroring
58
+ * `checkBriefSections`'s error style.
59
+ *
60
+ * `Dependency rationale` carries a second, stricter requirement the other
61
+ * seven fields do not: `amendRationaleDeps` (`@attalabs/aeg-forge-state`, the ONLY
62
+ * sanctioned way to edit `Depends-on`/`Conflicts-with`) locates this section
63
+ * by the exact anchor `SECTION_HEADER` — `**Dependency rationale**` with the
64
+ * bold closing immediately after the label. A body written
65
+ * `**Dependency rationale:** …` (colon inside the bold) satisfies the tolerant
66
+ * detector above but not `SECTION_HEADER`, so it passes here and then throws
67
+ * on the only sanctioned edit path. Importing `SECTION_HEADER` rather than a
68
+ * second hand-written regex keeps this one grammar.
69
+ */
70
+ export function checkIssueRationale(body: string): IssueSectionResult {
71
+ const errors: string[] = []
72
+ for (const f of RATIONALE_FIELDS) {
73
+ if (!hasRationaleField(body, f.pattern)) {
74
+ errors.push(
75
+ `issue-validation ${f.name}: rationale field not found in the Issue body — every task Issue carries the full Planner's rationale (aeg-root/contracts/planner-brief.md).`
76
+ )
77
+ continue
78
+ }
79
+ if (f.name === DEPENDENCY_RATIONALE_FIELD_NAME && !SECTION_HEADER.test(body)) {
80
+ errors.push(
81
+ `issue-validation ${f.name}: rationale field found, but not in the form amend-deps requires — ` +
82
+ 'write `**Dependency rationale** — …`, not `**Dependency rationale:** …`. ' +
83
+ '`amendRationaleDeps` (the only sanctioned way to edit Depends-on/Conflicts-with) locates this ' +
84
+ 'section by the exact anchor `**Dependency rationale**`; a colon inside the bold breaks that match ' +
85
+ 'and the Issue becomes unamendable.'
86
+ )
87
+ }
88
+ }
89
+ return { status: errors.length > 0 ? 'fail' : 'pass', errors }
90
+ }
91
+
92
+ /** true when any label marks this as a task Issue (the rationale contract applies). */
93
+ export function isTaskIssueLabelSet(labels: string[]): boolean {
94
+ return hasLabel('tranche', labels)
95
+ }
96
+
97
+ /** Every `vinaya/type:*` label id, in `labels.ts` order — the source of truth this check reads, never a second list. */
98
+ const TYPE_LABEL_IDS = LABELS.filter((l) => l.category === 'type').map((l) => l.id)
99
+
100
+ /**
101
+ * **The task-type axis.** A task Issue must carry exactly one `vinaya/type:*`
102
+ * label — the same commit-type vocabulary `developer.md`'s commit conventions
103
+ * declare, applied to the Issue instead of the commit. Zero means the task
104
+ * was never classified; two or more means two classifications compete and
105
+ * nothing downstream can pick between them.
106
+ *
107
+ * Non-task Issues (no tranche label) pass trivially, the same way every
108
+ * sibling content check treats them — the rationale contract, and everything
109
+ * built on it, applies to task Issues only.
110
+ *
111
+ * **Caller must invoke this at Issue CREATION only, never on `edit`.** The
112
+ * label is mandatory forward from this axis's own merge, not retroactively —
113
+ * a task Issue cut before the merge legitimately carries none, and
114
+ * `open-issue.ts` is the only sanctioned edit path for ANY Issue body, so
115
+ * calling this on every edit would refuse an unrelated edit (a typo fix, a
116
+ * dependency bump) to any pre-existing Issue for lacking a label nothing
117
+ * ever asked it to carry — a forced backfill through the back door. This
118
+ * function itself is pure and stateless (it cannot see create vs. edit); the
119
+ * gating lives in the caller (`open-issue.ts`'s `isEdit` branch).
120
+ */
121
+ export function checkIssueType(_body: string, labels: string[]): IssueSectionResult {
122
+ if (!isTaskIssueLabelSet(labels)) return { status: 'pass', errors: [] }
123
+ const present = TYPE_LABEL_IDS.filter((id) => labels.includes(id))
124
+ if (present.length === 1) return { status: 'pass', errors: [] }
125
+ const found = present.length === 0 ? 'none of them' : `${present.length} of them (${present.join(', ')})`
126
+ return {
127
+ status: 'fail',
128
+ errors: [
129
+ `issue-validation task type: a task Issue must carry exactly one \`vinaya/type:*\` label, and this one carries ${found}. Valid ids: ${TYPE_LABEL_IDS.join(', ')}.`
130
+ ]
131
+ }
132
+ }
133
+
134
+ // ---------------------------------------------------------------------------
135
+ // Content checks (A/B/D block, C warns)
136
+ //
137
+ // `checkIssueRationale` above checks the eight fields are PRESENT and
138
+ // well-formed. It never checks what they SAY against the surface the task
139
+ // touches — and three task Issues in `vinaya-pages-v2` (#621/#622/#626) passed
140
+ // it while being wrong in three distinct ways: a `packages/ui` edit declared
141
+ // `Project: vinaya` only (blast radius under-declared, so the review fans out
142
+ // through one product's lens instead of every consumer's); a `## References`
143
+ // block copied brief-time content into the Issue, where it goes stale before
144
+ // work starts; and nothing forced the rationale to name the docs/skills it
145
+ // touches, because the skill-check hook fires on file edits and a forge write
146
+ // edits no file. The checks below are those three failures turned into
147
+ // deterministic functions on the surface they happened on.
148
+ //
149
+ // EVERY ONE OF THEM READS BLOCK-STRIPPED TEXT, via the single exported
150
+ // `stripCode` — never a second regex (PR #617's rule). A rationale that quotes
151
+ // `## References`, `Premise:`, or a `packages/ui` path inside a **fence** is
152
+ // documenting, not leaking, and must not trip anything; GitHub's own parsers
153
+ // ignore code the same way.
154
+ //
155
+ // They differ on **inline spans**, and the split is not cosmetic:
156
+ //
157
+ // - B looks for brief-shaped *headings*, which never live in a span, so it
158
+ // takes the full default strip.
159
+ // - A/C/D look for *paths*, and prose writes paths in backticks by
160
+ // convention — #621 declares its own surface as "edits `packages/ui`". Run
161
+ // span-blind, A matches nothing on the very Issues it was built from and
162
+ // ships as a gate that always passes, which is worse than no gate. So they
163
+ // read `PATH_TEXT`: fences and indented blocks gone, spans intact.
164
+ //
165
+ // Accepted cost: brief content that leaks *entirely inside* a fence is
166
+ // invisible to B. Same trade every code-aware gate here makes; the alternative
167
+ // is the fence-blind false-positive machine #617 removed.
168
+ // ---------------------------------------------------------------------------
169
+
170
+ /**
171
+ * Block-stripped, span-preserving body text — the reading every path-shaped
172
+ * check uses. See the note above for why paths cannot be scanned span-blind.
173
+ */
174
+ const PATH_TEXT = (body: string): string => stripCode(body, { inlineSpans: 'keep' })
175
+
176
+ /** A registry row reduced to what the blast-radius check needs — `parseRegistry` output is assignable. */
177
+ export type ProjectPath = { name: string; path: string }
178
+
179
+ /**
180
+ * A C0 or C1 control character — `ESC` (and therefore every ANSI/OSC terminal
181
+ * escape), `BEL`, and the rest.
182
+ *
183
+ * Tested by code point rather than by a character-class regex on purpose: a
184
+ * regex spelling this range is itself a lint violation
185
+ * (`noControlCharactersInRegex`), and the rule is right — the readable way to
186
+ * say "control character" is to name the code points.
187
+ */
188
+ function isControlCodePoint(codePoint: number): boolean {
189
+ return codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f)
190
+ }
191
+
192
+ /**
193
+ * Characters that change how the rest of a line *renders* without printing
194
+ * anything themselves — the Trojan-Source class.
195
+ *
196
+ * Stripping C0/C1 closes ANSI and OSC, but it is not the whole of "a hostile
197
+ * value cannot repaint or hide the failure being reported": a bidi override
198
+ * (U+202E) reverses the rendered name in the operator's terminal, and a
199
+ * zero-width character (U+200B) splits a name so it reads as a registered one.
200
+ * Same untrusted sources, same goal, so they are dropped by the same pass.
201
+ *
202
+ * U+200B–200F zero width space/joiners, LRM/RLM
203
+ * U+202A–202E bidi embedding and override
204
+ * U+2066–2069 bidi isolates
205
+ * U+FEFF byte-order mark used as a zero-width no-break space
206
+ */
207
+ function isDisplayControlCodePoint(codePoint: number): boolean {
208
+ return (
209
+ (codePoint >= 0x200b && codePoint <= 0x200f) ||
210
+ (codePoint >= 0x202a && codePoint <= 0x202e) ||
211
+ (codePoint >= 0x2066 && codePoint <= 0x2069) ||
212
+ codePoint === 0xfeff
213
+ )
214
+ }
215
+
216
+ /**
217
+ * How many residue values one error message will name before summarising the
218
+ * rest. `field.unparsed` is one entry per comma-separated segment, so a single
219
+ * body of repeated `%,` pairs yields tens of thousands of them; uncapped they
220
+ * render into one enormous string that lands in `CheckFailure.reason` and in a
221
+ * blocking gate's `--json` output. Naming the first few is what an author needs
222
+ * to find the line; the count carries the rest.
223
+ */
224
+ const MESSAGE_VALUE_COUNT_MAX = 5
225
+
226
+ /** Renders a residue list for an error message: the first few values, then a count. */
227
+ function residueForMessage(values: string[]): string {
228
+ const shown = values.slice(0, MESSAGE_VALUE_COUNT_MAX).map((v) => `\`${forMessage(v)}\``)
229
+ const rest = values.length - shown.length
230
+ return rest > 0 ? `${shown.join(', ')} and ${rest} more` : shown.join(', ')
231
+ }
232
+
233
+ /** How much of one untrusted value an error message will carry before eliding. */
234
+ const MESSAGE_VALUE_MAX = 64
235
+
236
+ /**
237
+ * Renders an untrusted string into an error message.
238
+ *
239
+ * **Neither side of this gate's message is validated at its source.** Registered
240
+ * names come from `parseRegistry`, which by design "is forgiving" and applies no
241
+ * shape check at all — a name is whatever text sat in a markdown table cell. The
242
+ * declared side is a task Issue's body. Vinaya ships inside the published
243
+ * `@attalabs/vinaya` tarball and runs against *guest* repos, so both are attacker-
244
+ * reachable: a hostile `.vinaya/projects.md` row (or Issue body) carrying `ESC`
245
+ * renders ANSI/OSC sequences straight through a Vinaya error into the operator's
246
+ * terminal, where they can repaint or hide the very failure being reported.
247
+ *
248
+ * Sanitising here rather than at `parseRegistry` is deliberate: the registry's
249
+ * tolerance is load-bearing (a typo'd row must not crash Studio), and a name that
250
+ * is merely *odd* must still resolve for exact-match purposes. The constraint
251
+ * belongs where the value crosses into a rendered message, which is here.
252
+ *
253
+ * Strips control characters, collapses whitespace to single spaces (so a value
254
+ * cannot span lines and forge a second error line), and elides past
255
+ * `MESSAGE_VALUE_MAX`. Everything a well-formed project name is made of survives
256
+ * untouched — this is sanitation, not redaction.
257
+ */
258
+ function forMessage(value: string): string {
259
+ // Whitespace collapses FIRST, so a newline or tab becomes a space rather than
260
+ // vanishing and welding two words together — and a multi-line value cannot
261
+ // forge what looks like a second error line.
262
+ const kept: string[] = []
263
+ for (const ch of value.replace(/\s+/g, ' ')) {
264
+ const codePoint = ch.codePointAt(0) ?? 0
265
+ if (isControlCodePoint(codePoint) || isDisplayControlCodePoint(codePoint)) continue
266
+ kept.push(ch)
267
+ }
268
+ // Elide by CODE POINT, not by UTF-16 index. `slice` on a string counts code
269
+ // units, so a cut landing inside an astral character emits a lone surrogate —
270
+ // a malformed string, from the function whose job is to make this value safe.
271
+ const cleaned = kept.join('').trim()
272
+ if (cleaned.length === 0) return '(unprintable)'
273
+ const points = Array.from(cleaned)
274
+ return points.length > MESSAGE_VALUE_MAX ? `${points.slice(0, MESSAGE_VALUE_MAX).join('')}…` : cleaned
275
+ }
276
+
277
+ /**
278
+ * The projects a task Issue declares — its body's `**Project:**` field, and
279
+ * only that. Project is a **field, not a label** (doctrine): #614 dropped the
280
+ * `project:*` labels outright, and `@attalabs/aeg-forge-state`'s `list-tasks.ts`
281
+ * derives a task's project from the same field, so the two agree by
282
+ * construction. `labels` stays in the signature because callers pass it and
283
+ * the applicability question (`isTaskIssueLabelSet`) is label-shaped.
284
+ */
285
+ export function declaredProjects(body: string, _labels: string[]): string[] {
286
+ const field = /(?:\*\*)?Project(?:\(s\))?(?:\*\*)?\s*:\s*(?:\*\*)?\s*([^\n]+)/i.exec(PATH_TEXT(body))
287
+ const fromBody = (field?.[1] ?? '')
288
+ .split(/[,/]/)
289
+ // Trim the markup and sentence punctuation a field value carries in prose —
290
+ // the value is routinely written as "`Project: vinaya`." (backticked, with
291
+ // the sentence's full stop inside the span, which `PATH_TEXT` preserves).
292
+ .map((s) => s.replace(/\*\*/g, '').replace(/[`.;]/g, '').trim())
293
+ // The field's value may trail into prose ("vinaya. **BUT edits …**"); keep
294
+ // the bare-name shapes a registry row can actually carry.
295
+ .filter((s) => /^[a-z0-9][a-z0-9-]*$/i.test(s))
296
+ return [...new Set(fromBody.filter((s) => s.length > 0))]
297
+ }
298
+
299
+ /**
300
+ * **Every declared project resolves against the registry.** `planner.md` states
301
+ * this as a hard gate — *"Unregistered project or a `Project:` that doesn't
302
+ * resolve against `projects.md` → refuse"* — and until this function nothing
303
+ * mechanized it. Found live on 2026-08-12 on a draft plan declaring
304
+ * `Project: aeg-core, aeg-types, vinaya`; `aeg-types` has no registry row and
305
+ * the plan passed every gate.
306
+ *
307
+ * `.vinaya/projects.md` is the sole authority for valid names ("A `Project:`
308
+ * value is valid iff every name in it is a row above"), so this check only asks
309
+ * membership — never whether the *right* projects were chosen, which stays a
310
+ * review judgment like the rest of this module.
311
+ *
312
+ * **It calls `projectsFromBody` — the same function, not a matching regex.**
313
+ * `@attalabs/aeg-forge-state`'s `list-tasks.ts` is the repo's authority for what a
314
+ * task's project *is*: it is what fills `Task.projects`, and therefore what
315
+ * drives the board, dispatch, and doc fan-out. A gate that decides a project is
316
+ * unregistered must be reading the identical name the derivation resolved, or
317
+ * the two can disagree about what the task even declares — so this shares the
318
+ * parser by construction rather than by agreement. That is the same discipline
319
+ * `parseRationaleDeps`/`SECTION_HEADER` already enforce for dependency edges,
320
+ * and the import direction is the existing one (`aeg-core → aeg-forge-state`,
321
+ * as in `archive-task.ts`); nothing new is layered.
322
+ *
323
+ * **Never parse the `Project(s) + blast radius` prose heading here.** That
324
+ * heading is narrative that happens to mention project names alongside file
325
+ * paths; `PROJECT_FIELD` excludes it deliberately ("nothing there puts a `:`
326
+ * straight after the name"). Reading it makes the gate blind to the real
327
+ * declaration and invents projects out of the paths sharing the line. Equally,
328
+ * do not add a second regex that "also handles" the footer field — two parsers
329
+ * that agree today is exactly how the gate and the derivation drift apart.
330
+ *
331
+ * Matching is **exact, case-sensitive**. Every downstream consumer of a
332
+ * project name compares it literally — `verify-dispatch`'s
333
+ * `t.projects.includes(project)`, the board link, the doc fan-out — so
334
+ * case-folding here would pass `Project: Vinaya` while the whole rest of the
335
+ * system resolves it to nothing. Refusing it is the honest answer; the message
336
+ * names the row it differs from only in case, so the fix is obvious.
337
+ *
338
+ * A body with no `**Project:**` line **passes**. Whether the field exists is
339
+ * already `checkIssueRationale`'s job (`Project(s) + blast radius` is one of the
340
+ * eight required fields); duplicating it here would put one failure behind two
341
+ * gates with two different messages. This check answers only "do the declared
342
+ * names resolve".
343
+ *
344
+ * **A declared value that resolves to no name FAILS** — the distinction the
345
+ * parser's old bare `string[]` could not express. Every value used to be filtered
346
+ * through the slug shape and dropped without trace, so `**Project:** notaproject.`
347
+ * (trailing full stop) and a fully backticked or bolded value each arrived here as
348
+ * an empty list, identical to a body that declares nothing. The gate cannot refuse
349
+ * a name it never receives, so it passed **vacuously** on exactly the bodies it
350
+ * exists to catch. `projectFieldFromBody` now separates "no field" (still a pass)
351
+ * from "a field present that resolves to nothing" (a fail, naming the residue when
352
+ * there is one and calling the field empty when there is not) and from "a field
353
+ * this reader could not see" (an unterminated fence — also a fail, see above).
354
+ *
355
+ * **What the corpus does and does not say.** Measured across every task Issue
356
+ * in this repo's forge: zero carry any of those shapes, so this closed a
357
+ * fail-open without turning a single live body red. That is a statement about
358
+ * bodies that exist, not a proof that none can slip past — a constructed body
359
+ * can carry a shape the live corpus happens not to.
360
+ *
361
+ * **A `Project:` line inside CODE — a balanced fence, or a ≥4-column-indented
362
+ * block after a blank line — is an example, not a declaration, and is not
363
+ * read**, by the same rule `stripCode` already applies to every other
364
+ * code-aware gate in this file. This is deliberate, not a gap: it matches how
365
+ * the forge itself renders the line, so refusing to read it is refusing to read
366
+ * what GitHub also treats as code. Only an UNTERMINATED fence differs — it has
367
+ * no natural end, so `stripCode` blanks everything after it including the
368
+ * body's foot, and that swallowed region fails closed rather than reading as
369
+ * absent (see `hasUnterminatedFence` above). An indented block has no
370
+ * equivalent unterminated state — it always ends, either at a dedent or at the
371
+ * body's own end — so it stays a pass like any other example; pinned by test
372
+ * (`list-tasks.test.ts`) so the difference is a recorded decision, not a silent
373
+ * surprise.
374
+ *
375
+ * A known remaining gap of a DIFFERENT class, out of scope here because it is
376
+ * the field's grammar rather than its code-blindness: a `Project:` line inside
377
+ * an HTML comment still outranks the real declaration, since a comment is not
378
+ * code and `stripCode` correctly leaves it.
379
+ *
380
+ * **What reaches the message is constrained** (`forMessage`). Both the declared
381
+ * value and the registered names are untrusted — `parseRegistry` validates
382
+ * nothing, and Vinaya runs against guest repos — so neither is rendered raw.
383
+ *
384
+ * Dormant when `registeredNames` is empty (no `.vinaya/projects.md` on disk) —
385
+ * the same seam-is-dormant-when-absent shape `checkBlastRadiusScope` and
386
+ * `doc-owners` use. A single-project repo has no registry by design, and a
387
+ * check with no source of truth must not invent one. Both bin callers warn when
388
+ * they hand over an empty registry, so the dormancy is never silent.
389
+ */
390
+ export function checkProjectsRegistered(
391
+ body: string,
392
+ _labels: string[],
393
+ registeredNames: string[]
394
+ ): IssueSectionResult {
395
+ if (registeredNames.length === 0) return { status: 'pass', errors: [] }
396
+ const field = projectFieldFromBody(body)
397
+ if (!field.declared) return { status: 'pass', errors: [] }
398
+ const known = new Set(registeredNames.map((n) => n.trim()))
399
+ const registeredList = [...registeredNames].map(forMessage).join(', ')
400
+ const errors: string[] = []
401
+
402
+ // An unterminated fence ran to end of body and swallowed the region the field
403
+ // lives in, so the value below is what the RAW body says and cannot be trusted
404
+ // — inside a swallowed region a quoted example and a real declaration are
405
+ // indistinguishable. Fail closed and point at the malformed fence: accusing
406
+ // the name of not being a project would be wrong (it may be a perfectly good
407
+ // registered one) and would send the author to fix the wrong line.
408
+ if (field.unreadable) {
409
+ return {
410
+ status: 'fail',
411
+ errors: [
412
+ `issue-validation project registry: the body has an unterminated code fence, so its \`**Project:**\` field could not be read reliably — the fence runs to end of body (CommonMark, and how the forge renders it), swallowing everything after it including the foot field where the declaration lives by convention. Read from the raw body the field says ${residueForMessage(field.unparsed) || '(nothing)'}, but a line inside a swallowed region may be a quoted example rather than a real declaration, so this gate refuses rather than guessing which. Balance the fences — every opening run of backticks or tildes needs a closing run at least as long — and the field reads normally.`
413
+ ]
414
+ }
415
+ }
416
+
417
+ const unregistered = field.names.filter((p) => !known.has(p))
418
+ if (unregistered.length > 0) {
419
+ // A name differing from a real row only in case is the likeliest typo, and the
420
+ // least obvious from the registered list alone — call it out by name.
421
+ const caseHints = unregistered
422
+ .map((p) => {
423
+ const row = [...known].find((k) => k.toLowerCase() === p.toLowerCase())
424
+ return row ? `\`${forMessage(p)}\` differs from the registered \`${forMessage(row)}\` only in case` : null
425
+ })
426
+ .filter((h): h is string => h !== null)
427
+ errors.push(
428
+ `issue-validation project registry: the \`**Project:**\` field declares ${unregistered.map(forMessage).join(', ')} — no such row in \`.vinaya/projects.md\`, which is the authority for valid project names (registered: ${registeredList}). Fix the name, or register the project with \`vinaya init product <name> --path <folder>\` first; an unregistered project has no specs to read and no per-project state to update.${caseHints.length > 0 ? ` Note: ${caseHints.join('; ')} — project names are matched exactly, because every downstream consumer compares them literally.` : ''} This reads the same field \`projectsFromBody\` derives the task's project from, so a name here that is not a row is a task that resolves to a project that does not exist.`
429
+ )
430
+ }
431
+
432
+ // The fail-open this check was blind to. A value the parser cannot turn into a
433
+ // name never reached the loop above, so the gate had nothing to refuse and
434
+ // passed — on a body that declares a project as loudly as any other. Silence
435
+ // here is indistinguishable from "this task declares no project", and the two
436
+ // mean opposite things: one is a deliberate omission, the other is a
437
+ // declaration nothing in the system can resolve.
438
+ //
439
+ // Keyed on "declared and resolved to NOTHING", not on the residue: an empty
440
+ // `**Project:**` line yields no name and no residue either, and is the same
441
+ // vacuous pass one shape further along. The residue is named when there is
442
+ // one, because it is the whole of the fix — but its absence is not a pass.
443
+ if (field.names.length === 0) {
444
+ const residue = field.unparsed.length > 0 ? ` — ${residueForMessage(field.unparsed)}` : ' — the field is empty'
445
+ errors.push(
446
+ `issue-validation project registry: the \`**Project:**\` field is present but resolves to no project name${residue} (registered: ${registeredList}). A project name is a slug (\`[a-z0-9][a-z0-9-]*\`, matched exactly); prose, a parenthetical, or a sentence in this field resolves to no project at all, and this gate cannot check a name it never receives — which is how a declaration like this used to pass. Write the registered name on its own, or register the project with \`vinaya init product <name> --path <folder>\` first. If the task genuinely touches no registered project, omit the field rather than explaining its absence inside it — \`checkIssueRationale\` already requires the \`Project(s) + blast radius\` narrative field for that.`
447
+ )
448
+ } else if (field.unparsed.length > 0) {
449
+ errors.push(
450
+ `issue-validation project registry: the \`**Project:**\` field declares a value that is not a project name — ${residueForMessage(field.unparsed)} (registered: ${registeredList}). The rest of the field parsed, so this is a name the gate silently could not check rather than a field it could not read at all. A project name is a slug (\`[a-z0-9][a-z0-9-]*\`, matched exactly); write it on its own, or drop it if it names no project.`
451
+ )
452
+ }
453
+
454
+ return errors.length > 0 ? { status: 'fail', errors } : { status: 'pass', errors: [] }
455
+ }
456
+
457
+ /**
458
+ * Does the text name a path that puts this task **inside** the collision
459
+ * domain `path`? Matches the domain as a whole path token — `packages/ui` hits
460
+ * `packages/ui/topbar/index.tsx` and a bare `packages/ui`, never
461
+ * `packages/ui-next`.
462
+ *
463
+ * **A cited document is not a touched domain.** Every rationale points at docs
464
+ * for provenance — "the registry row in `.vinaya/projects.md`",
465
+ * "per `packages/ui/README.md`" — and counting those as edits fails correct
466
+ * plans wholesale (it fired on all three of #621/#622/#626 for a projects.md
467
+ * citation none of them edits). So an occurrence whose full path token ends in
468
+ * a doc extension does not count; a bare domain reference, or any non-doc path
469
+ * under it, does. Citing `packages/ui/README.md` *and* editing
470
+ * `packages/ui/topbar/index.tsx` still counts — the check looks for any one
471
+ * qualifying occurrence, not the first.
472
+ */
473
+ const DOC_EXTENSION_RE = /\.(?:md|mdx|txt)$/i
474
+
475
+ function namesPath(text: string, path: string): boolean {
476
+ const escaped = path.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
477
+ const occurrences = new RegExp(`(?:^|[^\\w./-])(${escaped}(?:/[\\w.@-]+)*)(?![\\w-])`, 'gm')
478
+ for (const m of text.matchAll(occurrences)) {
479
+ if (!DOC_EXTENSION_RE.test(m[1] as string)) return true
480
+ }
481
+ return false
482
+ }
483
+
484
+ /** An explicit, deliberate acknowledgment that the task's reach crosses products. */
485
+ const BLAST_RADIUS_ACK_RE = /(?:\*\*)?blast-radius-ack(?:\*\*)?\s*[:—–-]/i
486
+
487
+ /**
488
+ * **A — blast radius under-declared.** If the rationale names a path under a
489
+ * collision domain that none of the declared projects owns, the task reaches
490
+ * further than its `Project:` set admits. `projects.md` makes that set the
491
+ * review fan-out ("more projects = more review lenses = proportionally more
492
+ * rigor"), so a shared-primitive change carrying one product's label is
493
+ * reviewed through one lens and under-governs the regression its own prose
494
+ * usually admits in the same breath (#621: "**BUT edits `packages/ui`** … any
495
+ * topbar change is seen by every product").
496
+ *
497
+ * Two ways to satisfy it, both deliberate: list the other consumers in the
498
+ * project field (the fan-out actually widens), or write a `blast-radius-ack:`
499
+ * line (the reach is acknowledged and the Planner has decided one lens is
500
+ * enough). Silence is the only failure.
501
+ *
502
+ * **The multi-project bypass counts registry-validated names only.** A name
503
+ * with no row in `.vinaya/projects.md` resolves to no specs, no state and no
504
+ * reviewer, so it widens nothing — counting it lets a fictional name buy the
505
+ * benefit of the doubt this bypass exists to give real ones. Found live on a
506
+ * draft plan declaring three projects, one of which had no registry row: it
507
+ * cleared the bypass, and the check never ran on a genuinely shared edit.
508
+ * `checkProjectsRegistered` refuses that name in its own right; this function
509
+ * merely declines to be fooled by it, so the two stay independent.
510
+ *
511
+ * **Ownership, not mere listing.** A domain that IS a declared project's own
512
+ * registered path is owned by it — `Project: aeg-core` editing
513
+ * `packages/aeg-core` declares its blast radius exactly, and must not be
514
+ * failed. Without this, the check would block the legitimately single-project
515
+ * shared edit, which is a gate that blocks valid work.
516
+ *
517
+ * **The declared set is read through `projectsFromBody`** — the same parser
518
+ * `checkProjectsRegistered` and `@attalabs/aeg-forge-state`'s `list-tasks.ts` use,
519
+ * shared by construction rather than by agreement. It reads the line-anchored
520
+ * project field and only that. The previous body-wide read took the first
521
+ * field-shaped token *anywhere* in the body, which is routinely prose in Sizing
522
+ * or Boundary rather than the declaration: prose carries file paths, and a path
523
+ * fragment parses as an invented project name (#870's own blast-radius line
524
+ * yielded a project called `src`). Two parsers for one field is how the gate and
525
+ * the derivation come to disagree about what a task even declares — so do not
526
+ * add a second regex here, nor a pre-clean step that makes one "usually" agree.
527
+ *
528
+ * Dormant when `sharedPackages` is empty. As of `open-issue.ts`'s
529
+ * `readSharedPackages`, that only happens on a repo with no `packages/*`
530
+ * workspace member, none of the built-in cross-cutting defaults present, and
531
+ * no `vinaya.config.json` `blastRadius.extraDomains` — a genuinely edge-case
532
+ * repo, not the common "adopter never wrote the file" case this dormancy
533
+ * used to hide. This function itself stays source-blind —
534
+ * it takes the resolved list, never reads disk — the same
535
+ * seam-is-dormant-when-absent shape `doc-owners` uses. The check cannot be
536
+ * deterministic without its source of truth, and inventing one inline is worse
537
+ * than not running.
538
+ */
539
+ export function checkBlastRadiusScope(
540
+ body: string,
541
+ _labels: string[],
542
+ sharedPackages: string[],
543
+ projectPaths: ProjectPath[]
544
+ ): IssueSectionResult {
545
+ if (sharedPackages.length === 0) return { status: 'pass', errors: [] }
546
+ // Scoped to the two fields that declare the task's OWN surface. Scanning the
547
+ // whole body fails correct plans in bulk: a rationale names packages for many
548
+ // reasons that are not edits — a dependency it imports unchanged (#591/#599
549
+ // name `packages/aeg-core` because Vinaya's CLI imports it), a trap to avoid,
550
+ // an Origin note. A full-body scan flagged 46 of 166 historical task Issues,
551
+ // nearly all of them correctly-scoped work. Boundary and Project(s) + blast
552
+ // radius are where a task states what it touches, so that is where a
553
+ // touch-claim is load-bearing enough to block on.
554
+ const text = [
555
+ rationaleFieldText(PATH_TEXT(body), 'Boundary'),
556
+ rationaleFieldText(PATH_TEXT(body), 'Project\\(s\\)|Project(?:s)?\\s*\\+|blast radius')
557
+ ].join('\n')
558
+ const named = sharedPackages.filter((d) => namesPath(text, d))
559
+ if (named.length === 0) return { status: 'pass', errors: [] }
560
+
561
+ // Registry-validated, not merely declared. The bypass below widens the review
562
+ // fan-out on the Planner's word that a second project reviews the change — a
563
+ // name with no row in `.vinaya/projects.md` buys no lens, so it cannot buy the
564
+ // bypass either. Found live on a draft plan declaring three names, one of them
565
+ // fictional: it cleared the bypass and this check never ran.
566
+ const registered = new Set(projectPaths.map((p) => p.name.trim()))
567
+ const declared = projectsFromBody(body)
568
+ const projects = declared.filter((p) => registered.has(p))
569
+ const ownedPaths = projectPaths.filter((p) => projects.includes(p.name)).map((p) => p.path.replace(/\/+$/, ''))
570
+ const unowned = named.filter((d) => !ownedPaths.some((owned) => d === owned || d.startsWith(`${owned}/`)))
571
+ if (unowned.length === 0) return { status: 'pass', errors: [] }
572
+ if (projects.length > 1) return { status: 'pass', errors: [] }
573
+ // The ack is looked for in the WHOLE body, not the scoped surface fields — it
574
+ // is a deliberate statement about the task, and a Planner may reasonably put
575
+ // it under Stop-and-escalate or Traps rather than inside Boundary.
576
+ if (BLAST_RADIUS_ACK_RE.test(PATH_TEXT(body))) return { status: 'pass', errors: [] }
577
+
578
+ // Name the dropped names. Without this the message reads "a single project"
579
+ // at an author looking at three, and the real fix (register the name, or use
580
+ // the registered one) is invisible from here.
581
+ const unregistered = declared.filter((p) => !registered.has(p))
582
+ return {
583
+ status: 'fail',
584
+ errors: [
585
+ `issue-validation blast radius: the rationale names ${unowned.join(', ')} — a shared collision domain no declared project (${projects.join(', ') || 'none'}) owns — but declares a single registered project and no \`blast-radius-ack:\` line.${unregistered.length > 0 ? ` Not counted: ${unregistered.join(', ')} — no row in \`.vinaya/projects.md\`, so it adds no review lens.` : ''} Project(s) drives the review fan-out (.vinaya/projects.md); list every consumer in the blast radius, or add \`blast-radius-ack: <why one lens is enough>\`.`
586
+ ]
587
+ }
588
+ }
589
+
590
+ /**
591
+ * Headings/fields that belong to a **brief**, never to an Issue. Brief-authoring
592
+ * is explicit that a brief is never put in the task's forge Issue — it would go
593
+ * stale before work starts, and then two artifacts disagree about the same task
594
+ * with nothing to arbitrate them.
595
+ */
596
+ const BRIEF_MARKERS: Array<{ name: string; pattern: RegExp }> = [
597
+ { name: '## References', pattern: /(?:^#{1,6}\s*|\*\*)\s*References\b/im },
598
+ { name: 'Technical surface map', pattern: /(?:^#{1,6}\s*|\*\*)\s*Technical surface map\b/im },
599
+ { name: 'Premise', pattern: /(?:^#{1,6}\s*|\*\*)\s*Premise(?:\*\*)?\s*[:—–]/im },
600
+ { name: 'Step 0', pattern: /(?:^#{1,6}\s*|\*\*)\s*Step 0\b/im },
601
+ { name: 'Test Plan', pattern: /(?:^#{1,6}\s*|\*\*)\s*Test Plan\b/im }
602
+ ]
603
+
604
+ /**
605
+ * **B — brief content in the Issue.** Fails when the code-stripped body carries
606
+ * a brief-shaped heading. The Issue is the Planner's durable rationale; the
607
+ * brief is the Brief Author's just-in-time execution context, authored against
608
+ * the surface as it exists at dispatch. Copying the second into the first
609
+ * creates a stale copy nobody re-reads and nobody updates.
610
+ */
611
+ export function checkNoBriefContent(body: string): IssueSectionResult {
612
+ const text = stripCode(body)
613
+ const errors = BRIEF_MARKERS.filter((m) => m.pattern.test(text)).map(
614
+ (m) =>
615
+ `issue-validation brief content: the Issue body carries a brief-shaped "${m.name}" section. Brief-time content (surface pointers, skills-to-read, premise, test plan) belongs in the brief, not the Issue — it goes stale before work starts (aeg-root/skills/brief-authoring/SKILL.md). Move it to the brief.`
616
+ )
617
+ return { status: errors.length > 0 ? 'fail' : 'pass', errors }
618
+ }
619
+
620
+ /**
621
+ * Slices one rationale field's prose: from its label to the next bold/heading
622
+ * field or the end. Tolerates both live styles, exactly like
623
+ * `hasRationaleField`.
624
+ */
625
+ function rationaleFieldText(text: string, labelPattern: string): string {
626
+ // `labelPattern` is grouped. Ungrouped, its own `|` splits the WHOLE regex
627
+ // instead of just the label — `Docs to keep coherent|§7` compiled as
628
+ // "(**|#) Docs to keep coherent" OR "§7[^\n]*…", so the Docs branch matched
629
+ // the bare label and captured nothing, and D silently graded every Issue on
630
+ // its Traps field alone (#622 failed on exactly this).
631
+ // The terminator is `(?![\s\S])` — a real end-of-INPUT assertion — not `$`.
632
+ // The `m` flag is required for the `^#{1,4}` heading form, and under `m` a
633
+ // `$` matches end-of-LINE, so the lazy body satisfied the lookahead
634
+ // immediately and every heading-style field (`### §7`, #219) sliced to its
635
+ // own label with zero content. D then saw an empty field and failed Issues
636
+ // that name their docs perfectly well, one line further down.
637
+ const re = new RegExp(
638
+ `(?:\\*\\*|^#{1,4}\\s+)\\s*(?:${labelPattern})[^\\n]*\\n?([\\s\\S]*?)(?=\\n\\s*(?:\\*\\*[A-Z]|#{1,4}\\s)|(?![\\s\\S]))`,
639
+ 'im'
640
+ )
641
+ const m = re.exec(text)
642
+ return m ? m[0] : ''
643
+ }
644
+
645
+ /**
646
+ * Concrete doc surfaces — the artifact of having actually read the surface, not
647
+ * a paraphrase of it.
648
+ *
649
+ * Deliberately generous about *shape*, strict about *concreteness*. A first,
650
+ * narrower version (only `aeg-root/…`, `.claude/skills/…`, `apps/<x>/CLAUDE.md`,
651
+ * `apps/<x>/specs/…`) failed 66 of 166 historical task Issues, and the samples
652
+ * were not agents skipping the read — they were real docs written in shapes the
653
+ * pattern did not enumerate: `.claude/rules/ui-patterns.md` (rules, not
654
+ * skills), a bare `docs-index.md`, `packages/<x>/README.md`. Each omission would
655
+ * have blocked a correct plan. So: any repo-rooted `.md`/`.mdx` path, any
656
+ * `.claude/**` doc, and the handful of bare filenames that are unambiguous
657
+ * repo-level documents. What it still refuses is the actual failure — a field
658
+ * that names no document at all.
659
+ */
660
+ const DOC_PATH_RE =
661
+ /(?:(?:aeg-root|apps|packages|specs|docs|tools|\.claude|\.github)\/[\w./@-]*\.(?:md|mdx)|\.claude\/(?:skills|rules)\/[\w./-]+|\b(?:docs-index|decisions|projects|state-machine|enforcement|process|README|CLAUDE)\.md\b|\b[\w-]+-(?:spec|decisions|backlog)\.md\b)/i
662
+
663
+ /**
664
+ * The doc-less-surface exemption, shaped after `brief-validation`'s
665
+ * `Test Plan: unit-tests-only` sentinel: an explicit, greppable opt-out a human
666
+ * chose, never an empty field that merely looks like one. "Docs to keep
667
+ * coherent: none" is what an agent writes when it did not look; the sentinel is
668
+ * what a Planner writes when it looked and there was nothing.
669
+ */
670
+ const NO_DOC_SURFACE_RE = /(?:\*\*)?\s*no-doc-surface/i
671
+
672
+ /**
673
+ * **D — no read-obligation signal.** The root cause of A and B both: nothing
674
+ * forced the Planner to read the docs and skills governing the surface it was
675
+ * planning, because the skill-check hook fires on file edits and cutting an
676
+ * Issue edits no file. Requiring a concrete doc path in `Docs to keep coherent`
677
+ * / `Traps to avoid` makes the read leave an artifact — you cannot name
678
+ * `.claude/skills/ui-library-system/SKILL.md` as the design anchor without
679
+ * having gone looking for it.
680
+ *
681
+ * Presence-only, like the rest of this module: whether the named doc is the
682
+ * *right* one stays a judgment call for review. That it exists at all does not.
683
+ */
684
+ export function checkRationaleNamesDocs(body: string): IssueSectionResult {
685
+ const text = PATH_TEXT(body)
686
+ const scope = [rationaleFieldText(text, 'Docs to keep coherent|§7'), rationaleFieldText(text, 'Traps')].join('\n')
687
+ if (NO_DOC_SURFACE_RE.test(scope)) return { status: 'pass', errors: [] }
688
+ if (DOC_PATH_RE.test(scope)) return { status: 'pass', errors: [] }
689
+ return {
690
+ status: 'fail',
691
+ errors: [
692
+ 'issue-validation docs read: neither "Docs to keep coherent" nor "Traps to avoid" names a concrete doc path (aeg-root/…, .claude/skills/…, apps/*/CLAUDE.md, apps/*/specs/…). Naming one is the artifact of having read the surface being planned — the forge write triggers no skill-check hook, so this field is the only read-obligation signal. For a genuinely doc-less surface, write the explicit `no-doc-surface` sentinel.'
693
+ ]
694
+ }
695
+ }
696
+
697
+ /** One open task Issue, reduced to what the conflict-completeness warning needs. */
698
+ export type TaskIssueFacts = {
699
+ /** How the Issue is referred to in a `Conflicts-with` edge — its number, or its task id. */
700
+ ref: string
701
+ body: string
702
+ /** Already-parsed `Conflicts-with` ids (`parseRationaleDeps`) — never re-parsed here. */
703
+ conflictsWith: string[]
704
+ }
705
+
706
+ /** True when either side's declared edges name the other — `#621`, `621` and `8` all count. */
707
+ function edgesNameEachOther(a: TaskIssueFacts, b: TaskIssueFacts): boolean {
708
+ const norm = (s: string) => s.replace(/^#/, '').trim()
709
+ return a.conflictsWith.map(norm).includes(norm(b.ref)) || b.conflictsWith.map(norm).includes(norm(a.ref))
710
+ }
711
+
712
+ /**
713
+ * **C — conflict completeness. WARN-ONLY, by construction.** Two open task
714
+ * Issues naming the same collision domain and declaring no mutual
715
+ * `Conflicts-with` edge are *probably* a missed serialization — but an Issue
716
+ * does not declare a precise file surface, so "names the same domain" is a
717
+ * hint, not a fact. Failing on a hint would make the gate refuse correct plans,
718
+ * so this prints and never blocks. It is also why AEG's conflict rule is
719
+ * declared-and-static in the first place: a real answer needs a live
720
+ * task→changed-files map, the mutable state the model eliminates
721
+ * (`tranche-model.md` §5).
722
+ */
723
+ export function checkConflictCompleteness(
724
+ subject: TaskIssueFacts,
725
+ siblings: TaskIssueFacts[],
726
+ sharedPackages: string[]
727
+ ): string[] {
728
+ if (sharedPackages.length === 0) return []
729
+ const domainsOf = (facts: TaskIssueFacts) => {
730
+ const text = PATH_TEXT(facts.body)
731
+ return sharedPackages.filter((d) => namesPath(text, d))
732
+ }
733
+ const mine = domainsOf(subject)
734
+ if (mine.length === 0) return []
735
+ const warnings: string[] = []
736
+ for (const sibling of siblings) {
737
+ if (sibling.ref === subject.ref) continue
738
+ const shared = domainsOf(sibling).filter((d) => mine.includes(d))
739
+ if (shared.length === 0) continue
740
+ if (edgesNameEachOther(subject, sibling)) continue
741
+ warnings.push(
742
+ `issue-validation conflict completeness: this Issue and ${sibling.ref} both name ${shared.join(', ')} but neither declares the other in Conflicts-with. If they can run in parallel, say so; otherwise declare the edge (aeg-root/tranche-model.md §5).`
743
+ )
744
+ }
745
+ return warnings
746
+ }