@attalabs/vinaya 0.2.0 → 0.4.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.
@@ -36,7 +36,7 @@ The same check implementations run at ring 0 and ring 1 — one codebase, two en
36
36
  | --- | --- | --- | --- | --- | --- | --- |
37
37
  | Editing a governed file | Ever had a teammate change code they clearly never read the docs for? | hook | Refuses an edit to a governed file until the document that governs it has been read in this session. | **Edit gate** | The architecture document that owns the file has been loaded in this session — no editing governed code from pattern-matching alone | `.claude/hooks/check-skill.sh` |
38
38
  | `git commit` | Ever pushed code that didn't even compile? | hook | Refuses a commit that does not build or pass its own checks, before the broken state exists at all. | **Git pre-commit hooks** | The code compiles, passes lint and formatting, the unit tests pass, and the commit message follows the repo's message convention | `.husky/pre-commit` |
39
- | `git push` | Ever had someone accidentally push straight to main? | hook | Refuses a push that would land straight on main, on the machine that attempted it. | **Git pre-push hook** | No pushing straight to `main` — a direct push cannot be refused for a commit that already landed on `main` some other way (e.g. a repo-admin action outside the hook's reach); that residual class now has a ring-2 detection backstop (see below, task 24 item 1). A task branch's name must exactly match its planned task row in the tranche plan. A task branch whose most recent pull request is already `MERGED` or `CLOSED` is refused outright — closing the class of bug where stale local work lands on an already-resolved branch, silently reopening/confusing forge state (live-fire incident, 2026-07-03: six topology rows landed on a plan PR's branch after that PR had merged); this check is fail-open (hook-dependent), so it too now has a ring-2 detection backstop (see below, task 24 item 2). This check is deliberately **fail-open**: if the forge cannot be reached (network flake, auth issue, rate limit) it is treated the same as "no pull request exists yet" and the push is allowed — a transient reachability issue must never block every push. On a task branch's first push (before its PR exists), `verify-dispatch` also runs itself here — see the dedicated row below (task 25). Any changed code that has a designated owning document must have that document updated in the same branch, OR (task 29) carry an actor-verified `vinaya/waiver:docs` label on the open pull request — a waiver is a forge-authenticated human act, never a parseable string. Before a PR exists (first push) no such label can exist yet, so an owned-doc violation here is **warn-with-declared-intent, not a hard block**: the push always succeeds, and the printed message states plainly that ring 1 stays red until a principal applies the label or the doc is updated — this replaces the earlier first-push `Doc-waiver:` commit-trailer self-service, which is now dead code (that path is superseded). `Doc-ack:` (URL-pointer acknowledgment) is unaffected by the waiver change — still read from the branch's own last commit message before a PR exists, or — for a pre-authoring dry run via `verify-dispatch --simulate`, before any commit exists — from a local drafted-PR-body file. | `.husky/pre-push` |
39
+ | `git push` | Ever had someone accidentally push straight to main? | hook | Refuses a push that would land straight on main, on the machine that attempted it. | **Git pre-push hook** | No pushing straight to `main` — a direct push cannot be refused for a commit that already landed on `main` some other way (e.g. a repo-admin action outside the hook's reach); that residual class now has a ring-2 detection backstop (see below, task 24 item 1). A task branch's name must exactly match its planned task row in the tranche plan. A task branch whose most recent pull request is already `MERGED` or `CLOSED` is refused outright — closing the class of bug where stale local work lands on an already-resolved branch, silently reopening/confusing forge state (live-fire incident, 2026-07-03: six topology rows landed on a plan PR's branch after that PR had merged); this check is fail-open (hook-dependent), so it too now has a ring-2 detection backstop (see below, task 24 item 2). This check is deliberately **fail-open**: if the forge cannot be reached (network flake, auth issue, rate limit) it is treated the same as "no pull request exists yet" and the push is allowed — a transient reachability issue must never block every push. On a task branch's first push (before its PR exists), `verify-dispatch` also runs itself here — see the dedicated row below (task 25). Any changed code that has a designated owning document must have that document updated in the same branch, OR (task 29) carry an actor-verified `vinaya/waiver:docs` label on the open pull request, OR (task 4) carry a `Doc-neutral: <pointer> <note>` PR-body declaration whose matched-file diff is verified comment/whitespace-only (`isMechanicallyNeutralDiff`) — a waiver is a forge-authenticated human act, never a parseable string, and a Doc-neutral declaration is never sufficient by itself; only diff-confirmed evidence satisfies it. Before a PR exists (first push) no waiver label can exist yet, so an owned-doc violation here is **warn-with-declared-intent, not a hard block**: the push always succeeds, and the printed message states plainly that ring 1 stays red until a principal applies the label, the doc is updated, or a Doc-neutral declaration with confirming evidence is added — this replaces the earlier first-push `Doc-waiver:` commit-trailer self-service, which is now dead code (that path is superseded). `Doc-ack:` (URL-pointer acknowledgment) is unaffected by the waiver change — still read from the branch's own last commit message before a PR exists, or — for a pre-authoring dry run via `verify-dispatch --simulate`, before any commit exists — from a local drafted-PR-body file. | `.husky/pre-push` |
40
40
  | Creating a pull request, or editing its title/description | Ever opened a PR that was missing half the info reviewers needed? | event | Refuses to open or edit a pull request until it carries everything a reviewer needs to judge it. | **Forge command gate** — the raw command is refused; the validated wrapper is the only path | The title follows the naming convention. The description carries every required section of the task brief: the impact tier, the executing model, the project(s) touched, a tagged test plan, the file surface the task may touch, the documentation it must update, the isolated-worktree setup step, the stop conditions, the standing autonomy clause, the Issue this work closes,. A *plan* pull request must never close a task Issue. The documentation required by the change's impact tier is present — a doc-coverage finding here is honored ONLY by an actor-verified `vinaya/waiver:docs` label, never a body field. The branch, its planned task row, and the Issue it closes all agree with each other. **Single-plan-PR guard (task 19):** a diff that touches a tranche's topology file (`aeg-root/tranches/*.md`, excluding `completed/`) is refused outright if another OPEN pull request's diff already touches that SAME tranche's topology file — closing the race that produced two concurrent plan pull requests for the same tranche, each cut before the other's newly-added Issue was visible. Ordinary task pull requests never touch a topology file, so this never fires for them. A PR opened via the web UI bypasses this wrapper entirely, so this predicate (extracted to `packages/aeg-core/src/single-plan-pr.ts`, task 24 item 3) now also has a ring-1 CI backstop (see below) — one implementation, two enforcement points. | `packages/aeg-core/bin/open-pr.ts` |
41
41
  | Creating a task Issue, or editing its title/description | Ever seen a ticket with zero context on why it exists? | event | Refuses to open or edit a task Issue until it carries the full reasoning behind the task. | **Forge command gate** → validated wrapper | The title follows the naming convention, and the Issue carries the complete planning rationale — what the task is and is not, its sizing, the projects and blast radius it touches, why its dependency edges exist, the known traps, the suggested agent class, the stop-and-escalate conditions, and the documents it must keep coherent. Issues outside the task system pass through untouched. **Beyond presence, three content checks block: the rationale's declared surface must not reach into a shared collision domain (`.aeg/packages`) that none of its declared projects owns without either a second project or an explicit `blast-radius-ack:` line; the body must carry no brief-shaped section (`## References`, `Technical surface map`, `Premise`, `Step 0`, `Test Plan`) — brief content goes stale before work starts and belongs in the brief; and the rationale must name at least one concrete doc/skill path (or the explicit `no-doc-surface` sentinel), which is the only read-obligation signal a forge write produces, since the skill-check hook fires on file edits and cutting an Issue edits no file. A fourth check warns, never blocks: two open task Issues naming the same collision domain with no mutual `Conflicts-with` edge.** **Label-name validity is checked here too:** a tranche label is the one label whose value the Planner invents, and `vinaya/tranche:` spends 15 of GitHub's 50-character label budget before the slug starts — so a slug that reads fine in prose can be one the forge refuses to create. The wrapper refuses an over-long slug with the exact overshoot, at the point the label is first applied, rather than letting `gh` fail with an opaque 422. **Dependency-edge amendments have exactly one path — the `amend-deps` subcommand, which rewrites the structured `Dependency rationale` field AND appends the `**Amendment (...)**` note in one atomic write, gated on a runtime round-trip parse (`parseRationaleDeps` must read back exactly the requested edges) — no code path changes one representation without the other (closing a five-incident field-vs-amendment drift class this session; modeled on the actor-verified waiver pattern: the sanctioned path is the only path).** | `packages/aeg-core/bin/open-issue.ts` |
42
42
  | Writing to pull requests or Issues through the raw API | Ever had a bot silently edit a PR or issue behind your back? | event | Refuses raw API writes to pull requests and Issues, so nothing can edit them behind the gates. | **Forge command gate** | Refused outright — there is no unvalidated path | `.claude/hooks/check-forge-gates.sh` |
@@ -44,7 +44,8 @@ The same check implementations run at ring 0 and ring 1 — one codebase, two en
44
44
  | Starting the Dig (before authoring a brief) / starting Step 0 (before executing one) / **every push on a task branch before its PR exists** | Ever started work only to find out halfway it was blocked on something else? | hook | Refuses to start work until every precondition for the task is checked live and found clear. | **`verify-dispatch` CLI** (`packages/aeg-core/bin/verify-dispatch.ts` — mandated by `roles/developer.md` and `skills/brief-authoring/SKILL.md`; **now also hook-automated** — task 25) | Every dispatch precondition is re-checked live against the forge (the task's row/Issue are derived directly from a Milestone + `vinaya/tranche:<slug>`-labeled Issues as of the forge-native migration — no `origin/main` file read involved; leftover-branch detection still fetches `origin/main` for its own commit-count comparison): the task's row and Issue exist; the Issue passes the rationale gate; every `depends-on` PR is merged and no `conflicts-with` PR is open/in-flight; every named project's prior tranche is archived. (2026-07-13: the immediately-prior task's three-predicate archival bar — the row-adjacency predicate — was removed once the provenance-posting signal it existed to protect became automated.) A `NOT READY` result is a stop condition. Every `gh` call this script makes carries an explicit `-R <owner>/<repo>` (task 23) — from a linked `git worktree add` checkout, an untargeted `gh issue list`/`gh issue view`/`gh pr list` silently scopes to the wrong repo (or returns nothing), which previously produced false prior-tranche-archival blockers. **`.husky/pre-push`** now runs this gate itself on a `task/*/*` branch's first push (before a PR exists), refusing on genuine `NOT READY` and failing OPEN — loudly — when a `gh auth status` reachability probe fails or `verify-dispatch`'s own repo/token resolution fails (task 18's fail-open precedent). The gate reads only the `dispatch-readiness:` predicate, not verify-dispatch's combined exit code, which also folds in `leftover-detection` (a pre-Step-0 "safe to branch fresh" advisory that would otherwise false-block every push after the first on a task legitimately mid-flight — discovered live while building this gate). Once a PR exists, later pushes skip the gate: dispatch was already validated once. | `packages/aeg-core/bin/verify-dispatch.ts` |
45
45
  | Opening a task PR whose surface includes real code | Ever had a PR's description quietly stop matching what the code does? | event | Refuses a code-carrying pull request whose description no longer matches what it changes. | **Brief Validation — `checkPremiseCoverage`** | The PR body carries a `Premise:` block with at least one assertion (`contains`/`absent`/`sha256`) whose path falls inside the task's surface map — re-assertable at any point via `verify-dispatch --premise` or `verify-task`. | `packages/aeg-core/src/brief-validation.ts` |
46
46
  | Opening a task PR (final self-check before creation) | Ever opened a PR and only then discovered the tests were failing? | event | Runs the whole exit check before a pull request is created, so failures surface first. | **`verify-task` CLI** (`packages/aeg-core/bin/verify-task.ts` — mandated by `roles/developer.md`; **now also hook-automated** — task 25) | Typecheck, lint, tests, build, `verify-docs --pr`, and the premise coverage/recheck pair all pass, as one summary, against the PR's actual diff, before `open-pr.ts` is invoked. **`open-pr.ts` now runs this composite itself** for task branches (via `gatePlanForBranch`), invoked wholesale rather than partially re-implemented — non-task branches are unaffected (byte-identical gate set). `verify-docs --pr` runs twice on a task-branch PR-open as an accepted, measured overlap (~4s with a warm turbo cache). | `packages/aeg-core/bin/verify-task.ts` |
47
- | Spawning a check (`vinaya check`, ring-0 pre-push AND ring-1 CI) | Ever had a check quietly read a secret it had no business seeing, because nothing scoped what it could reach? | hook | Governs which environment variables a spawned check's child process can see, instead of every check inheriting the full parent environment unconditionally. | **Env allowlist** (`CheckSpec['env']`, `apps/vinaya/cli/src/checks/contract.ts`; construction: `buildCheckEnv`, `apps/vinaya/cli/src/checks/runner.ts`) | **Warn status today (task 2):** the grammar (`true` / `{ optional: true }` / `{ anyOf: [...] }` / a literal string) and the construction function exist and are unit-tested, but `runOne`'s `spawn()` call still passes no `env` override every check still inherits the full parent environment, unchanged. `vinaya check` prints one warning line per check whose bin reads `process.env`/`Bun.env`/`Deno.env` with no `env` declared (fires identically at the ring-0 pre-push hook and ring-1 CI, since both invoke the same `vinaya check --all`); `vinaya doctor` carries the same diagnostic permanently. All 15 core checks in `registry.ts` carry an audited declaration. **Once flipped (task 3, a later minor):** a spawned check's child process sees only a fixed baseline (`PATH`, `LANG`, `HOME`, `HTTPS_PROXY`, `HTTP_PROXY`, `NO_PROXY`, `TMPDIR`) plus whatever its own `env` declaration explicitly forwards an undeclared variable a check's own code reads becomes genuinely invisible to it, not merely warned about. | `apps/vinaya/cli/src/checks/runner.ts` |
47
+ | Spawning a check (`vinaya check`, ring-0 pre-push AND ring-1 CI) | Ever had a check quietly read a secret it had no business seeing, because nothing scoped what it could reach? | hook | Governs which environment variables a spawned check's child process can see, instead of every check inheriting the full parent environment unconditionally. | **Env allowlist** (`CheckSpec['env']`, `apps/vinaya/cli/src/checks/contract.ts`; construction: `buildCheckEnv`, `apps/vinaya/cli/src/checks/runner.ts`) | **⚠️ BREAKING, live (task 3):** a spawned check's child process sees only a fixed baseline (`PATH`, `LANG`, `HOME`, `HTTPS_PROXY`, `HTTP_PROXY`, `NO_PROXY`, `TMPDIR`) plus whatever its own `env` declaration (`true` / `{ optional: true }` / `{ anyOf: [...] }` / a literal string) explicitly forwards — **no longer the full parent environment.** An undeclared variable a check's own code reads is now genuinely invisible to it, not merely warned about; a `true` or unsatisfied `anyOf` declaration missing from the caller's environment synthesizes a `CheckError` and the check never spawns at all. All 15 core checks in `registry.ts` carry an audited declaration, and every core check that spawns `gh` or reaches the forge through the token-resolution chain additionally forwards `GITHUB_TOKEN`/`GH_TOKEN` as `{ optional: true }` on a CI runner those env vars are `gh`'s only authentication path, so a check that shells out without forwarding them runs unauthenticated there (hard-failing or silently degrading, depending on the bin's failure mode) while passing locally on `gh`'s keyring; the pairing is coupling-tested (`apps/vinaya/cli/tests/checks/registry-env.test.ts` detects `gh` invocations in any call shape — array-form, string-form, and template-literal — and demands both declarations). This repo's own core checks are therefore unaffected — an adopter's **custom** check that reads `process.env` directly with no `env` declared will lose that access at this minor; declare `env` on it (`vinaya.config.json`'s `checks.<name>.env`) before upgrading. `vinaya check`'s prior warn-phase print (task 2) is retired now that the behavior it warned about is live; `vinaya doctor` still carries the same missing-declaration diagnostic permanently, at `info` severity, so a custom check's gap remains visible after upgrade even though `vinaya check` no longer prints it inline. | `apps/vinaya/cli/src/checks/runner.ts` |
48
+ | `vinaya check` / `vinaya check --plan` (a CLI mechanism, not a git hook — this row is `.husky`-free) | Ever had a config entry silently double-run alongside the core check it was meant to replace, with no way to see that from the outside? | event | Resolves core-registered and config-registered checks into one deterministic table before anything runs, instead of letting a config entry run alongside the core check it collides with, unannounced. | **Checks-side resolver** (`resolveChecks`/`isValidNamespacedKey`, `apps/vinaya/cli/src/checks/resolver.ts`) | An exact-key match against a core check ID is an **override** — a complete replacement of the spec, never a merge. A key containing exactly one `/`, both segments matching `[a-z0-9][a-z0-9-]*` and the `vinaya` prefix rejected as an exact segment match, is **additive**. Anything else — a bare key with no `/` matching no core ID — is a loud `FAIL_CLOSED` refusal: never silently dropped, never defaulted, never last-wins. `--plan` never resolves or prints an env *value*, only how each one resolves. **This release feeds `vinaya check --plan` / `--plan --json` only** — `vinaya check`'s real execution (`check.ts`'s flat `[...coreCheckRegistry(), ...customSpecs]` concat) is unaffected until a later task wires it in. | `apps/vinaya/cli/src/checks/resolver.ts` |
48
49
 
49
50
  **Documentation coverage, specifically** (a historical pain point): the code→document ownership rule is enforced at *two* prevention chokepoints — at every push (over the branch's cumulative change set) and again at pull-request creation and editing. A change to owned code cannot be published, let alone turned into a pull request, without its owning document.
50
51
 
@@ -72,8 +73,8 @@ Every pull request, on open and on every push, re-runs the same checks in CI:
72
73
  | Test-plan state | Ever merged a PR with an unchecked "did you test this" box? | ci | Re-checks that the pull request’s test plan is genuinely ticked. | Unticked test-plan boxes block merge readiness | `packages/aeg-core/bin/verify-test-plan.ts` |
73
74
  | 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) | `.github/workflows/ci.yml` |
74
75
  | Conventions | Ever opened a PR full of inconsistent formatting and naming? | ci | Re-checks formatting and naming against the repo’s own conventions. | Lint/format, commit-message grammar, no hardcoded UI colors | `.github/workflows/forge-lifecycle.yml` |
75
- | AI review | Ever wished every PR got a second pair of eyes, even at 2am? | ci | Puts an automated reviewer on every pull request, whatever the hour. | Posts a judgment review of the change. Its own posting is still advisory (nothing requires this specific job to run or comment) but if its comment carries a clear `APPROVE`/`REQUEST_CHANGES`/`LGTM` marker, that comment now counts toward the Review gate below, same as any human or dispatched reviewer's comment (the gate is source-agnostic it reads every PR comment, not just this job's) | `.github/workflows/claude-code-review.yml` |
76
- | 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 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. | `packages/aeg-core/bin/verify-review-gate.ts` |
76
+ | AI review | Ever wished every PR got a second pair of eyes, even at 2am? | ci | Puts an automated reviewer on every pull request, whatever the hour. | Posts a judgment review of the change. **Advisory only, including its verdict markers** (ruled 2026-08-09, verdict-author verification): the Review gate below counts a verdict comment only when its author is on the principal allowlist, and this job posts under a bot identity — deliberately excluded, because the job reads the PR's own content, so a crafted PR can steer what it writes; letting its `APPROVE` gate merges would re-open the forged-verdict channel author verification closes. In practice this changes nothing: the gate has only ever flipped on principal-identity verdicts. The bot's review remains visible input to the human/dispatched reviews | `.github/workflows/claude-code-review.yml` |
77
+ | 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). **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`. 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. | `packages/aeg-core/bin/verify-review-gate.ts` |
77
78
  | 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. | `packages/aeg-core/bin/verify-registry.ts` |
78
79
  | 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. | Every file under `.husky/*`, `.claude/hooks/*.sh`, `packages/aeg-core/bin/*.ts` is named by some row's `implementation` — the inverse direction of G1: no enforcement mechanism exists off-page. | `packages/aeg-core/bin/verify-registry.ts` |
79
80
  | 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 the exact class of GitHub-mutating call `.claude/hooks/check-forge-gates.sh` itself gates (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. | `packages/aeg-core/bin/verify-registry.ts` |
@@ -21,6 +21,10 @@ Vinaya uses a handful of words in a specific, non-obvious sense — on the pages
21
21
 
22
22
  **Ratification** — Sign-off, from the person ultimately accountable for the product, on a decision or a piece of work that would be hard to undo. Higher-impact changes wait for a scheduled sign-off window rather than merging the moment they're technically ready.
23
23
 
24
+ **Registry ID / rendering ID** — Two names for the same role that can differ. The registry ID is the config key an adopter uses to claim or add a role — namespaced for an additive one. The rendering ID is the role file's own `role_id`, always slash-free, what every diagram and downstream reader actually sees. An override's rendering ID must equal its registry ID exactly; an additive role's rendering ID must equal the key's segment after the `/`.
25
+
26
+ **Resolution state** — One of three states — `default`, `overridden`, or `additive` — that every check or role ID resolves to. `default` means shipped-and-unmodified; `overridden` means a config entry currently claims that ID and satisfies its contract; `additive` means a wholly new, namespaced entry the shipped product has no opinion on.
27
+
24
28
  **Seam** — The handoff point between two people or roles, where one produces something — a document, a decision, a record — and the other consumes it. Each seam is written down once, in its own place, so both sides can agree on exactly what crosses it.
25
29
 
26
30
  **Step 0** — The literal first command someone runs before touching any code on a task: the one that creates that task's own isolated copy of the repository and its own branch. Nothing else happens until this command has run.
@@ -164,4 +164,4 @@ You are the last step of the flow (`process.md`) — the close-out that sits und
164
164
 
165
165
  ## Turn-end: record the ledger rows for every role that turned on this task
166
166
 
167
- You are the **sole writer** of `aeg-root/tranches/<name>.tokens.md` for a task branch — no other role appends its own row. **In practice there is usually no such file:** it is deleted for every active tranche and CI blocks creating a new one (item 6 above), so for those tasks the rows live in the forge — the Developer's PR-body "Token report" entries and the chat roles' `Tokens: …` verdict lines — and are re-derived on read. Do not create the file to have somewhere to write. Only when a legacy file survives do you append to it; then append one row per role-turn you collected in item 7 above (`Phase | Role | Agent/Model | Tokens in | Tokens out | Cost | Date`), then append your own turn's row last (`Phase: <task-id>: archive`, `Role: Archivist`). When you run as automation in Claude Code, you are a **terminal role** for your own row: fill its numeric cells with exact values from the session meter. Every chat-role row you record (Reviewer, Security, Planner) carries whatever the role reported — `—` where the report itself had no numeric figure; you never estimate or fill in a chat role's cell yourself. Drift cron: as part of the close-out checks, flag any merged task in this tranche that has **no Developer row** for `<task-id>: develop` (the role obligation was missed), and any inline `## Token ledger` section that violates the append-only rule (an existing row was edited rather than a new one appended). The ledger is append-only.
167
+ You are the **sole writer** of `aeg-root/tranches/<name>.tokens.md` for a task branch — no other role appends its own row. **In practice there is usually no such file:** it is deleted for every active tranche and CI blocks creating a new one (item 6 above), so for those tasks the rows live in the forge — the Developer's PR-body "Token report" entries and the chat roles' `Tokens: …` verdict lines — and are re-derived on read. Do not create the file to have somewhere to write. Only when a legacy file survives do you append to it; then append one row per role-turn you collected in item 7 above (`Phase | Role | Agent/Model | Tokens in | Tokens out | Cost | Date`), then append your own turn's row last (`Phase: <task-id>: archive`, `Role: Archivist`). When you run as automation in Claude Code, you are a **terminal role** for your own row: run `bun packages/aeg-core/bin/report-tokens.ts --phase "<task-id>: archive" --role Archivist` against your own session and fill its numeric cells with the exact values it emits — not `/cost`, which an automated session cannot invoke itself. Every chat-role row you record (Reviewer, Security, Planner) carries whatever the role reported — `—` where the report itself had no numeric figure; you never estimate or fill in a chat role's cell yourself. Drift cron: as part of the close-out checks, flag any merged task in this tranche that has **no Developer row** for `<task-id>: develop` (the role obligation was missed), and any inline `## Token ledger` section that violates the append-only rule (an existing row was edited rather than a new one appended). The ledger is append-only.
@@ -107,7 +107,7 @@ Items 3, 5, and 7 read live forge state. Item 6 checks the brief's own Step 0 te
107
107
 
108
108
  **Opening the PR with a complete description.** The PR description must (1) **carry the full brief** — paste it into the PR body; it is the brief's permanent, durable home, and 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.
109
109
 
110
- **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`. You are a **terminal role** — your session knows its own tokens (this repo's instance: `/cost` in Claude Code), so report exact figures, not `—`. The per-task Archivist reads this report at close-out and appends the ledger row post-merge — see `roles/archivist.md`. Re-entry (a second turn after `CHANGES_REQUESTED`) adds a **new** "Token report" entry to the PR body — never edits the first.
110
+ **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`. You are a **terminal role** — your session's own transcript carries its exact usage, and `/cost` is an operator-only slash command you cannot invoke yourself (there is no human at the interactive prompt in a dispatched session). Run `bun packages/aeg-core/bin/report-tokens.ts --phase "<task-id>: develop" --role Developer` and report the numbers it emits — real figures, not `—`. The per-task Archivist reads this report at close-out and appends the ledger row post-merge — see `roles/archivist.md`. Re-entry (a second turn after `CHANGES_REQUESTED`) adds a **new** "Token report" entry to the PR body — never edits the first.
111
111
 
112
112
  ---
113
113
 
@@ -180,7 +180,7 @@ All of the following must pass before the PR is opened:
180
180
  - [ ] Code passes lint/format (this repo: `bun run format-and-lint`)
181
181
  - [ ] Tests pass if applicable (this repo: `bun test`)
182
182
  - [ ] PR description follows the template, carries the brief, and declares `Tier: 0`
183
- - [ ] "Token report" section in the PR body with exact tokens from `/cost` (and again on each re-push after `CHANGES_REQUESTED`) — the Archivist appends the ledger row post-merge, you do not
183
+ - [ ] "Token report" section in the PR body with exact tokens from `bin/report-tokens.ts` (and again on each re-push after `CHANGES_REQUESTED`) — the Archivist appends the ledger row post-merge, you do not
184
184
 
185
185
  ### Tier 1 checklist
186
186
 
@@ -123,7 +123,7 @@ If you discover something that needs a decision above review authority — the b
123
123
 
124
124
  Phase 10 (Review) in `process.md`. The order is: **code-reviewer pass (you) → security pass (`roles/security.md`) → Principal code review → Brief Author spec review → merge.** Your verdict feeds the human reviews; it does not replace them.
125
125
 
126
- **Your verdict is also a mechanical merge gate (the review-gate tranche, task 1).** A required, blocking CI check (`packages/aeg-core/bin/verify-review-gate.ts`) reads every PR comment for a clean `APPROVE` verdict — `REQUEST CHANGES`, a missing verdict, or an unclear one all fail the check and block merge, same as this repo's own security pass. This is not advisory: it is the same enforcement class as typecheck or lint. A principal can waive it for one PR with an actor-verified `vinaya/waiver:review` label (`aeg-root/enforcement.md`) — label presence alone is never sufficient.
126
+ **Your verdict is also a mechanical merge gate (the review-gate tranche, task 1).** A required, blocking CI check (`packages/aeg-core/bin/verify-review-gate.ts`) reads every PR comment from a **principal-allowlisted author** (verdict-author verification, 2026-08-09 — bot and unknown-author comments are ignored) for a clean `APPROVE` verdict — `REQUEST CHANGES`, a missing verdict, or an unclear one all fail the check and block merge, same as this repo's own security pass. This is not advisory: it is the same enforcement class as typecheck or lint. A principal can waive it for one PR with an actor-verified `vinaya/waiver:review` label (`aeg-root/enforcement.md`) — label presence alone is never sufficient.
127
127
 
128
128
  ## Turn-end: report your tokens in the verdict comment
129
129
 
@@ -114,7 +114,7 @@ A security finding that implies a product/architecture decision (e.g., "the whol
114
114
 
115
115
  Phase 10 (Review) in `process.md`: code-reviewer pass → **security pass (you)** → Principal code review → Brief Author spec review → merge.
116
116
 
117
- **Your verdict is also a mechanical merge gate (the review-gate tranche, task 1).** A required, blocking CI check (`packages/aeg-core/bin/verify-review-gate.ts`) reads every PR comment for a clean `PASS` verdict — `FAIL`, a missing verdict, or an unclear one all fail the check and block merge, same as the code-reviewer pass. This is not advisory: it is the same enforcement class as typecheck or lint. A principal can waive it for one PR with an actor-verified `vinaya/waiver:review` label (`aeg-root/enforcement.md`) — label presence alone is never sufficient.
117
+ **Your verdict is also a mechanical merge gate (the review-gate tranche, task 1).** A required, blocking CI check (`packages/aeg-core/bin/verify-review-gate.ts`) reads every PR comment from a **principal-allowlisted author** (verdict-author verification, 2026-08-09 — bot and unknown-author comments are ignored) for a clean `PASS` verdict — `FAIL`, a missing verdict, or an unclear one all fail the check and block merge, same as the code-reviewer pass. This is not advisory: it is the same enforcement class as typecheck or lint. A principal can waive it for one PR with an actor-verified `vinaya/waiver:review` label (`aeg-root/enforcement.md`) — label presence alone is never sufficient.
118
118
 
119
119
  ## Turn-end: report your tokens in the verdict comment
120
120
 
@@ -443,10 +443,23 @@ For each changed code file in the PR, glob-match against every binding. Per **ma
443
443
  |---|---|
444
444
  | **Strong-pass** | The in-repo pointer is in the PR diff. |
445
445
  | **URL-ack** | The pointer is a URL **and** the PR body carries `Doc-ack: <pointer> — <note>` whose `<pointer>` exactly matches the binding's URL. |
446
+ | **Neutral-edit** | The pointer is in-repo, absent from the diff, **and** the PR body carries `Doc-neutral: <pointer> — <note>` whose `<pointer>` exactly matches the binding's doc, **and** the diff of every matched code file is comment/whitespace-only (see below). Both the declaration and the evidence are required — either alone fails. |
446
447
  | **Waiver** | The pull request carries the `vinaya/waiver:docs` label, AND the actor of that label's most recent labeling timeline event is a configured principal. PR-wide, not per-binding — one verified label satisfies every fired binding in the PR. Label presence alone is never sufficient; there is no PR-body waiver field. |
447
448
 
448
449
  If none of the above hold, C5 fails the PR. A binding whose in-repo pointer **does not exist on disk** is a distinct *dangling* failure (fix the binding or add the doc) — never silently ignored, because a dangling pointer is the failure mode the seam was built to prevent.
449
450
 
451
+ ### The Neutral-edit branch — an honest path for nothing-owed changes
452
+
453
+ The bind-or-waive rule is purely path-based: any edit to a bound file fires its binding, including comment fixes and mechanically-equivalent refactors that owe no doc update. Before this branch existed, a Developer facing one of these had exactly two honest options — write a no-op doc edit to satisfy strong-pass (which is what "correctness of the covered doc" in the table above exists to catch as a BLOCKER), or ask a principal for a `vinaya/waiver:docs` label on a change that isn't actually contested. Neither fits a change nothing is owed for.
454
+
455
+ `Doc-neutral: <pointer> — <note>` (`evaluateC5` in `packages/aeg-core/src/doc-owners.ts`) closes that gap **without weakening bind-or-waive's default** (silence is still always a failure) and **without touching waiver machinery** (the label stays a principal-only, forge-authenticated escape for genuinely contested cases). It is deliberately not self-serve: the declaration is necessary but not sufficient. `evaluateC5` also reads the unified diff of every code file the binding matched and requires **every** added/removed line, once trimmed, to be blank or a comment-line prefix (`isMechanicallyNeutralDiff`) — a Developer cannot type the field over a real behavior change and have it pass. A declaration whose diff fails that check is a distinct failure, `C5 doc-neutral-unverified`, not a silent pass-through.
456
+
457
+ This is bounded scope, by design: "mechanically neutral" here means comment/whitespace-only, not an open-ended semantic-equivalence judgment (reordering, dead-code removal, or a refactor that preserves behavior but changes tokens all still require strong-pass, URL-ack, or a waiver). The evidence stays diff-visible in both the PR body (the declaration + its `<note>`) and, mechanically, in the diff itself — a reviewer can judge both without re-deriving the claim from nothing, which is what keeps this branch honest under an agent that wants to pass the gate.
458
+
459
+ `isMechanicallyNeutralDiff` selects its comment markers **per source-language file extension**, not a single flat prefix table — a marker is only ever treated as comment-only if no legitimate construct in that language can start a trimmed line with it. TS/JS/JSX keep only `//`, because `*`, `#`, `/* `, `*/`, and `--` are all real collisions in those languages (a generator method `*gen() {`, a private class field `#count = 0`, a block-comment-close hiding trailing code, a pre-decrement statement) — a bare per-line prefix scan misclassified all of these as comment-only in an earlier revision (code review, task 4). A shebang line (`#!…`) is never neutral even in a shell file, since it changes what interpreter runs the script. A file extension the table doesn't recognize gets no safe marker at all, so it can never be classified neutral — fail-closed under uncertainty, not an attempt at full per-language comment/string-region parsing.
460
+
461
+ `--pr` and `--push` (ring 0) share the identical `evaluateC5` call — the Neutral-edit evidence check is one predicate evaluated at both enforcement points, never two implementations that could drift.
462
+
450
463
  ### Dormancy (silent no-op)
451
464
 
452
465
  Two cases produce **no output and no error** — not a "pass" message, nothing at all:
@@ -460,15 +473,16 @@ The seam has no opinion until the repo teaches it one. This is what makes the ga
460
473
 
461
474
  The tier system (Section 9) is class-level — "what kind of work is this, and what docs does that kind of work require?" The coherence seam is per-change — "this specific edit moved this specific surface; the doc bound to that surface must move with it." A Tier 0 PR that edits a bound code surface MUST satisfy C5; a Tier 3 PR that touches no bound code surface satisfies C5 trivially. Both gates run on every PR; both must pass.
462
475
 
463
- ### `Doc-ack:` — the one remaining PR-body field; the waiver is a label, not a field
476
+ ### `Doc-ack:` and `Doc-neutral:` — the PR-body fields; the waiver is a label, not a field
464
477
 
465
- `Doc-ack:` is a **PR-body field**, parsed from the body text — *not* a GitHub label. It lives alongside `Tier:` in the canonical PR body (form in `roles/developer.md` § PR body — canonical form).
478
+ `Doc-ack:` and `Doc-neutral:` are **PR-body fields**, parsed from the body text — *not* GitHub labels. Both live alongside `Tier:` in the canonical PR body (form in `roles/developer.md` § PR body — canonical form).
466
479
 
467
480
  - `Doc-ack: <pointer> — <note>` — acknowledgment for URL bindings. Unaffected — an acknowledgment, not a bypass.
481
+ - `Doc-neutral: <pointer> — <note>` — declares a fired in-repo binding's doc is not owed because the matched code diff is comment/whitespace-only. Never sufficient alone — see the Neutral-edit branch above; `evaluateC5` cross-checks the actual diff before honoring it.
468
482
 
469
483
  The waiver itself is **not** a PR-body field. removed `Doc-waiver:` entirely — the escape hatch is the `vinaya/waiver:docs` label instead (Section 14), added to the closed label set specifically because a waiver must be a forge-authenticated act (an actor-verified labeling timeline event), and a body field can never carry that verification — anyone who can edit the body can type any string.
470
484
 
471
- The separator between `<pointer>` and `<note>` in `Doc-ack:` is **flexible**: em-dash `—`, en-dash `–`, or a plain ASCII hyphen `-` (with surrounding whitespace) are all accepted by `verify-docs`. The em-dash form remains canonical in templates and prose, but a human typing `Doc-ack: <pointer> - <note>` on a US keyboard parses identically. Required whitespace around the ASCII hyphen disambiguates it from hyphens that legitimately appear inside pointers (e.g. `.vinaya/doc-owners`, `.claude/skills/ui-components/SKILL.md`); writers do not need to think about it as long as they put a space on each side.
485
+ The separator between `<pointer>` and `<note>` in `Doc-ack:` and `Doc-neutral:` is **flexible**: em-dash `—`, en-dash `–`, or a plain ASCII hyphen `-` (with surrounding whitespace) are all accepted by `verify-docs`. The em-dash form remains canonical in templates and prose, but a human typing `Doc-ack: <pointer> - <note>` on a US keyboard parses identically. Required whitespace around the ASCII hyphen disambiguates it from hyphens that legitimately appear inside pointers (e.g. `.vinaya/doc-owners`, `.claude/skills/ui-components/SKILL.md`); writers do not need to think about it as long as they put a space on each side.
472
486
 
473
487
  ### Where this leaves the Reviewer
474
488
 
@@ -276,10 +276,12 @@ Append-only. Each row records one role's turn at a phase. Re-entry appends a **n
276
276
 
277
277
  Token reporting is asymmetric — and any honest design has to encode that, because the asymmetry is a property of the surfaces, not of AEG. The asymmetry now lives in *what a role reports*, not in *who writes the ledger file* — the Archivist writes every row, but the precision of the numbers it copies still depends on the reporting role's surface:
278
278
 
279
- - **Terminal roles run in Claude Code (Developer; Archivist when automated).** The session knows its tokens. The role reports exact numbers from `/cost` in its PR body. The Archivist copies these numbers verbatim into the row it appends at close-out.
280
- - **claude.ai roles run in chat (Planner; Brief Author; Reviewer; Security).** A claude.ai conversation **cannot read its own token count** via tool or API. The role still reports at turn-end — phase, role, model, date, in its verdict comment or planning report — but leaves the numeric cells as `—`. The Archivist copies the report as-is; the **Principal** may later supply the real figures from the claude.ai UI usage figure, filling a previously-`—` cell (the one narrow forward-reference exception `state-machine.md` §13 allows).
279
+ The split is **operator vs. agent, not terminal vs. chat** — `/cost` is a Claude Code slash command an operator types at the interactive prompt; an unattended agent session (dispatched, automated, no human at the keyboard) has no way to invoke it, terminal or not. (2026-08-08: retracted an earlier revision of this section claimed a terminal role reports exact numbers "from `/cost`"; no agent session ever could.)
281
280
 
282
- V1 accepts the manual seam: chat turns are the cheap ones; coding (terminal) dominates spend and is captured exactly. Auto-capture for terminal roles is the obvious next layer; auto-capture for chat roles depends on the surface giving us a self-token API, which it does not today. **Known gap (flagged, not solved):** tranche-wide chat-role turns with no task PR to report into a Planner session outside a plan PR, a Brief Author session have no established recording path; see `roles/planner.md` "Plan-PR close-out."
281
+ - **Terminal roles run in Claude Code (Developer; Archivist when automated).** The session's own transcript (`~/.claude/projects/<slug>/<session-id>.jsonl`) carries a `usage` object per assistant message real, agent-reachable, no operator required. The role runs `packages/aeg-core/bin/report-tokens.ts` against its own session and reports the exact numbers it emits in its PR body. The Archivist copies these numbers verbatim into the row it appends at close-out.
282
+ - **claude.ai roles run in chat (Planner; Brief Author; Reviewer; Security).** A claude.ai conversation **cannot read its own token count** via tool or API, and has no transcript file the way a Claude Code session does — the reporter has nothing to run against. The role still reports at turn-end — phase, role, model, date, in its verdict comment or planning report — but leaves the numeric cells as `—`. The Archivist copies the report as-is; the **Principal** may later supply the real figures from the claude.ai UI usage figure, filling a previously-`—` cell (the one narrow forward-reference exception `state-machine.md` §13 allows).
283
+
284
+ Terminal self-capture is real, not manual: the reporter sums a session's own transcript, so a Developer or automated Archivist turn's numbers are exact without any operator step. The remaining manual seam is chat roles only — auto-capture there depends on claude.ai giving a session a way to read its own token count, which it does not today. **Known gap (flagged, not solved):** tranche-wide chat-role turns with no task PR to report into — a Planner session outside a plan PR, a Brief Author session — have no established recording path; see `roles/planner.md` "Plan-PR close-out."
283
285
 
284
286
  ### Live reads (Studio) — a second, narrower read path
285
287
 
@@ -1513,15 +1513,58 @@ function pointerToPath(p) {
1513
1513
  return idx === -1 ? p : p.slice(0, idx);
1514
1514
  }
1515
1515
  var SEPARATOR = /(?:[ \t]*[—–][ \t]*|[ \t]+-[ \t]+)/.source;
1516
- function readDocAcks(body) {
1517
- const acks = [];
1518
- const re = new RegExp(`^[ \\t]*Doc-ack[ \\t]*:[ \\t]*(.+?)${SEPARATOR}(.+?)[ \\t]*$`, "gim");
1516
+ function readPointerNoteField(body, field) {
1517
+ const out = [];
1518
+ const re = new RegExp(`^[ \\t]*${field}[ \\t]*:[ \\t]*(.+?)${SEPARATOR}(.+?)[ \\t]*$`, "gim");
1519
1519
  for (const m of body.matchAll(re)) {
1520
- acks.push({ surface: (m[1] ?? "").trim(), note: (m[2] ?? "").trim() });
1520
+ out.push({ surface: (m[1] ?? "").trim(), note: (m[2] ?? "").trim() });
1521
+ }
1522
+ return out;
1523
+ }
1524
+ function readDocAcks(body) {
1525
+ return readPointerNoteField(body, "Doc-ack");
1526
+ }
1527
+ function readDocNeutrals(body) {
1528
+ return readPointerNoteField(body, "Doc-neutral");
1529
+ }
1530
+ var LANGUAGE_COMMENT_PREFIXES = [
1531
+ { test: /\.(ts|tsx|js|jsx|mjs|cjs)$/, prefixes: ["//"] },
1532
+ { test: /(^|\/)(pre-push|pre-commit|pre-merge-commit)$/, prefixes: ["#"] },
1533
+ { test: /\.(sh|bash)$/, prefixes: ["#"] },
1534
+ { test: /\.ya?ml$/, prefixes: ["#"] },
1535
+ { test: /\.py$/, prefixes: ["#"] }
1536
+ ];
1537
+ function commentPrefixesForPath(path) {
1538
+ for (const { test, prefixes } of LANGUAGE_COMMENT_PREFIXES) {
1539
+ if (test.test(path))
1540
+ return prefixes;
1521
1541
  }
1522
- return acks;
1542
+ return [];
1523
1543
  }
1524
- function evaluateC5(changed, docOwnersContent, prBody, fileExists, waiverActive) {
1544
+ function isNeutralDiffContentLine(line, prefixes) {
1545
+ const t = line.trim();
1546
+ if (t === "")
1547
+ return true;
1548
+ if (t.startsWith("#!"))
1549
+ return false;
1550
+ return prefixes.some((p) => t.startsWith(p));
1551
+ }
1552
+ function isMechanicallyNeutralDiff(diffText, path) {
1553
+ const prefixes = commentPrefixesForPath(path);
1554
+ let sawChange = false;
1555
+ for (const raw of diffText.split(`
1556
+ `)) {
1557
+ if (raw.startsWith("+++ ") || raw.startsWith("--- ") || raw.startsWith("@@"))
1558
+ continue;
1559
+ if (raw.startsWith("+") || raw.startsWith("-")) {
1560
+ if (!isNeutralDiffContentLine(raw.slice(1), prefixes))
1561
+ return false;
1562
+ sawChange = true;
1563
+ }
1564
+ }
1565
+ return sawChange;
1566
+ }
1567
+ function evaluateC5(changed, docOwnersContent, prBody, fileExists, waiverActive, getDiff) {
1525
1568
  const out = { errors: [], notes: [] };
1526
1569
  if (docOwnersContent === null)
1527
1570
  return out;
@@ -1534,13 +1577,15 @@ function evaluateC5(changed, docOwnersContent, prBody, fileExists, waiverActive)
1534
1577
  const fired = [];
1535
1578
  for (const b of bindings) {
1536
1579
  const re = globToRegex(b.glob);
1537
- if (codeFiles.some((f) => re.test(f)))
1538
- fired.push(b);
1580
+ const matchedFiles = codeFiles.filter((f) => re.test(f));
1581
+ if (matchedFiles.length > 0)
1582
+ fired.push({ binding: b, matchedFiles });
1539
1583
  }
1540
1584
  if (fired.length === 0)
1541
1585
  return out;
1542
1586
  const acks = readDocAcks(prBody);
1543
- for (const b of fired) {
1587
+ const neutrals = readDocNeutrals(prBody);
1588
+ for (const { binding: b, matchedFiles } of fired) {
1544
1589
  if (waiverActive) {
1545
1590
  out.notes.push(`C5 doc-waiver active for ${b.pointer} (binding ${DOC_OWNERS_PATH}:${b.lineNum})`);
1546
1591
  continue;
@@ -1557,9 +1602,22 @@ function evaluateC5(changed, docOwnersContent, prBody, fileExists, waiverActive)
1557
1602
  out.errors.push(`C5 doc-owners-dangling: ${DOC_OWNERS_PATH}:${b.lineNum} points to ${b.pointer}, which does not exist on disk. Fix the binding or add the doc.`);
1558
1603
  continue;
1559
1604
  }
1560
- if (!changed.includes(pointerPath)) {
1561
- out.errors.push(`C5 doc-coverage: code change matched ${DOC_OWNERS_PATH}:${b.lineNum} (glob \`${b.glob}\` → ${b.pointer}), but ${pointerPath} is not in the PR diff. Update it, or have a principal apply the \`${WAIVER_LABEL}\` label.`);
1605
+ if (changed.includes(pointerPath))
1606
+ continue;
1607
+ const declared = neutrals.find((n) => n.surface === b.pointer);
1608
+ if (declared) {
1609
+ const evidenced = getDiff !== undefined && matchedFiles.every((f) => {
1610
+ const diff = getDiff(f);
1611
+ return diff !== null && isMechanicallyNeutralDiff(diff, f);
1612
+ });
1613
+ if (evidenced) {
1614
+ out.notes.push(`C5 doc-neutral: ${b.pointer} not required for ${DOC_OWNERS_PATH}:${b.lineNum} (glob \`${b.glob}\`) — declared neutral ("${declared.note}"), diff of ${matchedFiles.join(", ")} confirmed comment/whitespace-only.`);
1615
+ continue;
1616
+ }
1617
+ out.errors.push(`C5 doc-neutral-unverified: ${DOC_OWNERS_PATH}:${b.lineNum} (glob \`${b.glob}\` → ${b.pointer}) declared \`Doc-neutral: ${b.pointer} — ${declared.note}\`, but the diff of ${matchedFiles.join(", ")} contains changes beyond comments/whitespace. Update ${pointerPath}, or have a principal apply the \`${WAIVER_LABEL}\` label.`);
1618
+ continue;
1562
1619
  }
1620
+ out.errors.push(`C5 doc-coverage: code change matched ${DOC_OWNERS_PATH}:${b.lineNum} (glob \`${b.glob}\` → ${b.pointer}), but ${pointerPath} is not in the PR diff. Update it, declare \`Doc-neutral: ${b.pointer} — <why>\` if the change is genuinely mechanically-neutral, or have a principal apply the \`${WAIVER_LABEL}\` label.`);
1563
1621
  }
1564
1622
  return out;
1565
1623
  }
@@ -2112,8 +2170,11 @@ function checkReviewGate(input) {
2112
2170
  waived: true
2113
2171
  };
2114
2172
  }
2115
- const codeReview = extractCodeReviewVerdict(input.comments);
2116
- const security = extractSecurityReviewVerdict(input.comments);
2173
+ const verified = input.comments.filter((c) => c.author !== null && PRINCIPAL_ALLOWLIST.includes(c.author));
2174
+ const ignoredCount = input.comments.filter((c) => (c.author === null || !PRINCIPAL_ALLOWLIST.includes(c.author)) && c.body.includes("VERDICT")).length;
2175
+ const verifiedBodies = verified.map((c) => c.body);
2176
+ const codeReview = extractCodeReviewVerdict(verifiedBodies);
2177
+ const security = extractSecurityReviewVerdict(verifiedBodies);
2117
2178
  const codeReviewClean = codeReview.value === "APPROVE";
2118
2179
  const securityClean = security.value === "PASS";
2119
2180
  if (codeReviewClean && securityClean) {
@@ -2128,9 +2189,10 @@ function checkReviewGate(input) {
2128
2189
  problems.push(`code-reviewer verdict is not a clean APPROVE (found: ${codeReview.value})`);
2129
2190
  if (!securityClean)
2130
2191
  problems.push(`security-review verdict is not a clean PASS (found: ${security.value})`);
2192
+ const ignoredNote = ignoredCount > 0 ? ` ${ignoredCount} verdict-shaped comment(s) from authors outside the principal allowlist were ignored.` : "";
2131
2193
  return {
2132
2194
  verdict: "fail",
2133
- reason: `${problems.join("; ")}. A principal can apply an actor-verified \`${WAIVER_LABEL_REVIEW}\` label to skip this requirement, or post the missing/clean verdict comment(s).`,
2195
+ reason: `${problems.join("; ")}. A principal can apply an actor-verified \`${WAIVER_LABEL_REVIEW}\` label to skip this requirement, or post the missing/clean verdict comment(s).${ignoredNote}`,
2134
2196
  waived: false
2135
2197
  };
2136
2198
  }
@@ -1510,15 +1510,58 @@ function pointerToPath(p) {
1510
1510
  return idx === -1 ? p : p.slice(0, idx);
1511
1511
  }
1512
1512
  var SEPARATOR = /(?:[ \t]*[—–][ \t]*|[ \t]+-[ \t]+)/.source;
1513
- function readDocAcks(body) {
1514
- const acks = [];
1515
- const re = new RegExp(`^[ \\t]*Doc-ack[ \\t]*:[ \\t]*(.+?)${SEPARATOR}(.+?)[ \\t]*$`, "gim");
1513
+ function readPointerNoteField(body, field) {
1514
+ const out = [];
1515
+ const re = new RegExp(`^[ \\t]*${field}[ \\t]*:[ \\t]*(.+?)${SEPARATOR}(.+?)[ \\t]*$`, "gim");
1516
1516
  for (const m of body.matchAll(re)) {
1517
- acks.push({ surface: (m[1] ?? "").trim(), note: (m[2] ?? "").trim() });
1517
+ out.push({ surface: (m[1] ?? "").trim(), note: (m[2] ?? "").trim() });
1518
+ }
1519
+ return out;
1520
+ }
1521
+ function readDocAcks(body) {
1522
+ return readPointerNoteField(body, "Doc-ack");
1523
+ }
1524
+ function readDocNeutrals(body) {
1525
+ return readPointerNoteField(body, "Doc-neutral");
1526
+ }
1527
+ var LANGUAGE_COMMENT_PREFIXES = [
1528
+ { test: /\.(ts|tsx|js|jsx|mjs|cjs)$/, prefixes: ["//"] },
1529
+ { test: /(^|\/)(pre-push|pre-commit|pre-merge-commit)$/, prefixes: ["#"] },
1530
+ { test: /\.(sh|bash)$/, prefixes: ["#"] },
1531
+ { test: /\.ya?ml$/, prefixes: ["#"] },
1532
+ { test: /\.py$/, prefixes: ["#"] }
1533
+ ];
1534
+ function commentPrefixesForPath(path) {
1535
+ for (const { test, prefixes } of LANGUAGE_COMMENT_PREFIXES) {
1536
+ if (test.test(path))
1537
+ return prefixes;
1518
1538
  }
1519
- return acks;
1539
+ return [];
1520
1540
  }
1521
- function evaluateC5(changed, docOwnersContent, prBody, fileExists, waiverActive) {
1541
+ function isNeutralDiffContentLine(line, prefixes) {
1542
+ const t = line.trim();
1543
+ if (t === "")
1544
+ return true;
1545
+ if (t.startsWith("#!"))
1546
+ return false;
1547
+ return prefixes.some((p) => t.startsWith(p));
1548
+ }
1549
+ function isMechanicallyNeutralDiff(diffText, path) {
1550
+ const prefixes = commentPrefixesForPath(path);
1551
+ let sawChange = false;
1552
+ for (const raw of diffText.split(`
1553
+ `)) {
1554
+ if (raw.startsWith("+++ ") || raw.startsWith("--- ") || raw.startsWith("@@"))
1555
+ continue;
1556
+ if (raw.startsWith("+") || raw.startsWith("-")) {
1557
+ if (!isNeutralDiffContentLine(raw.slice(1), prefixes))
1558
+ return false;
1559
+ sawChange = true;
1560
+ }
1561
+ }
1562
+ return sawChange;
1563
+ }
1564
+ function evaluateC5(changed, docOwnersContent, prBody, fileExists, waiverActive, getDiff) {
1522
1565
  const out = { errors: [], notes: [] };
1523
1566
  if (docOwnersContent === null)
1524
1567
  return out;
@@ -1531,13 +1574,15 @@ function evaluateC5(changed, docOwnersContent, prBody, fileExists, waiverActive)
1531
1574
  const fired = [];
1532
1575
  for (const b of bindings) {
1533
1576
  const re = globToRegex(b.glob);
1534
- if (codeFiles.some((f) => re.test(f)))
1535
- fired.push(b);
1577
+ const matchedFiles = codeFiles.filter((f) => re.test(f));
1578
+ if (matchedFiles.length > 0)
1579
+ fired.push({ binding: b, matchedFiles });
1536
1580
  }
1537
1581
  if (fired.length === 0)
1538
1582
  return out;
1539
1583
  const acks = readDocAcks(prBody);
1540
- for (const b of fired) {
1584
+ const neutrals = readDocNeutrals(prBody);
1585
+ for (const { binding: b, matchedFiles } of fired) {
1541
1586
  if (waiverActive) {
1542
1587
  out.notes.push(`C5 doc-waiver active for ${b.pointer} (binding ${DOC_OWNERS_PATH}:${b.lineNum})`);
1543
1588
  continue;
@@ -1554,9 +1599,22 @@ function evaluateC5(changed, docOwnersContent, prBody, fileExists, waiverActive)
1554
1599
  out.errors.push(`C5 doc-owners-dangling: ${DOC_OWNERS_PATH}:${b.lineNum} points to ${b.pointer}, which does not exist on disk. Fix the binding or add the doc.`);
1555
1600
  continue;
1556
1601
  }
1557
- if (!changed.includes(pointerPath)) {
1558
- out.errors.push(`C5 doc-coverage: code change matched ${DOC_OWNERS_PATH}:${b.lineNum} (glob \`${b.glob}\` → ${b.pointer}), but ${pointerPath} is not in the PR diff. Update it, or have a principal apply the \`${WAIVER_LABEL}\` label.`);
1602
+ if (changed.includes(pointerPath))
1603
+ continue;
1604
+ const declared = neutrals.find((n) => n.surface === b.pointer);
1605
+ if (declared) {
1606
+ const evidenced = getDiff !== undefined && matchedFiles.every((f) => {
1607
+ const diff = getDiff(f);
1608
+ return diff !== null && isMechanicallyNeutralDiff(diff, f);
1609
+ });
1610
+ if (evidenced) {
1611
+ out.notes.push(`C5 doc-neutral: ${b.pointer} not required for ${DOC_OWNERS_PATH}:${b.lineNum} (glob \`${b.glob}\`) — declared neutral ("${declared.note}"), diff of ${matchedFiles.join(", ")} confirmed comment/whitespace-only.`);
1612
+ continue;
1613
+ }
1614
+ out.errors.push(`C5 doc-neutral-unverified: ${DOC_OWNERS_PATH}:${b.lineNum} (glob \`${b.glob}\` → ${b.pointer}) declared \`Doc-neutral: ${b.pointer} — ${declared.note}\`, but the diff of ${matchedFiles.join(", ")} contains changes beyond comments/whitespace. Update ${pointerPath}, or have a principal apply the \`${WAIVER_LABEL}\` label.`);
1615
+ continue;
1559
1616
  }
1617
+ out.errors.push(`C5 doc-coverage: code change matched ${DOC_OWNERS_PATH}:${b.lineNum} (glob \`${b.glob}\` → ${b.pointer}), but ${pointerPath} is not in the PR diff. Update it, declare \`Doc-neutral: ${b.pointer} — <why>\` if the change is genuinely mechanically-neutral, or have a principal apply the \`${WAIVER_LABEL}\` label.`);
1560
1618
  }
1561
1619
  return out;
1562
1620
  }
@@ -2109,8 +2167,11 @@ function checkReviewGate(input) {
2109
2167
  waived: true
2110
2168
  };
2111
2169
  }
2112
- const codeReview = extractCodeReviewVerdict(input.comments);
2113
- const security = extractSecurityReviewVerdict(input.comments);
2170
+ const verified = input.comments.filter((c) => c.author !== null && PRINCIPAL_ALLOWLIST.includes(c.author));
2171
+ const ignoredCount = input.comments.filter((c) => (c.author === null || !PRINCIPAL_ALLOWLIST.includes(c.author)) && c.body.includes("VERDICT")).length;
2172
+ const verifiedBodies = verified.map((c) => c.body);
2173
+ const codeReview = extractCodeReviewVerdict(verifiedBodies);
2174
+ const security = extractSecurityReviewVerdict(verifiedBodies);
2114
2175
  const codeReviewClean = codeReview.value === "APPROVE";
2115
2176
  const securityClean = security.value === "PASS";
2116
2177
  if (codeReviewClean && securityClean) {
@@ -2125,9 +2186,10 @@ function checkReviewGate(input) {
2125
2186
  problems.push(`code-reviewer verdict is not a clean APPROVE (found: ${codeReview.value})`);
2126
2187
  if (!securityClean)
2127
2188
  problems.push(`security-review verdict is not a clean PASS (found: ${security.value})`);
2189
+ const ignoredNote = ignoredCount > 0 ? ` ${ignoredCount} verdict-shaped comment(s) from authors outside the principal allowlist were ignored.` : "";
2128
2190
  return {
2129
2191
  verdict: "fail",
2130
- reason: `${problems.join("; ")}. A principal can apply an actor-verified \`${WAIVER_LABEL_REVIEW}\` label to skip this requirement, or post the missing/clean verdict comment(s).`,
2192
+ reason: `${problems.join("; ")}. A principal can apply an actor-verified \`${WAIVER_LABEL_REVIEW}\` label to skip this requirement, or post the missing/clean verdict comment(s).${ignoredNote}`,
2131
2193
  waived: false
2132
2194
  };
2133
2195
  }