@attalabs/vinaya 0.25.0 → 0.26.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 (59) hide show
  1. package/README.md +4 -4
  2. package/aeg-root/contracts/architect-planner.md +5 -5
  3. package/aeg-root/contracts/developer-reviewer.md +7 -7
  4. package/aeg-root/contracts/planner-developer.md +142 -0
  5. package/aeg-root/contracts/reviewer-archivist.md +2 -2
  6. package/aeg-root/contracts/tranche-archivist-planner.md +12 -11
  7. package/aeg-root/enforcement.md +15 -5
  8. package/aeg-root/process.md +53 -75
  9. package/aeg-root/roles/archivist.md +3 -3
  10. package/aeg-root/roles/developer.md +10 -10
  11. package/aeg-root/roles/planner.md +88 -23
  12. package/aeg-root/roles/principal.md +11 -11
  13. package/aeg-root/roles/reviewer.md +10 -10
  14. package/aeg-root/roles/security.md +7 -7
  15. package/aeg-root/roles/tranche-archivist.md +2 -2
  16. package/aeg-root/skills/aeg/SKILL.md +5 -5
  17. package/aeg-root/skills/aeg-roles/SKILL.md +7 -7
  18. package/aeg-root/state-machine.md +35 -34
  19. package/aeg-root/task-model.md +3 -3
  20. package/aeg-root/templates/brief-template.md +2 -2
  21. package/aeg-root/templates/issue-rationale-template.md +3 -3
  22. package/aeg-root/tranche-model.md +21 -21
  23. package/dist/checks/bin/check-body-bare-digits.js +116 -19
  24. package/dist/checks/bin/check-branch-topology.js +116 -19
  25. package/dist/checks/bin/check-brief-shape.js +121 -1101
  26. package/dist/checks/bin/check-changeset-coverage.js +877 -26
  27. package/dist/checks/bin/check-closes-n.js +116 -19
  28. package/dist/checks/bin/check-coherence.js +116 -19
  29. package/dist/checks/bin/check-dead-branch-push.js +116 -19
  30. package/dist/checks/bin/check-dispatch-readiness.js +119 -22
  31. package/dist/checks/bin/check-doc-coverage-push.js +877 -26
  32. package/dist/checks/bin/check-doc-coverage.js +879 -28
  33. package/dist/checks/bin/check-doctrine-no-procedures.js +116 -19
  34. package/dist/checks/bin/check-doctrine-portability.js +877 -26
  35. package/dist/checks/bin/check-evidence-fresh.js +116 -19
  36. package/dist/checks/bin/check-exec-bits.js +877 -26
  37. package/dist/checks/bin/check-first-push-dispatch.js +116 -19
  38. package/dist/checks/bin/check-issue-assignment.js +116 -19
  39. package/dist/checks/bin/check-main-branch-refusal.js +117 -20
  40. package/dist/checks/bin/check-no-disk-state.js +116 -19
  41. package/dist/checks/bin/check-pr-premise-reassert.js +5401 -0
  42. package/dist/checks/bin/check-pr-report-density.js +116 -19
  43. package/dist/checks/bin/check-quoted-command.js +875 -24
  44. package/dist/checks/bin/check-reader-resolvable-prose.js +875 -24
  45. package/dist/checks/bin/check-registry-gates.js +147 -21
  46. package/dist/checks/bin/check-retired-vocabulary.js +875 -24
  47. package/dist/checks/bin/check-review-gate.js +116 -19
  48. package/dist/checks/bin/check-single-plan-pr.js +116 -19
  49. package/dist/checks/bin/check-surface-scope.js +5722 -0
  50. package/dist/checks/bin/check-test-plan.js +116 -19
  51. package/dist/checks/bin/check-token-collection-wired.js +116 -19
  52. package/dist/checks/bin/check-token-report.js +128 -26
  53. package/dist/checks/bin/check-workspace-escape.js +875 -24
  54. package/dist/index.js +1002 -214
  55. package/package.json +1 -1
  56. package/aeg-root/contracts/brief-developer.md +0 -141
  57. package/aeg-root/contracts/planner-brief.md +0 -143
  58. package/aeg-root/roles/brief-author.md +0 -116
  59. package/aeg-root/skills/brief-authoring/SKILL.md +0 -509
@@ -29,7 +29,7 @@ Every fact in AEG lives in exactly **one** place. Nothing is duplicated; no arti
29
29
  | **The forge Issue** | Task identity + metadata (project label, ticket link, dependency/conflict references) | Planner (at plan time) |
30
30
  | **The thin tranche file** | Planning *topology* only — task→issue mapping, dependency graph, conflict graph, tranche grouping | Planner (at plan time) |
31
31
  | **The Git forge** (branch / PR / review / merge state) | All live execution *status* — derived, never stored | the act of working (opening a branch, a PR, a review, a merge) |
32
- | **The PR body** | The just-in-time brief — the task's full execution context | Brief Author, once, when work starts |
32
+ | **The task Issue's `aeg:brief:v1` comment** | The just-in-time brief — the task's full execution context | The Planner's dispatch act, once, when work starts (mechanically rendered, never hand-typed) |
33
33
 
34
34
  The cardinal rule, stated once and enforced everywhere below: **the forge holds what is happening; the file and the issue hold the plan. Never copy "what is happening" into the file or the issue.**
35
35
 
@@ -39,14 +39,14 @@ The cardinal rule, stated once and enforced everywhere below: **the forge holds
39
39
 
40
40
  The roadmap — what to build, why, in what priority — belongs to the company and lives in the company's tool (Jira, Linear, a doc they own). **AEG never holds it.** The moment AEG stores a roadmap it competes with Jira, loses, and creates a second rotting source of truth.
41
41
 
42
- What AEG holds is the **tranche**: the bounded set of tasks currently being turned into merged code. The link from roadmap → tranche is a **human** — the Planner / Brief Author translating tickets into agent-shaped tasks. There is no file for that link, because the link is a person's judgment.
42
+ What AEG holds is the **tranche**: the bounded set of tasks currently being turned into merged code. The link from roadmap → tranche is a **human** — the Planner translating tickets into agent-shaped tasks. There is no file for that link, because the link is a person's judgment.
43
43
 
