@attalabs/vinaya 0.21.0 → 0.22.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 (117) hide show
  1. package/README.md +3 -2
  2. package/aeg-root/enforcement.md +2 -1
  3. package/aeg-root/roles/archivist.md +1 -1
  4. package/aeg-root/roles/planner.md +1 -1
  5. package/aeg-root/templates/pr-report-template.md +2 -0
  6. package/aeg-root/tranche-model.md +3 -3
  7. package/dist/checks/bin/check-body-bare-digits.js +115 -9
  8. package/dist/checks/bin/check-branch-topology.js +111 -5
  9. package/dist/checks/bin/check-brief-shape.js +111 -5
  10. package/dist/checks/bin/check-changeset-coverage.js +113 -7
  11. package/dist/checks/bin/check-closes-n.js +111 -5
  12. package/dist/checks/bin/check-coherence.js +115 -9
  13. package/dist/checks/bin/check-dead-branch-push.js +111 -5
  14. package/dist/checks/bin/check-dispatch-readiness.js +116 -10
  15. package/dist/checks/bin/check-doc-coverage-push.js +116 -10
  16. package/dist/checks/bin/check-doc-coverage.js +116 -10
  17. package/dist/checks/bin/check-doctrine-portability.js +115 -9
  18. package/dist/checks/bin/check-evidence-fresh.js +111 -5
  19. package/dist/checks/bin/check-first-push-dispatch.js +113 -7
  20. package/dist/checks/bin/check-issue-assignment.js +111 -5
  21. package/dist/checks/bin/check-main-branch-refusal.js +111 -5
  22. package/dist/checks/bin/check-no-disk-state.js +111 -5
  23. package/dist/checks/bin/check-quoted-command.js +121 -12
  24. package/dist/checks/bin/check-reader-resolvable-prose.js +152 -20
  25. package/dist/checks/bin/check-registry-gates.js +131 -10
  26. package/dist/checks/bin/check-retired-vocabulary.js +144 -12
  27. package/dist/checks/bin/check-review-gate.js +113 -7
  28. package/dist/checks/bin/check-single-plan-pr.js +111 -5
  29. package/dist/checks/bin/check-test-plan.js +111 -5
  30. package/dist/checks/bin/check-token-collection-wired.js +113 -7
  31. package/dist/checks/bin/check-token-report.js +4096 -0
  32. package/dist/checks/bin/check-workspace-escape.js +113 -7
  33. package/dist/index.js +177 -104
  34. package/package.json +1 -1
  35. package/studio-standalone/apps/vinaya-studio/web/.next/BUILD_ID +1 -1
  36. package/studio-standalone/apps/vinaya-studio/web/.next/build-manifest.json +3 -3
  37. package/studio-standalone/apps/vinaya-studio/web/.next/prerender-manifest.json +3 -3
  38. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_global-error.html +1 -1
  39. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_global-error.rsc +1 -1
  40. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
  41. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  42. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  43. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  44. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  45. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_not-found/page/server-reference-manifest.json +2 -2
  46. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_not-found/page.js.nft.json +1 -1
  47. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  48. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/page/server-reference-manifest.json +2 -2
  49. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/page.js.nft.json +1 -1
  50. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/page_client-reference-manifest.js +1 -1
  51. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/backlog/page/server-reference-manifest.json +2 -2
  52. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/backlog/page.js.nft.json +1 -1
  53. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/backlog/page_client-reference-manifest.js +1 -1
  54. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/page/server-reference-manifest.json +2 -2
  55. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/page.js.nft.json +1 -1
  56. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/page_client-reference-manifest.js +1 -1
  57. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/page/server-reference-manifest.json +2 -2
  58. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/page.js.nft.json +1 -1
  59. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/page_client-reference-manifest.js +1 -1
  60. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/tranches/[slug]/page/server-reference-manifest.json +2 -2
  61. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/tranches/[slug]/page.js.nft.json +1 -1
  62. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/tranches/[slug]/page_client-reference-manifest.js +1 -1
  63. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/tranches/[slug]/tasks/[taskId]/page/server-reference-manifest.json +2 -2
  64. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/tranches/[slug]/tasks/[taskId]/page.js.nft.json +1 -1
  65. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/tranches/[slug]/tasks/[taskId]/page_client-reference-manifest.js +1 -1
  66. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/page/server-reference-manifest.json +2 -2
  67. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/page.js.nft.json +1 -1
  68. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/page_client-reference-manifest.js +1 -1
  69. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/tranches/page/server-reference-manifest.json +2 -2
  70. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/tranches/page.js.nft.json +1 -1
  71. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/tranches/page_client-reference-manifest.js +1 -1
  72. 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
  73. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/[root-of-the-server]__0053k9k._.js +1 -1
  74. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/[root-of-the-server]__0112h-k._.js +1 -1
  75. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/[root-of-the-server]__02c71kv._.js +1 -1
  76. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/{[root-of-the-server]__0ozbaq0._.js → [root-of-the-server]__069-_41._.js} +1 -1
  77. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/[root-of-the-server]__0o771t1._.js +1 -1
  78. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/[root-of-the-server]__0puovz5._.js +1 -1
  79. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/[root-of-the-server]__1hs0dcu._.js +1 -1
  80. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/[root-of-the-server]__1wc4-ip._.js +1 -1
  81. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/_03x_w6q._.js +1 -1
  82. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/_0gvm3og._.js +1 -1
  83. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/_0lwxg63._.js +1 -1
  84. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/_0o54suy._.js +1 -1
  85. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/_1-6rzip._.js +1 -1
  86. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/_1mrtj0f._.js +1 -1
  87. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/_1n0cnq-._.js +1 -1
  88. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/apps_vinaya-studio_web_src_app_studio_06it6s-._.js +1 -1
  89. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/node_modules_1vo08dj._.js +2 -2
  90. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/packages_ui_libraries_animate_components_index_ts_186fje1._.js +1 -1
  91. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/packages_ui_libraries_brutal_components_index_ts_0n_lcc4._.js +1 -1
  92. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/packages_ui_libraries_retro_components_index_ts_0wvpm_z._.js +1 -1
  93. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/packages_ui_libraries_retro_components_index_ts_1_cfi48._.js +1 -1
  94. package/studio-standalone/apps/vinaya-studio/web/.next/server/middleware-build-manifest.js +3 -3
  95. package/studio-standalone/apps/vinaya-studio/web/.next/server/pages/500.html +1 -1
  96. package/studio-standalone/apps/vinaya-studio/web/.next/server/server-reference-manifest.js +1 -1
  97. package/studio-standalone/apps/vinaya-studio/web/.next/server/server-reference-manifest.json +3 -3
  98. package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/00pi8vhg8w6gi.js +1 -0
  99. package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/{0r4i_ks9z02bw.js → 03q0q9n6-xah8.js} +1 -1
  100. package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/0vc2u252q5q6o.js +1 -0
  101. package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/{1k916ih3p4nsg.js → 13g16cu00g570.js} +1 -1
  102. package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/1hufpuody6vjv.js +1 -0
  103. package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/{2hmannd4bjhzy.js → 1sfxfunirkcr6.js} +1 -1
  104. package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/{1t2kft7bxzuxn.js → 206ajg8f1a-_h.js} +1 -1
  105. package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/{1hfhxjy577l5d.js → 267o-tsrnuwnv.js} +1 -1
  106. package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/{25kbt5k-xb2a5.js → 279qq4twinc97.js} +1 -1
  107. package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/{112g08xpx3kro.js → 2ka8dg687auhj.js} +1 -1
  108. package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/{0up5x3jg-cwov.js → 2rn89uwtf2lwc.js} +1 -1
  109. package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/{2_k6j1o9q5nn4.js → 37hnluhzj9bei.js} +1 -1
  110. package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/{3mqu_uhluy19i.css → 3qc67qhcvbg0o.css} +1 -1
  111. package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/{1yqs2zgvwa4p1.js → 3zugx9esh2x51.js} +1 -1
  112. package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/0_jnwbarv-cqk.js +0 -1
  113. package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/1ybrihl5_aocy.js +0 -1
  114. package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/3r5efmleneaco.js +0 -1
  115. /package/studio-standalone/apps/vinaya-studio/web/.next/static/{crNCHQ9uAgKOYC6DKkZma → Fmyc42qzLwFcWWHLLNy-G}/_buildManifest.js +0 -0
  116. /package/studio-standalone/apps/vinaya-studio/web/.next/static/{crNCHQ9uAgKOYC6DKkZma → Fmyc42qzLwFcWWHLLNy-G}/_clientMiddlewareManifest.js +0 -0
  117. /package/studio-standalone/apps/vinaya-studio/web/.next/static/{crNCHQ9uAgKOYC6DKkZma → Fmyc42qzLwFcWWHLLNy-G}/_ssgManifest.js +0 -0
