@attalabs/vinaya 0.26.0 → 0.28.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 (50) hide show
  1. package/README.md +6 -3
  2. package/aeg-root/enforcement.md +5 -5
  3. package/aeg-root/milestone-model.md +2 -0
  4. package/aeg-root/process.md +1 -1
  5. package/aeg-root/roles/developer.md +23 -17
  6. package/aeg-root/roles/planner.md +4 -0
  7. package/aeg-root/roles/principal.md +8 -2
  8. package/aeg-root/roles/reviewer.md +9 -5
  9. package/aeg-root/roles/security.md +9 -5
  10. package/dist/checks/bin/check-body-bare-digits.js +1038 -257
  11. package/dist/checks/bin/check-branch-topology.js +963 -199
  12. package/dist/checks/bin/check-brief-shape.js +1276 -203
  13. package/dist/checks/bin/check-changeset-coverage.js +1677 -281
  14. package/dist/checks/bin/check-closes-n.js +967 -203
  15. package/dist/checks/bin/check-coherence.js +1127 -284
  16. package/dist/checks/bin/check-dead-branch-push.js +910 -193
  17. package/dist/checks/bin/check-dispatch-readiness.js +1189 -295
  18. package/dist/checks/bin/check-doc-coverage-push.js +1673 -277
  19. package/dist/checks/bin/check-doc-coverage.js +1675 -279
  20. package/dist/checks/bin/check-doctrine-no-procedures.js +1020 -257
  21. package/dist/checks/bin/check-doctrine-portability.js +1672 -276
  22. package/dist/checks/bin/check-evidence-fresh.js +2526 -322
  23. package/dist/checks/bin/check-exec-bits.js +1670 -274
  24. package/dist/checks/bin/check-first-push-dispatch.js +1075 -265
  25. package/dist/checks/bin/check-issue-assignment.js +965 -201
  26. package/dist/checks/bin/check-issue-milestone-attach.js +5734 -0
  27. package/dist/checks/bin/check-issue-objectives-numbering.js +5736 -0
  28. package/dist/checks/bin/check-issue-parts-coverage.js +5736 -0
  29. package/dist/checks/bin/check-issue-surface-globs.js +6910 -0
  30. package/dist/checks/bin/check-issue-title-grammar.js +5736 -0
  31. package/dist/checks/bin/check-issue-tranche-label.js +5736 -0
  32. package/dist/checks/bin/check-main-branch-refusal.js +910 -193
  33. package/dist/checks/bin/check-no-disk-state.js +910 -193
  34. package/dist/checks/bin/check-pr-premise-reassert.js +1020 -257
  35. package/dist/checks/bin/check-pr-report-density.js +910 -193
  36. package/dist/checks/bin/check-quoted-command.js +1651 -274
  37. package/dist/checks/bin/check-reader-resolvable-prose.js +1655 -278
  38. package/dist/checks/bin/check-registry-gates.js +997 -193
  39. package/dist/checks/bin/check-retired-vocabulary.js +1649 -272
  40. package/dist/checks/bin/check-review-gate.js +1163 -316
  41. package/dist/checks/bin/check-single-plan-pr.js +910 -193
  42. package/dist/checks/bin/check-surface-scope.js +973 -201
  43. package/dist/checks/bin/check-test-plan.js +939 -206
  44. package/dist/checks/bin/check-token-collection-wired.js +910 -193
  45. package/dist/checks/bin/check-token-report.js +910 -193
  46. package/dist/checks/bin/check-workspace-escape.js +1668 -272
  47. package/dist/index.js +10602 -5352
  48. package/dist/lib/pre-push-changed-files.js +57 -0
  49. package/dist/lib/pre-push-select-tests.js +1473 -0
  50. package/package.json +4 -2
package/README.md CHANGED
@@ -26,7 +26,10 @@ npx @attalabs/vinaya init # or: pnpm dlx / yarn dlx / bunx
26
26
  | `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. |
27
27
  | `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 |
28
28
  | `vinaya brief render <tranche> <n> --surfaces <glob,...> [--out <path>]` | Emit the twelve-section brief skeleton from the task Issue and the tree, with every mechanically-derivable section filled: the header `Project:`/`Tier:`/`Closes #N`, the Step 0 worktree line, the dispatch-gate status as the pre-flight line, §4's file list (with consumer packages and a `sha256` premise pin per file, and Out of surface from the Issue's own `## Surface` `out:` list), §6 from the Issue's `## Parts`, §7 from the `.vinaya/doc-owners` derivation, §9 from the Issue's `## Test plan`, §10 from the Issue's `## Stop conditions` plus the rationale's Stop-and-escalate field, and every remaining section from the Issue's eight-field Planner rationale. Refuses, naming the missing section, when a section cannot be derived: no Issue, the dispatch gate not clear, a `--surfaces` glob matching no tracked file, or the Issue missing/malformed `## Surface`/`## Parts`/`## Test plan`/`## Stop conditions` — never a bracketed placeholder. Never writes under `aeg-root/` or to the Issue — stdout, or `--out <path>`, only; a brief is pasted to the Developer, never committed. |
