@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,240 @@
1
+ /**
2
+ * Milestone-shape validation for `vinaya milestone create` (vinaya-milestone-model-v1
3
+ * task 2). Pure — no `fs`, no `gh`. The command calls `checkMilestoneShape` before
4
+ * any forge write, so a malformed body never reaches GitHub.
5
+ *
6
+ * A Milestone's description carries three independent things, each parsed and
7
+ * removed from the remainder in turn: an optional `Release:` field (the sole
8
+ * authority for the milestone's version — never the title), an optional
9
+ * `### Tranche intents` section (one bullet per tranche this milestone will
10
+ * eventually hold, `- <slug>: <intent text>`), and the goal (whatever prose is
11
+ * left once both are removed). The goal is mandatory; `Release:` and the
12
+ * intents section are each optional but must parse if present.
13
+ */
14
+
15
+ import { stripCode } from '@attalabs/aeg-forge-state'
16
+
17
+ export type MilestoneIntent = { slug: string; goal: string }
18
+
19
+ export type ReleaseField = {
20
+ /** A `Release:` field line exists outside code. Says nothing about whether its value parsed. */
21
+ declared: boolean
22
+ /** The parsed version string, or `null` when absent or malformed. */
23
+ value: string | null
24
+ }
25
+
26
+ export type MilestoneShapeResult =
27
+ | { status: 'pass'; goal: string; release: string | null; intents: MilestoneIntent[] }
28
+ | { status: 'fail'; errors: string[] }
29
+
30
+ /**
31
+ * Line-anchored, `**`-optional on both sides, same shape as `PROJECT_FIELD`
32
+ * (`@attalabs/aeg-forge-state`'s `list-tasks.ts`) — the reference grammar this
33
+ * field is deliberately matched against rather than approximated. Un-global
34
+ * and `.exec`'d once: first match wins.
35
+ */
36
+ const RELEASE_FIELD = /^\s*(?:\*\*)?Release(?:\*\*)?\s*:\s*(?:\*\*)?\s*(.+)$/im
37
+
38
+ /** Peels the markup/punctuation a field value carries in prose — same edge set as `unwrapValue`. */
39
+ const VALUE_EDGE_MARKUP = /^[`.;\s]+|[`.;\s]+$/g
40
+
41
+ function unwrapValue(raw: string): string {
42
+ return raw.replace(/\*\*/g, '').replace(VALUE_EDGE_MARKUP, '')
43
+ }
44
+
45
+ /** A semver-shaped version: optional `v` prefix, `MAJOR.MINOR.PATCH`, optional pre-release/build. */
46
+ const RELEASE_VALUE = /^v?\d+\.\d+\.\d+(?:-[0-9A-Za-z][0-9A-Za-z.-]*)?(?:\+[0-9A-Za-z][0-9A-Za-z.-]*)?$/
47
+
48
+ /** Text with code fences/indented blocks stripped, inline spans kept — same reading `PROJECT_FIELD` uses. */
49
+ function pathText(body: string): string {
50
+ return stripCode(body, { inlineSpans: 'keep' })
51
+ }
52
+
53
+ /** The actual reader, over already-stripped text — shared so `checkMilestoneShape` (which already holds `text`) never pays for a second `stripCode` pass over the same body. */
54
+ function releaseFieldFromText(text: string): ReleaseField {
55
+ const m = text.match(RELEASE_FIELD)
56
+ if (!m) return { declared: false, value: null }
57
+ const raw = unwrapValue(m[1] ?? '')
58
+ return { declared: true, value: RELEASE_VALUE.test(raw) ? raw : null }
59
+ }
60
+
61
+ /**
62
+ * Reads the `Release:` field. `declared: true, value: null` is the malformed
63
+ * case — the field exists but its value isn't a version — distinct from
64
+ * `declared: false` (no field at all, which is a valid, versionless milestone).
65
+ */
66
+ export function releaseFieldFromBody(body: string): ReleaseField {
67
+ return releaseFieldFromText(pathText(body))
68
+ }
69
+
70
+ const INTENTS_HEADING = /^#{1,6}\s*Tranche intents\s*$/im
71
+ const NEXT_HEADING = /^#{1,6}\s+\S/m
72
+ const INTENT_BULLET = /^-\s+([a-z0-9][a-z0-9-]*)\s*:\s*(.+)$/i
73
+
74
+ /** The intents heading's own span plus its section body — `null` when there is no heading at all. */
75
+ function intentsBlock(text: string): { start: number; end: number; section: string } | null {
76
+ const start = text.match(INTENTS_HEADING)
77
+ if (!start || start.index === undefined) return null
78
+ const rest = text.slice(start.index + start[0].length)
79
+ const next = rest.match(NEXT_HEADING)
80
+ const sectionEnd = next && next.index !== undefined ? next.index : rest.length
81
+ return { start: start.index, end: start.index + start[0].length + sectionEnd, section: rest.slice(0, sectionEnd) }
82
+ }
83
+
84
+ /** Slices the intents section out of `pathText(body)` — from just after the heading to the next heading or end. */
85
+ function intentsSectionText(text: string): string | null {
86
+ return intentsBlock(text)?.section ?? null
87
+ }
88
+
89
+ /**
90
+ * Parses the `### Tranche intents` section, if present. `null` means no
91
+ * heading at all (a valid, intent-less milestone). A heading whose non-blank
92
+ * lines don't all match the bullet grammar is malformed — reported via
93
+ * `malformed: true` rather than silently dropping the bad line, mirroring
94
+ * `ProjectField.unparsed`'s fail-closed discipline.
95
+ */
96
+ function parseIntents(text: string): { intents: MilestoneIntent[]; malformed: boolean } {
97
+ const section = intentsSectionText(text)
98
+ if (section === null) return { intents: [], malformed: false }
99
+
100
+ const intents: MilestoneIntent[] = []
101
+ for (const line of section.split('\n')) {
102
+ const trimmed = line.trim()
103
+ if (trimmed.length === 0) continue
104
+ const m = trimmed.match(INTENT_BULLET)
105
+ if (!m) return { intents: [], malformed: true }
106
+ intents.push({ slug: (m[1] ?? '').toLowerCase(), goal: (m[2] ?? '').trim() })
107
+ }
108
+ return { intents, malformed: false }
109
+ }
110
+
111
+ /** Removes the first `Release:` line and the whole intents section (heading included) from `pathText(body)`. */
112
+ function goalRemainder(text: string): string {
113
+ const withoutRelease = text.replace(RELEASE_FIELD, '')
114
+ const block = intentsBlock(withoutRelease)
115
+ const remainder = block ? withoutRelease.slice(0, block.start) + withoutRelease.slice(block.end) : withoutRelease
116
+ return remainder.trim()
117
+ }
118
+
119
+ /**
120
+ * Refuses a milestone body before any forge write: the goal absent, `Release:`
121
+ * present but malformed, or the intents section unparseable. Shape-guarded
122
+ * only — like `checkIssueRationale`, this checks presence/well-formedness,
123
+ * never whether the content is right.
124
+ */
125
+ export function checkMilestoneShape(body: string): MilestoneShapeResult {
126
+ const errors: string[] = []
127
+ const text = pathText(body)
128
+
129
+ const release = releaseFieldFromText(text)
130
+ if (release.declared && release.value === null) {
131
+ errors.push(
132
+ 'milestone-validation Release: the `Release:` field is present but is not a version — ' +
133
+ 'write a semver value (e.g. `Release: 1.2.0`), or omit the field entirely for a milestone that declares no version.'
134
+ )
135
+ }
136
+
137
+ const { intents, malformed } = parseIntents(text)
138
+ if (malformed) {
139
+ errors.push(
140
+ 'milestone-validation intents: the `### Tranche intents` section does not parse — every non-blank ' +
141
+ 'line under it must read `- <slug>: <intent text>`.'
142
+ )
143
+ }
144
+
145
+ const goal = goalRemainder(text)
146
+ if (goal.length === 0) {
147
+ errors.push(
148
+ 'milestone-validation goal: no goal text — a milestone description must carry prose beyond its ' +
149
+ '`Release:` field and `### Tranche intents` section.'
150
+ )
151
+ }
152
+
153
+ if (errors.length > 0) return { status: 'fail', errors }
154
+ return { status: 'pass', goal, release: release.value, intents }
155
+ }
156
+
157
+ // ---------------------------------------------------------------------------
158
+ // `vinaya milestone adopt` (vinaya-milestone-migration-v1 task 1) — checkAdoptable
159
+ // ---------------------------------------------------------------------------
160
+
161
+ /**
162
+ * One requested slug's forge facts, gathered by the CLI before any write.
163
+ * `checkAdoptable` never fetches — the caller does one bounded round of
164
+ * reads (the repo's label list, the repo's Milestone list, and one
165
+ * `vinaya/tranche:<slug>`-labeled Issue list per requested slug) and hands
166
+ * the results in as plain data.
167
+ */
168
+ export type AdoptSlugFacts = {
169
+ slug: string
170
+ /** Whether `vinaya/tranche:<slug>` exists as a real label in this repo. */
171
+ labelExists: boolean
172
+ /** Issue numbers currently carrying the label, any state. */
173
+ issueNumbers: number[]
174
+ /**
175
+ * Native-milestone titles this slug's Issues are CURRENTLY attached to,
176
+ * excluding `null` (unattached — the ordinary state for a label-only
177
+ * tranche that has never been adopted), the slug itself (its own legacy
178
+ * 1:1 tranche-Milestone — the ordinary pre-adopt state for a
179
+ * legacy-titled tranche), and the requested target (already adopted here,
180
+ * a harmless no-op re-run). What remains is exactly "attached to some
181
+ * OTHER Milestone" — evidence of a prior `adopt` into a different target.
182
+ * Empty when none.
183
+ */
184
+ adoptedElsewhere: string[]
185
+ }
186
+
187
+ /** The target Milestone's forge facts. */
188
+ export type AdoptTargetFacts = {
189
+ title: string
190
+ exists: boolean
191
+ state: 'open' | 'closed' | null
192
+ }
193
+
194
+ export type AdoptFacts = {
195
+ target: AdoptTargetFacts
196
+ slugs: AdoptSlugFacts[]
197
+ }
198
+
199
+ export type AdoptResult = { status: 'pass' } | { status: 'fail'; errors: string[] }
200
+
201
+ /**
202
+ * Refuses an `adopt` invocation before any forge write. Four independent
203
+ * checks — an unknown slug, a slug whose label carries no Issues, a target
204
+ * that does not exist or is closed, and a slug already adopted into a
205
+ * different Milestone — evaluated over the WHOLE batch of requested slugs at
206
+ * once, so one bad slug in a multi-slug invocation blocks every slug in that
207
+ * invocation, not just its own: a half-applied adopt leaves Issues split
208
+ * across two Milestones with no undo. Pure: takes already-gathered facts,
209
+ * fetches nothing, writes nothing.
210
+ */
211
+ export function checkAdoptable(facts: AdoptFacts): AdoptResult {
212
+ const errors: string[] = []
213
+
214
+ if (!facts.target.exists) {
215
+ errors.push(
216
+ `milestone-adopt target: Milestone "${facts.target.title}" does not exist — create it first with \`vinaya milestone create\`.`
217
+ )
218
+ } else if (facts.target.state === 'closed') {
219
+ errors.push(`milestone-adopt target: Milestone "${facts.target.title}" is closed — adopt requires an open target.`)
220
+ }
221
+
222
+ for (const s of facts.slugs) {
223
+ if (!s.labelExists) {
224
+ errors.push(`milestone-adopt unknown-slug: no \`vinaya/tranche:${s.slug}\` label exists in this repo.`)
225
+ continue
226
+ }
227
+ if (s.issueNumbers.length === 0) {
228
+ errors.push(`milestone-adopt no-issues: \`vinaya/tranche:${s.slug}\` carries no Issues — nothing to adopt.`)
229
+ continue
230
+ }
231
+ if (s.adoptedElsewhere.length > 0) {
232
+ errors.push(
233
+ `milestone-adopt already-adopted: "${s.slug}" is already adopted into "${s.adoptedElsewhere.join('", "')}" — adopt it from there, not from here.`
234
+ )
235
+ }
236
+ }
237
+
238
+ if (errors.length > 0) return { status: 'fail', errors }
239
+ return { status: 'pass' }
240
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * New-on-disk-state guard (#512 Part D). Pure — no `fs`, no `git`/`gh` I/O.
3
+ * Flags a diff-touched path as a disk-state offender given its change
4
+ * status (`added` — the path didn't exist on the base ref — or `modified`
5
+ * — it already did):
6
+ *
7
+ * (a) A live tranche topology file directly under `aeg-root/tranches/`
8
+ * (not a subdirectory) — the residue class this task's own Part B proved
9
+ * unnecessary (forge derivation already covers it, see
10
+ * `aeg-drift-prevention-v1.md`'s deletion). Fails on `added` OR
11
+ * `modified`: this file class shouldn't exist at all post-cutover,
12
+ * whether newly created or resurrected via an edit.
13
+ *
14
+ * (b) Any `.md` file anywhere under `aeg-root/tranches/` (any depth,
15
+ * including `completed/**`) — but ONLY when
16
+ * `added`. `completed/**`'s existing files are legacy archive
17
+ * (explicitly excludes them, per `tranche-model.md` §4/§11 —
18
+ * they're never deleted or migrated, and editing one to fix a typo
19
+ * must stay legal); this rule instead closes the gap a path-shape-only
20
+ * exemption would leave open — a BRAND NEW file smuggled directly into
21
+ * `completed/` (or any other subdirectory) to dodge rule (a)'s
22
+ * top-level check. Deliberately status-based, not a hardcoded filename
23
+ * allowlist: the legacy set only grows via the (currently-dormant,
24
+ * forge-native-cutover-pending) Archivist move-to-completed flow, never
25
+ * via this gate's own exemption logic.
26
+ *
27
+ * (c) Any `*.tokens.md` file, anywhere in the repo — the pre- ledger
28
+ * shape `Tokens-in-PR-body` superseded — but ONLY when `added` (an
29
+ * edit to one of the 4 existing legacy `completed/*.tokens.md` files
30
+ * must stay legal, same reasoning as (b)).
31
+ */
32
+
33
+ export type DiskStateFileStatus = 'added' | 'modified'
34
+
35
+ const TOP_LEVEL_TOPOLOGY_FILE = /^aeg-root\/tranches\/[^/]+\.md$/
36
+ const ANY_DEPTH_TRANCHES_MD = /^aeg-root\/tranches\/.*\.md$/
37
+ const TOKENS_FILE = /\.tokens\.md$/
38
+
39
+ export function isNewDiskStateFile(path: string, status: DiskStateFileStatus): boolean {
40
+ // The tranche model doc used to live at `aeg-root/tranches/README.md`
41
+ // and needed an explicit carve-out here so editing it never read as new disk
42
+ // state. It is now `aeg-root/tranche-model.md`, outside the directory this
43
+ // guard watches, so the carve-out is gone rather than left as a line that
44
+ // can never fire.
45
+ if (TOP_LEVEL_TOPOLOGY_FILE.test(path)) return true
46
+
47
+ if (status !== 'added') return false
48
+
49
+ return ANY_DEPTH_TRANCHES_MD.test(path) || TOKENS_FILE.test(path)
50
+ }
@@ -0,0 +1,77 @@
1
+ import { readMarkdownTable } from './parse-registry'
2
+ import type { LedgerRow } from './types'
3
+
4
+ /**
5
+ * Parse the append-only token/cost ledger for a tranche. See
6
+ * `aeg-root/tranche-model.md` §12 for the canonical format.
7
+ *
8
+ * The ledger lives in a sibling file `aeg-root/tranches/<name>.tokens.md`
9
+ * (the recommended home, since two roles appending rows do not collide with
10
+ * a Planner editing the topology file) but the parser also accepts a
11
+ * `## Token ledger` section inside any markdown — so a future tranche that
12
+ * chose the inline form still parses.
13
+ *
14
+ * Pure: no I/O. The caller reads the file contents and hands them in.
15
+ *
16
+ * Tolerant by design: a malformed row is skipped rather than throwing, so a
17
+ * typo does not crash Studio's display of a tranche's other rows.
18
+ */
19
+ export function parseLedger(md: string): LedgerRow[] {
20
+ const rows = readMarkdownTable(md, /^#{1,6}\s+Token\s+ledger\b/i)
21
+ const out: LedgerRow[] = []
22
+ for (const row of rows) {
23
+ const parsed = rowFromCells(row)
24
+ if (parsed) out.push(parsed)
25
+ }
26
+ return out
27
+ }
28
+
29
+ /**
30
+ * Turn one 7-cell row (`Phase | Role | Agent/Model | Tokens in | Tokens out |
31
+ * Cost | Date`) into a `LedgerRow`, or `null` when the row is unusable.
32
+ * Exported so other sources that produce the same row-shape from a different
33
+ * layout (e.g. `parse-token-report.ts`'s PR-body/verdict-comment sources)
34
+ * reuse this exact cell-parsing/null-tolerance semantics rather than
35
+ * re-implementing it — see that file's module docstring.
36
+ */
37
+ export function rowFromCells(cells: string[]): LedgerRow | null {
38
+ // Expected 7 columns: Phase | Role | Agent/Model | Tokens in | Tokens out | Cost | Date.
39
+ if (cells.length < 7) return null
40
+ const phase = (cells[0] ?? '').trim()
41
+ // Phase is the row's identity — every other cell may be unknown, but a
42
+ // row without a phase is unusable (it can't be located on a re-pivot).
43
+ if (!phase) return null
44
+ return {
45
+ phase,
46
+ role: (cells[1] ?? '').trim(),
47
+ agentModel: (cells[2] ?? '').trim(),
48
+ tokensIn: parseIntCell(cells[3] ?? ''),
49
+ tokensOut: parseIntCell(cells[4] ?? ''),
50
+ cost: parseCostCell(cells[5] ?? ''),
51
+ date: (cells[6] ?? '').trim()
52
+ }
53
+ }
54
+
55
+ function parseIntCell(cell: string): number | null {
56
+ const trimmed = cell.replace(/`/g, '').trim()
57
+ if (!trimmed || isEmDashOrDash(trimmed)) return null
58
+ // Tolerate thousand-separator commas and underscores so a hand-written
59
+ // `184,327` or `184_327` still resolves.
60
+ const normalized = trimmed.replace(/[,_\s]/g, '')
61
+ if (!/^-?\d+$/.test(normalized)) return null
62
+ return Number(normalized)
63
+ }
64
+
65
+ function parseCostCell(cell: string): number | null {
66
+ const trimmed = cell.replace(/`/g, '').trim()
67
+ if (!trimmed || isEmDashOrDash(trimmed)) return null
68
+ // Strip a leading `$` and any thousand-separators; preserve the decimal.
69
+ const normalized = trimmed.replace(/^\$/, '').replace(/[,_\s]/g, '')
70
+ if (!/^-?\d+(\.\d+)?$/.test(normalized)) return null
71
+ return Number(normalized)
72
+ }
73
+
74
+ function isEmDashOrDash(s: string): boolean {
75
+ const t = s.trim()
76
+ return t === '—' || t === '-' || t === '–'
77
+ }
@@ -0,0 +1,105 @@
1
+ import type { Project, Registry } from './types'
2
+
3
+ /**
4
+ * Parse `aeg-root/projects.md` and return its `## Registry` table as a typed
5
+ * `Registry`. Surrounding prose is ignored.
6
+ *
7
+ * Tolerates a prose last-cell (e.g. "(state tracked globally for now)") by
8
+ * setting `statePath: null`. A real path is detected by the backtick wrapping
9
+ * (`<path>`) used in the live file's columns 2 + 3 + (when present) 4.
10
+ *
11
+ * The function is forgiving: a malformed row is skipped rather than throwing,
12
+ * so a typo in the markdown does not crash all of Studio. Validation of
13
+ * `Project:` references against the result is a separate concern.
14
+ */
15
+ export function parseRegistry(md: string): Registry {
16
+ const rows = readMarkdownTable(md, /^##\s+Registry\b/i)
17
+ const projects: Project[] = []
18
+ for (const row of rows) {
19
+ // Expect 4 columns: Project | Path | Specs | Per-project state.
20
+ if (row.length < 4) continue
21
+ const name = stripBackticks(row[0] ?? '').trim()
22
+ const path = stripBackticks(row[1] ?? '').trim()
23
+ const specsPath = stripBackticks(row[2] ?? '').trim()
24
+ const stateCell = (row[3] ?? '').trim()
25
+ if (!name || !path || !specsPath) continue
26
+ projects.push({
27
+ name,
28
+ path,
29
+ specsPath,
30
+ statePath: parseStateCell(stateCell)
31
+ })
32
+ }
33
+ return projects
34
+ }
35
+
36
+ function parseStateCell(cell: string): string | null {
37
+ if (!cell) return null
38
+ // Prose state cell — anything that doesn't look like a backtick-wrapped path.
39
+ // The live file uses `apps/<x>/aeg-project/` for tracked state and
40
+ // parenthesized prose like "(state tracked globally for now)" otherwise.
41
+ const stripped = stripBackticks(cell).trim()
42
+ // If we stripped backticks and got a path-shaped string, return it.
43
+ if (/^[\w./@-]+\/?$/.test(stripped) && cell.includes('`')) {
44
+ return stripped
45
+ }
46
+ return null
47
+ }
48
+
49
+ function stripBackticks(s: string): string {
50
+ return s.replace(/`/g, '')
51
+ }
52
+
53
+ /**
54
+ * Generic markdown-table reader: finds the first `| … |` table block following
55
+ * the `headingMatcher` regex, skips the header + separator rows, and returns
56
+ * each body row as an array of trimmed cell strings.
57
+ *
58
+ * Pipes can be escaped inline as `\|`; this is unescaped after the split.
59
+ * Leading/trailing pipes are tolerated. Lines outside the table block end
60
+ * iteration.
61
+ */
62
+ export function readMarkdownTable(md: string, headingMatcher: RegExp): string[][] {
63
+ const lines = md.split(/\r?\n/)
64
+ let i = 0
65
+ // Advance to the section heading.
66
+ for (; i < lines.length; i++) {
67
+ if (headingMatcher.test(lines[i] ?? '')) {
68
+ i++
69
+ break
70
+ }
71
+ }
72
+ if (i >= lines.length) return []
73
+ // Advance to the first table line.
74
+ for (; i < lines.length; i++) {
75
+ if ((lines[i] ?? '').trim().startsWith('|')) break
76
+ }
77
+ // Skip header + separator.
78
+ if (i + 1 >= lines.length) return []
79
+ i += 2
80
+ const out: string[][] = []
81
+ for (; i < lines.length; i++) {
82
+ const trimmed = (lines[i] ?? '').trim()
83
+ if (!trimmed.startsWith('|')) break
84
+ out.push(splitTableRow(trimmed))
85
+ }
86
+ return out
87
+ }
88
+
89
+ /** Exported for reuse by parsers that read a table body outside `readMarkdownTable`'s single-section-per-call model (e.g. `parse-token-report.ts`'s multi-entry "Token report" sections). */
90
+ export function splitTableRow(row: string): string[] {
91
+ // Use a placeholder for escaped pipes, split, then restore.
92
+ // The sentinel is a NUL, escaped rather than typed literally. A raw NUL byte
93
+ // in the source makes git classify this whole FILE as binary: `git diff`
94
+ // reports `Bin 3715 -> 3716 bytes, 0 insertions(+), 0 deletions(-)` instead
95
+ // of a reviewable diff, and `git grep -n` answers `Binary file ... matches`
96
+ // with no line number, so every sweep that greps this repo silently skipped
97
+ // it. `\u0000` is the same byte at runtime and keeps the file text.
98
+ const PLACEHOLDER = '\u0000PIPE\u0000'
99
+ const escaped = row.replace(/\\\|/g, PLACEHOLDER)
100
+ const parts = escaped.split('|').map((s) => s.trim())
101
+ // Strip the empty cells produced by leading and trailing `|`.
102
+ if (parts.length && parts[0] === '') parts.shift()
103
+ if (parts.length && parts[parts.length - 1] === '') parts.pop()
104
+ return parts.map((p) => p.split(PLACEHOLDER).join('|'))
105
+ }
@@ -0,0 +1,177 @@
1
+ import { rowFromCells } from './parse-ledger'
2
+ import { splitTableRow } from './parse-registry'
3
+ import type { LedgerRow } from './types'
4
+ import { isPrincipal } from './waiver-label'
5
+
6
+ /**
7
+ * Live-source token parsing (aeg-forge-state-v1 task 4b, #445). Where
8
+ * `parse-ledger.ts` reads the Archivist's hand-assembled `<name>.tokens.md`
9
+ * ledger, this file extracts the same `LedgerRow` shape directly from the
10
+ * artifacts says every role already produces on its own turn — the
11
+ * Developer's "Token report" section in a PR body, and the Reviewer's /
12
+ * Security's / Planner's one-line `Tokens: …` report in a verdict comment,
13
+ * PR body, or planning report. Nothing here does I/O — the caller (Studio's
14
+ * forge adapter, `apps/vinaya/web/src/lib/forge/fetch-token-ledger.ts`)
15
+ * fetches the PR bodies/comments and hands them to `aggregateTaskTokenRows`.
16
+ *
17
+ * Cell parsing (`—`/null tolerance, thousand-separator commas, `$` cost
18
+ * prefix) is NOT reimplemented here — every row is built through
19
+ * `parse-ledger.ts`'s `rowFromCells`, so a live-fetched row and a
20
+ * `.tokens.md`-parsed row for the same underlying report are byte-identical
21
+ * in how their cells resolve.
22
+ */
23
+
24
+ // A "Token report" heading, alone on its line: `## Token report`,
25
+ // `### Token report`, or the bold-inline `**Token report**` form seen in the
26
+ // wild (real PRs use both — #412 uses `## Token report`, #454 uses the bold
27
+ // form). A PR re-pushed after `CHANGES_REQUESTED` carries this heading more
28
+ // than once — every occurrence is parsed as its own entry (roles/developer.md:
29
+ // re-entry appends a new "Token report" entry, never edits the first).
30
+ const TOKEN_REPORT_HEADING = /^\s*(?:#{1,6}\s*Token report\s*|\*\*Token report\*\*)\s*$/i
31
+
32
+ // Inline field-list form, a real drift already present in this repo's own
33
+ // history (PR #374, #362) alongside the table form: one line reading
34
+ // `Phase: … | Role: … | Agent/Model: … | Tokens in: … | Tokens out: … |
35
+ // Cost: … | Date: …`. Tolerated as a second recognized shape rather than
36
+ // treated as malformed, since both are real, current, human-authored output.
37
+ const INLINE_FIELD_LIST =
38
+ /Phase:\s*([^|]+?)\s*\|\s*Role:\s*([^|]+?)\s*\|\s*Agent\/Model:\s*([^|]+?)\s*\|\s*Tokens in:\s*([^|]+?)\s*\|\s*Tokens out:\s*([^|]+?)\s*\|\s*Cost:\s*([^|]+?)\s*\|\s*Date:\s*(.+?)\s*$/im
39
+
40
+ /**
41
+ * Extract every "Token report" entry from a Developer's PR body. Each
42
+ * occurrence of the heading is parsed independently (table form preferred;
43
+ * falls back to the inline field-list form when no table follows), and
44
+ * every occurrence found contributes a row — a re-pushed PR undercounts if
45
+ * only the first/last match is read, per this task's own Traps section.
46
+ */
47
+ export function parseTokenReportEntries(body: string): LedgerRow[] {
48
+ const lines = body.split(/\r?\n/)
49
+ const headingIdxs: number[] = []
50
+ lines.forEach((line, i) => {
51
+ if (TOKEN_REPORT_HEADING.test(line)) headingIdxs.push(i)
52
+ })
53
+
54
+ const out: LedgerRow[] = []
55
+ for (let h = 0; h < headingIdxs.length; h++) {
56
+ const start = (headingIdxs[h] as number) + 1
57
+ const end = h + 1 < headingIdxs.length ? (headingIdxs[h + 1] as number) : lines.length
58
+ const section = lines.slice(start, end).join('\n')
59
+
60
+ const tableRows = parseTableSection(section)
61
+ if (tableRows.length > 0) {
62
+ out.push(...tableRows)
63
+ continue
64
+ }
65
+ const inlineRow = parseInlineFieldList(section)
66
+ if (inlineRow) out.push(inlineRow)
67
+ }
68
+ return out
69
+ }
70
+
71
+ /** Same table-body-reading contract as `parse-registry.ts`'s `readMarkdownTable`, minus the heading search (already sliced to a single "Token report" section, which may repeat within one body — the thing `readMarkdownTable` cannot express). */
72
+ function parseTableSection(section: string): LedgerRow[] {
73
+ const lines = section.split(/\r?\n/)
74
+ let i = 0
75
+ for (; i < lines.length; i++) {
76
+ if ((lines[i] ?? '').trim().startsWith('|')) break
77
+ }
78
+ if (i + 1 >= lines.length) return []
79
+ i += 2 // skip header + separator row
80
+ const out: LedgerRow[] = []
81
+ for (; i < lines.length; i++) {
82
+ const trimmed = (lines[i] ?? '').trim()
83
+ if (!trimmed.startsWith('|')) break
84
+ const row = rowFromCells(splitTableRow(trimmed))
85
+ if (row) out.push(row)
86
+ }
87
+ return out
88
+ }
89
+
90
+ function parseInlineFieldList(section: string): LedgerRow | null {
91
+ const m = section.match(INLINE_FIELD_LIST)
92
+ if (!m) return null
93
+ return rowFromCells([m[1], m[2], m[3], m[4], m[5], m[6], m[7]] as string[])
94
+ }
95
+
96
+ // `Tokens: <phase> — <Role> — <model> — <in>/<out>/<cost or — if unknown>`
97
+ // (`roles/reviewer.md`, `roles/security.md`, `roles/planner.md` — identical
98
+ // shape, phase/role text differs per role). Segments are separated by a dash
99
+ // with a space on both sides so an agent/model name's own internal hyphens
100
+ // (`claude-sonnet-5`) never split — a real separator always has surrounding
101
+ // whitespace. Tolerates `-`/`–` alongside the docs' own `—`, since no real
102
+ // verdict-comment example exists yet to confirm which glyph a live Reviewer
103
+ // turn actually reproduces.
104
+ const TOKENS_LINE_PREFIX = /^\s*(?:\*\*)?Tokens:(?:\*\*)?\s*(.+)$/i
105
+ const SEGMENT_SEP = /\s+[—–-]\s+/
106
+
107
+ /**
108
+ * Extract every Reviewer/Security/Planner `Tokens: …` line from a body of
109
+ * text (a verdict comment, a PR body, or a planning report). A re-review or
110
+ * re-planning reports again without editing the prior line, so more than one
111
+ * match is expected and every one is returned. A line that doesn't split
112
+ * into exactly the 4 documented segments is skipped, not guessed at — same
113
+ * discipline as `parse-ledger.ts`'s malformed-row tolerance.
114
+ */
115
+ export function parseTokensLines(text: string): LedgerRow[] {
116
+ const out: LedgerRow[] = []
117
+ for (const rawLine of text.split(/\r?\n/)) {
118
+ const m = rawLine.match(TOKENS_LINE_PREFIX)
119
+ if (!m) continue
120
+ const rest = (m[1] as string).trim()
121
+ const segments = rest.split(SEGMENT_SEP)
122
+ if (segments.length !== 4) continue
123
+ const [phase, role, agentModel, numbers] = segments as [string, string, string, string]
124
+ const [tokensInCell, tokensOutCell, costCell] = splitNumbers(numbers)
125
+ const row = rowFromCells([phase.trim(), role.trim(), agentModel.trim(), tokensInCell, tokensOutCell, costCell, ''])
126
+ if (row) out.push(row)
127
+ }
128
+ return out
129
+ }
130
+
131
+ function splitNumbers(numbers: string): [string, string, string] {
132
+ const trimmed = numbers.trim()
133
+ if (isDashOnly(trimmed)) return ['—', '—', '—']
134
+ const parts = trimmed.split('/')
135
+ return [parts[0] ?? '—', parts[1] ?? '—', parts[2] ?? '—']
136
+ }
137
+
138
+ function isDashOnly(s: string): boolean {
139
+ return s === '—' || s === '-' || s === '–'
140
+ }
141
+
142
+ /**
143
+ * One merged PR's body + top-level comments — the caller's fetch result, no
144
+ * I/O here. Each comment carries its author's login (`null` when the forge
145
+ * reports none, e.g. a deleted account) because a comment's `Tokens:` line is
146
+ * only a ledger row when an allowlisted principal posted it — every agent in
147
+ * this model posts under the Principal's own `gh` identity, so an unfiltered
148
+ * read counts a stranger's pasted table as a real turn.
149
+ */
150
+ export type TokenSourcePr = {
151
+ number: number
152
+ body: string
153
+ comments: { body: string; author: string | null }[]
154
+ }
155
+
156
+ /**
157
+ * Pure aggregation over every merged PR associated with a task (its own
158
+ * branch's PR(s), plus any plan/cross-referenced PR carrying the Planner's
159
+ * report) — Developer "Token report" entries and `Tokens: …` lines from
160
+ * each PR body, and `Tokens: …` lines from each PR's comments — the latter
161
+ * only from a comment an allowlisted principal authored. Missing or
162
+ * malformed reports simply produce no row for that report — never a
163
+ * fabricated figure — matching `archivist.md`'s own "flag it under DANGLING
164
+ * instead" discipline; the caller decides how to surface an empty result.
165
+ */
166
+ export function aggregateTaskTokenRows(prs: TokenSourcePr[], principalAllowlist: string[]): LedgerRow[] {
167
+ const out: LedgerRow[] = []
168
+ for (const pr of prs) {
169
+ out.push(...parseTokenReportEntries(pr.body))
170
+ out.push(...parseTokensLines(pr.body))
171
+ for (const comment of pr.comments) {
172
+ if (!isPrincipal(comment.author, principalAllowlist)) continue
173
+ out.push(...parseTokensLines(comment.body))
174
+ }
175
+ }
176
+ return out
177
+ }