@attalabs/vinaya 0.1.3 → 0.3.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,6 +44,7 @@ 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`) | **⚠️ 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, so this repo's own core checks are 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` |
47
48
 
48
49
  **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.
49
50
 
@@ -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
 
@@ -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
  }
@@ -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
  }
@@ -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
  }
@@ -1514,15 +1514,58 @@ function pointerToPath(p) {
1514
1514
  return idx === -1 ? p : p.slice(0, idx);
1515
1515
  }
1516
1516
  var SEPARATOR = /(?:[ \t]*[—–][ \t]*|[ \t]+-[ \t]+)/.source;
1517
- function readDocAcks(body) {
1518
- const acks = [];
1519
- const re = new RegExp(`^[ \\t]*Doc-ack[ \\t]*:[ \\t]*(.+?)${SEPARATOR}(.+?)[ \\t]*$`, "gim");
1517
+ function readPointerNoteField(body, field) {
1518
+ const out = [];
1519
+ const re = new RegExp(`^[ \\t]*${field}[ \\t]*:[ \\t]*(.+?)${SEPARATOR}(.+?)[ \\t]*$`, "gim");
1520
1520
  for (const m of body.matchAll(re)) {
1521
- acks.push({ surface: (m[1] ?? "").trim(), note: (m[2] ?? "").trim() });
1521
+ out.push({ surface: (m[1] ?? "").trim(), note: (m[2] ?? "").trim() });
1522
+ }
1523
+ return out;
1524
+ }
1525
+ function readDocAcks(body) {
1526
+ return readPointerNoteField(body, "Doc-ack");
1527
+ }
1528
+ function readDocNeutrals(body) {
1529
+ return readPointerNoteField(body, "Doc-neutral");
1530
+ }
1531
+ var LANGUAGE_COMMENT_PREFIXES = [
1532
+ { test: /\.(ts|tsx|js|jsx|mjs|cjs)$/, prefixes: ["//"] },
1533
+ { test: /(^|\/)(pre-push|pre-commit|pre-merge-commit)$/, prefixes: ["#"] },
1534
+ { test: /\.(sh|bash)$/, prefixes: ["#"] },
1535
+ { test: /\.ya?ml$/, prefixes: ["#"] },
1536
+ { test: /\.py$/, prefixes: ["#"] }
1537
+ ];
1538
+ function commentPrefixesForPath(path) {
1539
+ for (const { test, prefixes } of LANGUAGE_COMMENT_PREFIXES) {
1540
+ if (test.test(path))
1541
+ return prefixes;
1522
1542
  }
1523
- return acks;
1543
+ return [];
1524
1544
  }
1525
- function evaluateC5(changed, docOwnersContent, prBody, fileExists, waiverActive) {
1545
+ function isNeutralDiffContentLine(line, prefixes) {
1546
+ const t = line.trim();
1547
+ if (t === "")
1548
+ return true;
1549
+ if (t.startsWith("#!"))
1550
+ return false;
1551
+ return prefixes.some((p) => t.startsWith(p));
1552
+ }
1553
+ function isMechanicallyNeutralDiff(diffText, path) {
1554
+ const prefixes = commentPrefixesForPath(path);
1555
+ let sawChange = false;
1556
+ for (const raw of diffText.split(`
1557
+ `)) {
1558
+ if (raw.startsWith("+++ ") || raw.startsWith("--- ") || raw.startsWith("@@"))
1559
+ continue;
1560
+ if (raw.startsWith("+") || raw.startsWith("-")) {
1561
+ if (!isNeutralDiffContentLine(raw.slice(1), prefixes))
1562
+ return false;
1563
+ sawChange = true;
1564
+ }
1565
+ }
1566
+ return sawChange;
1567
+ }
1568
+ function evaluateC5(changed, docOwnersContent, prBody, fileExists, waiverActive, getDiff) {
1526
1569
  const out = { errors: [], notes: [] };
1527
1570
  if (docOwnersContent === null)
1528
1571
  return out;
@@ -1535,13 +1578,15 @@ function evaluateC5(changed, docOwnersContent, prBody, fileExists, waiverActive)
1535
1578
  const fired = [];
1536
1579
  for (const b of bindings) {
1537
1580
  const re = globToRegex(b.glob);
1538
- if (codeFiles.some((f) => re.test(f)))
1539
- fired.push(b);
1581
+ const matchedFiles = codeFiles.filter((f) => re.test(f));
1582
+ if (matchedFiles.length > 0)
1583
+ fired.push({ binding: b, matchedFiles });
1540
1584
  }
1541
1585
  if (fired.length === 0)
1542
1586
  return out;
1543
1587
  const acks = readDocAcks(prBody);
1544
- for (const b of fired) {
1588
+ const neutrals = readDocNeutrals(prBody);
1589
+ for (const { binding: b, matchedFiles } of fired) {
1545
1590
  if (waiverActive) {
1546
1591
  out.notes.push(`C5 doc-waiver active for ${b.pointer} (binding ${DOC_OWNERS_PATH}:${b.lineNum})`);
1547
1592
  continue;
@@ -1558,9 +1603,22 @@ function evaluateC5(changed, docOwnersContent, prBody, fileExists, waiverActive)
1558
1603
  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.`);
1559
1604
  continue;
1560
1605
  }
1561
- if (!changed.includes(pointerPath)) {
1562
- 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.`);
1606
+ if (changed.includes(pointerPath))
1607
+ continue;
1608
+ const declared = neutrals.find((n) => n.surface === b.pointer);
1609
+ if (declared) {
1610
+ const evidenced = getDiff !== undefined && matchedFiles.every((f) => {
1611
+ const diff = getDiff(f);
1612
+ return diff !== null && isMechanicallyNeutralDiff(diff, f);
1613
+ });
1614
+ if (evidenced) {
1615
+ 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.`);
1616
+ continue;
1617
+ }
1618
+ 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.`);
1619
+ continue;
1563
1620
  }
1621
+ 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.`);
1564
1622
  }
1565
1623
  return out;
1566
1624
  }