29
- | `vinaya task dispatch <tranche> <n> [--agent claude \| codex \| gemini] [--model <name>]` | Render the same brief `brief render` renders, then post it once as a frozen Issue comment (first line `<!-- aeg:brief:v1 -->`, second line `Brief hash: <sha256>` over everything below it) — refusing outright, naming the existing comment's URL, if the Issue already carries one. With `--agent`, starts the Developer through `dispatchRole` (`apps/cli/src/lib/dispatch.ts`) when that function is available; otherwise prints the rendered brief and the manual dispatch instruction and exits `0` — a soft dependency, never a hard block. `--model <name>` names the model to run, passed through to `dispatchRole` and always taking precedence over any class-derived resolution; without it, the task's own Issue rationale is read for its "Suggested agent-class" (`high`/`mid`/`fast`) and resolved through the chosen vendor's own class-to-model table, falling through to that vendor's default when neither resolves. Principal-only, with or without `--agent`: refuses before any render, forge read, or post when the authenticated `gh` identity is not on the Principal allowlist. |
29
+ | `vinaya task brief <tranche> <n>` \| `vinaya task brief --issue <n>` | Preparation only: render the same brief `brief render` renders, then post it once as a frozen Issue comment (first line `<!-- aeg:brief:v1 -->`, second line `Brief hash: <sha256>` over everything below it) — refusing outright, naming the existing comment's URL, if the Issue already carries one. `--issue <n>` names a backlog Issue directly one carrying no `vinaya/tranche:*` label and renders its brief from that Issue's own `## Objectives`/`## Surface`/`## Parts`/`## Test plan`/`## Stop conditions` sections, no tranche or Milestone involved. Starts no agent under any circumstances there is no `--agent` flag. Principal-only: refuses before any render, forge read, or post when the authenticated `gh` identity is not on the Principal allowlist. The successor to `task dispatch` for the preparation step; `task run` is the full unattended run. |
30
+ | `vinaya task dispatch <tranche> <n> [--agent claude \| codex \| gemini] [--model <name>]` | **Deprecated** — prefer `task brief` (preparation only) or `task run` (the full unattended loop). Renders the same brief `brief render` renders, then posts it once as a frozen Issue comment (first line `<!-- aeg:brief:v1 -->`, second line `Brief hash: <sha256>` over everything below it) — refusing outright, naming the existing comment's URL, if the Issue already carries one. With `--agent`, starts the Developer through `dispatchRole` (`apps/cli/src/lib/dispatch.ts`) when that function is available; otherwise prints the rendered brief and the manual dispatch instruction and exits `0` — a soft dependency, never a hard block. `--model <name>` names the model to run, passed through to `dispatchRole` and always taking precedence over any class-derived resolution; without it, the task's own Issue rationale is read for its "Suggested agent-class" (`high`/`mid`/`fast`) and resolved through the chosen vendor's own class-to-model table, falling through to that vendor's default when neither resolves. Principal-only, with or without `--agent`: refuses before any render, forge read, or post when the authenticated `gh` identity is not on the Principal allowlist. |
31
+ | `vinaya task run <tranche> <n> --agent <claude \| codex \| gemini>` \| `vinaya task run --issue <n> --agent …` | One command from a planned Issue to a reviewed pull request, exactly one developer started. `--issue <n>` runs a backlog Issue — no `vinaya/tranche:*` label, no Milestone — through the identical unattended path on branch `task/issue-<n>`, same brief, same loop, same gate. Composes `task brief`'s own preparation (starts no agent) with `dev-review-loop` (whose own round 1 reads the frozen brief off the Issue and is the only place a developer is ever dispatched from a fresh task) — nothing else. A brief already frozen on the Issue is reused, not re-posted; a task whose Issue refuses preparation is refused before any agent starts, with nothing posted; a frozen brief whose developer branch already has an open pull request refuses a second start. Exit `0` with the PR URL on a published, reviewed pull request; exit `1` with the exact `vinaya dev-review-loop --resume <pr>` command on a pause; exit `2` on a usage/argv error (an unrecognized flag, a missing `--agent`); exit `3` on any other failure (a refused preparation, an open-PR refusal) — never sharing `1` with a pause, so an unattended host tells the two apart from the exit code alone. The loop's own existing `--resume` flag is how a paused run continues, never a flag on this command. **Known gap:** a backlog Issue's run state (`running`/`paused`/`published`) does not yet appear on Vinaya Studio's backlog page — that reader lives in the separate `apps/vinaya-studio` checkout (attalabs monorepo, out of this repository's own Surface) and has not been updated for the `task/issue-<n>` shape; `vinaya task status --issue <n>` is the reader in the meantime. |
32
+ | `vinaya task status [<tranche> <n>] [--json]` | Every open task Issue carrying a frozen brief, its pull request, and whether its dev-review-loop is `running` (naming the driver pid, `review-validity-v1` task 7's pid record), `paused` (naming the reason from the pause record), `published` (the newest round's reviewer and security verdict effect markers both read `posted`), or has `no driver` — read from the outbox (`<outboxRoot>/dev-review-loop/<task>/`) and the forge, never from a `ps` scan and never by re-parsing posted verdict comments. Read-only: no write, no change to any loop. `vinaya task status <tranche> <n>` narrows to one task and adds the last round's held or published verdict lines plus the exact `vinaya dev-review-loop --resume <pr>` command when paused. `--json` for the enveloped machine form (schema: 1). `vinaya task status <tranche> <n> --follow` (and `--issue <n> --follow` for a backlog Issue) tails that task's per-driver log (`~/.vinaya/loops/<owner>-<repo>/<issue>.log`) live, `tail -f` style — every driver (`task run`, `dev-review-loop`) tees its own role-prefixed stream there regardless of where it was launched, so the state of any run is one command away with no path to remember. |
30
33
  | `vinaya pr create --body-file <path> --title <title> [--label ...] [--validate-only] [--json]` | Open a pull request after full brief-schema validation of the body against `vinaya.config.json`'s configured sections — never a body containing the retired `<!-- aeg:brief:start -->`/`<!-- aeg:brief:end -->` markers, refused outright: the brief lives on the task Issue's `aeg:brief:v1` comment now (`vinaya task dispatch`), never split out of or pasted into the PR body. `--validate-only` runs every gate and reports PASS without opening anything; `--json` for the enveloped machine form. |
31
34
  | `vinaya issue objectives edit <n> --add "<sentence>" \| --drop O<k> \| --replace O<k> "<sentence>" --reason "<text>"` | Rewrite a task Issue's `## Objectives` section by command — the Principal's way to change a task's scope mid-flight, findable and versioned rather than a silent hand-edit. Exactly one of `--add`/`--drop`/`--replace` plus a non-empty `--reason` is required; the edit runs through the same validated `issue edit` write path (`writeValidatedIssueEdit`) as `vinaya issue edit` itself. `--drop` never renumbers the survivors — a drop that leaves the list non-contiguous from `O1` is refused with `objectivesOf`'s own parser message, since task 1's contiguous-from-O1 grammar and this command's never-renumber rule can genuinely contradict on a real drop, and that contradiction is reported rather than papered over. Splices the rendered section back in place, leaving every other byte of the body untouched, then posts one comment marked `<!-- aeg:objectives:v<k> -->` carrying the previous list, the new list, the reason, and the new version hash — `k` counted on the forge at post time, never from a local file. |
32
35
  | `vinaya log flush --issue <n> \| --pr <n>` | Post a target Issue or PR's outbox (`~/.vinaya/outbox/<owner>-<repo>/<issue-or-none>.ndjson`, written by `log()`) as one or more comments, each opening with `<!-- aeg:log:<run_id>:<seq_from>-<seq_to> -->` followed by a fenced `ndjson` block. Splits first at `run_id` boundaries — a maximal run of consecutive lines sharing one `run_id`, so an interleaved outbox never produces a range spanning a gap — then at `FORGE_COMMENT_MAX_CHARS` (65536); a single line too large to fit alone is refused by its seq, never split. `--pr <n>` resolves the Issue from that PR's body `Closes #N` line and posts on the PR instead of the Issue; a body with none is refused. Logs its own `forge_write` line (`validated` before posting, `written` with every comment id after the last post succeeds, or `refused` with gh's error) through `log()` into the same outbox before truncating, so that line rides to the next flush; the outbox is truncated only to the lines confirmed posted, and a gh failure leaves the rest untouched. `--json` for the enveloped machine form. |
@@ -43,8 +46,8 @@ npx @attalabs/vinaya init # or: pnpm dlx / yarn dlx / bunx
43
46
  | `vinaya milestone close --slug <slug>` | Close a tranche's Milestone — the gated replacement for the raw `gh api .../milestones/<n> -X PATCH -f state=closed` recipe the Tranche Archivist used to run on faith. Resolves the target Milestone the same legacy-or-intent-declared way Issue create auto-attach does, then refuses to close on any mismatch between the label's Issues and the Milestone's natively attached Issues — naming each unattached or foreign Issue and its repair path (`gh issue edit <n> --milestone <title>`, or `vinaya milestone adopt`) — before the PATCH ever reaches the forge. `--validate-only` verifies attachment without closing; `--json` for the enveloped machine form. |
44
47
  | `vinaya quickstart [--yes] [--dry-run]` | Guided wizard that runs `init` → optional doc-owners bind → optional project registration → commit → `demo break` → `doctor` → `push` in sequence, prompting between steps. `--yes` forwards straight through to `init` and answers every one of quickstart's own prompts with its documented default (skip the two optional steps, run the refusal-then-fix proof, push) — no prompt is opened at all, so the command completes with no human at the keyboard rather than merely with stdin closed. `--dry-run` also forwards to `init` and stops immediately after its preview — nothing is installed, so no later step runs against an uninstalled repo. |
45
48
  | `vinaya release [--dry-run] [--allow-any-commit]` | Run this repo's own publish sequence in one command (`apps/cli/specs/self-hosting.md`, "How the published version is produced"). Refuses unless HEAD is the default branch, the tree is clean, HEAD equals `origin/<default>` (after `git fetch origin`), HEAD's commit subject starts with `Chore(release): Version packages` (unless `--allow-any-commit`), and `npm whoami` exits `0` — each its own refusal naming the fix. Then streams `bun install --frozen-lockfile`, `bun run build`, `bun run changeset:publish`, and a real `git push origin --tags`, so the repo's own generated pre-push hook sees the push exactly as any other push would. Afterward prints `npm view <pkg> version` for every tag now on HEAD, noting registry lag on `@attalabs/vinaya` (observed ~20 minutes) when it still shows the previous version. `--dry-run` stops after the preconditions and prints the plan; publishes nothing. |
46
- | `vinaya dispatch <role> --agent claude \| codex \| gemini --prompt-file <path> [--task <n> \| --pr <n>] [--round <n>] [--resume <id>] [--model <name>]` | Start a role's headless agent session, attributed: `VINAYA_RUN_ID`/`VINAYA_ROLE`/`VINAYA_TASK`/`VINAYA_ROUND` are set on the CHILD's environment only, never on this process's own — a role started this way is what makes its later `vinaya` calls (the Stop hook, a nested dispatch) attributable instead of `unattributed`. The prompt is read from `--prompt-file` and sent on the child's stdin, never argv. `--model <name>` is passed to the chosen vendor through that vendor's own model flag, and the dispatch log's `model` field never records the vendor name; omitted, no model flag reaches the vendor and its own default decides, exactly as before this flag existed. A model shaped as another vendor's own (e.g. a Claude alias passed to `codex`) is refused by name before any spawn, naming the vendor and what it accepts. Before the vendor produces any output, and for any dispatch that never completes, the log records the requested value as a marked `requested:<name>` label (or `default` when none was named) — never presented as a confirmed observation. Once the vendor's own headless output actually reports which model ran (Claude's `modelUsage` object key, Gemini's `stats.models` object key(s) — Codex emits no such receipt in any event), that bare, unprefixed model name is recorded instead, even when it differs from what was requested or when no model was requested at all. Refuses by name, before any spawn, when the named vendor binary is absent from `PATH` or present but not executable. While the child runs, its raw stdout/stderr bytes are teed (never taken from the same stream the outcome parser reads) to a machine-local file under `~/.vinaya/dispatch-output/<effect-id>.log` — never inside the repo tree — whose path is printed once to this process's own stderr so an operator can `tail -f` it; a heartbeat line reporting elapsed time prints there every minute so a working agent is distinguishable from a hung one without inspecting processes by hand. A wall-time ceiling (`dispatch.timeoutMs` in config, default four hours) sends `SIGTERM` then, after a grace window, `SIGKILL`; a warning prints before the ceiling fires (at most 5 minutes ahead, sooner for a shorter configured ceiling) so a timeout is an expected, visible event rather than a silent disappearance. Records `dispatched`/`outcome_received`/`dispatch_failed` through the Vinaya Log's `dispatch` family (`apps/cli/specs/log.md`); when `--task`/`--pr` is given, flushes that outbox via `vinaya log flush` immediately after. A successful dispatch's own vendor session/thread identifier is returned as `resumeId` (printed alongside the other fields); passing that value back as `--resume <id>` on a later call swaps in that vendor's own resume invocation instead of starting a fresh session. That id is also durably recorded — never only printed — at `~/.vinaya/dispatch-resume/<owner>-<repo>|unresolved/<role>-<agent>-<issue<n>\|pr<n>\|unscoped>.json` (the repo segment keeps two different repos' own same-numbered task from overwriting each other's record on this one machine-wide home), overwritten by each dispatch's own outcome; a run that stops with a question is answered by reading the id from that file (or from the run's own printed output, if that terminal is still open) and running `vinaya dispatch <role> --agent <vendor> --resume <id> --prompt-file <answer-file>` — the same session continues from where it stopped, rather than being killed and re-dispatched fresh. There is no live channel: an unattended dispatch never blocks on a read waiting for an answer, so a stop condition always degrades to record-and-exit, and the resume path above is how it is answered afterward. |
47
- | `vinaya dev-review-loop --task <n> --agent claude \| codex \| gemini [--json]` / `vinaya dev-review-loop --resume <pr> --agent … [--json]` | Dispatch the developer through `dispatchRole` with the brief read from the task Issue's frozen `aeg:brief:v1` comment, wait for the PR it opens, then run rounds by calling `assessRound` (`@attalabs/aeg-core`) — the entire policy — with observations read from the forge (`git ls-remote` for the head, the check-runs API for CI, `<!-- aeg:principal:ruling:<pr>-<k> -->`-marked comments for rulings) until it returns `publish` or `pause`. Each round's reviewer and security verdicts are dispatched fresh (never resumed), rendered through `review post`'s own render functions, and held as local files under the outbox — nothing is posted to the PR (`gh pr comment`) before `publish`. At `publish`, the two held verdicts and a `renderSummary` comment are posted, in order, each re-read back through the same extractors the merge gate uses, each idempotent across a rerun. At `pause`, one comment marked `<!-- aeg:loop:paused:<reason> -->` is posted (carrying the exact `--resume <pr>` command) and the process exits non-zero; `--resume <pr>` reads that held state and a since-posted Principal ruling off the same PR and continues. Full account: `apps/cli/specs/loop.md`. The developer's own session IS resumed every round via `dispatchRole`'s `resumeId`; a round whose resume fails for a vendor that resumed successfully the round before stops the loop rather than falling back to a fresh session. |
49
+ | `vinaya dispatch <role> --agent claude \| codex \| gemini --prompt-file <path> [--task <n> \| --pr <n>] [--round <n>] [--resume <id>] [--model <name>]` | Start a role's headless agent session, attributed: `VINAYA_RUN_ID`/`VINAYA_ROLE`/`VINAYA_TASK`/`VINAYA_ROUND` are set on the CHILD's environment only, never on this process's own — a role started this way is what makes its later `vinaya` calls (the Stop hook, a nested dispatch) attributable instead of `unattributed`. The prompt is read from `--prompt-file` and sent on the child's stdin, never argv. `--model <name>` is passed to the chosen vendor through that vendor's own model flag, and the dispatch log's `model` field never records the vendor name; omitted, no model flag reaches the vendor and its own default decides, exactly as before this flag existed. A model shaped as another vendor's own (e.g. a Claude alias passed to `codex`) is refused by name before any spawn, naming the vendor and what it accepts. Before the vendor produces any output, and for any dispatch that never completes, the log records the requested value as a marked `requested:<name>` label (or `default` when none was named) — never presented as a confirmed observation. Once the vendor's own headless output actually reports which model ran (Claude's `modelUsage` object key, Gemini's `stats.models` object key(s) — Codex emits no such receipt in any event), that bare, unprefixed model name is recorded instead, even when it differs from what was requested or when no model was requested at all. Refuses by name, before any spawn, when the named vendor binary is absent from `PATH` or present but not executable. Every line this command and the loop it can drive print to the terminal is prefixed with its role (e.g. `[developer]`) and colour-coded per role, with the coordinator/loop's own lines in a distinct colour from every role — colour is applied only on a live TTY and is disabled entirely, prefix kept, when `NO_COLOR` (https://no-color.org) is set to any value. While the child runs, its raw stdout/stderr bytes are teed (never taken from the same stream the outcome parser reads) to a machine-local file under `~/.vinaya/dispatch-output/<effect-id>.log` — never inside the repo tree — whose path is printed once to this process's own stderr so an operator can `tail -f` it; a heartbeat line reporting elapsed time prints there every minute so a working agent is distinguishable from a hung one without inspecting processes by hand. A wall-time ceiling (`dispatch.timeoutMs` in config, default four hours) sends `SIGTERM` then, after a grace window, `SIGKILL`; a warning prints before the ceiling fires (at most 5 minutes ahead, sooner for a shorter configured ceiling) so a timeout is an expected, visible event rather than a silent disappearance. Records `dispatched`/`outcome_received`/`dispatch_failed` through the Vinaya Log's `dispatch` family (`apps/cli/specs/log.md`); when `--task`/`--pr` is given, flushes that outbox via `vinaya log flush` immediately after. A successful dispatch's own vendor session/thread identifier is returned as `resumeId` (printed alongside the other fields); passing that value back as `--resume <id>` on a later call swaps in that vendor's own resume invocation instead of starting a fresh session. That id is also durably recorded — never only printed — at `~/.vinaya/dispatch-resume/<owner>-<repo>|unresolved/<role>-<agent>-<issue<n>\|pr<n>\|unscoped>.json` (the repo segment keeps two different repos' own same-numbered task from overwriting each other's record on this one machine-wide home), overwritten by each dispatch's own outcome; a run that stops with a question is answered by reading the id from that file (or from the run's own printed output, if that terminal is still open) and running `vinaya dispatch <role> --agent <vendor> --resume <id> --prompt-file <answer-file>` — the same session continues from where it stopped, rather than being killed and re-dispatched fresh. There is no live channel: an unattended dispatch never blocks on a read waiting for an answer, so a stop condition always degrades to record-and-exit, and the resume path above is how it is answered afterward. |
50
+ | `vinaya dev-review-loop --task <n> --agent claude \| codex \| gemini [--json]` / `vinaya dev-review-loop --resume <pr> --agent … [--json]` | `--issue <n>` is `--task <n>`'s exact synonym — the loop's own `task` field is already the Issue number, tranche or not, so a backlog Issue (no `vinaya/tranche:*` label) needs no separate flag, only the naming that matches `task run --issue <n>`. Dispatch the developer through `dispatchRole` with the brief read from the task Issue's frozen `aeg:brief:v1` comment, wait for the PR it opens, then run rounds by calling `assessRound` (`@attalabs/aeg-core`) — the entire policy — with observations read from the forge (`git ls-remote` for the head, the check-runs API for CI, `<!-- aeg:principal:ruling:<pr>-<k> -->`-marked comments for rulings) until it returns `publish` or `pause`. Each round's reviewer and security verdicts are dispatched fresh (never resumed), rendered through `review post`'s own render functions, and held as local files under the outbox — nothing is posted to the PR (`gh pr comment`) before `publish`. At `publish`, the two held verdicts and a `renderSummary` comment are posted, in order, each re-read back through the same extractors the merge gate uses, each idempotent across a rerun. At `pause`, one comment marked `<!-- aeg:loop:paused:<reason> -->` is posted (carrying the exact `--resume <pr>` command) and the process exits non-zero; `--resume <pr>` reads that held state and a since-posted Principal ruling off the same PR and continues. Full account: `apps/cli/specs/loop.md`. The developer's own session IS resumed every round via `dispatchRole`'s `resumeId`; a round whose resume fails for a vendor that resumed successfully the round before stops the loop rather than falling back to a fresh session. |
48
51
 
49
52
  Which one lib function backs each command, and today's dated exemptions where it calls more than that one: `apps/cli/specs/surface.md`, enforced by `apps/cli/tests/surface-index.test.ts`.
50
53
 
@@ -76,8 +76,8 @@ The same check implementations run at ring 0 and ring 1 — one codebase, two en
76
76
 
77
77
  **The two rules a gate author meets before adding a check:**
78
78
 
79
- 1. **Decide from parsed structure, never from prose.** A gate that must decide whether a task touches a domain, resolves a path, or crosses a boundary decides from a field a writer fills in a fixed grammar — a glob list, a numbered citation, a table row — never from scanning prose for a phrase that would mean the right thing if read charitably. Prose can name a thing to include it or to exclude it, and a scanning gate cannot tell the two apart; a heuristic built to guess which one a sentence meant is wrong in both directions, and a blocking gate that is wrong in either direction is worse than one that runs less often. Where the parsed structure does not yet exist for an artifact (an Issue below the cutover that first mandates a `## Surface`), the gate degrades to the old prose scan for that artifact only — it neither invents structure nobody asked for, nor extends the prose scan's blast radius to cover stock that predates it. `issue-validation.ts`'s `checkBlastRadiusScope` is the worked example: cutover-gated, it decides from `## Surface`'s `in:` glob list once one is mandatory, never again from `Boundary`/`Project(s) + blast radius` prose, whatever that prose says — naming a shared package to explicitly *exclude* it no longer trips a gate that used to read prose span-blind to intent.
80
- 2. **One fact, one implementation, resolved once and passed down.** A fact two gates must agree on — whether a glob resolves to a real tracked file, whether a domain falls under a path — is computed by one function and passed to every caller that needs the answer; it is never re-derived by a second hand-written implementation that merely happens to agree with the first today. Two implementations of the same predicate are a race the moment either one changes: the day they diverge is invisible until an artifact one side accepts and the other refuses reaches the seam between them, and by then neither implementation is provably the bug. Inject the shared function into whichever side cannot compute it itself — a pure, `fs`-free module (`issue-validation.ts`) takes the answer as an injected parameter (a `(glob: string) => boolean`, a resolved list); the impure caller that already computes it (`forge-write.ts`, injecting `brief-assembly.ts`'s own `expandGlob`) is the one and only place that does, so the authoring gate and the brief renderer read the identical resolution and can never disagree about whether a Surface resolves.
79
+ 1. **Decide from parsed structure, never from prose.** A gate that must decide whether a task touches a domain, resolves a path, or crosses a boundary decides from a field a writer fills in a fixed grammar — a glob list, a numbered citation, a table row — never from scanning prose for a phrase that would mean the right thing if read charitably. Prose can name a thing to include it or to exclude it, and a scanning gate cannot tell the two apart; a heuristic built to guess which one a sentence meant is wrong in both directions, and a blocking gate that is wrong in either direction is worse than one that runs less often. Where the parsed structure does not yet exist for an artifact (an Issue below the cutover that first mandates a `## Surface`), the gate degrades to the old prose scan for that artifact only — it neither invents structure nobody asked for, nor extends the prose scan's blast radius to cover stock that predates it. `checkBlastRadiusScope` is the worked example: cutover-gated, it decides from `## Surface`'s `in:` glob list once one is mandatory, never again from `Boundary`/`Project(s) + blast radius` prose, whatever that prose says — naming a shared package to explicitly *exclude* it no longer trips a gate that used to read prose span-blind to intent.
80
+ 2. **One fact, one implementation, resolved once and passed down.** A fact two gates must agree on — whether a glob resolves to a real tracked file, whether a domain falls under a path — is computed by one function and passed to every caller that needs the answer; it is never re-derived by a second hand-written implementation that merely happens to agree with the first today. Two implementations of the same predicate are a race the moment either one changes: the day they diverge is invisible until an artifact one side accepts and the other refuses reaches the seam between them, and by then neither implementation is provably the bug. Inject the shared function into whichever side cannot compute it itself — a pure, filesystem-free module takes the answer as an injected parameter (a `(glob: string) => boolean`, a resolved list); the impure caller that already computes it (the forge-write path, injecting the brief renderer's own glob expansion) is the one and only place that does, so the authoring gate and the brief renderer read the identical resolution and can never disagree about whether a Surface resolves.
81
81
 
82
82
  **The own-PR fixture rule.** A pull request that adds a check which reads a PR body ships a fixture test running that check over **that PR's own body**. A body-reading check is the one class whose real input exists at the moment the PR opens and is never exercised by a synthetic fixture the author also wrote: the author's fixture agrees with the author's mental model by construction, and the first real body it meets is the one it was supposed to grade. Running it over the body in hand costs one test and converts "it should work on a real body" from a belief into a passing assertion.
83
83
 
@@ -137,7 +137,7 @@ Every pull request, on open and on every push, re-runs the same checks in CI:
137
137
  | Typecheck + unit tests | Ever had a change silently break something it wasn't even touching? | ci | Re-runs the type checker and the unit tests for every package this change can reach. | Packages affected by this PR's diff, plus their full transitive-dependent set (`turbo --affected`; application builds are verified by the deployment pipeline). A diff touching no package (docs/workflow-only) runs 0 packages — sound, since repo-file gates like `verify-docs`/coherence run in their own jobs regardless (task 27) | repo-own | `.github/workflows/ci.yml` |
138
138
  | Conventions | Ever opened a PR full of inconsistent formatting and naming? | ci | States where formatting/naming conventions stand in this repo: currently unenforced. | **Nothing re-checks lint/format or commit-message grammar in this repo today** — the managed hooks run only the `vinaya check` suite, and the one CI workflow that builds the code (`ci.yml`, the implementation named here; its typecheck + unit-test substance belongs to the `Typecheck + unit tests` row) carries no lint or commitlint step. Those conventions are discipline, not a gate, until a step is added. | repo-own | `.github/workflows/ci.yml` |
139
139
  | AI review | Ever wished every PR got a second pair of eyes, even at 2am? | ci | Requires independent review verdicts to exist on every pull request before merge. | No automated reviewer workflow is installed in this repo — review passes are separate, fresh-context dispatched agent sessions that post verdict comments on the PR. `vinaya-review-verdict.yml` evaluates a landed verdict comment (only principal-allowlisted authors count — the same verdict-author verification rule the Review gate applies) and re-runs the required review-gate check so it can go green natively. Until clean verdicts exist, the required check stays red by design. | repo-own | `.github/workflows/vinaya-review-verdict.yml` |
140
- | Review gate | Ever had a PR merge with nobody actually approving it? | ci | Holds the merge until the required review verdicts actually exist. | Required, blocking: a task-branch PR must carry a clean code-reviewer `APPROVE` verdict comment AND a clean security-review `PASS` verdict comment before merge — reusing `extractCodeReviewVerdict`/`extractSecurityReviewVerdict` (`packages/aeg-core/src/verdict-extraction.ts`), extracted out of the post-merge Archivist's `archive-task.ts` so both the pre-merge gate and the post-merge provenance assembly read the identical detection logic (one implementation, two call sites). Picks the MOST RECENT comment carrying a clear value, not the first comment merely matching the loose marker pattern — fixed live during this task, since the original single-verdict `.find()` broke on real multi-comment PRs (a `REQUEST_CHANGES` verdict followed by fixes and a later clean `APPROVE`, or the post-merge provenance comment's own DANGLING-note prose, which contains the word "verdict" and would otherwise poison a naive first-match search). **A verdict is bound to the commit it judged** (reviewed-commit binding): the winning comment must also carry a same-comment `Judged head: <sha>` line (parsed by `verdict-extraction.ts`, same anchor discipline as the `VERDICT:` marker itself, abbreviated or full sha both accepted), and `checkReviewGate` requires it to cover the PR's *current* `headRefOid` — a verdict that predates a later push, or carries no binding at all, no longer counts as clean; the gate names both the verdict's sha and the current head in its failure message. **Verdict authors are verified** (2026-08-09): only comments whose author is on the principal allowlist (`PRINCIPAL_ALLOWLIST` — the same trust anchor the waiver actor-check uses) participate in verdict extraction; forged, bot, and unresolvable-author comments are ignored, never fatal — a later forged `APPROVE` can no longer override a real `REQUEST_CHANGES`. **What this precondition does and does not establish:** it defeats a *forged* or *bot*-authored comment; it does not defeat a comment genuinely posted by a dispatched reviewer agent sharing the Principal's own local `gh` credential — that comment IS on the allowlist, correctly, because it really is the Principal's forge identity, so author verification alone cannot tell the Principal's own verdict apart from an agent's verdict cast under that same shared credential (`vinaya review post`'s `Cast by:` line records which role/session actually cast it, making the inheritance auditable without closing it — the close requires running the reviewer somewhere the Principal's credential is not, tracked separately). A principal-actor-verified `vinaya/waiver:review` label (the same `isWaiverLabelActorVerified` pattern, now parameterized by label name and reused — not duplicated — for this second label) skips the requirement for one PR; label presence alone is never sufficient. **Tool-layer protection extended** (`check-forge-gates.sh`): mutating `vinaya/waiver:review` from an agent session is denied, exactly like `vinaya/waiver:docs` — without this, an agent sharing the Principal's own `gh` credential could self-apply the waiver and inherit the Principal's identity on the labeling event, defeating actor verification entirely (confirmed live during this task before the hook was extended). Non-task branches (plan PRs) bypass — a plan PR touching only topology files has no code to review. Going-forward only: never re-evaluates already-merged history. **Mechanical checks are a third precondition:** a task-branch PR must additionally carry at least one reported, green (non-review) check-run for the PR's current head — `checkReviewGate` reads a caller-supplied `mechanicalChecks` list (populated via `gh pr checks --json name,bucket`, with this repo's own `vinaya review gate` check-run name excluded so the gate cannot judge its own status) and fails the same way an unclean or unbound verdict does, naming which check is not green, or that none have reported yet. The exclusion of the gate's own check-run name is repo-specific and lives in the thin CLI shims that resolve check-run status via `gh`, never inside the pure evaluator itself, since that evaluator ships to every adopter under a different workflow name. **A verdict is also bound by PATCH IDENTITY** (this task): the gate compares each side's `git diff <base>...<sha>` output run through `git patch-id --stable`, for the judged head and the current head, and a verdict whose sha binding has lapsed still counts when the two identities are equal and neither is `null`. A merge from the main branch, or a rebase that leaves the PR's own patch untouched, therefore no longer voids a review that already read exactly those changes — the round it used to cost proved nothing. Fails closed on every uncertainty: `null` on either side (an unreachable judged head after a force-push, a shallow clone, no git) is "cannot answer", never "they match". The known limit is stated rather than papered over — a base that moved under an identical patch can carry a semantic conflict the earlier review could not have seen, and this binding still holds; that is the same limit GitHub's own stale-review rule has, and the green-mechanical-checks requirement this gate already imposes at the new head is the guard for it. **A verdict is also bound to the OBJECTIVES LIST it was judged against**: the winning comment's `Objectives version:` line (a hash `objectivesVersion` computes over the closed Issue's, or the PR body's own, `## Objectives` list) must equal the current version of that same list — a verdict cast before an edit to the objectives no longer counts as clean, even when its head binding still holds. `input.objectivesVersion === null` (an Issue below the objectives cutover, or no resolvable list at all) skips this half of the binding entirely, so the pre-cutover PR stock keeps passing unchanged. **Resolving that `objectivesVersion` value diverges between the two bins that call `checkReviewGate`**: this repo's live pre-merge path, `apps/cli/src/checks/bin/check-review-gate.ts`, resolves it for real (Issue-then-body, fail-closed on every unresolvable case, waiver-checked first so an actor-verified `vinaya/waiver:review` label is never blocked by that resolution); the older reference script named in this row's own implementation column, `packages/aeg-core/bin/verify-review-gate.ts`, has no equivalent Issue-fetch machinery and always passes `objectivesVersion: null`, unconditionally skipping this half of the binding. Read the objectives-version paragraph above as describing `checkReviewGate`'s pure evaluation logic (shared by both callers); for which file actually resolves the value it is handed, see `check-review-gate.ts`, not this row's pointer. | product | `apps/cli/src/checks/bin/check-review-gate.ts` |
140
+ | Review gate | Ever had a PR merge with nobody actually approving it? | ci | Holds the merge until the required review verdicts actually exist. | Required, blocking: a task-branch PR must carry a clean code-reviewer `APPROVE` verdict comment AND a clean security-review `PASS` verdict comment before merge — reusing `extractCodeReviewVerdict`/`extractSecurityReviewVerdict` (`packages/aeg-core/src/verdict-extraction.ts`), extracted out of the post-merge Archivist's `archive-task.ts` so both the pre-merge gate and the post-merge provenance assembly read the identical detection logic (one implementation, two call sites). Picks the MOST RECENT comment carrying a clear value, not the first comment merely matching the loose marker pattern — fixed live during this task, since the original single-verdict `.find()` broke on real multi-comment PRs (a `REQUEST_CHANGES` verdict followed by fixes and a later clean `APPROVE`, or the post-merge provenance comment's own DANGLING-note prose, which contains the word "verdict" and would otherwise poison a naive first-match search). **A verdict is bound to the commit it judged** (reviewed-commit binding): the winning comment must also carry a same-comment `Judged head: <sha>` line (parsed by `verdict-extraction.ts`, same anchor discipline as the `VERDICT:` marker itself, abbreviated or full sha both accepted), and `checkReviewGate` requires it to cover the PR's *current* `headRefOid` — a verdict that predates a later push, or carries no binding at all, no longer counts as clean; the gate names both the verdict's sha and the current head in its failure message. **Verdict authors are verified** (2026-08-09): only comments whose author is on the principal allowlist (`PRINCIPAL_ALLOWLIST` — the same trust anchor the waiver actor-check uses) participate in verdict extraction; forged, bot, and unresolvable-author comments are ignored, never fatal — a later forged `APPROVE` can no longer override a real `REQUEST_CHANGES`. **What this precondition does and does not establish:** it defeats a *forged* or *bot*-authored comment; it does not defeat a comment genuinely posted by a dispatched reviewer agent sharing the Principal's own local `gh` credential — that comment IS on the allowlist, correctly, because it really is the Principal's forge identity, so author verification alone cannot tell the Principal's own verdict apart from an agent's verdict cast under that same shared credential (`vinaya review post`'s `Cast by:` line records which role/session actually cast it, making the inheritance auditable without closing it — the close requires running the reviewer somewhere the Principal's credential is not, tracked separately). A principal-actor-verified `vinaya/waiver:review` label (the same `isWaiverLabelActorVerified` pattern, now parameterized by label name and reused — not duplicated — for this second label) skips the requirement for one PR; label presence alone is never sufficient. **Tool-layer protection extended** (`check-forge-gates.sh`): mutating `vinaya/waiver:review` from an agent session is denied, exactly like `vinaya/waiver:docs` — without this, an agent sharing the Principal's own `gh` credential could self-apply the waiver and inherit the Principal's identity on the labeling event, defeating actor verification entirely (confirmed live during this task before the hook was extended). Non-task branches (plan PRs) bypass — a plan PR touching only topology files has no code to review. Going-forward only: never re-evaluates already-merged history. **Mechanical checks are a third precondition:** a task-branch PR must additionally carry at least one reported, green (non-review) check-run for the PR's current head — `checkReviewGate` reads a caller-supplied `mechanicalChecks` list (populated via `gh pr checks --json name,bucket`, with this repo's own `vinaya review gate` check-run name excluded so the gate cannot judge its own status) and fails the same way an unclean or unbound verdict does, naming which check is not green, or that none have reported yet. The exclusion of the gate's own check-run name is repo-specific and lives in the thin CLI shims that resolve check-run status via `gh`, never inside the pure evaluator itself, since that evaluator ships to every adopter under a different workflow name. **A verdict is also bound by PATCH IDENTITY** (this task): the gate compares each side's `git diff <base>...<sha>` output run through `git patch-id --stable`, for the judged head and the current head, and a verdict whose sha binding has lapsed still counts when the two identities are equal and neither is `null`. A merge from the main branch, or a rebase that leaves the PR's own patch untouched, therefore no longer voids a review that already read exactly those changes — the round it used to cost proved nothing. Fails closed on every uncertainty: `null` on either side (an unreachable judged head after a force-push, a shallow clone, no git) is "cannot answer", never "they match". The known limit is stated rather than papered over — a base that moved under an identical patch can carry a semantic conflict the earlier review could not have seen, and this binding still holds; that is the same limit GitHub's own stale-review rule has, and the green-mechanical-checks requirement this gate already imposes at the new head is the guard for it. **A verdict is also bound to the OBJECTIVES LIST it was judged against**: the winning comment's `Objectives version:` line (a hash `objectivesVersion` computes over the closed Issue's, or the PR body's own, `## Objectives` list) must equal the current version of that same list — a verdict cast before an edit to the objectives no longer counts as clean, even when its head binding still holds. `input.objectivesVersion === null` (an Issue below the objectives cutover, or no resolvable list at all) skips this half of the binding entirely, so the pre-cutover PR stock keeps passing unchanged. **A verdict is also bound to the newest PRINCIPAL RULING on the PR** (`review-validity-v1` task 3): the winning comment's `Ruling ordinal: <k>` line (`0` when the PR carried no ruling at cast time — this line renders UNCONDITIONALLY, unlike the objectives-version line) must equal `input.rulingOrdinal`, the newest ruling ordinal `check-review-gate.ts` counts off the PR's own principal-authored `<!-- aeg:principal:ruling:<pr>-<k> -->` comments — a ruling posted after a verdict was cast no longer counts as clean, even when its head and objectives bindings still hold, and the gate names the newer ruling in its failure message. Unlike `objectivesVersion`, there is no `null`-means-skip input value here — a PR's ruling count is never ambiguous — but a verdict predating this feature (no `Ruling ordinal:` line at all) still binds when the PR's newest ruling ordinal is `0`, the same "nothing to bind against" case expressed as an equality rather than a skip. **Every binding above is now ONE typed manifest, compared by ONE function** (`review-validity-v1` task 4): one `ReviewInputManifest` — head, the frozen brief's own hash, objectives version, newest ruling ordinal, and the effective review policy's digest — is built by the driver before reviewers are dispatched and rendered into every verdict/escalation comment; `compareManifest` is the SAME comparison `checkReviewGate` and the dev-review-loop's own publication self-check both call, so no field binds on one side and is ignored on the other. Two more bindings ride this manifest: **a verdict is also bound to the FROZEN BRIEF it was judged against** — the winning comment's `Brief hash:` line (a `sha256` of the brief's posted content) must equal `input.briefHash` (optional, defaulting to `null` — skip — for every caller predating this field); a Planner superseding the frozen brief after a verdict was cast voids it the same way an objectives edit does. **A verdict is also bound to the REVIEW POLICY in effect when it was cast** — the winning comment's `Policy digest:` line (rendered unconditionally, right after `Ruling ordinal:`) must equal the current policy's digest; unlike the other bindings above, a `null` echo (a comment predating this line, or one stripped of it) is never grandfathered — a policy is always resolvable, so there is no genuine "nothing to bind against" case on the current side the way an unset objectives list or a ruling-free PR has, and a blanket forever-exempt reading of a missing line would let it silently outlive a real threshold change (found live during `review-validity-v1` task 4's own review). A digest that no longer matches a since-changed policy, missing or present, invalidates the verdict the same way a newer ruling does. **Resolving that `objectivesVersion` value diverges between the two bins that call `checkReviewGate`**: this repo's live pre-merge path, `apps/cli/src/checks/bin/check-review-gate.ts`, resolves it for real (Issue-then-body, fail-closed on every unresolvable case, waiver-checked first so an actor-verified `vinaya/waiver:review` label is never blocked by that resolution); the older reference script named in this row's own implementation column, `packages/aeg-core/bin/verify-review-gate.ts`, has no equivalent Issue-fetch machinery and always passes `objectivesVersion: null`, unconditionally skipping this half of the binding. Read the objectives-version paragraph above as describing `checkReviewGate`'s pure evaluation logic (shared by both callers); for which file actually resolves the value it is handed, see `check-review-gate.ts`, not this row's pointer. | product | `apps/cli/src/checks/bin/check-review-gate.ts` |
141
141
  | G1 — implementation exists | Ever read about a safeguard that turned out not to actually exist? | ci | Re-checks that every gate the doctrine describes has real code behind it. | Every ring's non-empty `implementation` cell on this very page resolves to a real path on disk — makes this page's registry columns load-bearing instead of decorative. | product | `packages/aeg-core/bin/verify-registry.ts` |
142
142
  | G2 — no orphan hook/CLI | Ever found a script nobody remembers the purpose of? | ci | Re-checks that every hook and CLI in the repo is one the doctrine claims, and that a row scaffolded to fix that stays visibly incomplete until a human finishes it. | Every file under `.husky/*`, `.claude/hooks/*.sh`, `packages/aeg-core/bin/*.ts`, `apps/cli/src/checks/bin/*.ts` is named by some row's `implementation`, OR — **twin-form recognition (O14)** — resolves, through the same `claimedCheckNames` derivation G6 uses, to a `coreCheckRegistry()` name some OTHER row's `implementation` already claims: the same enforcement mechanism routinely ships as both a `packages/aeg-core/bin/*.ts` standalone form and an `apps/cli/src/checks/bin/check-*.ts` CLI-registered form, and a row's single `implementation` cell can only name one — the other is not a second undocumented fact, it is the first fact read through its other physical shape. A `NON_GATE_BINS`-listed aeg-core bin (a forge writer, a one-shot reporter, an eval harness — `report-tokens.ts`, `eval-agent-compliance.ts`, …) and a listed non-gate hook script (`.claude/hooks/track-transcript.sh`, real Stop-hook plumbing feeding two already-documented mechanisms' own inputs, with no pass/fail decision of its own) implement no enforcement mechanism to be named for — the honest fix for those is that this gate stops asking, never an invented row (`.husky/*` matches nothing in this repo today — that directory does not exist here). **Blocking as of this task** (re-graded from report-only: the twin-form gap above was G2's entire non-scaffold-placeholder backlog, and a 0-finding G2 run means something again now that it can fail). **Scaffold-writer** (task 22, later widened to cover that second location): `verify-registry.ts --scaffold` auto-inserts a stub row for an orphan candidate whose ring is mechanically derivable — a `packages/aeg-core/bin/*.ts` file resolving through `GATE_AUDIENCE` to a registered check (its ring comes from that check's own `apps/cli/src/checks/registry.ts` entry), an `apps/cli/src/checks/bin/*.ts` file whose `check-`-stripped name resolves through the analogous `CLI_CHECK_RING` mirror table, or a `.husky`/`.claude/hooks` file (ring 0 by source). An orphan with no derivable ring (an unregistered bin under either directory) gets no stub and stays a plain finding — the writer never guesses. A second, independent half of G2 scans every row's `summary`/`description`/`spec` cells for the literal placeholder marker `[undocumented — fill in why]` the scaffold writes into every non-mechanical cell: a stub row that filled only `implementation` would otherwise read as "documented" to the orphan half above and silence G2 outright, which is the exact trap this second half exists to close. The writer only ever appends a new row after a ring table's last existing row — it never edits, reorders, or removes one, so a hand-authored row (including `workspace-escape`'s and `main-branch-refusal`'s own) is untouched. Repo-side only: the writer lives on `verify-registry.ts` (aeg-core), never on the shipped `check-registry-gates.ts` (apps/cli) — an adopter's checks stay read-only over doctrine. | product | `packages/aeg-core/bin/verify-registry.ts` |
