@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.
- package/README.md +13 -3
- package/aeg-root/contracts/archivist-tranche-archivist.md +2 -2
- package/aeg-root/contracts/brief-developer.md +1 -0
- package/aeg-root/contracts/developer-reviewer.md +3 -3
- package/aeg-root/contracts/reviewer-archivist.md +3 -3
- package/aeg-root/enforcement.md +26 -20
- package/aeg-root/process.md +8 -4
- package/aeg-root/roles/archivist.md +2 -2
- package/aeg-root/roles/brief-author.md +9 -3
- package/aeg-root/roles/developer.md +63 -31
- package/aeg-root/roles/planner.md +1 -1
- package/aeg-root/roles/principal.md +14 -0
- package/aeg-root/roles/reviewer.md +32 -15
- package/aeg-root/roles/security.md +15 -5
- package/aeg-root/skills/aeg/SKILL.md +9 -5
- package/aeg-root/skills/aeg-roles/SKILL.md +2 -2
- package/aeg-root/skills/brief-authoring/SKILL.md +39 -27
- package/aeg-root/state-machine.md +9 -11
- package/aeg-root/templates/brief-template.md +6 -6
- package/aeg-root/templates/pr-report-template.md +10 -7
- package/aeg-root/tranche-model.md +6 -2
- package/dist/checks/bin/check-body-bare-digits.js +552 -139
- package/dist/checks/bin/check-branch-topology.js +603 -144
- package/dist/checks/bin/check-brief-shape.js +593 -142
- package/dist/checks/bin/check-changeset-coverage.js +935 -161
- package/dist/checks/bin/check-closes-n.js +603 -144
- package/dist/checks/bin/check-coherence.js +614 -147
- package/dist/checks/bin/check-dead-branch-push.js +552 -139
- package/dist/checks/bin/check-dispatch-readiness.js +612 -145
- package/dist/checks/bin/check-doc-coverage-push.js +689 -218
- package/dist/checks/bin/check-doc-coverage.js +689 -218
- package/dist/checks/bin/check-doctrine-no-procedures.js +4794 -0
- package/dist/checks/bin/check-doctrine-portability.js +684 -146
- package/dist/checks/bin/check-evidence-fresh.js +994 -174
- package/dist/checks/bin/check-exec-bits.js +4931 -0
- package/dist/checks/bin/check-first-push-dispatch.js +603 -144
- package/dist/checks/bin/check-issue-assignment.js +603 -144
- package/dist/checks/bin/check-main-branch-refusal.js +552 -139
- package/dist/checks/bin/check-no-disk-state.js +552 -139
- package/dist/checks/bin/check-pr-report-density.js +4461 -0
- package/dist/checks/bin/check-quoted-command.js +621 -155
- package/dist/checks/bin/check-reader-resolvable-prose.js +617 -154
- package/dist/checks/bin/check-registry-gates.js +595 -144
- package/dist/checks/bin/check-retired-vocabulary.js +617 -154
- package/dist/checks/bin/check-review-gate.js +670 -141
- package/dist/checks/bin/check-single-plan-pr.js +552 -139
- package/dist/checks/bin/check-test-plan.js +553 -140
- package/dist/checks/bin/check-token-collection-wired.js +552 -139
- package/dist/checks/bin/check-token-report.js +552 -139
- package/dist/checks/bin/check-workspace-escape.js +985 -145
- package/dist/index.js +8500 -5518
- package/package.json +1 -1
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/actions.ts +173 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/anchored-region.ts +113 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/archive-task.ts +206 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/baseline-capture.ts +65 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/blast-radius-domains.ts +192 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/branch-topology-gate.ts +85 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/brief-validation.ts +511 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/claude-code-transcript.ts +460 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/coherence-checks.ts +787 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/dead-branch-push-audit.ts +55 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/dead-branch-push-guard.ts +77 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/derive-section7.ts +66 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/derive-tranche.ts +94 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/diagram-model.ts +372 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/direct-main-push.ts +39 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/dispatch-gate.ts +224 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/doc-owners.ts +314 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/docs/build-doc-nav.ts +38 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/docs/docs-coherence.ts +136 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/docs/index.ts +29 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/docs/legacy-anchors.ts +48 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/docs/nav-helpers.ts +17 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/docs/node-route.ts +134 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/docs/parse-doc.ts +43 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/docs/published-prose.ts +306 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/docs/surfaced-manifest.ts +89 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/docs/types.ts +33 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/doctrine-portability.ts +170 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/doctrine-source.ts +23 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/ensure-label.ts +28 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/file-classify.ts +51 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/first-push-dispatch-gate.ts +80 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/gate-audience.ts +168 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/index.ts +301 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/issue-assignment.ts +102 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/issue-validation.ts +746 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/leftover-detection.ts +68 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/local-anchor-coverage.ts +245 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/main-branch-refusal.ts +64 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/manifest-validity.ts +81 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/markdown-table.ts +76 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/metering-io-guard.ts +119 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/milestone-validation.ts +240 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/no-disk-state.ts +50 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/parse-ledger.ts +77 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/parse-registry.ts +105 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/parse-token-report.ts +165 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/parse-tranche.ts +183 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/pr-tier.ts +81 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/premise-check.ts +125 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/quoted-command.ts +275 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/reader-resolvable-prose.ts +320 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/registry-checks.ts +255 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/registry-parse.ts +164 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/registry-scaffold.ts +216 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/report-tokens.ts +261 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/retired-vocabulary.ts +163 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/review-gate.ts +269 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/single-plan-pr.ts +73 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/state-machine-model.ts +269 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/state-source.ts +14 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/status-block.ts +8 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/sum-ledger.ts +26 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/symbol-collisions.ts +78 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/test-plan-gate.ts +104 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/test-plan-section.ts +65 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/types.ts +174 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/verdict-extraction.ts +140 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/vocabulary-citation.ts +114 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/waiver-label.ts +52 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/workspace-escape.ts +172 -0
- package/studio-standalone/apps/vinaya-studio/web/.next/BUILD_ID +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/build-manifest.json +3 -3
- package/studio-standalone/apps/vinaya-studio/web/.next/prerender-manifest.json +3 -3
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_global-error.html +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_global-error.rsc +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_not-found/page/server-reference-manifest.json +2 -2
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_not-found/page.js.nft.json +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/api/coherence/route.js.nft.json +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/page/server-reference-manifest.json +2 -2
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/page.js.nft.json +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/page_client-reference-manifest.js +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/backlog/page/server-reference-manifest.json +2 -2
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/backlog/page.js.nft.json +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/backlog/page_client-reference-manifest.js +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/page/server-reference-manifest.json +2 -2
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/page.js.nft.json +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/page_client-reference-manifest.js +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/page/server-reference-manifest.json +2 -2
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/page.js.nft.json +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/page_client-reference-manifest.js +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/tranches/[slug]/page/server-reference-manifest.json +2 -2
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/tranches/[slug]/page.js.nft.json +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/tranches/[slug]/page_client-reference-manifest.js +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/tranches/[slug]/tasks/[taskId]/page/server-reference-manifest.json +2 -2
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/tranches/[slug]/tasks/[taskId]/page.js.nft.json +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/tranches/[slug]/tasks/[taskId]/page_client-reference-manifest.js +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/page/server-reference-manifest.json +2 -2
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/page.js.nft.json +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/page_client-reference-manifest.js +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/tranches/page/server-reference-manifest.json +2 -2
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/tranches/page.js.nft.json +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/tranches/page_client-reference-manifest.js +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/[root-of-the-server]__02a-3g8._.js +1 -1
- 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
- package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/[root-of-the-server]__0053k9k._.js +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/[root-of-the-server]__0112h-k._.js +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/[root-of-the-server]__0o771t1._.js +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/[root-of-the-server]__0puovz5._.js +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/[root-of-the-server]__1hs0dcu._.js +1 -1
- 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
- package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/[root-of-the-server]__1wc4-ip._.js +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/_03x_w6q._.js +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/_0gvm3og._.js +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/_0lwxg63._.js +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/_1fqw88f._.js +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/_1n0cnq-._.js +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/node_modules_1vo08dj._.js +2 -2
- package/studio-standalone/apps/vinaya-studio/web/.next/server/middleware-build-manifest.js +3 -3
- package/studio-standalone/apps/vinaya-studio/web/.next/server/pages/500.html +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/server-reference-manifest.js +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/server-reference-manifest.json +3 -3
- package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/{37hnluhzj9bei.js → 0jer_drkl1e4r.js} +4 -4
- package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/{267o-tsrnuwnv.js → 20x-t0a0v129z.js} +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/{1hufpuody6vjv.js → 2ou_81ahqw31e.js} +1 -1
- package/studio-standalone/apps/vinaya-studio/web/package.json +2 -2
- /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
- /package/studio-standalone/apps/vinaya-studio/web/.next/static/{Fmyc42qzLwFcWWHLLNy-G → 5xy_GA7G5KRuE3HDumIiw}/_buildManifest.js +0 -0
- /package/studio-standalone/apps/vinaya-studio/web/.next/static/{Fmyc42qzLwFcWWHLLNy-G → 5xy_GA7G5KRuE3HDumIiw}/_clientMiddlewareManifest.js +0 -0
- /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
|
+
}
|