package/README.md CHANGED
@@ -19,7 +19,7 @@ npx @attalabs/vinaya init # or: pnpm dlx / yarn dlx / bunx
19
19
  | `vinaya doctrine [--role <name>]` | Print the absolute path of the bundled doctrine's front door (`aeg-root/skills/aeg/SKILL.md`) on this machine. The committed root `VINAYA.md` pointer names the package, never a filesystem path — this command is the read-time resolution step it hands the reader. `--role <name>` resolves straight to a specific role's doctrine instead (`aeg-root/roles/<name>.md`, under the same resolved root), validated against the role names actually enumerated under `roles/*.md` at request time — an unknown name fails cleanly and lists the valid names, never a silent bad path. `--json` for the enveloped `{ root, entry }` form. |
20
20
  | `vinaya check <name> \| --all` | Run one check, or every registered check (core + `vinaya.config.json`-registered). `--json` for the enveloped `{ checks: CheckOutcome[] }` form; `--diff-only` scopes `scope: 'diff'` checks to changed files; `--parallel[=n]` caps concurrency (default: cpu-derived). Findings always print as the check contract's JSON lines on stderr, regardless of `--json`. Exit 0 iff every check passed. |
21
21
  | `vinaya new check <yourname>/<id>` | Scaffold a self-contained custom check into `./scripts/vinaya-checks/<id>.ts`, ready to register in `vinaya.config.json` under that namespaced key |
22
- | `vinaya pr report [--write <body-file>] [--phase <phase>] [--role <role>] [--model <id>] [--transcript <path>]` | Emit two generated, never-hand-typed blocks a PR body carries: `AEG:EVIDENCE` (the head sha, the width-invariant `git diff --numstat` against `origin/main`'s merge-base, and the result of `vinaya check --all --diff-only`) and `AEG:TOKENS` (real usage figures collected the same way `vinaya tokens` collects them, rendered into the `## Token report` heading's table). Without `--write`, prints the Evidence block to stdout. With `--write <body-file>`, replaces the Evidence block in place but APPENDS a new row to the Tokens block on every run — a re-entry after `CHANGES_REQUESTED` reports again, and a second report is a second row, never a sum or an overwrite. `--phase`/`--role` default to `<n>: develop`/`Developer` (`<n>` parsed off a `task/<tranche>/<n>` branch); `--transcript` names a session transcript directly, skipping Stop-hook pointer-file resolution. An incapable host (no transcript resolves, or it resolves to zero usage records) gets an all-`—` row carrying the probe's reason inline in the Agent/Model cell, never a fabricated `0/0/—`. Exits non-zero when any gate in Group B fails. |
22
+ | `vinaya pr report [--write <body-file>] [--phase <phase>] [--role <role>] [--model <id>] [--transcript <path>]` | Emit two generated, never-hand-typed blocks a PR body carries: `AEG:EVIDENCE` (the head sha, the width-invariant `git diff --numstat` against `origin/main`'s merge-base, and the result of `vinaya check --all --diff-only`) and `AEG:TOKENS` (real usage figures collected the same way `vinaya tokens` collects them, rendered into the `## Token report` heading's table). Without `--write`, prints the Evidence block to stdout. With `--write <body-file>`, replaces the Evidence block in place but APPENDS a new row to the Tokens block on every run — a re-entry after `CHANGES_REQUESTED` reports again, and a second report is a second row, never a sum or an overwrite. `--phase`/`--role` default to `<n>: develop`/`Developer` (`<n>` parsed off a `task/<tranche>/<n>` branch) but accept any role/phase pair — a Brief Author (`--role "Brief Author" --phase "<n>: brief"`) or Planner (`--role Planner --phase "<n>: plan"`) turn appends its own row into the same block alongside the Developer's, and each round-trips through `parseTokenReportEntries` into its own distinct ledger row; no role's figures ever collapse into another's. `--transcript` names a session transcript directly, skipping Stop-hook pointer-file resolution. An incapable host (no transcript resolves, or it resolves to zero usage records) gets an all-`—` row carrying the probe's reason inline in the Agent/Model cell, never a fabricated `0/0/—`. Exits non-zero when any gate in Group B fails. |
23
23
  | `vinaya review post --role code-reviewer \| security --pr <n> ...` | Render, post, and self-verify a code-reviewer or security-review verdict comment on a PR from structured flags (verdict, findings, per-field text) instead of a hand-typed comment. Resolves the PR's real head itself (`gh pr view --json headRefOid`); renders every structural `VERDICT:`/`Judged head:` line from validated inputs, never from caller-supplied text; refuses a contradictory verdict (a BLOCKER/CRITICAL-or-HIGH finding with a clean verdict) before posting anything; and after posting, re-fetches the comment and refuses to exit 0 unless it re-parses through the exact `extractCodeReviewVerdict`/`extractSecurityReviewVerdict` functions the merge gate calls. `--json` for the enveloped machine form. |
24
24
  | `vinaya tokens --phase <phase> --role <role> [--model <id>] [--transcript <path> \| --in <n> --out <n>]` | Print a role's `Tokens: …` report line — the portable front door over the token-report collection adapter, resolving `@attalabs/aeg-core`'s `resolveMeteringCapability`/`summarizeTranscript`/`formatTokensLine` from the installed package rather than a repo-relative path, so it works in an adopter checkout with no local `packages/`. `--transcript <path>` reads a session transcript directly; omitted, it resolves via the Stop-hook pointer file where one is installed. `--in <n> --out <n>` bypasses transcript resolution entirely for a host whose figures arrive by some other means. Refuses rather than emitting `0/0/—` when no transcript resolves, a resolved one can't be read, or it summarizes to zero usage records — the same capability probe backs a `vinaya doctor` finding when it reports incapable. |
25
25
  | `vinaya archive [--merge-sha <sha>]` | Post-merge Archivist: resolves the merged PR from a merge SHA (`HEAD` by default), assembles and posts the `### AEG provenance` comment via `@attalabs/aeg-core`'s `buildProvenanceBlock`, then closes the PR's `Closes #N` Issue. Idempotent — a PR that already carries the block (`hasProvenance`) is skipped, posting nothing new. Appends its own one-line `Tokens: …` report to that same comment, collected via the same metering adapter `vinaya tokens` uses: the sanctioned all-`—` line on an incapable host, or — when the probe reports capable but summarized to zero tokens — the line is omitted and flagged `DANGLING (tokens): …` instead, since a capable host's blank is never licensed to read as `—`. Provenance and Issue-closure always proceed regardless of that outcome; a missing token row never withholds either. `vinaya archive tranche <slug> [--yes]` closes a tranche's legacy Milestone once every `vinaya/tranche:<slug>`-labeled Issue is closed. `rings.ring2_asyncAudits: true` skips this command's work entirely (see Config below). |
@@ -140,7 +140,7 @@ On that legacy `.git/hooks` layout, note where the hook actually lives: hooks ar
140
140
 
141
141
  ## Claude Code Stop hook (transcript pointer)
142
142
 