44
44
  ```
45
45
  Company roadmap / Jira / project backlog ← NOT in AEG. Reference only. The human reads it.
46
- │ (human translation — Planner / Brief Author / Planner)
46
+ │ (human translation — Planner, plan act)
47
47
 
48
48
  Tranche = a set of forge Issues + a thin topology file ← TOP of AEG.
49
- ├─ Task (Issue) ── brief written just-in-timelands in its PR body
49
+ ├─ Task (Issue) ── brief rendered mechanically at dispatch posted as the Issue's frozen comment
50
50
  ├─ Task (Issue)
51
51
  └─ … edges (depends-on / conflicts-with) declared in the thin file
52
52
 
@@ -88,7 +88,7 @@ So: there is **no status column anywhere.** The Developer does not "flip to in-r
88
88
 
89
89
  The file held **only** what the forge models poorly: the task→Issue mapping and the dependency/conflict graph. It contains **no status, no PR numbers, no merge dates, no timestamps — nothing the forge already knows. It contains no task prose, no boundary descriptions, no rationale — nothing that belongs on the Issue.** Its task topology was edited only by the Planner, at plan time, so it could not race and could not drift on status (it stored none). The same rule now binds the Milestone and its Issues: the Planner cuts them, and nothing downstream writes status back. The one exception is the tranche's own **lifecycle marker** (active/complete — §12), a single header line the Archivist sets at close-out; this is the tranche's lifecycle, not per-task execution status, and it is set once when the whole tranche ends.
90
90
 
91
- **`#TBD` is still forbidden, wherever a task is recorded.** Every task must carry a real forge Issue number. A tranche that contains `#TBD` is an incomplete plan — the Planner has not cut the Issues, which is the canonical plan act. **The Planner's rationale (Boundary, Sizing, Project(s)+blast radius, Dependency rationale, Traps to avoid, Suggested agent-class, Stop-and-escalate) lives on the Issue body.** Nothing outside the Issue repeats the rationale. Brief Authors read it from the Issue, which is now its only home.
91
+ **`#TBD` is still forbidden, wherever a task is recorded.** Every task must carry a real forge Issue number. A tranche that contains `#TBD` is an incomplete plan — the Planner has not cut the Issues, which is the canonical plan act. **The Planner's rationale (Boundary, Sizing, Project(s)+blast radius, Dependency rationale, Traps to avoid, Suggested agent-class, Stop-and-escalate) lives on the Issue body.** Nothing outside the Issue repeats the rationale. The dispatch act's render reads it from the Issue, which is now its only home.
92
92
 
93
93
  `dependsOn`/`conflictsWith` for every active tranche is now genuinely forge-derived, no file fallback anywhere. `completed/*.md` files are never deleted, by design (§11) — the birth rule never applied to them.
94
94
 
@@ -99,7 +99,7 @@ Template:
99
99
  Lifecycle: active ← active | complete (§12). Set to complete by the Archivist when every task is merged.
100
100
 
101
101
  Goal (execution, not roadmap-why): <what ships, end to end>
102
- Repo: <repo> · Planner / Brief Author: <name>
102
+ Repo: <repo> · Planner: <name>
103
103
 
104
104
  ## Tasks (topology)
105
105
  | # | Task | Issue | Project(s) | Depends-on | Conflicts-with |
@@ -143,7 +143,7 @@ Two tasks conflict if they touch the same **collision domain** and therefore mus
143
143
 
144
144
  ## 6. The Planner
145
145
 
146
- The **Planner** is a mode of the Planner / Brief Author — same intelligence as Brief Author, one altitude up. Brief Author: intentone brief. Planner: intent + a slice of tickets → a whole tranche (a set of Issues + the thin topology file).
146
+ The **Planner** has two acts, same intelligence, two altitudes (`roles/planner.md`). Dispatch act: one task's Issue a rendered, gate-checked brief. Plan act: intent + a slice of tickets → a whole tranche (a set of Issues + the thin topology file).
147
147
 
148
148
  The Planner's job — the reason the tranche exists — is the relationships a brief-in-isolation can't see: decompose the ticket slice into agent-sized tasks (Issues), declare `depends-on` and `conflicts-with` edges, and decide **split vs. combine** by the **verification-coupling** test:
149
149
 