143
143
  | G3 — no seventh way into GitHub | Ever discovered a backdoor that skipped all your checks? | ci | Re-checks that no route into GitHub exists beyond the ones the doctrine gates. | Every file making a GitHub-mutating call of the gated class (PR/Issue create, PR/Issue body/title edit, `gh api` create/edit, raw curl/wget writes) is named by some Ring-0 row's `implementation` — a mutating call outside that set would be an unguarded hole in the prevention model. (The class was originally defined by attalabs's `check-forge-gates.sh` tool-interception hook; this repo has no such hook — see the raw-API ring-1 row above — but the scan's definition of "mutating call" is unchanged.) | product | `packages/aeg-core/bin/verify-registry.ts` |
@@ -154,7 +154,7 @@ Every pull request, on open and on every push, re-runs the same checks in CI:
154
154
  | 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 seven 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 seven 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` |
155
155
  | 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. **A ledger row must exist, not merely be well-formed when present** (O13): before this task, `!capability.capable` returned a silent pass BEFORE checking whether the body carried a "Token report" row at all, so a merged task on an incapable host could leave the ledger with no row at all for its own spend — a silent hole, never a stated `—`. The row-existence check now runs unconditionally (any capability), and only the numeric-cell shape check stays capability-gated — `tranche-model.md` §12's operator-metered case writes `—` in a row; it never omits the section. | product | `apps/cli/src/checks/bin/check-token-report.ts` |
156
156
  | Bare code-fact digits in a PR body | Ever had a PR body cite a line number that drifted the moment the file changed? | ci | Re-checks that a pull request's narrative prose carries no bare `<path>.<ext>:<digits>` code-fact pointer outside a fenced code span or a `Premise:` pin. | A bare digit anywhere in the body outside a fenced/indented code block or a documented anchor (`Closes`/`Project`/`Tier`/`Evidence`) — an Issue/PR ref, a date, a version, a path, a section number, a pass count, a duration, all need their own backticks or a fenced block, never bare prose. Reports every violation the body carries in one pass, not only the first (O12) — a mechanically-fixable body is corrected in one round, not one round-trip per digit. Its own workflow (`ownWorkflow: true`), `pull_request_target`-triggered only, never `pull_request` — a live-fetched author/branch check a `pull_request`-triggered run of this same file could otherwise spoof via the PR's own workflow YAML. | product | `apps/cli/src/checks/bin/check-body-bare-digits.ts` |
157
- | Evidence-block freshness | Ever seen a PR's own "tests pass" claim go stale the moment someone pushed again? | ci | Re-checks that a PR's `AEG:EVIDENCE` block still matches a fresh recompute at the PR's current head. | Group A (the diff stat) is recomputed exactly and byte-compared — a hand-typed or stale diff stat cannot survive this. Group B (the real gate run) and Group C (the `[agent]` fenced command list `vinaya pr report` runs) are checked for staleness against the current head, never re-run here — this closes fabrication for the two facts a checker can cheaply recompute, never for the Decisions section's prose. Resolves the real PR head via `gh pr view --json headRefOid`, never `HEAD` (the merge commit in CI). | product | `apps/cli/src/checks/bin/check-evidence-fresh.ts` |
157
+ | Evidence-block freshness | Ever seen a PR's own "tests pass" claim go stale the moment someone pushed again? | ci | Re-checks that a PR's Evidence block still matches a fresh recompute at the PR's current head. | Group A (the diff stat) is recomputed exactly and byte-compared — a hand-typed or stale diff stat cannot survive this. Group B (the real gate run) and Group C (the `[agent]` fenced command list `vinaya pr report` runs) are checked for staleness against the current head, never re-run here — this closes fabrication for the two facts a checker can cheaply recompute, never for the Decisions section's prose. Resolves the real PR head via `gh pr view --json headRefOid`, never `HEAD` (the merge commit in CI). | product | `apps/cli/src/checks/bin/check-evidence-fresh.ts` |
158
158
  | Documentation gate (PR open/edit) | Ever opened a PR, watched the doc-coverage gate pass, then pushed a second commit that broke it? | ci | Re-checks C5 doc-coverage — the SAME code→doc binding the push-time row above enforces — again at PR create/edit time, not only on push. | Every code change matching a `.vinaya/doc-owners` binding carries its owning doc in the same diff, a `Doc-ack:`/`Doc-neutral:` declaration, or an actor-verified `vinaya/waiver:docs` label, resolved live via `gh` from `PR_NUMBER` (fixing a prior silent-unreachable label lookup in an adopter's CI, where the label was never actually forwarded to this check). `roles/developer.md` names this as one of two chokepoints "at every push... and again at pull-request creation and editing" — this row is the second, previously undocumented despite already shipping and already running from `vinaya pr create`'s own pre-flight. | product | `apps/cli/src/checks/bin/check-doc-coverage.ts` |
159
159
  | Surface-scope (out-of-boundary file) | Ever had a "small" task quietly touch a file its own plan said it wouldn't? | ci | Re-checks that a task branch's changed files stay inside its own Issue's declared `## Surface` — never inside a declared `out:` glob. | Every file in the branch's diff against `origin/main` is checked against the task's own Issue (resolved from the branch name → forge topology → Issue number → `## Surface` `out:` list) with the same `globCoversPath` predicate the Issue-authoring gate and the blast-radius check (O4) both already use — an undeclared boundary crossing is caught mechanically, naming the file and the glob it crosses, rather than depending on a reviewer noticing. Same check runs at the `git push` hook (ring 0) and again here — same runner, one codebase, two enforcement points. Dormant (never blocks) when the branch is not a task branch, the forge/repo cannot be resolved or reached, the task's row carries no Issue yet, the Issue's `## Surface` doesn't parse (below the brief-sections cutover, or malformed — caught at authoring time instead), or the Issue declares no `out:` globs at all. | product | `apps/cli/src/checks/bin/check-surface-scope.ts` |