143
- When `--agents` includes `claude` (the default), `vinaya init`/`vinaya upgrade` also install a Claude Code `Stop` hook — `.claude/hooks/track-transcript.sh`, registered in `.claude/settings.json` — that records each session's transcript pointer (session id and transcript path, tab-separated) to `${TMPDIR:-/tmp}/claude-transcript-<sanitized-project-dir>.txt`. This is the pointer `packages/aeg-core/bin/report-tokens.ts`'s token-report adapter reads, so a fresh Claude Code adopter's token-report obligation resolves without an operator naming a transcript by hand, and without falling back to scanning `~/.claude/projects/` for the newest file (which grabs the wrong session's transcript when two worktrees run concurrently).
143
+ When `--agents` includes `claude` (the default), `vinaya init`/`vinaya upgrade` also install a Claude Code `Stop` hook — `.claude/hooks/track-transcript.sh`, registered in `.claude/settings.json` — that records each session's transcript pointer (session id and transcript path, tab-separated) to `${TMPDIR:-/tmp}/claude-transcript-<sanitized-project-dir>-<sha256-of-project-dir>.txt`. Appending a full `SHA-256` digest of the (uncollapsed) project directory keeps two project directories whose paths differ only in non-alphanumeric characters (e.g. `/a/b` vs `/a-b`) from sharing one pointer file — the sanitized prefix alone would collapse both to the same name (`#315`). The reader falls back to the pre-`#315`, digest-less filename when the new one is absent, so a pointer an older hook already wrote stays readable. This is the pointer `packages/aeg-core/bin/report-tokens.ts`'s token-report adapter reads, so a fresh Claude Code adopter's token-report obligation resolves without an operator naming a transcript by hand, and without falling back to scanning `~/.claude/projects/` for the newest file (which grabs the wrong session's transcript when two worktrees run concurrently). The adapter reads both the primary and legacy pointer names through the same hardened, symlink-and-FIFO-safe I/O every other metering call site uses (`hardenedMeteringDeps()`, `packages/aeg-core/src/metering-io-guard.ts`), never a hand-rolled `existsSync`/`readFileSync` pair.
144
144
 