@@ -156,14 +156,14 @@ The Planner writes no briefs (those are just-in-time, §7), writes no status (th
156
156
 
157
157
  ## 7. Where briefs live
158
158
 
159
- The brief is the task's full execution context. It has two homes:
159
+ The brief is the task's full execution context. It has one home, and it is not the PR:
160
160
 
161
- - **Before work starts — nowhere persistent.** It does not exist yet. It is written (human + Brief Author) when the task is picked up, tranche-aware. Pasted, not committed. **Never in the Issue**the Issue is task identity + metadata only; a brief in the Issue would age, attract edits, and become stale planning documentation. (The Issue *does* carry the Planner's rationale — durable conclusions — which the Brief Author consumes via the `planner-brief` contract; the brief itself is the perishable execution detail and lives only in the PR.)
162
- - **From PR-open onward — the PR body.** The Developer pastes the brief into the PR description when opening the PR. That is its permanent, durable home, attached to exactly the work it governed, and what the Reviewer and Archivist read.
161
+ - **Before dispatch — nowhere persistent.** It does not exist yet. The task Issue carries the Planner's rationale (durable conclusions, written at plan time), never the brief itself — a brief in the Issue's own body would age, attract edits, and become stale planning documentation.
162
+ - **At dispatch — the task Issue's `aeg:brief:v1` comment, posted once, frozen.** The Planner's dispatch act runs `vinaya task dispatch`, which mechanically renders the brief from the Issue's rationale and judgment sections (consuming the `planner-developer` contract) and posts it as a comment on that same Issue, before the Developer's worktree exists. That comment is the brief's permanent, durable home, attached to exactly the work it governs, and what the Developer, Reviewer, and Archivist all read. It is never carried in the PR body, which holds only the Developer's report; a reference copy may ride along inside a collapsed `<details>` block there, but the frozen comment is the source of truth.
163
163
 
164
- Retry reuses the same PR body; no rewrite.
164
+ Retry reads the same frozen comment; no re-render, no rewrite.
165
165
 
166
- For who reads and who writes documentation at each seam of this flow (Planner's whole-tranche read, Brief Author's task-scoped re-read + §7, Developer's execution, Reviewer's dual check, Archivist's confirmation), see `documentation-coherence.md`.
166
+ For who reads and who writes documentation at each seam of this flow (the Planner's whole-tranche read at plan time, its task-scoped re-read + §7 at dispatch time, Developer's execution, Reviewer's dual check, Archivist's confirmation), see `documentation-coherence.md`.
167
167
 
168
168
  ---
169
169
 
@@ -176,7 +176,7 @@ Two gates make parallel developers safe — both **forge-answerable, zero stored
176
176
 
177
177
  With a single principal these rules live in one head; with a team they must be a **lock**, not a whiteboard. In manual mode they are preconditions each Developer checks against the forge before beginning. A dispatch tool can enforce them in code. Either way the conflict is declared at planning time and enforced at dispatch time — never discovered at merge time, which is too late.
178
178
 
179
- **v1 honesty:** in manual mode the gates are *trusted, not enforced* — read and complied with, but nothing mechanically stops a human ignoring them. Acceptable for a small, watched team. Mechanical enforcement arrives when a dispatch tool runs the gates. Until then: trusted discipline.
179
+ **v1 honesty:** before Step 0, checking these gates is still *trusted, not enforced* — the Developer reads and complies, but nothing mechanically stops starting anyway. By the first push, though, they are mechanically enforced: the `.vinaya/hooks/pre-push` `first-push-dispatch` check runs the same `checkDispatchReadiness` derivation the `dispatch-readiness` check uses, on a task branch's first push, and refuses it outright if the dependency's PR isn't merged or the conflicting sibling's PR is open (it fails open only when the forge itself is unreachable). It shares that check's narrower parity, though — its prior-tranche-archival predicate always reports empty, the same gap `roles/developer.md` documents, so it does not run the full `verify-dispatch.ts` derivation. What remains trusted discipline is catching it earlier, before any work is done, with the unabridged derivation — the hook is the backstop, not the first line.
180
180
 
181
181
  ---
182
182
 
@@ -187,7 +187,7 @@ The review panel predicted, unanimously, two of the ways teams will accidentally
187
187
  1. **No execution metadata in the thin file or the Issue.** Never add `status`, `PR #`, `merged date`, `current state`, `assignee history`, or generated collision data to the tranche file. The reason is always reasonable ("just to glance without querying") and it is always wrong — the forge already holds these, and copying them in recreates the racing, drifting, lying status store. **Thin file = topology. Forge = state.** The line is bright; keep it bright. (The tranche's own active/complete lifecycle marker in §12 is **not** an exception to this: it is the tranche's lifecycle set once at close-out, not per-task execution status, and the forge has no native fact for "this whole tranche is done.")
188
188
  2. **No dynamic conflict scanner.** Do not build a script that checks out in-flight branches and diffs them to "catch conflicts the Planner missed." It cannot work without a live task→changed-files map — the mutable state we removed. When unsure two tasks collide, **declare the conflict and serialize** (§5). Conservative declaration is the sanctioned answer; a scanner is not.
189
189
  3. **No planning metadata on Issues.** No priority, estimates, points, or roadmap fields. Enforced mechanically: a required Issue template (deps, conflicts, project label, ticket link — and nothing else) + a CI check that rejects forbidden fields/labels. Discipline alone will not hold this; the *place to put planning info is removed*, not just discouraged.
190
- 4. **No committed report/scratch files.** Never commit a new repo file whose sole purpose is a one-off report, audit finding, coverage summary, or working brief. The reason is always reasonable ("it's a big deliverable, it deserves its own file," "there's no prior convention, I'll set one") and it is always wrong — that content belongs in the PR body (task-scoped findings) or an Issue/PR comment (findings with no task PR of their own), exactly like a brief's permanent home is the PR body, never the Issue or a repo file (§7). A committed scratch file recreates the racing, drifting problem the other three rules already forbid, one layer up: it is a fifth truth domain nobody asked for, competing with the forge for where "what happened" lives. This is not hypothetical — it has already happened twice: a 120-row audit deliverable committed as `aeg-root/tranches/<name>.audit.md` broke AEG Studio's tranche loader (which globs every `.md` file in this directory as a tranche), and a full task brief was committed as a permanent file under `aeg-project/briefs/`, contradicting §7's own rule that a brief is pasted, not committed. **Thin file = topology. Forge = state. PR body / Issue comment = findings and briefs.** Sanctioned exceptions: durable reference artifacts (specs, skills, role docs, contracts) and the `.tokens.md` sibling ledgers (§12) — these are pre-existing, separately-governed, durable-by-design; a one-off report is neither.
190
+ 4. **No committed report/scratch files.** Never commit a new repo file whose sole purpose is a one-off report, audit finding, coverage summary, or working brief. The reason is always reasonable ("it's a big deliverable, it deserves its own file," "there's no prior convention, I'll set one") and it is always wrong — that content belongs in the PR body (task-scoped findings) or an Issue/PR comment (findings with no task PR of their own), exactly like a brief's permanent home is a frozen Issue comment, never the Issue body or a repo file (§7). A committed scratch file recreates the racing, drifting problem the other three rules already forbid, one layer up: it is a fifth truth domain nobody asked for, competing with the forge for where "what happened" lives. This is not hypothetical — it has already happened twice: a 120-row audit deliverable committed as `aeg-root/tranches/<name>.audit.md` broke AEG Studio's tranche loader (which globs every `.md` file in this directory as a tranche), and a full task brief was committed as a permanent file under `aeg-project/briefs/`, contradicting §7's own rule that a brief is pasted, not committed. **Thin file = topology. Forge = state. PR body / Issue comment = findings and briefs.** Sanctioned exceptions: durable reference artifacts (specs, skills, role docs, contracts) and the `.tokens.md` sibling ledgers (§12) — these are pre-existing, separately-governed, durable-by-design; a one-off report is neither.
191
191
 
192
192
  ---
193
193
 
@@ -213,7 +213,7 @@ The earlier sections describe a single tranche's *internals*. This section cover
213
213
  - **planned** — the thin file exists and the Issues are cut, but no work has started. Every task is `todo` (open, unassigned — committed tranche work, minimum `todo`). The tranche is a plan ready to execute.
214
214
  - **active** — at least one task has an open branch (`in-flight`) or is further along. The tranche is in flight. `Lifecycle: active` in the header.
215
215
  - **complete** — **every task's PR is merged** (every task derives to `merged` from the forge). The work is done. At this point — and only this point — the **Archivist** sets `Lifecycle: complete` in the header (one line; the single lifecycle mutation the file ever takes after plan time) and assembles the per-task provenance blocks on the merged PRs. "Complete" is itself **derived** from the forge (all linked PRs merged); the header marker is a convenience flag the Archivist writes once, not a status anyone maintains.
216
- - **archived** — a complete tranche's file **moves to `aeg-root/tranches/completed/<name>.md`**. It is **not deleted.**
216
+ - **archived** — for a forge-native tranche, the Tranche Archivist **closes the Milestone**; that closed Milestone is the current signal a new tranche's readiness gate reads (`contracts/tranche-archivist-planner.md`). **Legacy exception:** a tranche still carrying a pre-cutover topology file also has that file **moved to `aeg-root/tranches/completed/<name>.md`**, kept (not deleted) for tranches created before the forge-native cutover.
217
217
 
218
218
  ### Flow stages — what actually happens, in order
219
219
 
@@ -221,7 +221,7 @@ The lifecycle above is the derived-status vocabulary — what Studio reads off t
221
221
 
222
222
  1. **Plan** — the Planner turns an intent plus a slice of tickets into the tranche's tasks (Issues) and their `depends-on`/`conflicts-with` edges (§6). No Milestone required.
223
223
  2. **Dispatch** — each task runs its own Task flow (`task-model.md` §3: Brief → Code → Review → Verify → Merge → Archive), independently, in parallel wherever `depends-on` allows and the conflict rule (§5) doesn't force a serialization. Deciding *when* each task actually starts is a real act, not an implicit one: today the Principal or a thin dispatch script — this file's own opening note names that actor for every altitude — tomorrow the Atta Engine's scheduler.
224
- 3. **Archive** — once every task has merged, the Tranche Archivist closes out: sets the lifecycle marker, moves the file to `completed/`, flags (does not perform) orphaned branches and worktree removal.
224
+ 3. **Archive** — once every task has merged, the Tranche Archivist closes out: closes the Milestone (the legacy exception additionally sets the lifecycle marker and moves the file to `completed/`), flags (does not perform) orphaned branches and worktree removal.
225
225
 
226
226
  ### Tranches are never deleted — they are durable history
227
227
 
@@ -266,13 +266,13 @@ Append-only. Each row records one role's turn at a phase. Re-entry appends a **n
266
266
  | Phase | Role | Agent/Model | Tokens in | Tokens out | Cost | Date |
267
267
  |-------|------|-------------|-----------|------------|------|------|
268
268
  | planning | Planner | claude-opus-4-7 (chat) | — | — | — | 2026-06-13 |
269
- | 9: brief | Brief Author | claude-opus-4-7 (chat) | — | — | — | 2026-06-15 |
269
+ | 9: dispatch | Planner | claude-opus-4-7 (chat) | — | — | — | 2026-06-15 |
270
270
  | 9: develop | Developer | claude-opus-4-7 (CC) | 184327 | 12502 | $3.4781 | 2026-06-15 |
271
271
  | 9: review | Reviewer | claude-opus-4-7 (chat) | — | — | — | 2026-06-15 |
272
272
  ```
273
273
 
274
274
  - **Phase** — free-text. Convention: `<task-id>: <phase>` for per-task work (e.g. `9: develop`, `9: review`), or a bare phase for tranche-wide work (e.g. `planning`). Phase is opaque to the parser; the convention exists so a future view can pivot by task.
275
- - **Role** — the AEG role doing the work (`Planner`, `Brief Author`, `Developer`, `Reviewer`, `Security`, `Archivist`).
275
+ - **Role** — the AEG role doing the work (`Planner`, `Developer`, `Reviewer`, `Security`, `Archivist`).
276
276
  - **Agent/Model** — free text: the role's agent + model, with a short tag for the host surface in parentheses (`claude-opus-4-7 (CC)` for a coding-agent session, `claude-opus-4-7 (chat)` for a conversational one — the tags the archived ledgers already use). Nothing parses the tag; it is there because the surface is what tells a reader which collection capability applied (below), and therefore whether a `—` cell was sanctioned.
277
277
  - **Tokens in / Tokens out** — integers from the meter, or `—` for "not yet known."
278
278
  - **Cost** — USD as `$X.XXXX`, or `—`. *(V1 honesty: no maintained $/token pricing table for current models ships in this package, so `formatTokensLine` renders this cell as `—` on every row it emits. Historical rows may carry a `$X.XXXX` value supplied by hand or by an earlier table. Tokens are still exact; pricing is a known backlog dependency, not a ledger bug.)*
@@ -280,7 +280,7 @@ Append-only. Each row records one role's turn at a phase. Re-entry appends a **n
280
280
 
281
281
  ### The append rule (read this exactly the way you read derived status)
282
282
 
283
- - **No role appends its own row on a task branch.** Two live-fire incidents forced this: read-only roles (Reviewer, Security, Planner, Brief Author) structurally cannot append — they hold no task branch, and some never touch the repo's filesystem at all; and parallel Developer sessions on different tasks collided appending to the same shared `tokens.md` file. Instead, every role **reports** its token spend in the artifact its turn already produces — the PR body ("Token report" section) for a role holding a branch, the verdict comment for a reviewing role, the plan PR or planning report for the Planner — and the per-task **Archivist appends every row at task close-out**, one row per role-turn, including its own.
283
+ - **No role appends its own row on a task branch.** Two live-fire incidents forced this: read-only roles (Reviewer, Security, Planner) structurally cannot append — they hold no task branch, and some never touch the repo's filesystem at all; and parallel Developer sessions on different tasks collided appending to the same shared `tokens.md` file. Instead, every role **reports** its token spend in the artifact its turn already produces — the PR body ("Token report" section) for a role holding a branch, the verdict comment for a reviewing role, the plan PR or planning report for the Planner (either act) — and the per-task **Archivist appends every row at task close-out**, one row per role-turn, including its own.
284
284
  - **Never edit** an existing row. If you discover a mistake, append a new row that supersedes it in prose (or fix the source file in a separate, declared edit — same exception that `state-machine.md` §13 carves for forward-reference fields).
285
285
  - **Re-entry appends.** A Developer dispatched for `9: develop`, asked for changes, and re-running for `9: develop` again produces a **second** `9: develop` report, which the Archivist appends as a **second** row — never a sum, never an overwrite. The two rows both count.
286
286
  - The **tranche total is `sum(rows)`**, derived at read time, never stored. This is the same philosophy as forge-derived status (don't store the aggregate; sum the immutable entries). A stored total reintroduces the merge-collision + stale-aggregate problem.
@@ -302,13 +302,13 @@ Token reporting is asymmetric — and any honest design has to encode that, beca
302
302
  The split is **operator vs. agent, not terminal vs. chat** — a figure a human reads off an interactive prompt or a usage dashboard is not reachable from an unattended agent session (dispatched, automated, no human at the keyboard), whatever kind of surface it runs on. (2026-08-08: retracted — an earlier revision of this section claimed a terminal role reports exact numbers "from `/cost`", an operator-typed slash command in one particular host; no agent session ever could, in that host or any other.)
303
303
 
304
304
  - **Self-metering** — a role whose host exposes the running session's own usage to the agent itself, so the role can collect real figures with no operator step. Typically the Developer, and the Archivist when it runs as automation. The role collects through its host's layer-2 mechanism and reports the exact numbers in its PR body; the Archivist copies them verbatim into the row it appends at close-out. **A self-metering role may not report `—` in the `Tokens in` or `Tokens out` cells** — real figures, or the collection step is broken and *that* is what to report. The `Cost` cell is outside this rule: it reads `—` on every row the shipped renderer emits, for the pricing-table reason given above.
305
- - **Operator-metered** — a role whose host exposes no usage to the agent, leaving a human the only source of the figure. Typically the Planner, Brief Author, Reviewer and Security, which run in conversational surfaces with no session record the agent can read and no meter it can query. The role **still reports at turn-end** — phase, role, model, date, in its verdict comment or planning report — and leaves the numeric cells as `—` when no figure reached it. The blank is licensed by the **absence of a figure**, not by the role's label: where an operator hands the role real numbers at report time (a route layer 2 explicitly allows), those numbers go in the cells and `—` is not correct. The Archivist copies the report as-is; the **Principal** may later supply the real figures from whatever usage view the host does offer a human, filling a previously-`—` cell (the one narrow forward-reference exception `state-machine.md` §13 allows).
305
+ - **Operator-metered** — a role whose host exposes no usage to the agent, leaving a human the only source of the figure. Typically the Planner (either act), Reviewer and Security, which run in conversational surfaces with no session record the agent can read and no meter it can query. The role **still reports at turn-end** — phase, role, model, date, in its verdict comment or planning report — and leaves the numeric cells as `—` when no figure reached it. The blank is licensed by the **absence of a figure**, not by the role's label: where an operator hands the role real numbers at report time (a route layer 2 explicitly allows), those numbers go in the cells and `—` is not correct. The Archivist copies the report as-is; the **Principal** may later supply the real figures from whatever usage view the host does offer a human, filling a previously-`—` cell (the one narrow forward-reference exception `state-machine.md` §13 allows).
306
306
 
307
307
  **Per-cell optionality is conditioned on host capability, never on convenience.** `—` in a **token** cell is sanctioned in exactly one situation: the host cannot expose the figure to the agent. (The **Cost** cell is separate and is always `—` while this package carries no maintained $/token table — see the Cost bullet above.) It is never a shortcut for a role that could have collected the number, and no role ever estimates a figure or fills in another role's cell — an invented number in an append-only ledger is worse than an honest unknown, because nothing downstream can tell the two apart.
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 — 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.
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 plan-act 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 Planner dispatch-act session already has a destination when a plan PR exists — the same plan PR the plan act reports into — 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 dispatch-act session that ends with no PR of any kind yet to write into — mid-dispatch, 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
 
@@ -27,6 +27,15 @@ var GATE_AUDIENCE = {
27
27
  internal: "A composite that shells this repo's own `typecheck`/`lint`/`test`/`build` scripts by name. Those names are this monorepo's toolchain (bun + turbo + biome), not an adopter's; the shipped equivalent of its intent is `vinaya check --all`, which is portable by construction."
28
28
  }
29
29
  };
30
+ var NON_GATE_BINS = [
31
+ "archive-task",
32
+ "assign-task-issue",
33
+ "dead-branch-audit",
34
+ "eval-agent-compliance",
35
+ "open-issue",
36
+ "open-pr",
37
+ "report-tokens"
38
+ ];
30
39
  function isShipped(a) {
31
40
  return "shippedAs" in a;
32
41
  }
@@ -387,7 +396,7 @@ var LABELS = [
387
396
  id: "vinaya/needs:brief-correction",
388
397
  category: "needs",
389
398
  form: "literal",
390
- carries: "Waiting on the Brief Author — the brief contradicts the surface it describes."
399
+ carries: "Waiting on the Planner — the brief contradicts the surface it describes."
391
400
  },
392
401
  {
393
402
  key: "waiver-docs",
@@ -2394,7 +2403,7 @@ function objectivesOf(body) {
2394
2403
  continue;
2395
2404
  }
2396
2405
  if (hasBacktickedPath(text) && wordCount(stripObjectiveBackticks(text)) < MIN_WORDS_OUTSIDE_BACKTICKS) {
2397
- errors.push(`O${n} is little more than a file path — an objective states an observable outcome, never a bare path (the Brief Author maps it to files).`);
2406
+ errors.push(`O${n} is little more than a file path — an objective states an observable outcome, never a bare path (the Planner maps it to files).`);
2398
2407
  continue;
2399
2408
  }
2400
2409
  objectives.push({ id: `O${n}`, text });
@@ -2606,18 +2615,14 @@ function checkTestPlanExclusivity(prBody) {
2606
2615
  function checkPrincipalPlaceholder(prBody) {
2607
2616
  const region = testPlanRegion(prBody);
2608
2617
  const lineRe = /^-\s*\[[ xX]\]\s*\*{2}\[principal\]\*{2}(.*)$/gim;
2618
+ const errors = [];
2609
2619
  for (const m of region.matchAll(lineRe)) {
2610
2620
  const content = m[1] ?? "";
2611
2621
  if (/^\s*None\b/i.test(content)) {
2612
- return {
2613
- status: "fail",
2614
- errors: [
2615
- 'brief-validation Test Plan shape: a `**[principal]**` checkbox item is a "None" placeholder — if there is no principal-runnable surface, omit the item entirely; an untickable placeholder box blocks the merge gate forever.'
2616
- ]
2617
- };
2622
+ errors.push(`brief-validation Test Plan shape: a \`**[principal]**\` checkbox item is a "None" placeholder ("${content.trim()}") — if there is no principal-runnable surface, omit the item entirely; an untickable placeholder box blocks the merge gate forever.`);
2618
2623
  }
2619
2624
  }