160
160
  | PR-body premise reassertion | Ever had a brief's pinned fact quietly go stale, and the merge never noticed? | ci | Re-checks, in CI, that a pull request body's `Premise:` pins still hold against the PR's own current tree — not only at Step 0, authoring time. | Every `contains`/`absent`/`sha256` pin `parsePremiseBlock` extracts from the PR body, re-asserted via the same frozen `checkPremises`/`reassertPremiseFile` (`apps/cli/src/checks/premise-reassert-logic.ts`) `verify-dispatch --premise` already uses at Step 0 — a second caller, not a second implementation, so a pin the PR's own diff falsifies fails CI instead of merging as decoration. Trigger is the block's presence alone: a body with no `Premise:` header parses to zero pins and this check is silent, on every branch — there is no branch-name condition anywhere in it. | product | `apps/cli/src/checks/bin/check-pr-premise-reassert.ts` |
@@ -213,7 +213,7 @@ A full audit of this page against the installed hooks and CI workflows (2026-07-
213
213
  | `verify-task` (pre-PR exit composite) | ✅ **new** — `open-pr.ts` runs it wholesale for task branches (`gatePlanForBranch`); on `edit <n>` the gate set now resolves from the target PR's **actual forge head branch** (`gh pr view --json headRefName`, fetch failure = hard refusal), not the local checkout/`BRANCH` env, which silently skipped `closes-n`/`verify-task` on task-PR body edits from a `main` checkout (task 36 bundled finding) | — | — | **Forced, ring-0 only (this task, item 2)**; **edit-path gate-selection bypass fixed** (task 36) | was (b) — closed; ring-1/2 backstop not yet built (same accepted shape as the branch-ID check below) |
214
214
  | Runtime Test Plan checkbox state (`verify-test-plan`) | n/a (CI-only check) | ✅ now a step of the AEG gate suite job (`verify-test-plan.yml` deleted — task 31; still runs on PR-body `edited` events) — **reads `BRANCH`; parses both the inline `**Test Plan:**` marker and the `## N. Test Plan` heading form** (task 25, item 3) | — | Paired (pre-existing); **parser bug fixed** | n/a — not a coverage gap, a correctness fix (a live-fire gap: heading-form sections were never matched, so a task PR with an unticked `[principal]` box advisory-PASSED) |
215
215
  | Waiver-label-actor verification (task 29; **extended to `vinaya/waiver:review`, review-gate task 1**) | ✅ **new** — pre-push is warn-only (no `vinaya/waiver:docs`/`vinaya/waiver:review` label can exist before a PR does) **+ tool-layer (attalabs only)**: the attalabs reference implementation adds a session hook denying any agent-session command that mutates either label; this repo has no tool-interception layer (see the raw-API ring-0 row), so the shared-credential hole is OPEN here — a local agent using the Principal's own `gh` credential can apply a waiver label whose timeline actor then reads as the Principal, and the actor-verification step cannot distinguish it. Until an interception layer exists, waiver labels in this repo rest on credential discipline, not a gate | ✅ **new** — a GraphQL `LABELED_EVENT`/REST timeline step resolves the label's actor, verified by `isWaiverLabelActorVerified` (now parameterized by label name) and fed into `verify-docs --pr`'s C5 waiver check (`vinaya/waiver:docs`) and `verify-review-gate.ts`'s review check (`vinaya/waiver:review`) | — | **New (this task)**; **extended (review-gate task 1)** | n/a — new mechanism |
216
- | Review gate — code-reviewer + security-review verdicts (review-gate task 1) | n/a (CI-only check; the underlying verdict comments come from a human, a dispatched reviewer subagent, or the AI-review job) | ✅ **new** — `verify-review-gate.ts`, a step of the AEG gate suite job. Blocks merge unless a clean code-reviewer `APPROVE` AND a clean security-review `PASS` verdict comment exist, both bound to the PR's current head commit (`headRefOid` — a stale or unbound verdict fails the same as a missing one), or the PR carries an actor-verified `vinaya/waiver:review` label | — | **New (this task)**; **reviewed-commit binding added (review-gate-verdict-sha-binding)**; **objectives-version binding added by a later task — implemented in `apps/cli/src/checks/bin/check-review-gate.ts`, this repo's actual live gate path, not in this row's own `verify-review-gate.ts`, which never resolves it (see the Ring 1 Review gate row above)** | n/a — new mechanism (closes the gap where task PRs could previously merge with no review pass at all — the post-merge Archivist's `extractVerdict`/DANGLING flag was advisory-only; the reviewed-commit binding closed a second gap where a clean verdict could still merge stale code because nothing checked which commit it covered) |
216
+ | Review gate — code-reviewer + security-review verdicts (review-gate task 1) | n/a (CI-only check; the underlying verdict comments come from a human, a dispatched reviewer subagent, or the AI-review job) | ✅ **new** — `verify-review-gate.ts`, a step of the AEG gate suite job. Blocks merge unless a clean code-reviewer `APPROVE` AND a clean security-review `PASS` verdict comment exist, both bound to the PR's current head commit (`headRefOid` — a stale or unbound verdict fails the same as a missing one), or the PR carries an actor-verified `vinaya/waiver:review` label | — | **New (this task)**; **reviewed-commit binding added (review-gate-verdict-sha-binding)**; **objectives-version binding added by a later task — implemented in `apps/cli/src/checks/bin/check-review-gate.ts`, this repo's actual live gate path, not in this row's own `verify-review-gate.ts`, which never resolves it (see the Ring 1 Review gate row above)**; **ruling-freshness binding added by `review-validity-v1` task 3 — no divergence here: both `check-review-gate.ts` and `verify-review-gate.ts` count real ruling comments off `pr.comments`, already fetched by each before `checkReviewGate` is called** | n/a — new mechanism (closes the gap where task PRs could previously merge with no review pass at all — the post-merge Archivist's `extractVerdict`/DANGLING flag was advisory-only; the reviewed-commit binding closed a second gap where a clean verdict could still merge stale code because nothing checked which commit it covered) |
217
217
 