145
145
  The script follows the same never-clobber discipline as the git hooks above: it is a marker-delimited managed block, appended onto an adopter's existing file at that path rather than overwritten. `.claude/settings.json`, by contrast, is refuse-if-foreign (created only when absent) — strict JSON has no comment syntax the marker convention could use, so an adopter's existing `settings.json` is left untouched; wire the hook in by hand (see the install diff's REFUSE guidance) if you already have one.
146
146
 
@@ -212,6 +212,7 @@ Full field-by-field reference: [vinaya.attalabs.dev/docs/cli](https://vinaya.att
212
212
  - Never self-enforce a timeout — the runner does that (`vinaya.config.json`'s `timeoutMs`, or the runner's default).
213
213
  - Never reach the network unless explicitly declared as an exception (today: none of the custom-check surface; the core `coherence`/`dispatch-readiness` checks are the only declared exceptions).
214
214
  - `token-collection-wired` (ring 0, part of `vinaya check --all --local`'s managed hooks) is a worked example of this contract's narrowest shape: it consults only local `fs`/`process.env` — no PR body, no network — and refuses a commit only when the host's token-metering probe (`resolveMeteringCapability`, `@attalabs/aeg-core`) found a transcript pointer that is this session's on one of two grounds — its recorded id matches ours and the transcript it named could not be reached, or its id could not be read at all yet the file sits at this project's own pointer path owned by this user, which is broken wiring whoever wrote it. A host never wired to meter passes, and so does one holding only a pointer that provably belongs to another session — a plain human terminal carrying an earlier session's leftover, or a second agent session whose Stop hook has not fired yet. Both are the sanctioned operator-metered case, not a wiring defect.
215
+ - `token-report` (ring 1, `requiresOpenPr`) is a worked example of the CI-only shape: it reads `PR_BODY` and re-runs the same `resolveMeteringCapability` probe fresh in its own process, then fails only when the host is metering-capable AND the PR body's "Token report" section is missing, or carries a blank/non-numeric Tokens in/out cell — an incapable host, or an empty `PR_BODY` (no PR yet), both pass silently. It proves presence and shape only, never that the reported figures are true — a check has no transcript of its own to recompute them against. A probe that itself throws (as opposed to cleanly reporting incapable) is never caught into a false pass: left uncaught, it surfaces as `status: 'error'` through the runner's own malformed-stderr path, never a silent `status: 'pass'`.
215
216
 
216
217
  `vinaya new check <yourname>/<id>` scaffolds a worked, self-contained example that honors this contract out of the box, and prints the exact — namespaced — registration to paste.
217
218
 
@@ -140,10 +140,11 @@ Every pull request, on open and on every push, re-runs the same checks in CI:
140
140
  | workspace-escape | Ever deleted a package only to break a sibling's tests through a path nobody's dependency graph saw? | ci | Re-checks that no source file's constructed filesystem reference reaches outside its own workspace package, or points at a path that does not exist. | Every `readFileSync`/`readFile` call with a literal relative-path argument, and every `new URL('…', import.meta.url)`, across every `.ts`/`.tsx` file under `apps/*`/`packages/*` — resolved against the citing file's own directory and judged against its own two-segment workspace-package prefix (`apps/<name>`, `packages/<name>`), never against `import`/`require` module specifiers (the dependency graph already owns those). Zero I/O in `@attalabs/aeg-core`'s `findWorkspaceEscapes`; the bin supplies the only I/O, walking the working tree once for source content and once for the existence-check universe (files AND directories, so a reference to a directory itself resolves as existing). Registered in `coreCheckRegistry()` (task 17), report-only like `reader-resolvable-prose`/`retired-vocabulary`/`doctrine-portability` — this repo's own corpus carries one pre-existing genuine escape (`packages/sources/src/commands-router-coverage.test.ts` reaching into `apps/cli/src/index.ts`, structurally the same incident this check exists to catch), and a blocking check on day one would fail this repo's own CI on that finding before a separate task has triaged fixing it. | product | `apps/cli/src/checks/bin/check-workspace-escape.ts` |
141
141
  | changeset-coverage | Ever shipped a change to a published package and the release train never picked it up? | ci | Re-checks that a diff touching a published package's own shipped files also carries a changeset in the same diff. | For each member of `.changeset/config.json`'s `fixed` group, a changed path counts as SHIPPED iff it falls under that member's own `package.json` `files` allowlist, read live from every workspace member's own manifest — never a hardcoded path list, never a directory heuristic. A diff that hits at least one shipped path with no `.changeset/*.md` entry (excluding `README.md`) in the same diff is a finding, naming the shipped paths hit. The Changesets-release branch itself is exempt by construction (its diff IS the changesets being consumed) — the exemption reuses `@attalabs/aeg-core`'s own release-branch constant, the same one `body-bare-digits`'s Changesets-release exemption is keyed on, rather than inventing a second branch-name special-case; unlike that check it never live-fetches a PR author, since a report-only, always-exit-`0` check has no gate for a spoofed branch name to bypass. Written after four separate published-package pull requests, over the course of one day, each merged with no changeset and caught only by a human after the fact — the obligation existed in no enforceable place before this check: not in `roles/developer.md`'s deliverable conventions (now amended alongside this row), not in any registered check. Zero I/O in the pure predicate (`changeset-coverage-logic.ts`); the bin supplies the only I/O — reading the config, every fixed-group member's own manifest, and the diff. Registered in `coreCheckRegistry()`, report-only like `reader-resolvable-prose`/`retired-vocabulary`/`doctrine-portability`/`workspace-escape` above — graduating to a blocking check, and any waiver-label escape that would need, is a later, separately-dispatched decision once the false-positive rate is observed; v1 carries no such escape. | product | `apps/cli/src/checks/bin/check-changeset-coverage.ts` |
142
142
  | quoted-command | Ever read a doc that quoted a command as "what runs today", and the command changed underneath it? | ci | Re-checks that a doc's explicitly marked quote of a command or config line still matches, verbatim, the file it names as its source. | Marker-based only, by explicit Principal decision, not inferred from command-looking spans: a doc opts a span in with `<!-- AEG:QUOTES-FILE:START:<path> -->…<!-- AEG:QUOTES-FILE:END -->` (the same invisible-on-render, code-blind HTML-comment idiom `anchored-region.ts` uses for its own six PR/Issue-body fields, reusing that file's masking primitive rather than a second parser — but a distinct marker kind, since the cited path is free text, not one of that file's six closed field names). An adopter-facing command in a README with no marker is instruction, not a claim, and is never flagged — inference over command-looking spans was explicitly rejected as the false-positive shape that gets a gate disabled. `findCitedQuotes` sweeps the identical `ships`/`reader-facing` corpus `reader-resolvable-prose` sweeps (never a second notion of "governed doc"); `evaluateCitedQuotes` then checks each marked span against its cited file's live content, wherever that file lives in the repo, naming both sides in a finding — what the doc claims, and which file no longer contains it verbatim. Zero I/O in `@attalabs/aeg-core`'s pure predicate (`quoted-command.ts`), corpus-tested against the real `aeg-root/**` tree; the bin supplies the only I/O. Ships with one real, live annotation (this very paragraph's own sibling callout above, quoting the CI invocation this page names) rather than only synthetic fixtures. Written after a measured incident: a CI invocation was pinned to a version in one pull request; this page quoted the pre-pin form verbatim as current fact; every registered check passed, `doctor` reported healthy, security passed, and a code-review agent found the drift only after independent re-derivation. Registered in `coreCheckRegistry()`, report-only like `reader-resolvable-prose`/`retired-vocabulary`/`doctrine-portability`/`workspace-escape`/`changeset-coverage` above — graduating to a blocking check is a later, separately-dispatched decision once the false-positive rate is observed against real adopter corpora; v1 carries no waiver escape. | product | `apps/cli/src/checks/bin/check-quoted-command.ts` |
143
+ | token-report | Ever seen a cost figure nobody could trace to a source? | ci | Re-checks that a PR's "Token report" section actually carries real numeric Tokens in/out figures, on any host this repo can prove is metering-capable. | The presence and shape of the `## Token report` block `roles/developer.md` requires every self-metering role to paste at turn-end — never whether the reported figures are TRUE, which CI structurally cannot recompute (the same bounded honesty this page's `evidence-fresh` row states for its own Group B). Runs `resolveMeteringCapability` (task 1's probe, `@attalabs/aeg-core`) fresh in THIS process; an incapable verdict (any reason) passes silently — the sanctioned operator-metered case, same discipline `token-collection-wired` above already applies. **Ring derived mechanically as `1`, not chosen** (a 2026-08-29 amendment correcting the original brief rationale's now-retracted ring-0/1 instruction): `requiresOpenPr: true` makes this check CI-only by `CoreCheckRing`'s own derivation rule, since the PR body it reads does not exist before a PR does. A probe that itself fails to run (an unexpected `exists`/`readFile` throw the probe does not catch) is never read as a clean incapable verdict: the bin lets it propagate uncaught, which the runner surfaces as `status: 'error'`, distinct from the `status: 'pass'` a real incapable verdict produces. | product | `apps/cli/src/checks/bin/check-token-report.ts` |
143
144
 
144
145
  **G-checks rollout (task 3):** G1/G2 shipped report-only at first — they could only ever print an `info` finding, never fail CI, so pre-existing gaps (four orphan hooks, several orphan `bin/*.ts` CLIs found on first run) surfaced as visible debt without retroactively failing in-flight work. G3/G4/G5 have been blocking since that same tranche. **G1 flipped to blocking in task 8**, once its report-only window had let the orphan backlog get cleaned up — a permanent `info` finding on every run is indistinguishable from silence, which is exactly how the gap task 8 closes (G6, below) stayed invisible for as long as it did. G2's inverse sweep (no orphan hook/CLI) stays report-only; its rollout is unchanged. **G6 — doctrine-registry parity**, also added in task 8, ships blocking from the start: it checks that every row this page marks `product` (the `Audience` column, above) actually resolves to a real `coreCheckRegistry()` entry, closing the gap where a row could claim shipped enforcement that no adopter's `vinaya check` ever actually ran.
145
146
 
146
- **reader-resolvable-prose / retired-vocabulary rollout (task 15; task 7 registered both in `coreCheckRegistry()`):** both ship report-only, same precedent as G1/G2 above — each can only ever print a `warning` finding, never fail CI (the check's own exit code always stays `0`), so any real backlog surfaces as visible debt rather than a wave of newly-red pull requests (161 findings, all class 2, across `aeg-root` on the day `reader-resolvable-prose` first shipped). A later, separately-dispatched task flips either to blocking once its own backlog is cleared.
147
+ **reader-resolvable-prose / retired-vocabulary rollout (task 15; task 7 registered both in `coreCheckRegistry()`):** both ship report-only, same precedent as G1/G2 above — each can only ever print a `warning` finding, never fail CI (the check's own exit code stays `0`), so any real backlog surfaces as visible debt rather than a wave of newly-red pull requests (161 findings, all class 2, across `aeg-root` on the day `reader-resolvable-prose` first shipped). A later, separately-dispatched task flips either to blocking once its own backlog is cleared. **Exception, both checks (checkout-independent doctrine-root resolution):** the unconfigured doctrine-root default now resolves relative to the repo actually under check (its own real repository root) first, falling back to the check's own package-relative "shipped copy" resolution only when the repo under check has no local doctrine tree of its own — so the same commit sweeps identically regardless of where it happens to be checked out. When NEITHER resolves (no doctrine tree findable at all, relative to either anchor), that is not a `warning` finding and not a clean pass — it is reported as its own distinct outcome, with a non-`0`/non-`1` exit code, so a scan that never ran cannot be mistaken for a scan that ran and found nothing.
147
148
 
148
149
  **changeset-coverage rollout:** ships report-only, same precedent as the report-only rows above — the check's own exit code always stays `0`, so registering it cannot newly redden any existing install. Graduating to blocking, and the waiver-label escape that graduation would need, are both deliberately deferred to a later, separately-dispatched task once the check's own false-positive rate is observed in practice; v1 carries no self-serve or principal-applied escape at all.
149
150
 
@@ -93,7 +93,7 @@ Work through this checklist for the merged task. Confirm each against reality
93
93
  2. **Docs updated.** The tier-required docs the brief listed actually moved. (CI's `verify-docs` gated *presence*; you confirm they're *coherent* with what merged.)
94
94
  3. **Non-derivable facts recorded as Issues — never as a state document.** The per-project state layer (`state.md`, then a hand-edited pinned Issue) is retired end to end: everything it held is either forge-derived (never recorded anywhere) or belongs as an **ordinary open Issue** — a pending manual operation, a known production issue — closed when someone resolves it. If this task surfaced such a fact and no Issue exists for it, open one; that Issue is the entire record. (`now.md` no longer exists either.)
95
95
  4. **`docs-index.md`** updated if files were added, removed, or renamed.
96
- 5. **Token ledger rows recorded.** No role appends its own row on a task branch. **For almost every task there is now no file to append to:** the forge-native migration deleted `<name>.tokens.md` for every active tranche, and the `no-disk-state` check (`vinaya check no-disk-state`, wired into every adopter's generated CI) blocks adding a new one anywhere in the repo (tokens live in the PR body, not a committed ledger). Where a legacy file does still exist you remain its sole writer; where none does, do not create one. Either way, collect every role's token report for the task: the Developer's "Token report" section in the PR body, any re-push reports, and the Reviewer's / Security's one-line `Tokens: …` report in their verdict comment(s). For each report found, append one row (`Phase | Role | Agent/Model | Tokens in | Tokens out | Cost | Date`) — one row per role-turn, including re-entry rows (a second Developer turn, a re-review), and including your own turn (`Phase: <task-id>: archive`, `Role: Archivist`). Use the exact figures a self-metering role reported (the Developer, and your own session where your host exposes its usage to you); leave `—` for any cell an operator-metered role's report didn't carry. If a role's report is missing entirely (e.g. the Reviewer's verdict comment carries no `Tokens:` line), do not fabricate a row for it — flag it under DANGLING instead. **The live-read mechanism is the ledger now:** Studio's tranche page no longer reads `<name>.tokens.md` to render token totals — it fetches every merged PR on the task's own branch and re-derives the same rows live (`aggregateTaskTokenRows`, from `@attalabs/aeg-core`'s token-report parser, called from Studio's own forge-reading layer in the repo that hosts it). Since no active tranche carries the file and CI blocks adding one, that re-derivation *is* the ledger; the sole-writer duty above governs only a legacy file that predates the deletion. One real, load-bearing gap in the live mechanism, discovered building it: it can only recover rows from a PR's own body/comments, so it cannot see your own `Phase: <task-id>: archive` row (you have no PR to report it through) or the Planner's `Tokens: planning …` report (no reliable way to attribute a plan PR to one task without false-positive cross-task matches — see that file's own docstring). Those two sources therefore have **no durable home today** — that is a known, open gap, not a licence to create a `.tokens.md` for them.
96
+ 5. **Token ledger rows recorded.** No role appends its own row on a task branch. **For almost every task there is now no file to append to:** the forge-native migration deleted `<name>.tokens.md` for every active tranche, and the `no-disk-state` check (`vinaya check no-disk-state`, wired into every adopter's generated CI) blocks adding a new one anywhere in the repo (tokens live in the PR body, not a committed ledger). Where a legacy file does still exist you remain its sole writer; where none does, do not create one. Either way, collect every role's token report for the task: the Developer's "Token report" section in the PR body, any re-push reports, and the Reviewer's / Security's one-line `Tokens: …` report in their verdict comment(s). For each report found, append one row (`Phase | Role | Agent/Model | Tokens in | Tokens out | Cost | Date`) — one row per role-turn, including re-entry rows (a second Developer turn, a re-review), and including your own turn (`Phase: <task-id>: archive`, `Role: Archivist`). Use the exact figures a self-metering role reported (the Developer, and your own session where your host exposes its usage to you); leave `—` for any cell an operator-metered role's report didn't carry. If a role's report is missing entirely (e.g. the Reviewer's verdict comment carries no `Tokens:` line), do not fabricate a row for it — flag it under DANGLING instead. **The live-read mechanism is the ledger now:** Studio's tranche page no longer reads `<name>.tokens.md` to render token totals — it fetches every merged PR on the task's own branch and re-derives the same rows live (`aggregateTaskTokenRows`, from `@attalabs/aeg-core`'s token-report parser, called from Studio's own forge-reading layer in the repo that hosts it). Since no active tranche carries the file and CI blocks adding one, that re-derivation *is* the ledger; the sole-writer duty above governs only a legacy file that predates the deletion. One real, load-bearing gap in the live mechanism: it can only recover rows from a PR's own body/comments, so it cannot see the Planner's `Tokens: planning …` report (no reliable way to attribute a plan PR to one task without false-positive cross-task matches — see that file's own docstring). Your own `Phase: <task-id>: archive` row is not part of that gap: `vinaya archive` folds it into the same provenance comment it posts on the merged task PR, so the live mechanism recovers it the same way it recovers every other role's row on that PR. That leaves the Planner's tranche-wide report as the one source with **no durable home today** — a known, open gap, not a licence to create a `.tokens.md` for it.
97
97
  6. **Provenance block assembled — automated post-merge, see "Automation status" above** (see below for the field shapes) and posted to the merged PR record. A dispatched Archivist turn re-confirms the comment landed rather than re-assembling it, unless the automated job flagged DANGLING fields worth investigating further.
98
98
 
99
99
  ## The provenance block
@@ -294,4 +294,4 @@ AEG defines **task close-out** (the per-task Archivist) and **tranche close-out*
294
294
 
295
295
  ## Turn-end: report your tokens, don't append them
296
296
 
297
- You do not append your own row to `aeg-root/tranches/<name>.tokens.md` — self-append was retired for every role. When the planning session closes, report your tokens instead: `Tokens: planning — Planner — <model> — in/out/cost or — if unknown`, in the plan PR body if one exists, or in your planning report to the Principal otherwise. Planning normally runs **operator-metered** — on a host that exposes no usage figure to the agent — so report `—` for the numeric cells; that host capability is the one sanctioned reason for a blank token cell (`tranche-model.md` §12), never inconvenience, and you never estimate. If your host does expose your own usage to you, report the real figures instead. Re-planning a wave reports again, never edits the prior report. The ledger is append-only.
297
+ You do not append your own row to `aeg-root/tranches/<name>.tokens.md` — self-append was retired for every role. When the planning session closes, report your tokens instead: `Tokens: planning — Planner — <model> — in/out/cost or — if unknown`, in the plan PR body when one exists; when it doesn't, in a comment on the pinned lessons Issue — the existing forge object, never a new file or forge object of your own (your new-file hard gate forbids that regardless). Planning is normally **self-metering**, the same as the Developer, on any host that exposes the session's own usage to the agent (a transcript it writes, a usage field on its responses, a meter it exposes) — collect the real figures through whatever mechanism your host offers and report them; a blank token cell is not the default outcome. **Operator-metered** — a host that exposes no usage figure to the agent at all is the fallback case, not the norm: only then does `—` for the numeric cells become the sanctioned outcome (`tranche-model.md` §12), never inconvenience, and you never estimate. Re-planning a wave reports again, never edits the prior report. The ledger is append-only.
@@ -64,9 +64,11 @@ The block opens with `Head:` and a `Summary:` line — a file and line count der
64
64
 
65
65
  ## Token report
66
66
 
67
+ <!-- AEG:TOKENS:START -->
67
68
  | Phase | Role | Agent/Model | Tokens in | Tokens out | Cost | Date |
68
69
  |---|---|---|---|---|---|---|
69
70
  | [task-id]: develop | Developer | [model] | [exact in] | [exact out] | [cost] | [YYYY-MM-DD] |
71
+ <!-- AEG:TOKENS:END -->
70
72
 
71
73
  ## Reference — the dispatched brief
72
74
 
@@ -293,7 +293,7 @@ Token reporting is three separable things, and only one of them depends on the a
293
293
  2. **Collection — host-specific.** *How* a role obtains true figures for its own turn. This is the only layer an adapter belongs in, and the only layer that may name a host, a transcript format, or a script path.
294
294
  3. **The destination and the grammar — portable.** The `Tokens: …` line, in the artifact the role's turn already produced: the PR body's "Token report" section for a role that holds a branch, the verdict comment for a reviewing role, the plan PR or planning report for the Planner. The ledger stays append-only, and the Archivist transcribes what each role reported rather than re-deriving it.
295
295
 
296
- The destination was never the coupled part — no role writes a shared ledger file on a task branch, so layer 3 is satisfied through the forge for every task-branch role. Two sources remain the known open gap recorded in `roles/archivist.md`: the Archivist's own row, and the Planner's tranche-wide report. **An adopter implements layer 2 for their own harness and inherits layers 1 and 3 unchanged.** A doctrine sentence that names a tool belongs in layer 2 or nowhere.
296
+ The destination was never the coupled part — no role writes a shared ledger file on a task branch, so layer 3 is satisfied through the forge for every task-branch role. One source remains the known open gap recorded in `roles/archivist.md`: the Planner's tranche-wide report — the Archivist's own row is not part of it (see the "Known gap" paragraph below). **An adopter implements layer 2 for their own harness and inherits layers 1 and 3 unchanged.** A doctrine sentence that names a tool belongs in layer 2 or nowhere.
297
297
 
298
298
  ### Two collection capabilities (the design constraint, not a bug)
299
299
 
@@ -308,7 +308,7 @@ The split is **operator vs. agent, not terminal vs. chat** — a figure a human
308
308
 
309
309
  Which capability applies is a fact about the **host**, not a fixed property of the role: the same role is self-metering on a harness that exposes usage and operator-metered on one that does not. Read the role names above as today's common case, not as an allocation.
310
310
 
311
- Self-metering capture is real, not estimated: the adapter sums the session's own usage records, so a Developer or automated Archivist turn's numbers are exact rather than approximated. Whether it also runs with *no* operator step depends on the host's wiring — where a Stop hook writes a transcript pointer — configured in the repo or in the operator's own host settings — the adapter finds it unaided; with no pointer written, the caller names the transcript with `--transcript <path>`. Either way the figures are read, never guessed. The remaining manual seam is operator-metered roles only — closing it depends on the host giving a session a way to read its own usage, which conversational surfaces generally do not today. **Known gap (flagged, not solved):** tranche-wide operator-metered turns with no task PR to report into a Planner session outside a plan PR, a Brief Author session — have no established recording path; see `roles/planner.md` "Plan-PR close-out."
311
+ Self-metering capture is real, not estimated: the adapter sums the session's own usage records, so a Developer or automated Archivist turn's numbers are exact rather than approximated. Whether it also runs with *no* operator step depends on the host's wiring — where a Stop hook writes a transcript pointer — configured in the repo or in the operator's own host settings — the adapter finds it unaided; with no pointer written, the caller names the transcript with `--transcript <path>`. Either way the figures are read, never guessed. The remaining manual seam is operator-metered roles only — closing it depends on the host giving a session a way to read its own usage, which conversational surfaces generally do not today. **Known gap now partly closed:** tranche-wide operator-metered turns with no task PR to report into no longer lack a destination across the board. A Planner session outside a plan PR now reports into a comment on the pinned lessons Issue — the existing forge object, never a new one (`roles/planner.md` "Turn-end: report your tokens, don't append them"). A Brief Author session already has a destination when a plan PR exists the same plan PR the Planner reports into (`roles/brief-author.md`) — unchanged by this fix. The Archivist's own `<task-id>: archive` row was never actually homeless either: `vinaya archive` folds the Archivist's `Tokens: …` line into the same provenance comment it posts on the merged task PR (`roles/archivist.md` "The provenance block") — a different fact from the narrower Studio live-read caveat that same file still carries, which is about re-deriving ledger totals from a PR's own body/comments, not about where the row is written. What remains genuinely open: a Brief Author session that ends with no PR of any kind yet to write into — mid-authoring, before a plan PR exists — still has nowhere durable to report; it falls back to an ephemeral report to the Principal, and that fallback is not fixed here.
312
312
 
313
313
  ### The collection adapter AEG ships (one layer-2 instance, not the requirement)
314
314
 
@@ -322,7 +322,7 @@ AEG ships exactly one collection adapter, for the Claude Code host, exposed as `
322
322
 
323
323
  Vinaya Studio's tranche page no longer reads `<name>.tokens.md` off disk to render token totals — it re-derives the same row shape live off the forge: every MERGED PR on a task's own branch (`task/<tranche>/<id>`), parsing the Developer's "Token report" entries from the PR body (every one, including re-push entries) and the Reviewer's/Security's `Tokens: …` lines from that PR's comments (`@attalabs/aeg-core`'s token-report parser, `aggregateTaskTokenRows`, fetched by Studio's own forge-reading layer in the repo that hosts it). Same row shape, same `sumLedger` totals math (`parse-ledger.ts`) — different source.
324
324
 
325
- This is deliberately **narrower** than what the Archivist collects into the file: it cannot recover the Archivist's own `<task-id>: archive` row (no PR carries it — the file itself is that row's only record) or the Planner's `Tokens: planning …` report (no reliable way to attribute a plan PR to one task from the forge alone without false-positive cross-task matches, confirmed live during 4b's build — a task Issue's cross-reference timeline picks up ANY PR that merely mentions its number in passing prose, not just its own plan PR). Both remain recoverable only from `.tokens.md`, which is why the file is not deleted here (task 7's job, once the live mechanism is proven in wider use). A task's report that's missing or malformed (e.g. a "Token report" heading with no table and no parseable text after it) yields no row for that report, never a fabricated one — same discipline as the Archivist's own DANGLING convention.
325
+ This is deliberately **narrower** than what the Archivist collects into the file: it cannot recover the Planner's `Tokens: planning …` report (no reliable way to attribute a plan PR to one task from the forge alone without false-positive cross-task matches, confirmed live during 4b's build — a task Issue's cross-reference timeline picks up ANY PR that merely mentions its number in passing prose, not just its own plan PR). The Archivist's own `<task-id>: archive` row is not part of that gap: `vinaya archive` posts it inside the same provenance comment it posts on the merged task PR, so this mechanism recovers it exactly as it recovers every other role's row on that PR. The Planner's report remains recoverable only from `.tokens.md`, which is why the file is not deleted here (task 7's job, once the live mechanism is proven in wider use). A task's report that's missing or malformed (e.g. a "Token report" heading with no table and no parseable text after it) yields no row for that report, never a fabricated one — same discipline as the Archivist's own DANGLING convention.
326
326
 
327
327
  ### Anti-regression
328
328
 
@@ -1791,6 +1791,99 @@ var DERIVED_STATUSES = [
1791
1791
  "incoherent"
1792
1792
  ];
1793
1793
  var DERIVABLE_STATUSES = DERIVED_STATUSES.filter((s) => s !== "backlog");
1794
+ // ../../packages/aeg-core/src/parse-ledger.ts
1795
+ function rowFromCells(cells) {
1796
+ if (cells.length < 7)
1797
+ return null;
1798
+ const phase = (cells[0] ?? "").trim();
1799
+ if (!phase)
1800
+ return null;
1801
+ return {
1802
+ phase,
1803
+ role: (cells[1] ?? "").trim(),
1804
+ agentModel: (cells[2] ?? "").trim(),
1805
+ tokensIn: parseIntCell(cells[3] ?? ""),
1806
+ tokensOut: parseIntCell(cells[4] ?? ""),
1807
+ cost: parseCostCell(cells[5] ?? ""),
1808
+ date: (cells[6] ?? "").trim()
1809
+ };
1810
+ }
1811
+ function parseIntCell(cell) {
1812
+ const trimmed = cell.replace(/`/g, "").trim();
1813
+ if (!trimmed || isEmDashOrDash(trimmed))
1814
+ return null;
1815
+ const normalized = trimmed.replace(/[,_\s]/g, "");
1816
+ if (!/^-?\d+$/.test(normalized))
1817
+ return null;
1818
+ return Number(normalized);
1819
+ }
1820
+ function parseCostCell(cell) {
1821
+ const trimmed = cell.replace(/`/g, "").trim();
1822
+ if (!trimmed || isEmDashOrDash(trimmed))
1823
+ return null;
1824
+ const normalized = trimmed.replace(/^\$/, "").replace(/[,_\s]/g, "");
1825
+ if (!/^-?\d+(\.\d+)?$/.test(normalized))
1826
+ return null;
1827
+ return Number(normalized);
1828
+ }
1829
+ function isEmDashOrDash(s) {
1830
+ const t = s.trim();
1831
+ return t === "—" || t === "-" || t === "–";
1832
+ }
1833
+ // ../../packages/aeg-core/src/parse-token-report.ts
1834
+ var TOKEN_REPORT_HEADING = /^\s*(?:#{1,6}\s*Token report\s*|\*\*Token report\*\*)\s*$/i;
1835
+ var INLINE_FIELD_LIST = /Phase:\s*([^|]+?)\s*\|\s*Role:\s*([^|]+?)\s*\|\s*Agent\/Model:\s*([^|]+?)\s*\|\s*Tokens in:\s*([^|]+?)\s*\|\s*Tokens out:\s*([^|]+?)\s*\|\s*Cost:\s*([^|]+?)\s*\|\s*Date:\s*(.+?)\s*$/im;
1836
+ function parseTokenReportEntries(body) {
1837
+ const lines = body.split(/\r?\n/);
1838
+ const headingIdxs = [];
1839
+ lines.forEach((line, i) => {
1840
+ if (TOKEN_REPORT_HEADING.test(line))
1841
+ headingIdxs.push(i);
1842
+ });
1843
+ const out = [];
1844
+ for (let h = 0;h < headingIdxs.length; h++) {
1845
+ const start = headingIdxs[h] + 1;
1846
+ const end = h + 1 < headingIdxs.length ? headingIdxs[h + 1] : lines.length;
1847
+ const section = lines.slice(start, end).join(`
1848
+ `);
1849
+ const tableRows = parseTableSection(section);
1850
+ if (tableRows.length > 0) {
1851
+ out.push(...tableRows);
1852
+ continue;
1853
+ }
1854
+ const inlineRow = parseInlineFieldList(section);
1855
+ if (inlineRow)
1856
+ out.push(inlineRow);
1857
+ }
1858
+ return out;
1859
+ }
1860
+ function parseTableSection(section) {
1861
+ const lines = section.split(/\r?\n/);
1862
+ let i = 0;
1863
+ for (;i < lines.length; i++) {
1864
+ if ((lines[i] ?? "").trim().startsWith("|"))
1865
+ break;
1866
+ }
1867
+ if (i + 1 >= lines.length)
1868
+ return [];
1869
+ i += 2;
1870
+ const out = [];
1871
+ for (;i < lines.length; i++) {
1872
+ const trimmed = (lines[i] ?? "").trim();
1873
+ if (!trimmed.startsWith("|"))
1874
+ break;
1875
+ const row = rowFromCells(splitTableRow(trimmed));
1876
+ if (row)
1877
+ out.push(row);
1878
+ }
1879
+ return out;
1880
+ }
1881
+ function parseInlineFieldList(section) {
1882
+ const m = section.match(INLINE_FIELD_LIST);
1883
+ if (!m)
1884
+ return null;
1885
+ return rowFromCells([m[1], m[2], m[3], m[4], m[5], m[6], m[7]]);
1886
+ }
1794
1887
  // ../../packages/aeg-core/src/report-tokens.ts
1795
1888
  var DASH_LOOKALIKES = {
1796
1889
  "-": "‑",
@@ -1799,6 +1892,7 @@ var DASH_LOOKALIKES = {
1799
1892
  };
1800
1893
  var DASH_CHARS = new Set(Object.keys(DASH_LOOKALIKES));
1801
1894
  // ../../packages/aeg-core/src/claude-code-transcript.ts
1895
+ import { createHash } from "node:crypto";
1802
1896
  function summarizeTranscript(jsonl) {
1803
1897
  const seen = new Set;
1804
1898
  const components = {
@@ -1847,7 +1941,14 @@ function numberOr(value, fallback) {
1847
1941
  function sanitizeKey(value) {
1848
1942
  return value.replace(/[^A-Za-z0-9]+/g, "-");
1849
1943
  }
1944
+ function collisionResistantKey(value) {
1945
+ const digest = createHash("sha256").update(value).digest("hex");
1946
+ return `${sanitizeKey(value)}-${digest}`;
1947
+ }
1850
1948
  function transcriptPointerPath(projectDir, tmpDir) {
1949
+ return `${tmpDir}/claude-transcript-${collisionResistantKey(projectDir)}.txt`;
1950
+ }
1951
+ function legacyTranscriptPointerPath(projectDir, tmpDir) {
1851
1952
  return `${tmpDir}/claude-transcript-${sanitizeKey(projectDir)}.txt`;
1852
1953
  }
1853
1954
  function resolvePointer(explicitTranscriptPath, deps) {
@@ -1855,13 +1956,15 @@ function resolvePointer(explicitTranscriptPath, deps) {
1855
1956
  return { path: explicitTranscriptPath, corroborated: true };
1856
1957
  const projectDir = deps.env.CLAUDE_PROJECT_DIR ?? deps.cwd;
1857
1958
  const tmpDir = deps.env.TMPDIR ?? "/tmp";
1858
- const pointerPath = transcriptPointerPath(projectDir, tmpDir);
1959
+ const primaryPointerPath = transcriptPointerPath(projectDir, tmpDir);
1960
+ const legacyPointerPath = legacyTranscriptPointerPath(projectDir, tmpDir);
1859
1961
  const currentSessionId = deps.env.CLAUDE_CODE_SESSION_ID;
1860
- if (!deps.exists(pointerPath)) {
1962
+ const pointerPath = deps.exists(primaryPointerPath) ? primaryPointerPath : deps.exists(legacyPointerPath) ? legacyPointerPath : undefined;
1963
+ if (!pointerPath) {
1861
1964
  return {
1862
1965
  pointerExisted: false,
1863
1966
  corroborated: false,
1864
- error: `No transcript pointer at ${pointerPath} and no --transcript given. ` + "Either this repo installs no track-transcript.sh Stop hook (lacking one is not a defect — " + "name the transcript directly instead), or no session has completed a turn yet."
1967
+ error: `No transcript pointer at ${primaryPointerPath} (nor its pre-migration name ${legacyPointerPath}) ` + "and no --transcript given. Either this repo installs no track-transcript.sh Stop hook (lacking one " + "is not a defect — name the transcript directly instead), or no session has completed a turn yet."
1865
1968
  };
1866
1969
  }
1867
1970
  let contents;
@@ -1936,6 +2039,9 @@ function isTokenCollectionWiringBroken(capability) {
1936
2039
  return false;
1937
2040
  return capability.reason !== "no-transcript-resolved";
1938
2041
  }
2042
+ // ../../packages/aeg-core/src/metering-io-guard.ts
2043
+ import { closeSync, constants as fsConstants, fstatSync, openSync, readFileSync } from "node:fs";
2044
+ var GUARD_OPEN_FLAGS = fsConstants.O_RDONLY | fsConstants.O_NONBLOCK | fsConstants.O_NOFOLLOW;
1939
2045
  // ../../packages/aeg-core/src/file-classify.ts
1940
2046
  function isCodeFile(p) {
1941
2047
  if (p.endsWith(".md"))
@@ -2142,7 +2248,7 @@ function overrideActive(opts) {
2142
2248
  return false;
2143
2249
  }
2144
2250
  // ../../packages/aeg-core/src/premise-check.ts
2145
- import { createHash } from "node:crypto";
2251
+ import { createHash as createHash2 } from "node:crypto";
2146
2252
  var ASSERTION_KINDS = new Set(["contains", "absent", "sha256"]);
2147
2253
  function isPremiseHeader(line) {
2148
2254
  const stripped = line.replace(/[*#]/g, "").trim();
@@ -2187,7 +2293,7 @@ function checkPremises(assertions, fileReader) {
2187
2293
  } else if (a.kind === "absent" && content.includes(a.value)) {
2188
2294
  failures.push(`premise-check: ${a.path} now contains "${a.value}", but the brief pinned it absent — the premise has moved; re-dig before proceeding.`);
2189
2295
  } else if (a.kind === "sha256") {
2190
- const actual = createHash("sha256").update(content).digest("hex");
2296
+ const actual = createHash2("sha256").update(content).digest("hex");
2191
2297
  if (actual !== a.value.toLowerCase()) {
2192
2298
  failures.push(`premise-check: ${a.path} sha256 mismatch (brief pinned ${a.value}, file is now ${actual}) — the file changed since this brief was authored; re-dig before proceeding.`);
2193
2299
  }
@@ -3915,7 +4021,7 @@ function findWorkspaceEscapes(files, knownPaths, workspaceDirs = DEFAULT_WORKSPA
3915
4021
  }
3916
4022
  // src/lib/config.ts
3917
4023
  import { execFileSync as execFileSync2 } from "node:child_process";
3918
- import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
4024
+ import { existsSync, mkdirSync, readFileSync as readFileSync2, realpathSync, writeFileSync } from "node:fs";
3919
4025
  import { homedir } from "node:os";
3920
4026
  import { dirname, join, resolve } from "node:path";
3921
4027
  import { z } from "zod";
@@ -4208,7 +4314,7 @@ function loadConfig() {
4208
4314
  if (!path)
4209
4315
  return null;
4210
4316
  try {
4211
- const raw = JSON.parse(readFileSync(path, "utf-8"));
4317
+ const raw = JSON.parse(readFileSync2(path, "utf-8"));
4212
4318
  return stripGlobalOnlyKeys(VinayaConfigSchema.parse(raw), path);
4213
4319
  } catch {
4214
4320
  return null;
@@ -4410,8 +4516,8 @@ function blankTokenReportSection(body) {
4410
4516
  const lines = body.split(`
4411
4517
  `);
4412
4518
  const HEADING2 = /^#{1,6}\s/;
4413
- const TOKEN_REPORT_HEADING = /^#{1,6}\s*token report\s*$/i;
4414
- const startIdx = lines.findIndex((l) => TOKEN_REPORT_HEADING.test(l));
4519
+ const TOKEN_REPORT_HEADING2 = /^#{1,6}\s*token report\s*$/i;
4520
+ const startIdx = lines.findIndex((l) => TOKEN_REPORT_HEADING2.test(l));
4415
4521
  if (startIdx === -1)
4416
4522
  return body;
4417
4523
  let endIdx = lines.length;
@@ -1791,6 +1791,99 @@ var DERIVED_STATUSES = [
1791
1791
  "incoherent"
1792
1792
  ];
1793
1793
  var DERIVABLE_STATUSES = DERIVED_STATUSES.filter((s) => s !== "backlog");
1794
+ // ../../packages/aeg-core/src/parse-ledger.ts
1795
+ function rowFromCells(cells) {
1796
+ if (cells.length < 7)
1797
+ return null;
1798
+ const phase = (cells[0] ?? "").trim();
1799
+ if (!phase)
1800
+ return null;
1801
+ return {
1802
+ phase,
1803
+ role: (cells[1] ?? "").trim(),
1804
+ agentModel: (cells[2] ?? "").trim(),
1805
+ tokensIn: parseIntCell(cells[3] ?? ""),
1806
+ tokensOut: parseIntCell(cells[4] ?? ""),
1807
+ cost: parseCostCell(cells[5] ?? ""),
1808
+ date: (cells[6] ?? "").trim()
1809
+ };
1810
+ }
1811
+ function parseIntCell(cell) {
1812
+ const trimmed = cell.replace(/`/g, "").trim();
1813
+ if (!trimmed || isEmDashOrDash(trimmed))
1814
+ return null;
1815
+ const normalized = trimmed.replace(/[,_\s]/g, "");
1816
+ if (!/^-?\d+$/.test(normalized))
1817
+ return null;
1818
+ return Number(normalized);
1819
+ }
1820
+ function parseCostCell(cell) {
1821
+ const trimmed = cell.replace(/`/g, "").trim();
1822
+ if (!trimmed || isEmDashOrDash(trimmed))
1823
+ return null;
1824
+ const normalized = trimmed.replace(/^\$/, "").replace(/[,_\s]/g, "");
1825
+ if (!/^-?\d+(\.\d+)?$/.test(normalized))
1826
+ return null;
1827
+ return Number(normalized);
1828
+ }
1829
+ function isEmDashOrDash(s) {
1830
+ const t = s.trim();
1831
+ return t === "—" || t === "-" || t === "–";
1832
+ }
1833
+ // ../../packages/aeg-core/src/parse-token-report.ts
1834
+ var TOKEN_REPORT_HEADING = /^\s*(?:#{1,6}\s*Token report\s*|\*\*Token report\*\*)\s*$/i;
1835
+ var INLINE_FIELD_LIST = /Phase:\s*([^|]+?)\s*\|\s*Role:\s*([^|]+?)\s*\|\s*Agent\/Model:\s*([^|]+?)\s*\|\s*Tokens in:\s*([^|]+?)\s*\|\s*Tokens out:\s*([^|]+?)\s*\|\s*Cost:\s*([^|]+?)\s*\|\s*Date:\s*(.+?)\s*$/im;
1836
+ function parseTokenReportEntries(body) {
1837
+ const lines = body.split(/\r?\n/);
1838
+ const headingIdxs = [];
1839
+ lines.forEach((line, i) => {
1840
+ if (TOKEN_REPORT_HEADING.test(line))
1841
+ headingIdxs.push(i);
1842
+ });
1843
+ const out = [];
1844
+ for (let h = 0;h < headingIdxs.length; h++) {
1845
+ const start = headingIdxs[h] + 1;
1846
+ const end = h + 1 < headingIdxs.length ? headingIdxs[h + 1] : lines.length;
1847
+ const section = lines.slice(start, end).join(`
1848
+ `);
1849
+ const tableRows = parseTableSection(section);
1850
+ if (tableRows.length > 0) {
1851
+ out.push(...tableRows);
1852
+ continue;
1853
+ }
1854
+ const inlineRow = parseInlineFieldList(section);
1855
+ if (inlineRow)
1856
+ out.push(inlineRow);
1857
+ }
1858
+ return out;
1859
+ }
1860
+ function parseTableSection(section) {
1861
+ const lines = section.split(/\r?\n/);
1862
+ let i = 0;
1863
+ for (;i < lines.length; i++) {
1864
+ if ((lines[i] ?? "").trim().startsWith("|"))
1865
+ break;
1866
+ }
1867
+ if (i + 1 >= lines.length)
1868
+ return [];
1869
+ i += 2;
1870
+ const out = [];
1871
+ for (;i < lines.length; i++) {
1872
+ const trimmed = (lines[i] ?? "").trim();
1873
+ if (!trimmed.startsWith("|"))
1874
+ break;
1875
+ const row = rowFromCells(splitTableRow(trimmed));
1876
+ if (row)
1877
+ out.push(row);
1878
+ }
1879
+ return out;
1880
+ }
1881
+ function parseInlineFieldList(section) {
1882
+ const m = section.match(INLINE_FIELD_LIST);
1883
+ if (!m)
1884
+ return null;
1885
+ return rowFromCells([m[1], m[2], m[3], m[4], m[5], m[6], m[7]]);
1886
+ }
1794
1887
  // ../../packages/aeg-core/src/report-tokens.ts
1795
1888
  var DASH_LOOKALIKES = {
1796
1889
  "-": "‑",
@@ -1799,6 +1892,7 @@ var DASH_LOOKALIKES = {
1799
1892
  };
1800
1893
  var DASH_CHARS = new Set(Object.keys(DASH_LOOKALIKES));
1801
1894
  // ../../packages/aeg-core/src/claude-code-transcript.ts
1895
+ import { createHash } from "node:crypto";
1802
1896
  function summarizeTranscript(jsonl) {
1803
1897
  const seen = new Set;
1804
1898
  const components = {
@@ -1847,7 +1941,14 @@ function numberOr(value, fallback) {
1847
1941
  function sanitizeKey(value) {
1848
1942
  return value.replace(/[^A-Za-z0-9]+/g, "-");
1849
1943
  }
1944
+ function collisionResistantKey(value) {
1945
+ const digest = createHash("sha256").update(value).digest("hex");
1946
+ return `${sanitizeKey(value)}-${digest}`;
1947
+ }
1850
1948
  function transcriptPointerPath(projectDir, tmpDir) {
1949
+ return `${tmpDir}/claude-transcript-${collisionResistantKey(projectDir)}.txt`;
1950
+ }
1951
+ function legacyTranscriptPointerPath(projectDir, tmpDir) {
1851
1952
  return `${tmpDir}/claude-transcript-${sanitizeKey(projectDir)}.txt`;
1852
1953
  }
1853
1954
  function resolvePointer(explicitTranscriptPath, deps) {
@@ -1855,13 +1956,15 @@ function resolvePointer(explicitTranscriptPath, deps) {
1855
1956
  return { path: explicitTranscriptPath, corroborated: true };
1856
1957
  const projectDir = deps.env.CLAUDE_PROJECT_DIR ?? deps.cwd;
1857
1958
  const tmpDir = deps.env.TMPDIR ?? "/tmp";
1858
- const pointerPath = transcriptPointerPath(projectDir, tmpDir);
1959
+ const primaryPointerPath = transcriptPointerPath(projectDir, tmpDir);
1960
+ const legacyPointerPath = legacyTranscriptPointerPath(projectDir, tmpDir);
1859
1961
  const currentSessionId = deps.env.CLAUDE_CODE_SESSION_ID;
1860
- if (!deps.exists(pointerPath)) {
1962
+ const pointerPath = deps.exists(primaryPointerPath) ? primaryPointerPath : deps.exists(legacyPointerPath) ? legacyPointerPath : undefined;
1963
+ if (!pointerPath) {
1861
1964
  return {
1862
1965
  pointerExisted: false,
1863
1966
  corroborated: false,
1864
- error: `No transcript pointer at ${pointerPath} and no --transcript given. ` + "Either this repo installs no track-transcript.sh Stop hook (lacking one is not a defect — " + "name the transcript directly instead), or no session has completed a turn yet."
1967
+ error: `No transcript pointer at ${primaryPointerPath} (nor its pre-migration name ${legacyPointerPath}) ` + "and no --transcript given. Either this repo installs no track-transcript.sh Stop hook (lacking one " + "is not a defect — name the transcript directly instead), or no session has completed a turn yet."
1865
1968
  };
1866
1969
  }
1867
1970
  let contents;
@@ -1936,6 +2039,9 @@ function isTokenCollectionWiringBroken(capability) {
1936
2039
  return false;
1937
2040
  return capability.reason !== "no-transcript-resolved";
1938
2041
  }
2042
+ // ../../packages/aeg-core/src/metering-io-guard.ts
2043
+ import { closeSync, constants as fsConstants, fstatSync, openSync, readFileSync } from "node:fs";
2044
+ var GUARD_OPEN_FLAGS = fsConstants.O_RDONLY | fsConstants.O_NONBLOCK | fsConstants.O_NOFOLLOW;
1939
2045
  // ../../packages/aeg-core/src/file-classify.ts
1940
2046
  function isCodeFile(p) {
1941
2047
  if (p.endsWith(".md"))
@@ -2142,7 +2248,7 @@ function overrideActive(opts) {
2142
2248
  return false;
2143
2249
  }
2144
2250
  // ../../packages/aeg-core/src/premise-check.ts
2145
- import { createHash } from "node:crypto";
2251
+ import { createHash as createHash2 } from "node:crypto";
2146
2252
  var ASSERTION_KINDS = new Set(["contains", "absent", "sha256"]);
2147
2253
  function isPremiseHeader(line) {
2148
2254
  const stripped = line.replace(/[*#]/g, "").trim();
@@ -2187,7 +2293,7 @@ function checkPremises(assertions, fileReader) {
2187
2293
  } else if (a.kind === "absent" && content.includes(a.value)) {
2188
2294
  failures.push(`premise-check: ${a.path} now contains "${a.value}", but the brief pinned it absent — the premise has moved; re-dig before proceeding.`);
2189
2295
  } else if (a.kind === "sha256") {
2190
- const actual = createHash("sha256").update(content).digest("hex");
2296
+ const actual = createHash2("sha256").update(content).digest("hex");
2191
2297
  if (actual !== a.value.toLowerCase()) {
2192
2298
  failures.push(`premise-check: ${a.path} sha256 mismatch (brief pinned ${a.value}, file is now ${actual}) — the file changed since this brief was authored; re-dig before proceeding.`);
2193
2299
  }