2620
- return { status: "pass", errors: [] };
2625
+ return errors.length > 0 ? { status: "fail", errors } : { status: "pass", errors: [] };
2621
2626
  }
2622
2627
  function checkSurfaceMap(prBody) {
2623
2628
  return headingCheck(prBody, "(?:technical\\s+)?surface map", "Technical surface map");
@@ -2933,6 +2938,18 @@ function checkBriefSections(prBody, readTier, options = {}) {
2933
2938
  ];
2934
2939
  return { errors: results.flatMap((r) => r.errors) };
2935
2940
  }
2941
+ var AEG_BRIEF_V1_MARKER = "<!-- aeg:brief:v1 -->";
2942
+ function contentAfterTwoLines(body) {
2943
+ const first = body.indexOf(`
2944
+ `);
2945
+ if (first === -1)
2946
+ return "";
2947
+ const second = body.indexOf(`
2948
+ `, first + 1);
2949
+ if (second === -1)
2950
+ return "";
2951
+ return body.slice(second + 1);
2952
+ }
2936
2953
  // ../../packages/aeg-core/src/doctrine-portability.ts
2937
2954
  var DEFAULT_SHIPS_PREFIX = "aeg-root/";
2938
2955
  var STATIC_PORTABLE_PREFIXES = [
@@ -3175,6 +3192,66 @@ function checkIssueObjectives(body, issueNumber) {
3175
3192
  return { status: "pass", errors: [] };
3176
3193
  return { status: "fail", errors: result.errors.map((e) => `issue-validation objectives: ${e}`) };
3177
3194
  }
3195
+ function topLevelSectionText(body, headingName) {
3196
+ const headingRe = new RegExp(`^##[ \\t]*${headingName}[ \\t]*$`, "im");
3197
+ const heading = headingRe.exec(body);
3198
+ if (!heading)
3199
+ return null;
3200
+ const afterHeading = body.slice(heading.index + heading[0].length);
3201
+ const next = /^##[ \t]/m.exec(afterHeading);
3202
+ return next ? afterHeading.slice(0, next.index) : afterHeading;
3203
+ }
3204
+ function looksLikeFilePath(entry2) {
3205
+ const stripped = entry2.replace(/\/\*\*?$/, "");
3206
+ const lastSegment = stripped.split("/").pop() ?? stripped;
3207
+ const extMatch = /\.[A-Za-z0-9]{1,6}$/.exec(lastSegment);
3208
+ return extMatch !== null && extMatch.index > 0;
3209
+ }
3210
+ function splitGlobList(raw) {
3211
+ return raw.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
3212
+ }
3213
+ function parseIssueSurface(body) {
3214
+ const section = topLevelSectionText(body, "Surface");
3215
+ if (section === null)
3216
+ return { ok: false, errors: ["no `## Surface` heading found in the body."] };
3217
+ const inLine = /^in:\s*(.+)$/im.exec(section);
3218
+ const outLine = /^out:\s*(.+)$/im.exec(section);
3219
+ const errors = [];
3220
+ if (!inLine)
3221
+ errors.push("`## Surface` has no `in:` line.");
3222
+ if (!outLine)
3223
+ errors.push("`## Surface` has no `out:` line.");
3224
+ if (errors.length > 0)
3225
+ return { ok: false, errors };
3226
+ const inGlobs = splitGlobList(inLine[1]);
3227
+ const outGlobs = splitGlobList(outLine[1]);
3228
+ for (const g of [...inGlobs, ...outGlobs]) {
3229
+ if (looksLikeFilePath(g)) {
3230
+ errors.push(`\`${g}\` in \`## Surface\` looks like a file path — Surface entries are directory-level globs, never file paths.`);
3231
+ }
3232
+ }
3233
+ if (inGlobs.length === 0)
3234
+ errors.push("`## Surface`'s `in:` line resolved to zero globs.");
3235
+ if (errors.length > 0)
3236
+ return { ok: false, errors };
3237
+ return { ok: true, value: { in: inGlobs, out: outGlobs } };
3238
+ }
3239
+ function globCoversPath(glob, path) {
3240
+ const g = glob.replace(/\/\*\*?$/, "").replace(/\/+$/, "");
3241
+ const p = path.replace(/\/+$/, "");
3242
+ return g === p || g.startsWith(`${p}/`) || p.startsWith(`${g}/`);
3243
+ }
3244
+ function checkSurfaceScope(changedFiles, outGlobs) {
3245
+ if (outGlobs.length === 0)
3246
+ return { ok: true };
3247
+ const violations = [];
3248
+ for (const file of changedFiles) {
3249
+ const glob = outGlobs.find((g) => globCoversPath(g, file));
3250
+ if (glob)
3251
+ violations.push({ file, glob });
3252
+ }
3253
+ return violations.length > 0 ? { ok: false, violations } : { ok: true };
3254
+ }
3178
3255
  function isTaskIssueLabelSet(labels) {
3179
3256
  return hasLabel("tranche", labels);
3180
3257
  }
@@ -3239,6 +3316,8 @@ function checkProjectsRegistered(body, _labels, registeredNames) {
3239
3316
  }
3240
3317
  return errors.length > 0 ? { status: "fail", errors } : { status: "pass", errors: [] };
3241
3318
  }
