@attalabs/vinaya 0.22.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 (188) hide show
  1. package/README.md +13 -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 +552 -139
  23. package/dist/checks/bin/check-branch-topology.js +603 -144
  24. package/dist/checks/bin/check-brief-shape.js +593 -142
  25. package/dist/checks/bin/check-changeset-coverage.js +935 -161
  26. package/dist/checks/bin/check-closes-n.js +603 -144
  27. package/dist/checks/bin/check-coherence.js +614 -147
  28. package/dist/checks/bin/check-dead-branch-push.js +552 -139
  29. package/dist/checks/bin/check-dispatch-readiness.js +612 -145
  30. package/dist/checks/bin/check-doc-coverage-push.js +689 -218
  31. package/dist/checks/bin/check-doc-coverage.js +689 -218
  32. package/dist/checks/bin/check-doctrine-no-procedures.js +4794 -0
  33. package/dist/checks/bin/check-doctrine-portability.js +684 -146
  34. package/dist/checks/bin/check-evidence-fresh.js +994 -174
  35. package/dist/checks/bin/check-exec-bits.js +4931 -0
  36. package/dist/checks/bin/check-first-push-dispatch.js +603 -144
  37. package/dist/checks/bin/check-issue-assignment.js +603 -144
  38. package/dist/checks/bin/check-main-branch-refusal.js +552 -139
  39. package/dist/checks/bin/check-no-disk-state.js +552 -139
  40. package/dist/checks/bin/check-pr-report-density.js +4461 -0
  41. package/dist/checks/bin/check-quoted-command.js +621 -155
  42. package/dist/checks/bin/check-reader-resolvable-prose.js +617 -154
  43. package/dist/checks/bin/check-registry-gates.js +595 -144
  44. package/dist/checks/bin/check-retired-vocabulary.js +617 -154
  45. package/dist/checks/bin/check-review-gate.js +670 -141
  46. package/dist/checks/bin/check-single-plan-pr.js +552 -139
  47. package/dist/checks/bin/check-test-plan.js +553 -140
  48. package/dist/checks/bin/check-token-collection-wired.js +552 -139
  49. package/dist/checks/bin/check-token-report.js +552 -139
  50. package/dist/checks/bin/check-workspace-escape.js +985 -145
  51. package/dist/index.js +8500 -5518
  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]__0o771t1._.js +1 -1
  167. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/[root-of-the-server]__0puovz5._.js +1 -1
  168. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/[root-of-the-server]__1hs0dcu._.js +1 -1
  169. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/{[root-of-the-server]__069-_41._.js → [root-of-the-server]__1mcitst._.js} +1 -1
  170. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/[root-of-the-server]__1wc4-ip._.js +1 -1
  171. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/_03x_w6q._.js +1 -1
  172. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/_0gvm3og._.js +1 -1
  173. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/_0lwxg63._.js +1 -1
  174. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/_1fqw88f._.js +1 -1
  175. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/_1n0cnq-._.js +1 -1
  176. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/node_modules_1vo08dj._.js +2 -2
  177. package/studio-standalone/apps/vinaya-studio/web/.next/server/middleware-build-manifest.js +3 -3
  178. package/studio-standalone/apps/vinaya-studio/web/.next/server/pages/500.html +1 -1
  179. package/studio-standalone/apps/vinaya-studio/web/.next/server/server-reference-manifest.js +1 -1
  180. package/studio-standalone/apps/vinaya-studio/web/.next/server/server-reference-manifest.json +3 -3
  181. package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/{37hnluhzj9bei.js → 0jer_drkl1e4r.js} +4 -4
  182. package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/{267o-tsrnuwnv.js → 20x-t0a0v129z.js} +1 -1
  183. package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/{1hufpuody6vjv.js → 2ou_81ahqw31e.js} +1 -1
  184. package/studio-standalone/apps/vinaya-studio/web/package.json +2 -2
  185. /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
  186. /package/studio-standalone/apps/vinaya-studio/web/.next/static/{Fmyc42qzLwFcWWHLLNy-G → 5xy_GA7G5KRuE3HDumIiw}/_buildManifest.js +0 -0
  187. /package/studio-standalone/apps/vinaya-studio/web/.next/static/{Fmyc42qzLwFcWWHLLNy-G → 5xy_GA7G5KRuE3HDumIiw}/_clientMiddlewareManifest.js +0 -0
  188. /package/studio-standalone/apps/vinaya-studio/web/.next/static/{Fmyc42qzLwFcWWHLLNy-G → 5xy_GA7G5KRuE3HDumIiw}/_ssgManifest.js +0 -0