218
218
  This table describes **installed mechanisms only** (the page's own change-discipline rule, below). The `verify-dispatch`/`verify-task` rows and the `verify-test-plan` fix landed in task 25's own change set, per that rule.
219
219
 
@@ -58,6 +58,8 @@ The same three states a tranche derives (`tranche-model.md` §11, one altitude d
58
58
 
59
59
  Nobody sets this by hand and nothing writes it back to the Milestone object — it's asked of the forge the same way every other altitude is.
60
60
 
61
+ `vinaya milestone status <n>` is that ask, made concrete: for every `- <slug>: …` line in a Milestone's `### Tranche intents` section, it prints the slug, its derived lifecycle, and its labeled Issues' counts (`merged`/`open`/`not planned`) — read-only, nothing written. A slug with an intent line and zero labeled Issues yet prints `planned` with `0 issues`, per §1: a real, not-yet-started tranche, never an error.
62
+
61
63
  ### The trap this level actually has: a closed legacy Milestone with real work still under it
62
64
 
63
65
  `vinaya milestone adopt` closes the old one-tranche Milestone it retires — closed, never deleted, so the provenance survives. The closed Milestone still legacy-title-matches its slug forever (§1's exception). Found live, `0.19.1`: a reader that trusts a closed legacy Milestone's own `state` unconditionally reports the tranche `complete` even when its real, still-open Issues have already moved to a new Milestone via `adopt` — the tranche's true lifecycle lives in the label population, not in the Milestone object `adopt` walked away from. The fix: when a legacy-matched Milestone is closed AND its slug's labeled Issues are non-empty, derive from those Issues, not from the closed Milestone's `state`. An empty label population under a closed legacy Milestone still means what it always meant — a genuinely historical tranche, or one nobody has adopted away from yet.
@@ -89,7 +89,7 @@ Before dispatching a task, the Planner checks four gates (`roles/planner.md` §
89
89
 
90
90
  `vinaya check dispatch-readiness`, run against the task, re-derives gates 1–3 mechanically in one command.
91
91
 
92
- **Running the dispatch:** `vinaya task dispatch <tranche> <n> [--agent claude | codex | gemini]` performs the whole phase once the gates pass: it renders the brief (the same render `brief render` uses, from `aeg-root/templates/brief-template.md` and every derivable fact), posts it once, frozen, as the task Issue's own `aeg:brief:v1` comment, then — with `--agent` — starts the Developer through `dispatchRole`. Without `--agent` it prints the rendered brief and the manual dispatch instruction and exits `0`, so a human can paste it instead. The brief MUST include (all mechanically derived or refused on):
92
+ **Running the dispatch:** `vinaya task dispatch <tranche> <n> [--agent claude | codex | gemini]` performs the whole phase once the gates pass: it renders the brief (the same render `brief render` uses, from `aeg-root/templates/brief-template.md` and every derivable fact), posts it once, frozen, as the task Issue's own `aeg:brief:v1` comment, then — with `--agent` — starts the Developer through `dispatchRole`. Without `--agent` it prints the rendered brief and the manual dispatch instruction and exits `0`, so a human can paste it instead. `vinaya task dispatch` is deprecated: `vinaya task run <tranche> <n> --agent <claude | codex | gemini>` is the one command that now runs this whole phase AND the phases after it — preparation through this same render/freeze/post, then the developer/review loop — unattended, from one planned Issue to a reviewed pull request, exactly one developer started; a paused run resumes with the loop's own `vinaya dev-review-loop --resume <pr>`. The brief itself MUST include (all mechanically derived or refused on):
93
93
  - Impact tier (0 / 1 / 3), rendered from the Issue's tier declaration
94
94
  - Type 1 / Type 2 declaration if architectural decisions are expected, gated at dispatch (a PENDING Type 1 decision blocks dispatch)
95
95
  - `principal_delegate:` if the work runs while the Principal is offline
@@ -109,6 +109,8 @@ Items 3, 5, and 7 read live forge state. Item 6 checks the brief's own Step 0 te
109
109
 
110
110
  **Commit per Part, push once.** Commit after each numbered Part in the brief — small, one logical change per commit, so the history reads as a narrative of how you approached the problem. Push exactly once, immediately before `pr create` — not after every commit. The pre-push hook itself runs the affected test suite (`bunx turbo test --affected`) on that one push and refuses it on failure; you do not additionally run it yourself per Part. The affected suite covers only the packages `turbo`'s own dependency graph marks affected by this diff — a rule about one package's files that lives in another package's test never runs on a push that only touches the first package (found live: a CLI-only diff never marked `aeg-core` affected, so a rule about CLI files, asserted only in an `aeg-core` test, never ran at the push hook at all). A rule meant to bind a package's own files belongs in a `vinaya check`, not in a sibling package's test suite. A regeneration or evidence run (`vinaya pr report --write`/`--push`) passes `--force` to its own test invocation. A verdict binds to the head it judged; a push landing after the newest verdict's judged head voids it and is named as such: `vinaya review status` prints `push after verdict — re-review required`, and merge waits on a fresh review round.
111
111
 
112
+ **Push and PR-open are foreground, never backgrounded.** A dispatched turn cannot run a shell command in the background at all: `vinaya dispatch` wires a permission deny rule into the session's settings for exactly this (Bash's own `run_in_background: true`), so the tool call itself is refused, with the refusal text naming the foreground alternative — this is enforcement, not a convention you are trusted to follow unprompted. The push and the `pr create` (or `pr edit`) call that opens or updates the pull request are foreground steps, not something you fire and end your turn on. Run each to completion and confirm it: your turn does not end until `gh pr view` on that branch shows the pull request. A turn that ends with the push or the PR-open still running in the background — or with commits made but never pushed — is a contract violation, not a completed turn; the driver treats it as incomplete and resumes you, once, with the exact commands to finish (found live, twice: a backgrounded `git push` that reported "running in the background" died with the session, leaving only local commits, while the loop polled for a pull request that no process was ever going to open). If the branch is behind the base in a way that conflicts, merge or rebase and resolve before pushing — never push, or end a turn, with an unresolved conflict against the base; a resume that finds you here names the conflicting files.
113
+
112
114
  **Opening the PR with a complete description.** The PR description must (1) **carry the report only** — the brief itself never rides in this body at all; it is already posted, frozen, as the task Issue's `aeg:brief:v1` comment by `vinaya task dispatch`, before your worktree even exists, and that comment is the brief's permanent, durable home — the Reviewer and Archivist read it there; (2) follow the canonical form in [§ PR body — canonical form](#pr-body--canonical-form) below — that section holds the verbatim copy-pasteable template, including the **exact `Tier:` field syntax** the `verify-docs` gate requires; (3) reference the task's Issue (`Closes #N`) so the merge auto-closes it. The description is not optional — the reviews depend on it. Opening the PR is itself the `in-flight → in-review` transition; you write no status field. **The body is authored once, at open.** After the PR is open, you never hand-edit it again — not to append a response to a review round, not to record a decision, not for any reason. Two writes are sanctioned after open, both machine-regenerated, never typed: the Evidence block, and one appended row in the Token report for a re-entry turn (see [§ Evidence is emitted, never typed](#evidence-is-emitted-never-typed)). Everything else a review round produces — your response to findings, re-run `[agent]` evidence, any disclosure the brief didn't anticipate — is a PR comment.
113
115
 
114
116
  **Reporting exact tokens in the PR body at turn-end.** You do not append your own row to `aeg-root/tranches/<name>.tokens.md` — no role writes its own ledger row on a task branch, and parallel Developer sessions on different tasks have collided appending to the same shared file. Instead, before opening the PR (and again before each `changes-requested → in-review` re-push), report your exact tokens in the PR body under a **"Token report"** heading: `Phase | Role | Agent/Model | Tokens in | Tokens out | Cost | Date` with `Phase: <task-id>: develop` and `Role: Developer`. **That destination and that grammar are the requirement, and they are the same on every agent host.**
@@ -201,15 +203,11 @@ The `AEG:EVIDENCE` block is populated by running `vinaya pr report --write <body
201
203
 
202
204
  `check-evidence-fresh` (CI) refuses a body whose block doesn't match the head it's attached to — recomputing Group A exactly and checking Group B for staleness. This closes fabrication for **Group A only** (a hand-typed diff stat cannot survive a byte-compare); Group B is checked for freshness, not re-run, so a stale-but-not-fabricated Group B slips past unless the block is also out of date. Do not claim in this PR's own Evidence section, or anywhere else, that this closes fabrication generally — it closes it for the two facts a checker can cheaply recompute, never for the Decisions section's prose.
203
205
 
204
- **Regeneration is one command, run last, after every other change.** The Developer never hand-edits the live PR body after open there is no local body file to keep in sync with the forge. When a push forces the Evidence block to go stale or a re-entry turn needs its one appended Token report row — run, from the repo root, after every commit for that round is already pushed:
205
-
206
- `vinaya pr report --push <n>`
206
+ **After open, regenerating `AEG:EVIDENCE` is the driver's job, not yours.** Before this fix, a push-forced-stale Evidence block was the Developer's own turn to re-close: run `vinaya pr report --push <n>` and wait for it, a re-run of the real gate suite that could take past ten minutes and stall the whole loop on the Developer's single tool call. That command still exists the underlying engine module both `vinaya pr report --push` and the driver's own in-process call now share, a command never calling a command but you no longer run it. Once your turn ends at the push (below), the driver runs the SAME engine function itself, in-process, the moment the head's CI turns green, and posts the round marker comment in your place too. You never see a stale Evidence block to fix, because you never reach for the command that used to fix it.
207
207
 
208
- **On this repo's toolchain**, substitute `bun apps/cli/src/index.ts` for `vinaya` in that command.
208
+ **`--push <n> --body-file <path>` still exists as a narrower, separate mode** for the one case a routine splice cannot cover: a section outside the two generated blocks (a Decisions bullet, most often) that only ever existed in a local draft, never yet posted. It does not relax "the body is authored once, at open" above — reaching for it to restate the routine splice's own job is scope creep, not a shortcut. This mode is still yours to run by hand if you ever need it; the driver's own automatic call never uses `--body-file`.
209
209
 
210
- It fetches the PR's live body itself, splices the fresh `AEG:EVIDENCE`/`AEG:TOKENS` content into it through the same anchor resolver `--write` uses, pushes the result via the forge's own PR-edit, then re-reads the live body and refusesrestoring the pre-edit body unless the two agree outside those two anchored regions. A `[principal]` tick, if one landed since this turn started, is a live-body write only this command's own fetch-then-splice sequence carries forward correctly; it refuses rather than appending when the live body carries no real `AEG:EVIDENCE` pair at all.
211
-
212
- After open the Developer changes nothing outside the `AEG:EVIDENCE` anchor and one appended `AEG:TOKENS` row. The Principal's `[principal]` ticks are the Principal's writes and must survive every Developer edit. Everything else a review round produces — the response to findings, re-run `[agent]` evidence, any disclosure the brief didn't anticipate — is a PR comment, never a body edit.
210
+ After open, the Developer changes nothing in the PR body at all the driver's own automatic call regenerates `AEG:EVIDENCE` every round; the Principal's `[principal]` ticks are the Principal's writes and must survive every edit. **`AEG:TOKENS` is deliberately untouched by the driver's automatic call:** the driver runs in its own session, not yours, so a token row it collected would misattribute the driver's own usage to your `<task-id>: develop` phase. Token reporting for a re-entry round therefore has no automatic mechanism right now a known, accepted gap this fix does not close, not a silent one: if you need a re-entry round's tokens recorded, run `vinaya pr report --write <body-file>` yourself and hand-splice the result, or ask the Principal to record it, rather than treating the missing row as this fix's oversight. Everything else a review round produces the response to findings, any disclosure the brief didn't anticipate is a PR comment, never a body edit.
213
211
 
214
212
  ---
215
213
 
@@ -265,16 +263,18 @@ A spike is exploratory, not a permanent excuse to skip documentation. The pull r
265
263
 
266
264
  ---
267
265
 
268
- ## After you open the PR — the post-open sequence
266
+ ## After you open the PR — your turn ends here
267
+
268
+ Opening the PR is the end of your turn — for round 1, and for every later round too: after you push a fix in response to review findings, your turn ends at that push, on the same branch, no new PR. You do not run `vinaya pr report --push`, you do not post a round comment, and you do not tick anything.
269
269
 
270
- Opening the PR is not the end of your turn; it is the point at which the rest of your turn becomes runnable. The `[agent]` half of your Test Plan is no longer something you run by hand and paste it is a fenced command list in §9 that `vinaya pr report` itself executes from the PR head and writes into the `AEG:EVIDENCE` block (task 12; Principal ruling: an agent never ticks a box or edits a PR body). What remains for you to do runs now, in this order, and the order is load-bearing:
270
+ This is a change from before. It used to take four more steps merge main if behind, regenerate the Evidence block, post a `Head: <sha>` comment carrying the round marker, tick nothing and the second of those, a full re-run of the real gate suite, could take past ten minutes on a real Test Plan and stall the whole loop waiting on your one tool call to finish. None of those four steps are yours any more:
271
271
 
272
- 1. **Merge the main branch first if you are behind it.** A branch behind its base is judged against a base nobody will merge into. `vinaya review status <pr>` prints that distance as a second line reading `behind main by <n> — merge first`, and exits non-zero unless the loop is converging at a branch that is not behind. Merge, push, and only then continue — the head your evidence names must be the head your reviewer will read.
273
- 2. **Regenerate the Evidence block** — one command, `vinaya pr report --push <n>`, never a hand edit. This runs the real gates (Group B) AND every `[agent]` command in your §9 fenced list (Group C), writing each command's actual output into `AEG:EVIDENCE`. This is the only sanctioned write into the body after open.
274
- 3. **Post one comment, headed `Head: <sha>`, carrying the round marker `<!-- aeg:developer:round-<n> -->` and your `Tokens:` line.** One comment per round, never an edit to the one already there. No Test Plan output belongs in this comment any more it lives in the Evidence block `pr report` just wrote. The marker is what makes the round machine-readable for the round-derivation logic that still reads it.
275
- 4. **Tick nothing.** There is no `[agent]` checkbox left to tick the fenced command list has no box at all. **Never tick a `[principal]` box.** You structurally cannot satisfy one, and the asymmetry is the point.
272
+ - **The `AEG:EVIDENCE` block** is regenerated by the driver itself, in-process, the moment your head's required CI turns green the same engine module `vinaya pr report --push` always called, now also called directly by the loop's own driver rather than shelled out to as a subprocess.
273
+ - **The round marker comment** — `Head: <sha>`, `<!-- aeg:developer:round-<n> -->`, and (starting from round 2) the ids of the findings you addressed is composed and posted by the driver too, from a small side-channel file you write before your turn ends: see the next paragraph.
274
+ - **A branch behind its base** is caught by the driver's own mergeability check before it ever dispatches a reviewer or runs the report you never need to check this yourself; a conflicting head is sent back to you with the conflicting files named, same as before.
275
+ - **Ticking `[agent]`/`[principal]` boxes** was never yours to begin with (task 12; Principal ruling: an agent never ticks a box or edits a PR body) nothing changes there.
276
276
 
277
- Step 1 is not optional and not reorderable: there is no path through this sequence that reaches a `Head:` comment while the branch is behind. The evidence in that comment is a claim about a head, and a head that is about to be superseded by a merge you have not done yet is the wrong head to make it about.
277
+ **Citing which findings you addressed, from round 2 on.** When your resumed turn is sent back to fix review findings, write a `FINDING_IDS:` line the same comma-separated grammar the reviewer's own `report.txt` already uses — to `.vinaya-round-response` at the root of your worktree, naming the ids (e.g. `F1`, `F2`) you addressed this round, before your turn ends. The driver reads and clears this file itself once your push lands and the head goes green, and folds it into the round comment it composes. This file is best-effort by design: if you forget it, or your turn crashes before writing it, the driver still posts the round comment — with no citation line — and the round is never judged stalled for want of one. You are never resumed a second time just to write it.
278
278
 
279
279
  Then stop. Review is a separate invocation.
280
280
 
@@ -309,6 +309,8 @@ Escalate with the appropriate severity — a manual escalation note, or, if you
309
309
 
310
310
  The brief's stop conditions tell you when to STOP and ask. Honor them. If the stop conditions say "STOP if you discover X" and you discover X, you stop. You do not improvise a workaround.
311
311
 
312
+ **Refusing or escalating before you have ever pushed.** The entry-gate refusals in this doc, and a stop condition hit before your first commit, happen before a branch or pull request exists — there is no PR yet to comment on, and an unattended loop has nothing else to read but the task Issue. In that case only, post your refusal or escalation as a comment on the task Issue itself, with `<!-- aeg:developer:stop -->` as the comment's own first line, followed by your reason. This is what lets an automation layer end the run at once rather than wait out a full poll budget for a pull request you were never going to open. Once you have pushed at least once, escalate normally — a PR exists, and every later escalation goes there per your automation layer's own request-input mechanism, never this marker.
313
+
312
314
  ---
313
315
 
314
316
  ## Stop conditions
@@ -411,7 +413,7 @@ Items 1–4 are also composed into one command, `bun packages/aeg-core/bin/verif
411
413
 
412
414
  The checks above are **static**: they prove the change compiles, lints, types and matches its declared surface. They do not prove the feature works. Verification is the separate, mandatory phase that runs the brief's Test Plan against a booted app, after the review passes and before the Principal merges.
413
415
 
414
- **It is a phase, not an actor.** There is no Verifier to dispatch. The plan splits by who can structurally execute it: `vinaya pr report` runs the `[agent]` half's fenced command list from the PR head and writes it into `AEG:EVIDENCE`; the Principal runs the `[principal]` half in a real signed-in browser and ticks its boxes. Both halves must be satisfied before a merge is allowed — the `[agent]` half by the Evidence block existing and matching a fresh recompute (`evidence-fresh`), the `[principal]` half by every unticked `[principal]` box in the PR body, which `test-plan` refuses a merge while any remain unticked.
416
+ **It is a phase, not an actor.** There is no Verifier to dispatch. The plan splits by who can structurally execute it: `vinaya pr report` runs the `[agent]` half's fenced command list from the PR head and writes it into `AEG:EVIDENCE`; the Principal runs the `[principal]` half in a real signed-in browser and ticks its boxes. Both halves must be satisfied before a merge is allowed — the `[agent]` half by the Evidence block existing and matching a fresh recompute (`evidence-fresh`), the `[principal]` half by every unticked `[principal]` box in the PR body, which `review-gate` refuses a merge while any remain unticked — `test-plan` grades the `[agent]` half and the plan's structure only; it is `principalOwed`, so its own `pending` failure never blocks the loop's mechanical gate, and enforcement of the unticked box lives at `review-gate` instead.
415
417
 
416
418
  **Why it exists:** four consecutive features once merged with green CI and were broken at runtime — a missing migration, a missing environment variable, a missing provider, an unexecuted test plan. The static gates ran and passed; the reviews read the diff; nobody booted the app. Verification is the phase that closes that gap.
417
419
 
@@ -424,13 +426,17 @@ The checks above are **static**: they prove the change compiles, lints, types an
424
426
 
425
427
  If the brief declares `unit-tests-only` and the diff really is pure logic, the phase is satisfied by the unit-test gate; record that as the outcome.
426
428
 
427
- ### The `[agent]` half — yours
429
+ ### The `[agent]` half — under the loop, the driver's; standalone, yours
430
+
431
+ **Under the automated dev-review loop, this already ran.** The driver's own per-round evidence report (see [§ After you open the PR — your turn ends here](#after-you-open-the-pr--your-turn-ends-here)) executes the SAME §9 fenced command list, from the SAME PR head, into the SAME `AEG:EVIDENCE` block, the moment your head's CI turns green — every round, automatically. You do not separately run this phase; by the time review finishes, it has already run.
432
+
433
+ **If you are working outside the loop** — dispatched by hand, with no driver watching this PR — the phase is still yours to run explicitly:
428
434
 
429
435
  1. **Boot the app(s)** named in the brief from the worktree, and wait until each is reachable, if your §9 fenced commands need one running. If it does not boot, that is the failure — the plan never gets a chance to run.
430
436
  2. **Run `vinaya pr report --push <n>`.** It executes every line in your §9 fenced command list from the PR head and writes each command plus its actual output into `AEG:EVIDENCE` — never a hand-pasted comment, never a checkbox tick. Round-tripping through prose is how falsely-passing claims slip through; a command this tool did not run is not evidence. **Accepted risk, Principal default:** `pr report --push` executes the PR's own §9 commands on the machine running it, with no check of who is running it — only the PR's author runs it; nothing enforces that today.
431
437
  3. **Stop there.** Do not execute `[principal]` items; you structurally cannot. Mark them as awaiting the Principal.
432
438
 
433
- A failed `[agent]` command makes the PR unmergeable (`vinaya pr report`'s own exit code reflects it, and `evidence-fresh` binds the recorded output to the PR head). Fix on the same branch and re-run `vinaya pr report --push <n>` — it overwrites the block with fresh output, never appends a second copy.
439
+ A failed `[agent]` command makes the PR unmergeable (`vinaya pr report`'s own exit code reflects it, and `evidence-fresh` binds the recorded output to the PR head). Fix on the same branch under the loop, the next round's own automatic report overwrites the block with fresh output; standalone, re-run `vinaya pr report --push <n>` yourself either way it overwrites, never appends a second copy.
434
440
 
435
441
  ### The `[principal]` half — not yours
436
442
 
@@ -43,6 +43,8 @@ You turn an intent and a slice of work into a whole tranche — not one task, an
43
43
 
44
44
  **Forge-native by default — no topology file, no plan PR, no commit, and — usually — no Milestone.** Cut task Issues labeled `vinaya/tranche:<slug>` with the full Planner's rationale (see "The Planner's rationale" below) in each body. `@attalabs/aeg-forge-state` derives topology, dependencies, and lifecycle purely from those forge objects — nothing to write to `main`, nothing for `verify-coherence` to fall back to a file for. This cutover is now complete for every active tranche. Do not create a new topology file for a new tranche; if you find yourself about to write one, stop — the forge-native path below is the whole job. Creating a Milestone is not your job either — that's the Architect's (`roles/architect.md`), and most tranches never get one. If a Milestone already exists naming this slug in its `### Tranche intents` section, this tranche's goal is picked up from that intent line automatically; you neither create nor edit the Milestone to make that happen.
45
45
 
46
+ **A backlog Issue (no `vinaya/tranche:*` label at all) is out of the Planner's scope entirely.** A quick fix with no relationships to size against another task doesn't need a tranche, a topology, or this role — `vinaya issue create` (no `--label`) opens it directly, with the same body grammar (Objectives, Surface, Parts, Test plan, Stop conditions) every task Issue carries, validated by the same gates minus the tranche/Milestone attach, and `vinaya task run --issue <n>` dispatches it straight off that Issue, no planning stage in between. This is not a narrower tranche of one — it is the doctrine's other dispatchable shape, for the one thing the tranche model was never meant to size: a task with no dependency, no conflict, and no sibling to relate it to. If a Type-1 decision, an unrelated shared-package consumer, or a second task depending on it turns up while you're looking at one, that is your signal it needed a tranche after all — cut it into one rather than leaving it a backlog Issue.
47
+
46
48
  Read this with `tranche-model.md` (the model) and the `aeg` skill's session-start forge queries (orientation). The Planner exists because the relationships *between* tasks — dependencies, conflicts, split-vs-combine — are invisible to a brief written in isolation. Seeing them is the whole job.
47
49
 
48
50
  ---
@@ -337,6 +339,8 @@ These four are the same shape as `roles/developer.md`'s entry gate items 2, 3, a
337
339
 
338
340
  `vinaya task dispatch <tranche> <n> [--agent claude | codex | gemini]` is the one command that performs the dispatch act once the gates above pass: it renders the brief, posts it as the frozen `aeg:brief:v1` Issue comment, and — with `--agent` — starts the Developer through `dispatchRole`. Without `--agent` it prints the rendered brief and the manual dispatch instruction and exits `0`, so a human can paste it instead. Either way, posting the frozen comment **is** the `todo → in-flight` transition once the Developer's own worktree opens — nobody writes a status field.
339
341
 
342
+ `vinaya task dispatch` is deprecated in favor of two narrower commands: `vinaya task brief <tranche> <n>` performs preparation only — the render, the refusals, and the frozen post — and starts no agent under any circumstances; `vinaya task run <tranche> <n> --agent <claude|codex|gemini>` composes that same preparation with starting the Developer and running the full review loop unattended — this is the operator's normal way to run the dispatch act end to end, one command from a planned Issue to a reviewed pull request. Use `task brief` when the act is freeze-only — confirming a brief renders cleanly, or handing it to a human to paste — rather than reaching for `task dispatch` with no `--agent`.
343
+
340
344
  **Announce the act on entry, the same way the plan act does (conversational protocol, above).** *"I'm dispatching task <n>. Checking the four gates first, then running `vinaya task dispatch`."* Confirm the render's output looks right before handing it (or its `--agent` session) over — a rendered brief that inherited a stale or malformed rationale field is still your responsibility to catch, the same way a code review catches a bug the linter didn't.
341
345
 
342
346
  **`[NEEDS CLARIFICATION]` markers block dispatch.** If the rendered brief carries any `[NEEDS CLARIFICATION: …]` marker (surfaced from an ambiguous or incomplete rationale field), do not dispatch it — resolve the ambiguity by editing the Issue's rationale (or, if the ambiguity is a real open decision, put it to the Principal) and re-render. A dispatched brief has no open markers; the Developer's own standing autonomy clause governs ambiguity met *during* execution, not ambiguity left unresolved at dispatch.
@@ -54,8 +54,8 @@ The Principal holds final authority over:
54
54
  - **Right to reject.** The Principal can reject a direction at any phase — idea, brief, PR, or post-merge — and the system accepts that without pushback. Agents can surface concerns but not override.
55
55
  - **Ratification.** Type 1 decisions are not ratified until the Principal explicitly says so. PENDING decisions wait; they do not auto-promote.
56
56
  - **Editing an already-declared Milestone.** `vinaya milestone edit <n> --body-file <path>` corrects a Milestone's goal or `Release:` field after the Architect's one-time write — the same product call declaring it was (`milestone-model.md` §5), never the Architect's or Planner's (`roles/architect.md` "What you do NOT do").
57
- - **Changing a task's scope mid-flight.** `vinaya issue objectives edit <n> --add "<sentence>" | --drop O<k> | --replace O<k> "<sentence>" --reason "<text>"` rewrites the task Issue's `## Objectives` section through the validated forge-write path and posts one versioned, findable comment recording the previous list, the new list, and the reason. This voids the standing review verdict on that task's open PR — `vinaya review status` names it `objectives moved — re-review required` (once the sibling task wiring that reason lands).
58
- - **Ruling on a contested finding.** `vinaya pr rule <pr> --file <ruling.md>` posts the Principal's decision as its own marked, versioned PR comment — never mistaken for a code-review or security verdict, since it refuses a file carrying verdict grammar.
57
+ - **Changing a task's scope mid-flight.** `vinaya issue objectives edit <n> --add "<sentence>" | --drop O<k> | --replace O<k> "<sentence>" --reason "<text>"` rewrites the task Issue's `## Objectives` section through the validated forge-write path and posts one versioned, findable comment recording the previous list, the new list, and the reason. This voids the standing review verdict on that task's open PR — `vinaya review status` names it `objectives moved — re-review required` (once the sibling task wiring that reason lands). If a `dev-review-loop` is running that task, it reads objectives from this same versioned edit comment (never the live Issue body), so its next round picks up the change automatically; if the edit lands between a round's reviewer dispatch and its verdicts coming back, the loop discards that round's verdicts unheld and unpublished and pauses with reason `objectives_changed`, naming the superseded version, the new version, and this exact edit invocation — resume it the same way as any other pause, with `vinaya dev-review-loop --resume <pr>`.
58
+ - **Ruling on a contested finding.** `vinaya pr rule <pr> --file <ruling.md>` posts the Principal's decision as its own marked, versioned PR comment — never mistaken for a code-review or security verdict, since it refuses a file carrying verdict grammar. A ruling posted after a clean verdict already exists voids it (`review-validity-v1` task 3, O2): every rendered verdict carries the newest ruling ordinal it was judged against on a `Ruling ordinal:` line, and the merge gate compares that ordinal to the PR's actual newest ruling — a mismatch reads as unbound, the same fail-closed shape `objectives moved — re-review required` already uses, until reviewers re-cast against the new ruling. If a `dev-review-loop` is running that task and a ruling lands between a round's reviewer dispatch and its verdicts coming back, the loop discards that round's verdicts unheld and unpublished and pauses with reason `ruling_posted`, naming the superseded ordinal, the new ordinal, and the ruling's own marker identifier — resume it the same way as any other pause, with `vinaya dev-review-loop --resume <pr>`.
59
59
 
60
60
  ---
61
61
 
@@ -82,6 +82,10 @@ In a typical working period:
82
82
  6. Reviews PRs on the forge. Code review for correctness and scope compliance.
83
83
  7. Merges PRs after Planner spec review and CI passes. (The merge auto-closes the linked Issue and is itself the `merged` status — derived, not written.)
84
84
 
85
+ **A quick fix skips step 3 entirely.** For a backlog Issue — no `vinaya/tranche:*` label, no relationship to another task worth a Planner's dig — `vinaya issue create` (no `--label`) opens it directly, and `vinaya task run --issue <n> --agent <vendor>` dispatches it straight off that Issue: same brief grammar, same unattended loop, same review gate, on branch `task/issue-<n>`. This is the Principal's own dispatch act for the one shape that never needed a tranche — step 4 above, run without steps 1–3 ever happening. If the work turns out to need a dependency, a conflict, or a Type-1 decision after all, that is the signal to route it to the Planner instead, cut into a real tranche.
86
+
87
+ `vinaya task status` answers "what's running right now" across every dispatched task in one glance — each open task Issue with a frozen brief, its pull request, and whether its loop is `running` (with the driver pid), `paused` (with the reason), `published`, or has `no driver` — read from the outbox and the forge, never from a terminal-by-terminal `ps` scan. `vinaya task status <tranche> <n>` narrows to one task with its last round's verdict lines and the exact resume command.
88
+
85
89
  The Principal does not need to be present during task execution. Dispatch and escalation routing are handled by the Planner (and an automation layer, if used); the Developer executes. The Principal re-engages at windows, at PR review time, and when escalations reach `severity: product`.
86
90
 
87
91
  ---
@@ -99,6 +103,8 @@ Until a coordinator program exists, the Principal is the loop's coordinator, per
99
103
 
100
104
  A coordinator program replaces this by-hand duty when one exists.
101
105
 
106
+ **Recovering the automated loop.** `vinaya dev-review-loop --task <n>` is that coordinator. If it crashes, or a poll (waiting for the PR to appear, or for the branch head to change after a gate-red dispatch) times out, the recovery is simply re-running the same command against the same task. Round `1`'s own entry checks the forge first: an already-open pull request on the developer's branch means it attaches — no developer is started — and a remote branch with no open pull request yet resumes the recorded developer session once, instructed to open it. Either way, a re-run never starts a second developer.
107
+
102
108
  ---
103
109
 
104
110
  ## Communication style with the Principal
@@ -57,6 +57,8 @@ A review turn started via `vinaya dispatch code-reviewer --agent <vendor>` carri
57
57
  - The task Issue carries the brief, frozen on its `aeg:brief:v1` comment, posted by the Planner's dispatch act before the Developer started.
58
58
  - Your job is to judge whether the PR does what the brief said, safely and honestly — not to improve it yourself.
59
59
 
60
+ **Dispatched by `vinaya dev-review-loop` (unattended)?** You do not run `vinaya review post` yourself — write `findings.txt` and `report.txt` to the work directory the dispatch names, plus `objectives.txt` (one `O<n>|MET|<evidence>` or `O<n>|NOT MET|<evidence>` line per objective) whenever the task carries objectives; leave `findings.txt` empty if there are none. A work directory still missing a required file after the dispatch is an infrastructure failure, not a clean approval.
61
+
60
62
  ## Entry gate (self-locating) — refuse if it isn't your turn
61
63
 
62
64
  - **No open PR** for the task → *"Nothing to review — there's no open PR. Come back when one is open."*
@@ -97,7 +99,7 @@ This is why the review is a separate pass and not something the Developer does t
97
99
 
98
100
  ## Output format
99
101
 
100
- **Run `vinaya review post --role code-reviewer` with this data; do not hand-type a verdict comment.** The `VERDICT:` line is bare — no bold, no heading, no blockquote — it is machine-read by the pre-merge review gate. So is the `Judged head:` line immediately below it: the gate binds your verdict to the exact commit you reviewed, and a verdict that does not cover the PR's current head does not count as clean, however clean its `VERDICT:` value is (`review-gate.ts`). A third head line, `Objectives version:`, binds your verdict the same way to the objectives list you judged it against — a hash the command computes from the Issue's (or the PR body's) `## Objectives` list; if the Issue's objectives change after you cast a verdict, the gate treats it exactly like a stale head. A verdict also holds for a later head whose patch identity equals the judged head's: the gate compares `git diff <base>...<sha> | git patch-id --stable` on both sides, so a merge from the main branch or a rebase that leaves the PR's own patch untouched keeps your verdict alive rather than costing a round to re-cast it over changes you already read. That comparison ignores whitespace, so a whitespace-only push also keeps your verdict; any change to non-whitespace content does not, and comes back to you. Free-typing this shape into `gh pr comment` is no longer the sanctioned path — a decorated heading or a bolded/blockquoted line the gate's line-anchored parser cannot see reaches the forge looking correct to a human reader and is invisible to `verify-review-gate.ts`, with no pointer back to what was wrong until CI goes red. `vinaya review post` resolves the PR's real head itself (`gh pr view --json headRefOid` — never a self-reported sha), renders every structural line from your validated inputs, posts the comment, and refuses to exit 0 unless its own post re-parses clean through the exact same `extractCodeReviewVerdict` function the gate calls:
102
+ **Run `vinaya review post --role code-reviewer` with this data; do not hand-type a verdict comment.** The `VERDICT:` line is bare — no bold, no heading, no blockquote — it is machine-read by the pre-merge review gate. So is the `Judged head:` line immediately below it: the gate binds your verdict to the exact commit you reviewed, and a verdict that does not cover the PR's current head does not count as clean, however clean its `VERDICT:` value is (`review-gate.ts`). A third head line, `Objectives version:`, binds your verdict the same way to the objectives list you judged it against — a hash the command computes from the Issue's (or the PR body's) `## Objectives` list; if the Issue's objectives change after you cast a verdict, the gate treats it exactly like a stale head. A fourth line, `Ruling ordinal:`, renders unconditionally — `0` when the PR carried no principal ruling at cast time — and binds the same way to the newest principal ruling on the PR (`review-validity-v1` task 3): a ruling posted after you cast your verdict turns the gate red exactly like a stale head, until you re-cast against it. A verdict also holds for a later head whose patch identity equals the judged head's: the gate compares `git diff <base>...<sha> | git patch-id --stable` on both sides, so a merge from the main branch or a rebase that leaves the PR's own patch untouched keeps your verdict alive rather than costing a round to re-cast it over changes you already read. That comparison ignores whitespace, so a whitespace-only push also keeps your verdict; any change to non-whitespace content does not, and comes back to you. Free-typing this shape into `gh pr comment` is no longer the sanctioned path — a decorated heading or a bolded/blockquoted line the gate's line-anchored parser cannot see reaches the forge looking correct to a human reader and is invisible to `verify-review-gate.ts`, with no pointer back to what was wrong until CI goes red. `vinaya review post` resolves the PR's real head itself (`gh pr view --json headRefOid` — never a self-reported sha), renders every structural line from your validated inputs, posts the comment, and refuses to exit 0 unless its own post re-parses clean through the exact same `extractCodeReviewVerdict` function the gate calls:
101
103
 
102
104
  ```
103
105
  vinaya review post --role code-reviewer --pr <n> --verdict APPROVE|REQUEST_CHANGES \
@@ -109,7 +111,7 @@ vinaya review post --role code-reviewer --pr <n> --verdict APPROVE|REQUEST_CHANG
109
111
  <!-- AEG:CLAIM: apps/cli/src/commands/review-post.ts contains:export function checkDocCorrectnessSearch(description: string, lineNumber: number): void { -->
110
112
  The findings file is one finding per line, `SEVERITY|file:line|description` (`|`-delimited: `file:line` already contains a colon), severity one of `BLOCKER|MAJOR|MINOR`. The `description` field begins with the finding's id and class, `F<n> <class>: <what is wrong>` — class is one of `correctness`, `type-safety`, `performance`, `resource-leak`, `maintainability`, `scope`, `test-honesty`, `doc-correctness`, or `other:<slug>` when none fits. A `doc-correctness` finding carries a `Search:` pattern — a repo-wide `git grep -n -iE` pattern, with no path filter — and is resolved only when every hit it returns at the new head is a true statement. It goes at the end of the description; run it before writing it down and confirm it matches the anchored line. The pattern carries no `|` character — this file is `|`-delimited — so reach for a character class, or the stem the copies share, rather than alternation. `vinaya review post` refuses the finding outright when the pattern is missing or path-filtered: a false sentence is a text-duplication defect, not a location defect. What no check can judge, and what stays yours, is whether the pattern really matches the claim and whether each hit it returns is true. This is free text that carries no `|` character inside the existing field, not a grammar change. Omit `--findings-file` for zero findings.
111
113
 
112
- The objectives file is one line per objective, `O<n>|MET|<evidence>` or `O<n>|NOT MET|<evidence>` — the same `|`-delimited shape, evidence being the rest of the line. **Judge MET/NOT MET from `git diff`, never from the Developer's own report** — the objective is a fact about the code, and the Developer's Decisions section is not evidence for it, the same discipline check 3's `SCOPE:` line already holds you to. `--objectives-file` is required whenever the closed Issue (or the PR body's own `## Objectives` section, when the PR closes none) has a list to judge; its ids must cover that list exactly — a missing or extra `O<n>` is refused before posting. An Issue that predates the objectives cutover renders no `Objectives version:` line and no block at all, matching the gate's own skip for that stock. The command renders this exact shape (kept here so a human or a debugging agent can still read what it produces — this is documentation, not something to write by hand):
114
+ The objectives file is one line per objective, `O<n>|MET|<evidence>` or `O<n>|NOT MET|<evidence>` — the same `|`-delimited shape, evidence being the rest of the line. **Judge MET/NOT MET from `git diff`, never from the Developer's own report** — the objective is a fact about the code, and the Developer's Decisions section is not evidence for it, the same discipline check 3's `SCOPE:` line already holds you to. **`NOT MET` requires a code or test location as its evidence** — a real `file:line`, the same shape a finding's own location takes, naming where the objective is unmet in the diff. Evidence that names only a PR body section, a comment, or a role file is not a location the objective's own unmetness lives at — the dev-review-loop's own report parser reclassifies such a line `MET (prose note)` before it ever reaches a round's outcome, the same `isProseLocation` predicate (`@attalabs/aeg-core`) the body-located `MINOR` cap already applies to a finding's location, so writing one costs the round nothing but a wasted line: it never blocks, it is never re-litigated next round, and it is not what you intended. If the objective is genuinely unmet, point at the code that fails to meet it. **`NOT MET` means you verified the objective is not met — never a decline.** An objective outside your own lens (a security-shaped objective reaching a code-reviewer verdict, or the reverse) is `MET`, citing the other reviewer's evidence or verifying it yourself directly — never `NOT MET` with an out-of-scope note; a reviewer that declines an objective this way forces a review round over nothing. `--objectives-file` is required whenever the closed Issue (or the PR body's own `## Objectives` section, when the PR closes none) has a list to judge; its ids must cover that list exactly — a missing or extra `O<n>` is refused before posting. An Issue that predates the objectives cutover renders no `Objectives version:` line and no block at all, matching the gate's own skip for that stock. The command renders this exact shape (kept here so a human or a debugging agent can still read what it produces — this is documentation, not something to write by hand):
113
115
 
114
116
  ```
115
117
  VERDICT: APPROVE | REQUEST CHANGES
@@ -118,6 +120,8 @@ Judged head: <sha>
118
120
 
119
121
  Objectives version: <hash>
120
122
 
123
+ Ruling ordinal: <k>
124
+
121
125
  BRIEF CONFORMANCE: [does it do what the brief asked? 1-2 sentences]
122
126
  SPEC CONFORMANCE: [does it agree with the Product spec? "n/a — no Product named" | "clean" | drift listed in findings]
123
127
 
@@ -137,15 +141,15 @@ DOCS: [tier-appropriate | missing items listed in findings]
137
141
  <!-- AEG:CLAIM: packages/aeg-core/src/verdict-extraction.ts contains:function firstFiveLines(comment: string): string { -->
138
142
  <!-- AEG:CLAIM: apps/cli/src/commands/review-post.ts contains:export function renderEscalationComment(input: EscalationInput): string { -->
139
143
  <!-- AEG:CLAIM: apps/cli/src/commands/review-post.ts contains:export function checkRenderedComment(body: string, expectation: RenderExpectation): RenderCheckResult { -->
140
- `vinaya review post` also refuses before posting anything if you pass a BLOCKER finding together with `--verdict APPROVE`, or any `NOT MET` objective together with `--verdict APPROVE` — both contradictions are caught mechanically, not left to review. Before its own post reaches the forge, it refuses to post anything the gate would misread: it runs the exact same `VERDICT:`/`Judged head:`/`Objectives version:` extraction the merge gate uses over the rendered comment, and requires exactly the intended verdict to come back. Free text in a finding, a conformance field, an objective's evidence, or `--scope-evidence-file` can say `VERDICT` or span multiple lines without risk — the extraction reads only a comment's first five lines, and in a code-reviewer or security comment a caller field never opens one of those lines (it only trails a fixed, renderer-owned label); an escalation's `--summary` is the one field that does occupy line 5 outright, which is exactly why this pre-post re-parse exists rather than construction alone.
144
+ `vinaya review post` also refuses before posting anything if you pass a BLOCKER finding together with `--verdict APPROVE`, or any `NOT MET` objective together with `--verdict APPROVE` — both contradictions are caught mechanically, not left to review. Before its own post reaches the forge, it refuses to post anything the gate would misread: it runs the exact same `VERDICT:`/`Judged head:`/`Objectives version:`/`Ruling ordinal:` extraction the merge gate uses over the rendered comment, and requires exactly the intended verdict to come back. Free text in a finding, a conformance field, an objective's evidence, or `--scope-evidence-file` can say `VERDICT` or span multiple lines without risk — the extraction reads only a comment's first five lines (the ruling ordinal's own first-seven-line window is wider still), and in a code-reviewer or security comment a caller field never opens one of those lines (it only trails a fixed, renderer-owned label); an escalation's `--summary` occupies line 7 at minimum (`Ruling ordinal:` renders unconditionally ahead of it), which is exactly why this pre-post re-parse exists rather than construction alone.
141
145
 
142
146
  - **BLOCKER** — blocks merge. Wrong behavior; a dishonest test; a document the brief's documentation-update list names that is absent from the diff or states the changed behavior backwards; a scope violation; a **spec contradiction**.
143
- - **MAJOR** — surfaced, never blocks. A likely bug, weak error handling, spec drift short of contradiction, a wrong sentence in a document the brief did not name. Listed in the verdict, shown to the Principal at the go (the Principal's decision, before merge, on whether any surfaced finding blocks this change), published in the record — the Principal decides whether a surfaced finding blocks this change.
147
+ - **MAJOR** — blocks when the repository's policy says so. A likely bug, weak error handling, spec drift short of contradiction, a wrong sentence in a document the brief did not name. Listed in the verdict, shown to the Principal at the go (the Principal's decision, before merge, on whether any surfaced finding blocks this change), published in the record — the Principal decides whether a surfaced finding blocks this change.
144
148
  - **MINOR** — noted; Developer's discretion. Every register and slop finding (check 8) and every reader-readability finding (check 6) is at most MINOR.
145
149
 
146
150
  The `SCOPE:` line, and any blast-radius assertion under check 7, are evidence-backed claims: each may be written only after its named check has run — `git diff origin/main...HEAD --stat` for scope, the consumer grep for multi-project reach — with the output quoted in the same review comment the verdict lands in: a fenced block directly below the verdict block, so evidence sits in one predictable place. The other verdict lines carry no such requirement.
147
151
 
148
- VERDICT is `REQUEST CHANGES` if and only if at least one BLOCKER finding exists. Otherwise VERDICT is `APPROVE`, with every MAJOR and MINOR finding still listed under FINDINGS — an APPROVE is not silence about them, it is a statement that none of them blocks. (A REQUEST CHANGES sets the PR's review decision to `CHANGES_REQUESTED`, which is the derived `changes-requested` status — no one writes it down.) You do not type that decision by hand: `vinaya review post` derives it from the findings file you pass it — REQUEST CHANGES iff a BLOCKER is present, APPROVE otherwise — and renders the bare `VERDICT:` line and the `Judged head:` binding itself. `--verdict` is optional; if you pass one anyway, the command refuses before posting anything when it disagrees with the derivation, naming the derived value. It refuses to exit 0 unless its own post re-parses clean through the gate's extractors. The severity you assign to each finding is caller-asserted and not checked — the derivation trusts your severities, not your arithmetic.
152
+ VERDICT is `REQUEST CHANGES` if and only if at least one BLOCKER finding exists. Otherwise VERDICT is `APPROVE`, with every MAJOR and MINOR finding still listed under FINDINGS — an APPROVE is not silence about them, it is a statement that none of them blocks. (A REQUEST CHANGES sets the PR's review decision to `CHANGES_REQUESTED`, which is the derived `changes-requested` status — no one writes it down.) You do not type that decision by hand: `vinaya review post` derives it from the findings file you pass it — REQUEST CHANGES iff a BLOCKER is present, APPROVE otherwise — and renders the bare `VERDICT:` line and the `Judged head:` binding itself. `--verdict` is optional; if you pass one anyway, the command refuses before posting anything when it disagrees with the derivation, naming the derived value. It refuses to exit 0 unless its own post re-parses clean through the gate's extractors. The severity you assign to each finding is caller-asserted and not checked — the derivation trusts your severities, not your arithmetic. A finding whose own `file:line` names the PR body, a comment, or a role file is capped to MINOR by the policy evaluator before it counts toward the threshold, regardless of the severity you assign it — write its real severity anyway; the cap is applied for you, never something to pre-empt by under-reporting.
149
153
 
150
154
  A re-review (a fresh-context reviewer invoked again after the Developer pushes fixes) does two things, in order. First, it reports the state of every prior id — `F1`, `F2`, … — before listing any new finding, one of exactly `open`, `fix-claimed`, `reproduced`, or `resolved` per id, confirmed by re-checking the artifact, never by assuming a push means a fix. For a prior doc-correctness finding, "re-checking the artifact" means re-running its `Search:` pattern at the new head, with no path filter, and reading every hit; the finding is resolved only when every remaining hit is a true statement, and a still-false sibling copy keeps the original id open rather than becoming a new finding. Write that state directly in the finding's own description, `F<n> <class> <state>: <text>` — that is what `vinaya review post` reads back on the next round to confirm every prior id is still accounted for; a findings file that drops a prior id with no state token is refused before posting. An id is assigned once, when a finding is first reported, and never renumbered; rewording a finding's description does not create a new id. The prior ids and the previously judged head are read from the prior verdict comment on the PR: its FINDINGS list and its `Judged head:` line. Every prior objective reappears too — the same rule, one level up: a re-review's `--objectives-file` that drops a prior `O<n>` is refused before posting, the id read from the prior comment's own `OBJECTIVES:` block. Second, round two is delta-only for every non-blocking severity: it judges only the lines changed since the previously judged head, and `vinaya review post` refuses a MAJOR or MINOR finding whose `file:line` falls outside that diff. A BLOCKER outside the delta still drives the verdict on any round and is always accepted. A prior BLOCKER you mark `resolved` keeps its BLOCKER severity in the record but no longer drives the verdict — `vinaya review post` derives the verdict only from findings not marked `resolved`; mark `fix-claimed` or `reproduced` instead if it is not actually fixed. After round two the Principal decides; there is no round three unless the Principal orders it.
151
155