3319
+ var DOC_PATH_RE = /(?:(?:aeg-root|apps|packages|specs|docs|tools|\.claude|\.github)\/[\w./@-]*\.(?:md|mdx)|\.claude\/(?:skills|rules)\/[\w./-]+|\b(?:docs-index|decisions|projects|state-machine|enforcement|process|README|CLAUDE)\.md\b|\b[\w-]+-(?:spec|decisions|backlog)\.md\b)/i;
3320
+ var DOC_PATH_RE_GLOBAL = new RegExp(DOC_PATH_RE.source, "gi");
3242
3321
 
3243
3322
  // ../../packages/aeg-core/src/coherence-checks.ts
3244
3323
  var COHERENCE_ENFORCED_FROM = "2026-07-01";
@@ -3768,16 +3847,33 @@ function checkG1(rows, existsFn) {
3768
3847
  return { check: "G1", status: findings.length > 0 ? "fail" : "pass", findings };
3769
3848
  }
3770
3849
  var SCAFFOLD_PLACEHOLDER = "[undocumented — fill in why]";
3850
+ var NON_GATE_BIN_NAMES = NON_GATE_BINS;
3851
+ var TWIN_CANDIDATE_EXCEPTIONS = {
3852
+ "apps/cli/src/checks/bin/check-dead-branch-push.ts": 'Ring 0 "Pushing to a branch whose pull request already resolved" (implementation: check-push-target.ts)',
3853
+ "apps/cli/src/checks/bin/check-issue-assignment.ts": `Ring 0 "A task branch's first push (Issue self-assignment)" (implementation: assign-task-issue.ts)`,
3854
+ "apps/cli/src/checks/bin/check-closes-n.ts": 'Ring 1 "Closes linkage" (implementation: verify-coherence.ts)'
3855
+ };
3856
+ var NON_GATE_HOOK_SCRIPTS = [".claude/hooks/track-transcript.sh"];
3771
3857
  function checkG2(rows, candidateFiles) {
3772
3858
  const implementations = new Set(rows.map((r) => r.implementation).filter((p) => p !== ""));
3859
+ const claimedNames = new Set(rows.flatMap((r) => claimedCheckNames(r.implementation)));
3773
3860
  const findings = [];
3774
3861
  for (const path of candidateFiles) {
3775
- if (!implementations.has(path)) {
3776
- findings.push({
3777
- path,
3778
- reason: `"${path}" is not named as the implementation of any row in enforcement.md's ring tables`
3779
- });
3780
- }
3862
+ if (implementations.has(path))
3863
+ continue;
3864
+ if (path.startsWith(AEG_CORE_BIN_PATH_PREFIX) && NON_GATE_BIN_NAMES.includes(basenameNoExt(path)))
3865
+ continue;
3866
+ if (NON_GATE_HOOK_SCRIPTS.includes(path))
3867
+ continue;
3868
+ if (path in TWIN_CANDIDATE_EXCEPTIONS)
3869
+ continue;
3870
+ const candidateNames = claimedCheckNames(path);
3871
+ if (candidateNames.length > 0 && candidateNames.some((n) => claimedNames.has(n)))
3872
+ continue;
3873
+ findings.push({
3874
+ path,
3875
+ reason: `"${path}" is not named as the implementation of any row in enforcement.md's ring tables`
3876
+ });
3781
3877
  }
3782
3878
  for (const row of rows) {
3783
3879
  const carriesPlaceholder = [row.summary, row.description, row.spec].some((cell) => cell === SCAFFOLD_PLACEHOLDER);
@@ -3789,7 +3885,7 @@ function checkG2(rows, candidateFiles) {
3789
3885
  });
3790
3886
  }