@@ -0,0 +1,164 @@
1
+ /**
2
+ * registry-parse.ts — pure, no-I/O parser for `aeg-root/enforcement.md`'s
3
+ * three ring markdown tables (Ring 0/1/2). enforcement.md's tables ARE the
4
+ * gate registry (no live file duplicates forge state); this
5
+ * parses them rather than maintaining a second copy.
6
+ *
7
+ * The generic markdown-table extraction lives in `./markdown-table` — the one
8
+ * aeg-core-owned parser every doctrine consumer shares. This module
9
+ * imports `findTable`/`findHeadingLine` from there rather than re-deriving
10
+ * them; it adds only the enforcement-specific normalization on top (the
11
+ * ring-heading list, `stripBackticks`, and the last-two-columns rule).
12
+ *
13
+ * Each ring's table has a different header wording (Ring 0: Action/Gate/...;
14
+ * Ring 1: CI check/Re-verifies; Ring 2: Mechanism/Runs/Catches). The FIRST
15
+ * column is always the row's identifying label; `implementation` and
16
+ * `description` are resolved BY HEADER NAME, since those two are spelled
17
+ * identically in all three tables. Positional reads are used only where no
18
+ * shared header name exists.
19
+ *
20
+ * The one exception is `Description`, which IS looked up by header name: it is
21
+ * the only column spelled identically in all three tables, and the only one a
22
+ * table can legitimately lack. Position cannot express that difference (a
23
+ * 7-column table means two different shapes depending on the ring), so the
24
+ * name is the only honest key. See the lookup in `parseEnforcementRegistry`.
25
+ */
26
+
27
+ import { findHeadingLine, findTable } from './markdown-table'
28
+
29
+ export type GateRing = 'ring0' | 'ring1' | 'ring2'
30
+
31
+ export type GateRow = {
32
+ ring: GateRing
33
+ action: string
34
+ summary: string
35
+ category: 'ci' | 'hook' | 'event'
36
+ /** One plain sentence: what this gate does, for a reader. Present in all
37
+ * three ring tables and resolved by header name (see the lookup below).
38
+ *
39
+ * The counterpart to `spec`, and not interchangeable with it — `spec` is
40
+ * written to ENFORCE: 17 of the 31 real rows cite an Issue number, a
41
+ * task number or a file path, and the longest runs 2708 chars. That is
42
+ * correct for a gate and unreadable on a page. This column is the same fact
43
+ * addressed to a person, and is what a row's `summary` question gets
44
+ * answered by. The same bar `roles/*.md`/`contracts/*.md` hold via
45
+ * `description:` frontmatter and `ACTIONS` holds via its `description`
46
+ * field, so a reader gets one register whatever they click.
47
+ *
48
+ * Optional on the type only so a malformed table degrades instead of
49
+ * throwing — every real row carries one, asserted against the live file. */
50
+ description?: string
51
+ /** The substantive middle column — "What must be true..." (ring0),
52
+ * "Re-verifies" (ring1), "Catches" (ring2) — always the column
53
+ * immediately before `implementation`. Each ring names it differently,
54
+ * but structurally it's always the same slot: the normative spec, written
55
+ * for enforcement. Distinct from `summary`'s rhetorical question and from
56
+ * `description`'s plain-language answer to it. Undefined when a table has
57
+ * no such column. */
58
+ spec?: string
59
+ implementation: string
60
+ /**
61
+ * `product` — this row's implementation is a check registered in
62
+ * `coreCheckRegistry()` (`apps/cli/src/checks/registry.ts`), so it ships to
63
+ * every adopter through `vinaya check`. `repo-own` — everything else: a
64
+ * mechanism specific to how this repository enforces itself on top of the
65
+ * product (a hand-written CI job, the check runner/resolver, a forge-write
66
+ * command, or a check this repo runs on its own doctrine but has not
67
+ * registered). Read from an `Audience` column, by header name like
68
+ * `description`. A table carrying no such column, or a cell holding
69
+ * anything other than the literal `product`, resolves to `repo-own` — the
70
+ * safe default, since it makes no shipped claim for G6 to verify.
71
+ */
72
+ audience: 'product' | 'repo-own'
73
+ line: number
74
+ }
75
+
76
+ const RING_HEADINGS: Array<{ ring: GateRing; pattern: RegExp }> = [
77
+ { ring: 'ring0', pattern: /^##\s+Ring 0\b/ },
78
+ { ring: 'ring1', pattern: /^##\s+Ring 1\b/ },
79
+ { ring: 'ring2', pattern: /^##\s+Ring 2\b/ }
80
+ ]
81
+
82
+ function stripBackticks(cell: string): string {
83
+ const trimmed = cell.trim()
84
+ if (trimmed.length >= 2 && trimmed.startsWith('`') && trimmed.endsWith('`')) {
85
+ return trimmed.slice(1, -1)
86
+ }
87
+ return trimmed
88
+ }
89
+
90
+ /** Strips markdown `**bold**` markers from a gate/check row's own name.
91
+ * `action` is the one cell this parser has never sanitized — most rows are
92
+ * plain text, but a real row can legitimately bold its name for doctrine-
93
+ * prose emphasis, and that literal `**...**` was leaking straight through
94
+ * to `DiagramNode.label` (visible asterisks in the how-it-works UI; every
95
+ * label already renders bold via CSS where it matters, so the markdown
96
+ * marker carries zero information downstream). Root-cause fix, not a
97
+ * per-row doctrine patch — the next accidentally-bolded name is covered
98
+ * too, not just the ones caught so far. */
99
+ function stripBold(cell: string): string {
100
+ return cell.trim().replace(/\*\*/g, '')
101
+ }
102
+
103
+ /**
104
+ * Parses the three ring tables out of `enforcement.md`'s raw content into a
105
+ * flat, normalized `GateRow[]`. Each row's `action` is its table's first
106
+ * column; `implementation` is resolved by header name, falling back to the
107
+ * last column.
108
+ */
109
+ export function parseEnforcementRegistry(content: string): GateRow[] {
110
+ const lines = content.split('\n')
111
+ const result: GateRow[] = []
112
+
113
+ for (const { ring, pattern } of RING_HEADINGS) {
114
+ const headingLine = findHeadingLine(lines, pattern)
115
+ if (headingLine === null) continue
116
+ const table = findTable(lines, headingLine + 1)
117
+ if (!table) continue
118
+ // `description`/`Audience` are found BY HEADER NAME, never by position —
119
+ // the two columns here that can be. Every other column is positional out
120
+ // of necessity: the three ring tables name their first column
121
+ // differently ("Action"/"CI check"/"Mechanism") and their middle columns
122
+ // differently again ("Gate" + "What must be true" / "Re-verifies" /
123
+ // "Runs" + "Catches"), so only the ends are reliable. "Description" and
124
+ // "Audience" are spelled the same in all three, which makes a name
125
+ // lookup possible — and a name lookup is what keeps a table WITHOUT the
126
+ // column from having some other column silently read as its value. By
127
+ // index that is undetectable: a 7-column table means "has Description"
128
+ // in one ring and "has Gate" in another, and the parser cannot tell
129
+ // which. -1 here simply means the table doesn't have that column.
130
+ const descriptionIndex = table.headers.findIndex((h) => h.trim().toLowerCase() === 'description')
131
+ const implementationIndex = table.headers.findIndex((h) => h.trim().toLowerCase() === 'implementation')
132
+ const audienceIndex = table.headers.findIndex((h) => h.trim().toLowerCase() === 'audience')
133
+ for (const row of table.rows) {
134
+ const cells = row.cells
135
+ if (cells.length < 3) continue
136
+ const action = stripBold(cells[0] ?? '')
137
+ const summary = stripBackticks(cells[1] ?? '')
138
+ const category = stripBackticks(cells[2] ?? '') as GateRow['category']
139
+ const implementation = stripBackticks(
140
+ (implementationIndex === -1 ? cells[cells.length - 1] : cells[implementationIndex]) ?? ''
141
+ )
142
+ const description =
143
+ descriptionIndex === -1 ? undefined : stripBackticks(cells[descriptionIndex] ?? '') || undefined
144
+ // `spec` is the ring-specific column immediately before `implementation`
145
+ // — normally the last cell, but `Audience`, when present, now sits
146
+ // between them. `specIndex` follows whichever of the two comes first:
147
+ // one before `Audience` if the table carries one, one before the last
148
+ // cell (`implementation`'s positional fallback) if it doesn't — so a
149
+ // table predating this column (a fixture, an un-upgraded adopter copy)
150
+ // still resolves `spec` exactly as it did before `Audience` existed.
151
+ const specIndex = (audienceIndex === -1 ? cells.length - 1 : audienceIndex) - 1
152
+ const spec = specIndex >= 3 ? stripBackticks(cells[specIndex] ?? '') || undefined : undefined
153
+ const audience: GateRow['audience'] =
154
+ audienceIndex === -1
155
+ ? 'repo-own'
156
+ : stripBackticks(cells[audienceIndex] ?? '') === 'product'
157
+ ? 'product'
158
+ : 'repo-own'
159
+ result.push({ ring, action, summary, category, description, spec, implementation, audience, line: row.line })
160
+ }
161
+ }
162
+
163
+ return result
164
+ }
@@ -0,0 +1,216 @@
1
+ /**
2
+ * registry-scaffold.ts — pure insertion logic that turns a G2 "orphan
3
+ * hook/CLI" finding into a stub row, instead of leaving it a drift warning a
4
+ * human has to notice and fix by hand.
5
+ *
6
+ * Purity contract identical to `registry-checks.ts`'s: no fs/git/gh here.
7
+ * The caller (`bin/verify-registry.ts`) supplies the parsed rows, the
8
+ * already-globbed candidate files, and the raw `enforcement.md` content to
9
+ * rewrite; this module only ever reads `GATE_AUDIENCE` (a same-package,
10
+ * aeg-core-local data table — see its own doc comment for why aeg-core
11
+ * cannot instead import `apps/cli`'s `CORE_CHECK_RING` directly).
12
+ *
13
+ * Three derivation classes, per Issue #104's Principal decision (extended
14
+ * by Issue #307 for the second registry-backed location):
15
+ * - a `packages/aeg-core/bin/*.ts` candidate that IS a registered check
16
+ * (its basename resolves in `GATE_AUDIENCE` to a `ShippedGate`) gets a
17
+ * stub in the ring `GATE_AUDIENCE` names for it;
18
+ * - an `apps/cli/src/checks/bin/*.ts` candidate that IS a registered check
19
+ * (its `check-`-stripped basename resolves in `CLI_CHECK_RING`) gets a
20
+ * stub in the ring `CLI_CHECK_RING` names for it — most core check bins
21
+ * live here, not under `packages/aeg-core/bin/` (Issue #307: 22 of 27 at
22
+ * the time it was filed);
23
+ * - a `.husky/*` or `.claude/hooks/*.sh` candidate is a managed hook by
24
+ * construction — ring 0, no registry lookup needed.
25
+ * Anything else (a bin with no registry entry — `internal`, `NON_GATE_BINS`,
26
+ * or simply undeclared) gets NO stub: ring is genuinely underivable there,
27
+ * and guessing is exactly what this task's Stop-and-escalate condition
28
+ * forbids. It stays a plain G2 finding, same as today.
29
+ *
30
+ * Never touches an existing row: stubs are computed only for candidates
31
+ * whose path is absent from every parsed row's `implementation` — the
32
+ * identical predicate `checkG2` itself uses — so a candidate already
33
+ * documented (including every row `#67` wrote by hand) is left alone, and
34
+ * running the scaffold twice inserts nothing the second time (the first
35
+ * run's own stubs are now present, so the same predicate excludes them).
36
+ *
37
+ * Only mechanical facts are filled: the check name / candidate path
38
+ * (`Action`/`CI check`/`Mechanism`), `Category` (fixed per ring — matches
39
+ * every existing row: ring 0 is `hook`, ring 1 is `ci`, ring 2 is `event`),
40
+ * `Audience` (`product` for a registered check, `repo-own` for a bare hook
41
+ * script), and `implementation` (the path itself). A ring-0 stub also gets a
42
+ * mechanical `Gate` cell naming the check/its invocation — never the
43
+ * substantive "what must be true"/"re-verifies"/"catches" reasoning, which
44
+ * (along with `Summary`/`Description`) is the placeholder marker, verbatim,
45
+ * on every stub this module ever produces.
46
+ */
47
+
48
+ import { CLI_CHECK_RING, GATE_AUDIENCE, isShipped } from './gate-audience'
49
+ import { findHeadingLine, findTable } from './markdown-table'
50
+ import type { GateRing, GateRow } from './registry-parse'
51
+
52
+ /** The literal marker a stub's every non-mechanical cell carries. Never
53
+ * synthesized reasoning — `checkG2`'s placeholder scan (registry-checks.ts)
54
+ * looks for this exact string. */
55
+ export const PLACEHOLDER = '[undocumented — fill in why]'
56
+
57
+ const HUSKY_PREFIX = '.husky/'
58
+ const CLAUDE_HOOKS_PREFIX = '.claude/hooks/'
59
+ const AEG_CORE_BIN_PREFIX = 'packages/aeg-core/bin/'
60
+ const CLI_BIN_PREFIX = 'apps/cli/src/checks/bin/'
61
+
62
+ const RING_CATEGORY: Record<GateRing, string> = { ring0: 'hook', ring1: 'ci', ring2: 'event' }
63
+
64
+ const RING_HEADING_PATTERN: Record<GateRing, RegExp> = {
65
+ ring0: /^##\s+Ring 0\b/,
66
+ ring1: /^##\s+Ring 1\b/,
67
+ ring2: /^##\s+Ring 2\b/
68
+ }
69
+
70
+ export type ScaffoldStub = {
71
+ ring: GateRing
72
+ path: string
73
+ checkName?: string
74
+ /** Ordered cell values, matching that ring table's own column count/order. */
75
+ cells: string[]
76
+ }
77
+
78
+ export type ScaffoldSkip = { path: string; reason: string }
79
+
80
+ export type ScaffoldPlan = {
81
+ stubs: ScaffoldStub[]
82
+ skipped: ScaffoldSkip[]
83
+ }
84
+
85
+ function ringFromNumber(n: 0 | 1 | 2): GateRing {
86
+ if (n === 0) return 'ring0'
87
+ if (n === 1) return 'ring1'
88
+ return 'ring2'
89
+ }
90
+
91
+ /** Classifies one G2-orphan candidate path. Returns null when no ring is
92
+ * derivable — the candidate must remain a plain G2 finding, never guessed. */
93
+ function classify(path: string): { ring: GateRing; checkName?: string } | null {
94
+ if (path.startsWith(HUSKY_PREFIX) || path.startsWith(CLAUDE_HOOKS_PREFIX)) {
95
+ return { ring: 'ring0' }
96
+ }
97
+ if (path.startsWith(AEG_CORE_BIN_PREFIX) && path.endsWith('.ts')) {
98
+ const base = path.slice(AEG_CORE_BIN_PREFIX.length, -'.ts'.length)
99
+ const audience = GATE_AUDIENCE[base]
100
+ if (audience && isShipped(audience)) {
101
+ const checkName = Array.isArray(audience.shippedAs) ? audience.shippedAs[0] : audience.shippedAs
102
+ return { ring: ringFromNumber(audience.ring), checkName }
103
+ }
104
+ return null
105
+ }
106
+ if (path.startsWith(CLI_BIN_PREFIX) && path.endsWith('.ts')) {
107
+ const base = path.slice(CLI_BIN_PREFIX.length, -'.ts'.length)
108
+ if (!base.startsWith('check-')) return null
109
+ const checkName = base.slice('check-'.length)
110
+ const ring = CLI_CHECK_RING[checkName]
111
+ if (ring === undefined) return null
112
+ return { ring: ringFromNumber(ring), checkName }
113
+ }
114
+ return null
115
+ }
116
+
117
+ function gateCell(path: string, checkName: string | undefined): string {
118
+ if (checkName) return `\`${checkName}\` check (\`vinaya check ${checkName}\`)`
119
+ return `managed hook script (\`${path}\`)`
120
+ }
121
+
122
+ function buildCells(ring: GateRing, path: string, checkName: string | undefined): string[] {
123
+ const action = checkName ?? path
124
+ const category = RING_CATEGORY[ring]
125
+ const audience = checkName ? 'product' : 'repo-own'
126
+ const implementation = `\`${path}\``
127
+ if (ring === 'ring0') {
128
+ return [
129
+ action,
130
+ PLACEHOLDER,
131
+ category,
132
+ PLACEHOLDER,
133
+ gateCell(path, checkName),
134
+ PLACEHOLDER,
135
+ audience,
136
+ implementation
137
+ ]
138
+ }
139
+ if (ring === 'ring1') {
140
+ return [action, PLACEHOLDER, category, PLACEHOLDER, PLACEHOLDER, audience, implementation]
141
+ }
142
+ return [action, PLACEHOLDER, category, PLACEHOLDER, PLACEHOLDER, PLACEHOLDER, audience, implementation]
143
+ }
144
+
145
+ /** Computes which G2-orphan candidates get a stub row, and where. Pure: no
146
+ * I/O, deterministic over its inputs. Idempotent by construction — a
147
+ * candidate already present in `rows` (including one from a prior scaffold
148
+ * run) is excluded before classification ever runs. */
149
+ export function computeScaffoldPlan(rows: GateRow[], candidateFiles: string[]): ScaffoldPlan {
150
+ const implementations = new Set(rows.map((r) => r.implementation).filter((p) => p !== ''))
151
+ const stubs: ScaffoldStub[] = []
152
+ const skipped: ScaffoldSkip[] = []
153
+ for (const path of candidateFiles) {
154
+ if (implementations.has(path)) continue
155
+ const classified = classify(path)
156
+ if (!classified) {
157
+ skipped.push({ path, reason: 'no derivable ring — remains a plain G2 finding' })
158
+ continue
159
+ }
160
+ stubs.push({
161
+ ring: classified.ring,
162
+ path,
163
+ checkName: classified.checkName,
164
+ cells: buildCells(classified.ring, path, classified.checkName)
165
+ })
166
+ }
167
+ return { stubs, skipped }
168
+ }
169
+
170
+ function formatRow(cells: string[]): string {
171
+ return `| ${cells.join(' | ')} |`
172
+ }
173
+
174
+ /**
175
+ * Rewrites `content` to append the plan's stub rows to their ring tables.
176
+ * Only ever APPENDS new lines after a ring table's last existing row —
177
+ * never edits, reorders, or removes an existing line, so a hand-written row
178
+ * (including every row task `#67` wrote) is untouched byte-for-byte.
179
+ *
180
+ * Rings are processed from the bottom of the file upward (ring 2 first,
181
+ * then ring 1, then ring 0) so an earlier insertion's line numbers, computed
182
+ * once up front from the ORIGINAL `rows`, stay valid throughout — a later
183
+ * ring's insertion never shifts an earlier ring's already-resolved splice
184
+ * point.
185
+ *
186
+ * Throws if a ring with stubs to insert has no existing table/rows to
187
+ * anchor on — the caller (`bin/verify-registry.ts`) must not write anything
188
+ * to disk when this throws; computing this in memory before any write is
189
+ * what keeps the round-trip guard restore-free (nothing is ever written
190
+ * badly in the first place).
191
+ */
192
+ export function applyScaffoldPlan(content: string, plan: ScaffoldPlan): string {
193
+ if (plan.stubs.length === 0) return content
194
+ const lines = content.split('\n')
195
+
196
+ const byRing: Record<GateRing, ScaffoldStub[]> = { ring0: [], ring1: [], ring2: [] }
197
+ for (const stub of plan.stubs) byRing[stub.ring].push(stub)
198
+
199
+ for (const ring of ['ring2', 'ring1', 'ring0'] as const) {
200
+ const stubs = byRing[ring]
201
+ if (stubs.length === 0) continue
202
+ const headingLine = findHeadingLine(lines, RING_HEADING_PATTERN[ring])
203
+ if (headingLine === null) {
204
+ throw new Error(`registry-scaffold: no "${ring}" heading found in enforcement.md`)
205
+ }
206
+ const table = findTable(lines, headingLine + 1)
207
+ if (!table || table.rows.length === 0) {
208
+ throw new Error(`registry-scaffold: ${ring} table has no existing rows to insert after`)
209
+ }
210
+ const lastRowLine = table.rows[table.rows.length - 1]?.line ?? headingLine
211
+ const newLines = stubs.map((s) => formatRow(s.cells))
212
+ lines.splice(lastRowLine, 0, ...newLines)
213
+ }
214
+
215
+ return lines.join('\n')
216
+ }
@@ -0,0 +1,261 @@
1
+ /**
2
+ * report-tokens — the **portable** half of the token-report contract
3
+ * (`aeg-root/tranche-model.md` §12, layers 1 and 3). No value this file reads,
4
+ * computes or emits is host-specific: it defines the summary shape every
5
+ * collection adapter must produce, and renders that shape into the frozen
6
+ * `Tokens: …` grammar `parse-token-report.ts` reads back. A host is named
7
+ * below only to point at the one shipped adapter and to record a retraction —
8
+ * never to branch on, and never in rendered output.
9
+ *
10
+ * `TranscriptSummary` **is the seam.** A host adapter's whole job is to
11
+ * produce one — from a session transcript, an API usage response, a meter the
12
+ * harness exposes, or figures an operator supplies by hand. AEG ships exactly
13
+ * one such adapter, for Claude Code, in `claude-code-transcript.ts`; a repo
14
+ * on another harness writes its own and reuses everything here unchanged.
15
+ * Adding host knowledge to this file would silently re-couple the portable
16
+ * layers to one vendor — that coupling is the defect the split exists to
17
+ * prevent, and this file is where it would reappear first.
18
+ *
19
+ * Historical note (misc-hardening-v1 task 1, #675): this module retracted
20
+ * §12's earlier claim that a role reports exact tokens "from `/cost`" — an
21
+ * operator-typed slash command no unattended agent session can invoke, in
22
+ * that host or any other. §12 records the retraction; the fix was to collect
23
+ * from a real per-turn source instead.
24
+ *
25
+ * Pure — no `fs`, no `process.env`. The CLI shim (`bin/report-tokens.ts`)
26
+ * does the I/O; these functions take values and produce the report.
27
+ */
28
+
29
+ /**
30
+ * The four usage figures a role reports, in host-neutral terms. Every
31
+ * collection adapter maps its host's own field names onto these, so no field
32
+ * name from a host's own API or transcript format appears downstream of the
33
+ * adapter — including in this file.
34
+ */
35
+ export type UsageComponents = {
36
+ inputTokens: number
37
+ outputTokens: number
38
+ cacheCreationInputTokens: number
39
+ cacheReadInputTokens: number
40
+ }
41
+
42
+ /**
43
+ * **The adapter seam.** What a host's collection step must produce for one
44
+ * role-turn, and the only thing the portable layers below consume. Named for
45
+ * the shipped adapter's source (a session transcript), but the shape is not
46
+ * transcript-specific: an adapter that reads an API usage response or takes
47
+ * operator-supplied figures produces this same object.
48
+ */
49
+ export type TranscriptSummary = {
50
+ components: UsageComponents
51
+ /** The model id for the turn, or `null` when the host does not report one. */
52
+ model: string | null
53
+ /**
54
+ * How many distinct usage records were summed. Callers use `0` as the
55
+ * "nothing usable was collected" signal — a real turn always has at least
56
+ * one — rather than letting a zeroed summary format as a plausible `0/0/—`.
57
+ */
58
+ messageCount: number
59
+ }
60
+
61
+ export type TokensLineInput = {
62
+ phase: string
63
+ role: string
64
+ /**
65
+ * `null` only when the role is operator-metered — its host exposes no
66
+ * usage to the agent at all — which produces the all-`—` numbers segment
67
+ * the parser already tolerates. Never a convenience escape for a
68
+ * self-metering role whose collection step merely failed: that case is an
69
+ * error to report, not a blank to emit (`aeg-root/tranche-model.md` §12).
70
+ */
71
+ summary: TranscriptSummary | null
72
+ /** Overrides the model the adapter derived, if given. */
73
+ modelOverride?: string
74
+ }
75
+
76
+ /**
77
+ * Format the one-line `Tokens: <phase> — <role> — <model> — <in>/<out>/<cost>`
78
+ * report that `parse-token-report.ts`'s `parseTokensLines` consumes. The
79
+ * grammar is frozen (three parsers depend on it) — this function must never
80
+ * change its shape, only what feeds it.
81
+ *
82
+ * `Tokens in` is the full input-side total (`inputTokens +
83
+ * cacheCreationInputTokens + cacheReadInputTokens`) — genuinely every
84
+ * token that went in, not a partial figure. Cache reads can outweigh fresh
85
+ * input by two orders of magnitude on a long session, so that total is
86
+ * never the whole story on its own; `formatBreakdown` reports the four
87
+ * components separately for anyone reading the reporter's own output,
88
+ * rather than letting the single blended cell stand as if it were.
89
+ *
90
+ * Cost is always reported as `—`: no maintained, accurate $/token pricing
91
+ * table for current models lives in this package (the one in
92
+ * `@atta/adapter-langgraph` is a different product's provider-pricing
93
+ * table, out of this task's surface, and does not cover these model ids) —
94
+ * an unverified guess baked into a PR's permanent history is worse than an
95
+ * honest unknown.
96
+ */
97
+ /**
98
+ * An embedded newline could forge a synthetic row/line neither writer ever
99
+ * intended, regardless of which grammar's own delimiter is in play — shared
100
+ * by both sanitizers below.
101
+ */
102
+ function stripNewlines(value: string): string {
103
+ return value.replace(/\r?\n/g, ' ')
104
+ }
105
+
106
+ /**
107
+ * Table-row sanitizer: escapes `|` (the cell delimiter `splitTableRow`
108
+ * already expects `\|` for on read — the same escape convention, applied on
109
+ * write) on top of `stripNewlines`. Used by `formatTokenReportRow` for every
110
+ * free-text cell — `phase`, `role`, the derived `model`, and `date` —
111
+ * because any of them can arrive from an untrusted CLI flag or a git branch
112
+ * name, both of which can legally contain `|` and newlines (found live: a
113
+ * crafted branch name produced a row whose columns silently shifted past
114
+ * `parseTokenReportEntries`, discarding real measured usage with no error).
115
+ */
116
+ function sanitizeForTableCell(value: string): string {
117
+ return stripNewlines(value).replace(/\|/g, '\\|')
118
+ }
119
+
120
+ /**
121
+ * `Tokens: …` line sanitizer. `parse-token-report.ts`'s `SEGMENT_SEP`
122
+ * (`/\s+[—–-]\s+/`) is a whitespace-flanked dash — and a field-local fix
123
+ * (removing the flanking whitespace around a dash the field itself
124
+ * contains) is provably incomplete: the JOIN ITSELF contributes whitespace
125
+ * on both sides of every field (the literal `" — "` between segments), so a
126
+ * field merely ENDING or STARTING with a dash reconstructs the exact same
127
+ * pattern from the boundary alone — `"9 -"` joined with the next field's
128
+ * leading `" — "` reads back as `"...9 - — ..."`, a real `SEGMENT_SEP`
129
+ * match, with no dash-adjacent whitespace inside the field at all to strip
130
+ * (found live: `--phase "9 -" --role "Dev -"` silently dropped the real
131
+ * `tokensIn`). No amount of field-local whitespace-stripping closes this —
132
+ * the hazard is the CHARACTER, reachable from any position once anything
133
+ * joins around it, not a particular spacing.
134
+ *
135
+ * The fix that actually closes it: remove the character itself from the
136
+ * field's vocabulary, but ONLY where it's actually reachable by
137
+ * `SEGMENT_SEP` — a dash counts as reachable on a given side when that
138
+ * side is whitespace, OR is the very edge of the field (every field in
139
+ * this grammar sits directly against a `" — "` join or the `"Tokens: "`
140
+ * prefix, so an edge is guaranteed adjacent to boundary whitespace once
141
+ * concatenated). A dash is substituted only when BOTH sides are reachable
142
+ * — matching exactly what `SEGMENT_SEP` itself requires (whitespace on
143
+ * both sides) once the guaranteed boundary whitespace is accounted for.
144
+ * An ordinary hyphenated identifier like `"claude-sonnet-5"` has
145
+ * non-whitespace, non-edge neighbors on both sides of every hyphen and is
146
+ * left completely untouched — a whole-field substitution (an earlier draft
147
+ * of this fix) needlessly mangled every such identifier, which is why this
148
+ * checks each dash's actual neighbors instead of blanket-replacing the
149
+ * class. This is also less lossy than the whitespace-stripping this
150
+ * replaces: two phase labels that only differed in spacing around a
151
+ * hazardous hyphen (`"9-fix"` — never hazardous, untouched — vs.
152
+ * `"9 - fix"` — hazardous, substituted) used to sanitize to byte-identical
153
+ * output; substitution preserves spacing, changing only the one character
154
+ * that must never survive verbatim in a hazardous position.
155
+ */
156
+ const DASH_LOOKALIKES: Record<string, string> = {
157
+ '-': '‑', // U+2011 NON-BREAKING HYPHEN
158
+ '–': '‒', // U+2012 FIGURE DASH
159
+ '—': '―' // U+2015 HORIZONTAL BAR
160
+ }
161
+ const DASH_CHARS = new Set(Object.keys(DASH_LOOKALIKES))
162
+
163
+ /**
164
+ * `formatTokenReportRow`'s own table cells escape `|` as `\|` because
165
+ * `splitTableRow` reads that convention back on the other side (`#313`).
166
+ * This line has no such reader — nothing unescapes a backslash out of it —
167
+ * so a literal `\|` here would just be a backslash followed by a visible
168
+ * bar, one more hazardous character rather than fewer. A same-glyph
169
+ * lookalike, matching `DASH_LOOKALIKES`'s own approach, neutralizes the
170
+ * delimiter unconditionally (unlike a dash, no ordinary model id legitimately
171
+ * contains a pipe, so there is no adjacency case to preserve): an
172
+ * attacker-controlled `model` field (`#313`'s live reproduction: a transcript
173
+ * whose `message.model` read `attacker | evil-injected-cell | extra`) must
174
+ * not survive with a real `|` if this line is later quoted verbatim inside
175
+ * an actual markdown table cell by anything downstream — `pr report --write`
176
+ * already escapes correctly at its own render step (`formatTokenReportRow`),
177
+ * but this line, not that one, is what `#313`'s reproduction actually
178
+ * printed, and it carries no such guarantee of its own once it leaves here.
179
+ */
180
+ const PIPE_LOOKALIKE = '|' // U+FF5C FULLWIDTH VERTICAL LINE
181
+
182
+ function sanitizeForTokensLine(value: string): string {
183
+ const stripped = stripNewlines(value)
184
+ let result = ''
185
+ for (let i = 0; i < stripped.length; i++) {
186
+ const ch = stripped[i] as string
187
+ if (ch === '|') {
188
+ result += PIPE_LOOKALIKE
189
+ continue
190
+ }
191
+ if (DASH_CHARS.has(ch)) {
192
+ const leftReachable = i === 0 || /\s/.test(stripped[i - 1] as string)
193
+ const rightReachable = i === stripped.length - 1 || /\s/.test(stripped[i + 1] as string)
194
+ if (leftReachable && rightReachable) {
195
+ result += DASH_LOOKALIKES[ch]
196
+ continue
197
+ }
198
+ }
199
+ result += ch
200
+ }
201
+ return result
202
+ }
203
+
204
+ /** Shared by `formatTokensLine` and `formatTokenReportRow` — one place that turns a summary into the model/tokensIn/tokensOut cells both grammars report, so the two shapes can never drift on the arithmetic. `model` is `null` rather than the `—` placeholder when neither `modelOverride` nor the summary supplies one — that placeholder is a sentinel THIS module emits, never untrusted content, and must never be run through either grammar's sanitizer (which would rewrite its `—` into something that no longer reads as "unknown"). Each caller substitutes the literal `—` for `null` itself, after sanitizing everything that came from `input`. */
205
+ function renderCells(input: TokensLineInput): { model: string | null; tokensIn: string; tokensOut: string } {
206
+ const model = input.modelOverride ?? input.summary?.model ?? null
207
+ if (!input.summary) return { model, tokensIn: '—', tokensOut: '—' }
208
+ const { inputTokens, cacheCreationInputTokens, cacheReadInputTokens, outputTokens } = input.summary.components
209
+ const tokensIn = inputTokens + cacheCreationInputTokens + cacheReadInputTokens
210
+ return { model, tokensIn: String(tokensIn), tokensOut: String(outputTokens) }
211
+ }
212
+
213
+ export function formatTokensLine(input: TokensLineInput): string {
214
+ const { model, tokensIn, tokensOut } = renderCells(input)
215
+ const phase = sanitizeForTokensLine(input.phase)
216
+ const role = sanitizeForTokensLine(input.role)
217
+ const safeModel = model === null ? '—' : sanitizeForTokensLine(model)
218
+ if (!input.summary) {
219
+ return `Tokens: ${phase} — ${role} — ${safeModel} — —`
220
+ }
221
+ return `Tokens: ${phase} — ${role} — ${safeModel} — ${tokensIn}/${tokensOut}/—`
222
+ }
223
+
224
+ export type TokenReportRowInput = TokensLineInput & {
225
+ /** `YYYY-MM-DD`. Caller-supplied, never derived here — this file stays `Date.now()`-free per its purity charter above. */
226
+ date: string
227
+ }
228
+
229
+ /**
230
+ * The same cells `formatTokensLine` reports, rendered as one `| Phase | Role
231
+ * | Agent/Model | Tokens in | Tokens out | Cost | Date |` markdown-table row
232
+ * — the shape `aeg-root/roles/developer.md`'s "Token report" heading and
233
+ * `parse-token-report.ts`'s `parseTokenReportEntries` (table form) both
234
+ * already expect. `Cost` is always `—`, same reasoning as `formatTokensLine`'s
235
+ * own Cost cell: no maintained per-model pricing table exists in this package.
236
+ */
237
+ export function formatTokenReportRow(input: TokenReportRowInput): string {
238
+ const { model, tokensIn, tokensOut } = renderCells(input)
239
+ const phase = sanitizeForTableCell(input.phase)
240
+ const role = sanitizeForTableCell(input.role)
241
+ const safeModel = model === null ? '—' : sanitizeForTableCell(model)
242
+ const date = sanitizeForTableCell(input.date)
243
+ return `| ${phase} | ${role} | ${safeModel} | ${tokensIn} | ${tokensOut} | — | ${date} |`
244
+ }
245
+
246
+ /**
247
+ * Human-readable component breakdown for the reporter's own console output
248
+ * — never folded silently into the single `Tokens in` cell. Not parsed by
249
+ * anything; informational only.
250
+ */
251
+ export function formatBreakdown(summary: TranscriptSummary): string {
252
+ const { inputTokens, cacheCreationInputTokens, cacheReadInputTokens, outputTokens } = summary.components
253
+ return [
254
+ ` fresh input tokens: ${inputTokens}`,
255
+ ` cache creation tokens: ${cacheCreationInputTokens}`,
256
+ ` cache read tokens: ${cacheReadInputTokens}`,
257
+ ` output tokens: ${outputTokens}`,
258
+ ` model: ${summary.model ?? '—'}`,
259
+ ` messages summed: ${summary.messageCount}`
260
+ ].join('\n')
261
+ }