3791
3887
  }
3792
- return { check: "G2", status: findings.length > 0 ? "info" : "pass", findings };
3888
+ return { check: "G2", status: findings.length > 0 ? "fail" : "pass", findings };
3793
3889
  }
3794
3890
  function checkG3(ring0Rows, crossingFiles) {
3795
3891
  const ring0Implementations = new Set(ring0Rows.map((r) => r.implementation));
@@ -4665,7 +4761,6 @@ function findWorkspaceEscapes(files, knownPaths, workspaceDirs = DEFAULT_WORKSPA
4665
4761
  import { z } from "zod";
4666
4762
  var ROLE_VALUES = [
4667
4763
  "planner",
4668
- "brief-author",
4669
4764
  "developer",
4670
4765
  "code-reviewer",
4671
4766
  "security",
@@ -4739,18 +4834,20 @@ var dispatchShared = {
4739
4834
  round: z.number().int().optional(),
4740
4835
  effect_id: z.string()
4741
4836
  };
4837
+ var dispatchUsageField = z.object({ input: z.number().nonnegative(), output: z.number().nonnegative() }).strict().nullable();
4742
4838
  var DispatchEventSchema = z.discriminatedUnion("event", [
4743
4839
  z.object({ ...dispatchShared, event: z.literal("dispatched"), prompt_hash: z.string() }).strict(),
4744
4840
  z.object({
4745
4841
  ...dispatchShared,
4746
4842
  event: z.literal("outcome_received"),
4747
4843
  outcome: DispatchOutcomeSchema,
4748
- usage: z.object({ input: z.number().nonnegative(), output: z.number().nonnegative() }).strict().nullable()
4844
+ usage: dispatchUsageField
4749
4845
  }).strict(),
4750
4846
  z.object({
4751
4847
  ...dispatchShared,
4752
4848
  event: z.literal("dispatch_failed"),
4753
- reason: z.enum(["timeout", "crash", "refused", "unattributed_write"])
4849
+ reason: z.enum(["timeout", "crash", "refused", "unattributed_write"]),
4850
+ usage: dispatchUsageField
4754
4851
  }).strict()
4755
4852
  ]);
4756
4853
  var loopShared = {
@@ -27,6 +27,15 @@ var GATE_AUDIENCE = {
27
27
  internal: "A composite that shells this repo's own `typecheck`/`lint`/`test`/`build` scripts by name. Those names are this monorepo's toolchain (bun + turbo + biome), not an adopter's; the shipped equivalent of its intent is `vinaya check --all`, which is portable by construction."
28
28
  }
29
29
  };
30
+ var NON_GATE_BINS = [
31
+ "archive-task",
32
+ "assign-task-issue",
33
+ "dead-branch-audit",
34
+ "eval-agent-compliance",
35
+ "open-issue",
36
+ "open-pr",
37
+ "report-tokens"
38
+ ];
30
39
  function isShipped(a) {
31
40
  return "shippedAs" in a;
32
41
  }
@@ -387,7 +396,7 @@ var LABELS = [
387
396
  id: "vinaya/needs:brief-correction",
388
397
  category: "needs",
389
398
  form: "literal",
390
- carries: "Waiting on the Brief Author — the brief contradicts the surface it describes."
399
+ carries: "Waiting on the Planner — the brief contradicts the surface it describes."
391
400
  },
392
401
  {
393
402
  key: "waiver-docs",
@@ -2394,7 +2403,7 @@ function objectivesOf(body) {
2394
2403
  continue;
2395
2404
  }
2396
2405
  if (hasBacktickedPath(text) && wordCount(stripObjectiveBackticks(text)) < MIN_WORDS_OUTSIDE_BACKTICKS) {
2397
- errors.push(`O${n} is little more than a file path — an objective states an observable outcome, never a bare path (the Brief Author maps it to files).`);
2406
+ errors.push(`O${n} is little more than a file path — an objective states an observable outcome, never a bare path (the Planner maps it to files).`);
2398
2407
  continue;
2399
2408
  }
2400
2409
  objectives.push({ id: `O${n}`, text });
@@ -2606,18 +2615,14 @@ function checkTestPlanExclusivity(prBody) {
2606
2615
  function checkPrincipalPlaceholder(prBody) {
2607
2616
  const region = testPlanRegion(prBody);
2608
2617
  const lineRe = /^-\s*\[[ xX]\]\s*\*{2}\[principal\]\*{2}(.*)$/gim;
2618
+ const errors = [];
2609
2619
  for (const m of region.matchAll(lineRe)) {
2610
2620
  const content = m[1] ?? "";
2611
2621
  if (/^\s*None\b/i.test(content)) {
2612
- return {
2613
- status: "fail",
2614
- errors: [
2615
- 'brief-validation Test Plan shape: a `**[principal]**` checkbox item is a "None" placeholder — if there is no principal-runnable surface, omit the item entirely; an untickable placeholder box blocks the merge gate forever.'
2616
- ]
2617
- };
2622
+ errors.push(`brief-validation Test Plan shape: a \`**[principal]**\` checkbox item is a "None" placeholder ("${content.trim()}") — if there is no principal-runnable surface, omit the item entirely; an untickable placeholder box blocks the merge gate forever.`);
2618
2623
  }
2619
2624
  }
2620
- return { status: "pass", errors: [] };
2625
+ return errors.length > 0 ? { status: "fail", errors } : { status: "pass", errors: [] };
2621
2626
  }
2622
2627
  function checkSurfaceMap(prBody) {
2623
2628
  return headingCheck(prBody, "(?:technical\\s+)?surface map", "Technical surface map");
@@ -2933,6 +2938,18 @@ function checkBriefSections(prBody, readTier, options = {}) {
2933
2938
  ];
2934
2939
  return { errors: results.flatMap((r) => r.errors) };
2935
2940
  }
2941
+ var AEG_BRIEF_V1_MARKER = "<!-- aeg:brief:v1 -->";
2942
+ function contentAfterTwoLines(body) {
2943
+ const first = body.indexOf(`
2944
+ `);
2945
+ if (first === -1)
2946
+ return "";
2947
+ const second = body.indexOf(`
2948
+ `, first + 1);
2949
+ if (second === -1)
2950
+ return "";
2951
+ return body.slice(second + 1);
2952
+ }
2936
2953
  // ../../packages/aeg-core/src/doctrine-portability.ts
2937
2954
  var DEFAULT_SHIPS_PREFIX = "aeg-root/";
2938
2955
  var STATIC_PORTABLE_PREFIXES = [
@@ -3175,6 +3192,66 @@ function checkIssueObjectives(body, issueNumber) {
3175
3192
  return { status: "pass", errors: [] };
3176
3193
  return { status: "fail", errors: result.errors.map((e) => `issue-validation objectives: ${e}`) };
3177
3194
  }
3195
+ function topLevelSectionText(body, headingName) {
3196
+ const headingRe = new RegExp(`^##[ \\t]*${headingName}[ \\t]*$`, "im");
3197
+ const heading = headingRe.exec(body);
3198
+ if (!heading)
3199
+ return null;
3200
+ const afterHeading = body.slice(heading.index + heading[0].length);
3201
+ const next = /^##[ \t]/m.exec(afterHeading);
3202
+ return next ? afterHeading.slice(0, next.index) : afterHeading;
3203
+ }
3204
+ function looksLikeFilePath(entry2) {
3205
+ const stripped = entry2.replace(/\/\*\*?$/, "");
3206
+ const lastSegment = stripped.split("/").pop() ?? stripped;
3207
+ const extMatch = /\.[A-Za-z0-9]{1,6}$/.exec(lastSegment);
3208
+ return extMatch !== null && extMatch.index > 0;
3209
+ }
3210
+ function splitGlobList(raw) {
3211
+ return raw.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
3212
+ }
3213
+ function parseIssueSurface(body) {
3214
+ const section = topLevelSectionText(body, "Surface");
3215
+ if (section === null)
3216
+ return { ok: false, errors: ["no `## Surface` heading found in the body."] };
3217
+ const inLine = /^in:\s*(.+)$/im.exec(section);
3218
+ const outLine = /^out:\s*(.+)$/im.exec(section);
3219
+ const errors = [];
3220
+ if (!inLine)
3221
+ errors.push("`## Surface` has no `in:` line.");
3222
+ if (!outLine)
3223
+ errors.push("`## Surface` has no `out:` line.");
3224
+ if (errors.length > 0)
3225
+ return { ok: false, errors };
3226
+ const inGlobs = splitGlobList(inLine[1]);
3227
+ const outGlobs = splitGlobList(outLine[1]);
3228
+ for (const g of [...inGlobs, ...outGlobs]) {
3229
+ if (looksLikeFilePath(g)) {
3230
+ errors.push(`\`${g}\` in \`## Surface\` looks like a file path — Surface entries are directory-level globs, never file paths.`);
3231
+ }
3232
+ }
3233
+ if (inGlobs.length === 0)
3234
+ errors.push("`## Surface`'s `in:` line resolved to zero globs.");
3235
+ if (errors.length > 0)
3236
+ return { ok: false, errors };
3237
+ return { ok: true, value: { in: inGlobs, out: outGlobs } };
3238
+ }
3239
+ function globCoversPath(glob, path) {
3240
+ const g = glob.replace(/\/\*\*?$/, "").replace(/\/+$/, "");
3241
+ const p = path.replace(/\/+$/, "");
3242
+ return g === p || g.startsWith(`${p}/`) || p.startsWith(`${g}/`);
3243
+ }
3244
+ function checkSurfaceScope(changedFiles, outGlobs) {
3245
+ if (outGlobs.length === 0)
3246
+ return { ok: true };
3247
+ const violations = [];
3248
+ for (const file of changedFiles) {
3249
+ const glob = outGlobs.find((g) => globCoversPath(g, file));
3250
+ if (glob)
3251
+ violations.push({ file, glob });
3252
+ }
3253
+ return violations.length > 0 ? { ok: false, violations } : { ok: true };
3254
+ }
3178
3255
  function isTaskIssueLabelSet(labels) {
3179
3256
  return hasLabel("tranche", labels);
3180
3257
  }
@@ -3239,6 +3316,8 @@ function checkProjectsRegistered(body, _labels, registeredNames) {
3239
3316
  }
3240
3317
  return errors.length > 0 ? { status: "fail", errors } : { status: "pass", errors: [] };
3241
3318
  }
3319
+ var DOC_PATH_RE = /(?:(?:aeg-root|apps|packages|specs|docs|tools|\.claude|\.github)\/[\w./@-]*\.(?:md|mdx)|\.claude\/(?:skills|rules)\/[\w./-]+|\b(?:docs-index|decisions|projects|state-machine|enforcement|process|README|CLAUDE)\.md\b|\b[\w-]+-(?:spec|decisions|backlog)\.md\b)/i;
3320
+ var DOC_PATH_RE_GLOBAL = new RegExp(DOC_PATH_RE.source, "gi");
3242
3321
 
3243
3322
  // ../../packages/aeg-core/src/coherence-checks.ts
3244
3323
  var COHERENCE_ENFORCED_FROM = "2026-07-01";
@@ -3768,16 +3847,33 @@ function checkG1(rows, existsFn) {
3768
3847
  return { check: "G1", status: findings.length > 0 ? "fail" : "pass", findings };
3769
3848
  }
3770
3849
  var SCAFFOLD_PLACEHOLDER = "[undocumented — fill in why]";
3850
+ var NON_GATE_BIN_NAMES = NON_GATE_BINS;
3851
+ var TWIN_CANDIDATE_EXCEPTIONS = {
3852
+ "apps/cli/src/checks/bin/check-dead-branch-push.ts": 'Ring 0 "Pushing to a branch whose pull request already resolved" (implementation: check-push-target.ts)',
3853
+ "apps/cli/src/checks/bin/check-issue-assignment.ts": `Ring 0 "A task branch's first push (Issue self-assignment)" (implementation: assign-task-issue.ts)`,
3854
+ "apps/cli/src/checks/bin/check-closes-n.ts": 'Ring 1 "Closes linkage" (implementation: verify-coherence.ts)'
3855
+ };
3856
+ var NON_GATE_HOOK_SCRIPTS = [".claude/hooks/track-transcript.sh"];
3771
3857
  function checkG2(rows, candidateFiles) {
3772
3858
  const implementations = new Set(rows.map((r) => r.implementation).filter((p) => p !== ""));
3859
+ const claimedNames = new Set(rows.flatMap((r) => claimedCheckNames(r.implementation)));
3773
3860
  const findings = [];
3774
3861
  for (const path of candidateFiles) {
3775
- if (!implementations.has(path)) {
3776
- findings.push({
3777
- path,
3778
- reason: `"${path}" is not named as the implementation of any row in enforcement.md's ring tables`
3779
- });
3780
- }
3862
+ if (implementations.has(path))
3863
+ continue;
3864
+ if (path.startsWith(AEG_CORE_BIN_PATH_PREFIX) && NON_GATE_BIN_NAMES.includes(basenameNoExt(path)))
3865
+ continue;
3866
+ if (NON_GATE_HOOK_SCRIPTS.includes(path))
3867
+ continue;
3868
+ if (path in TWIN_CANDIDATE_EXCEPTIONS)
3869
+ continue;
3870
+ const candidateNames = claimedCheckNames(path);
3871
+ if (candidateNames.length > 0 && candidateNames.some((n) => claimedNames.has(n)))
3872
+ continue;
3873
+ findings.push({
3874
+ path,
3875
+ reason: `"${path}" is not named as the implementation of any row in enforcement.md's ring tables`
3876
+ });
3781
3877
  }
3782
3878
  for (const row of rows) {
3783
3879
  const carriesPlaceholder = [row.summary, row.description, row.spec].some((cell) => cell === SCAFFOLD_PLACEHOLDER);
@@ -3789,7 +3885,7 @@ function checkG2(rows, candidateFiles) {
3789
3885
  });
3790
3886
  }
3791
3887
  }
3792
- return { check: "G2", status: findings.length > 0 ? "info" : "pass", findings };
3888
+ return { check: "G2", status: findings.length > 0 ? "fail" : "pass", findings };
3793
3889
  }
3794
3890
  function checkG3(ring0Rows, crossingFiles) {
3795
3891
  const ring0Implementations = new Set(ring0Rows.map((r) => r.implementation));
@@ -4665,7 +4761,6 @@ function findWorkspaceEscapes(files, knownPaths, workspaceDirs = DEFAULT_WORKSPA
4665
4761
  import { z } from "zod";
4666
4762
  var ROLE_VALUES = [
4667
4763
  "planner",
4668
- "brief-author",
4669
4764
  "developer",
4670
4765
  "code-reviewer",
4671
4766
  "security",
@@ -4739,18 +4834,20 @@ var dispatchShared = {
4739
4834
  round: z.number().int().optional(),
4740
4835
  effect_id: z.string()
4741
4836
  };
4837
+ var dispatchUsageField = z.object({ input: z.number().nonnegative(), output: z.number().nonnegative() }).strict().nullable();
4742
4838
  var DispatchEventSchema = z.discriminatedUnion("event", [
4743
4839
  z.object({ ...dispatchShared, event: z.literal("dispatched"), prompt_hash: z.string() }).strict(),
4744
4840
  z.object({
4745
4841
  ...dispatchShared,
4746
4842
  event: z.literal("outcome_received"),
4747
4843
  outcome: DispatchOutcomeSchema,
4748
- usage: z.object({ input: z.number().nonnegative(), output: z.number().nonnegative() }).strict().nullable()
4844
+ usage: dispatchUsageField
4749
4845
  }).strict(),
4750
4846
  z.object({
4751
4847
  ...dispatchShared,
4752
4848
  event: z.literal("dispatch_failed"),
4753
- reason: z.enum(["timeout", "crash", "refused", "unattributed_write"])
4849
+ reason: z.enum(["timeout", "crash", "refused", "unattributed_write"]),
4850
+ usage: dispatchUsageField
4754
4851
  }).strict()
4755
4852
  ]);
4756
4853
  var loopShared = {