The fleet's checks and review tooling, run by hand, with the deterministic ones also gating CI. Each one exists because the CI linters pass on the failure it catches: markdownlint, cspell, actionlint, and editorconfig-checker all report clean on prose that breaks a documented GOVERNANCE.md rule. The pre-commit hook runs two deterministic doc gates, each at the scope that fits it. uv run --no-project --script .github/actions/prose-gate/prose_lint.py . --diff HEAD is diff-scoped, at about 0.15 seconds where its whole-tree sweep costs about 2.2. python3 scripts/repo_gate.py --check eol is repo-wide, since it reads .gitattributes against .editorconfig and takes no file list, and at about 0.04 seconds there is nothing to scope. The earlier decision to keep doc linters out was made when a sweep was the only mode, and what reversed it is the diff scope rather than a change of preference. The gates needing Docker, and sha-pin which resolves a pin against the GitHub API, stay in CI. A plain repo_gate.py run also includes composite-actions, whose shell half needs Docker and either PyYAML or uvx, and whose schema half needs uvx or pipx. The hook reads the working tree rather than the index, so a partially staged file is judged on all of its edits. The pull request gate reads every line the branch changes against its base. A merge commit is diffed against MERGE_HEAD rather than HEAD, so the lines the merged-in parent brought are not read as added.
Hosted here and reached, never carried. These are not declared in spec/files.json, so the audit does not expect a downstream repo to ship them, the same footing as spec/audit.py. That is the fleet model rather than an omission: a gate holding no per-repo content has one canonical implementation for the fleet. The prose and repository gate implementations live beside their composite actions under .github/actions/, which lets reusable workflows reach the implementation at their pinned hub commit through $/.github/actions/ without checking out the hub. The same-named files in scripts/ are thin local entry points that call those action-owned implementations, so maintainers and hooks retain the established commands from a hub checkout without creating a second copy of either gate. A repository that cannot reach the hub reports a local check as not run rather than reconstructing it, since a rebuilt gate encodes its author's reading of the rule and agrees with no other repository.
Python only, and standard library only, with markdown-it-py the one exception. build_dist.py, canonical_review.py, carry.py, pr_review.py, pr_review_copilot.py, and pr_cleanup.py declare it in their inline script metadata. build_dist.py and carry.py import it only on the path that reads a region, since skills_install.py and handoff.py import them under a plain python3. Every check script is read-only and exits non-zero on a finding. build_dist.py, local_review.py, canonical_review.py, handoff.py, and skills_install.py below are the exceptions, since a generator, two recorders, an installer, and the handoff chain all exist to write. build_dist.py --check and skills_install.py --report are read-only modes for CI and for asking without changing anything. local_review.py status and check answer without recording anything, but they are not read-only in that same sense: reading the working tree stages it, which writes inside .git and runs any filter.*.clean the repository configures, and only the receipt is left untouched. canonical_review.py reads files rather than staging them, so its list, status, sweep, and report are read-only outright, and record alone writes.
The directory separates its kinds by name and by tree. A gate checks and exits non-zero on a finding, and its name carries a _lint or _gate suffix saying what it gates. The prose_lint.py and repo_gate.py entry points delegate to the action-owned implementations that gate this tree in CI, and host_gate.py gates the machine it runs on. A utility does work rather than gating and carries no suffix: backlog_metrics.py, build_dist.py, canonical_review.py, handoff.py, local_review.py, pr_cleanup.py, pr_review.py, pr_review_copilot.py, skills_install.py. gh_graphql.py is a library with no command line, the GraphQL transport and cursor walker that backlog_metrics.py, handoff.py, pr_cleanup.py, and pr_review.py import from beside them. pr_review_checks.py, pr_review_rounds.py, and pr_review_digest.py are libraries with no command line as well. They are the pure readers pr_review.py imports for the head's checks, its review rounds, and the digest rendered from both. The unit tests live apart at the repository root under tests/, one test_<script>.py per script, driving the canonical implementations against input they must reject. The reader modules pr_review.py imports are tested through test_pr_review.py. A gate nobody has watched fail is a gate nobody knows works. Where a case covers a table it reads the live table rather than restating it, and each one asserts a floor on what a healthy run reaches, since a check whose scan matches nothing reports zero findings and reads exactly like a pass.
python3 tests/test_prose_lint.py
python3 tests/test_repo_gate.py
uv run --no-project --with-requirements tests/requirements.txt python tests/test_pr_review.py
uv run --no-project --with-requirements tests/requirements.txt python tests/test_pr_review_copilot.py
uv run --no-project --with-requirements tests/requirements.txt python tests/test_pr_cleanup.py
python3 tests/test_handoff.py
python3 tests/test_backlog_metrics.py
python3 tests/test_gh_graphql.py
python3 tests/test_local_review.py
uv run --no-project --with-requirements tests/requirements.txt python tests/test_canonical_review.py
uv run --no-project --with-requirements tests/requirements.txt python tests/test_build_dist.py
python3 tests/test_skills_install.py
uv run --no-project --with-requirements tests/requirements.txt python tests/test_carry.py
uv run --no-project --with-requirements tests/requirements.txt python -m unittest discover -s tests # all of them, and exits 5 if the suite vanishes
uvx --with-requirements tests/requirements.txt coverage@latest run --source=scripts,spec,host-setup -m unittest discover -s tests && uvx coverage@latest reportRuns the fleet Docker lint set with one bounded and observable procedure:
python3 scripts/docker_lint.py
python3 scripts/docker_lint.py --timeout 120 --linter markdownlint --linter cspellThe runner discovers tracked and unignored targets, excluding any path under node_modules at any depth, before pulling applicable images. It resolves each pull to a repository digest before execution. Lint containers use disabled networking and a read-only mount of a temporary snapshot. The snapshot holds only the tracked and unignored files, so a git-ignored file never reaches a container. Long target lists run in bounded batches. PSScriptAnalyzer installs its pinned module without any repository mount.
Every Docker command has a timeout. The output identifies target counts, phase boundaries, starts, completions, skips, timeouts, and failures. A timed-out named container is removed before the command reports failure.
Enforces the GOVERNANCE.md "Documentation Style Conventions" rules that no linter checks: non-ASCII judged against the charset rule's tiers, a semicolon in prose, a spaced hyphen joining or interrupting a sentence, a duplicated consecutive word, a British spelling, and the shape of a comment's prose. It carries one rule from elsewhere in that document, home-path, which comes from "Representative Data in Agent-Authored Text" and catches an absolute home path naming a real account. That rule closes the pattern-detectable sliver of its section and nothing beyond it, since the exposure the section exists for was name-shaped and no pattern finds a name. OPERATIONS.md is exempt because its runbooks carry the literal path an operator types.
The gate reads a file in three layers. Extraction is shared: code and config go through the Pygments lexer the file's name or shebang selects, Markdown goes through markdown-it-py, and each file becomes text units, a comment run, a docstring, a Markdown paragraph, or the lines outside a fenced block. Normalization is per language and is data: prose-gate.json beside the script names each language's comment markers, its documentation forms, the tool directives a checker reads after prose, the docstring section names, and the nested lexes, a YAML run: block read as shell and an ESPHome lambda read as C++. A file whose lexer the data declares no language for is read as plain text, so only the line rules reach it, and the scope line names it. Evaluation is shared: each rule is one function of a text unit, registered by name, and the data file holds every rule's message, its default membership, and the vocabulary and exemptions the rules read. The script declares its two packages in a PEP 723 block, so it runs as uv run --no-project --script .github/actions/prose-gate/prose_lint.py, and the local shim scripts/prose_lint.py delegates to that after answering --help itself.
A repository may carry .prose-gate.json at its root to add to the carried exemptions and to nothing else. Each key is a list merged over the carried one: hub_hosted and home_path_exempt take repository-relative paths, generated_trees takes directory names matched at any depth, service_accounts takes account names, dupword_allow and spelling_allow take phrases and words, and tool_directives takes directive names matched literally where a comment opens. Any other key, a value that is not a list of nonblank strings, and a file that is not JSON each refuse the run, since an override that silently does nothing reads as one that works, and a blank directive name would match every comment.
The tiers decide by context rather than by a flat ban. Tier 1 carries no meaning its ASCII form loses and always flags. Tier 2 is an operator, kept next to a figure or another operator and replaced between words, so a threshold table reads as the range it is. Tier 3 is a unit or scientific symbol whose ASCII form would be a lie and never flags. Tier 4 is a fixed set of emoji callout markers (warning, information, check mark, cross mark, memo, light bulb), each admitted alone or followed by VARIATION SELECTOR-16, and a U+FE0F anywhere else still flags. Developer-typed characters such as other emoji are preserved by the rule but not admitted by the gate, and an un-tiered one other than a Latin letter or a U+0300-U+036F diacritic it carries is still reported as charset-unknown until it is classified.
A character in no tier is a charset-unknown finding rather than a silent pass, since a gate that allows whatever it does not recognize stops gating as the character set grows. A Latin letter, with any diacritic from the U+0300-U+036F combining block that it carries, is the exception and never flags, since it may spell a recorded name, which the charset rule says keeps its actual spelling, and nothing tells a name from a word by its characters. A letter of another script, a compatibility form such as a ligature, any other mark, and a diacritic standing alone still flag, so the gate covers the Latin part of that rule and nothing wider. Classifying one is a fleet-law edit, so the whole-tree step that reports the backlog is warn-only, while the pull request gate blocks on one the change itself introduces. Which letters count as Latin follows the Unicode data of the Python running the gate, so a letter newer than that data is still reported.
Run it scoped to changed lines, matching the standing rule that existing prose is corrected as each file is next edited rather than swept:
python3 scripts/prose_lint.py . --diff origin/developWhole-tree (python3 scripts/prose_lint.py .) reports zero, so a finding is a line the change under review wrote rather than backlog it inherited. What gates a pull request is the composite action, which passes no --check and so gates every rule in the default set, charset-unknown included. The separate whole-tree --check charset-unknown --summary step in .github/actions/validate is the warn-only one, because classifying a character is a fleet-law edit rather than a prose fix, and it reports rather than gates.
The default rule set covers comment shape (comment-wrap and comment-case) alongside the prose rules. It did not, which meant a run nobody parameterized reported clean on a wrapped comment while the rule read as enforced, and comment shape is the most frequently regressed rule in agent-authored work. Reading the backlog it exposes needs no flag now, and gating it needed --diff while the tree carried several hundred of them. That backlog is cleared, so both comment rules gate whole-tree, and --diff is now about scoping a run rather than about surviving one.
The default set also carries comment-added, which reports the prose comment lines a change writes into code or config. Two weeks of fleet review rounds put 8% of every bot finding on agent-written comment text, and removing the surface was measured as the largest reduction available short of telling the reviewers not to read prose at all (ptr727/ProjectTemplate#1631). About half of them are classed as real defects rather than taste, so this removes a surface rather than a category of noise. The rule reports a prose comment that opens its own line, in the diff's scope, and infers nothing beyond that. A trailing comment is out of scope, because which mid-line marker opens a comment is a parser fact that differs by language: TOML and HCL carry a # inside a multi-line string, a git pattern file holds no trailing comment at all, PowerShell opens one after almost anything, and shell after its metacharacters. Six attempts at deciding that from the marker's neighbourhood each got a language wrong, reporting a line holding no comment or silencing one that held several, and a marker opening its line is unambiguous in all of them. comment-wrap and comment-case have judged only leading comments for years, which is why neither ever met those defects. git diff --unified=0 counts a modified line as an added one, so re-indenting a commented block and rewording a comment each report it. That is the rule's cost rather than a defect in it. An earlier shape read the file at the diff's base to tell an authored comment from a touched one, and the rename, duplicate and prose-filter cases that opened cost four review passes, for precision the rule does not need, since a wanted comment takes the same label either way. It is the one default rule a whole-tree run does not check, since with no diff every comment in the tree is in scope, and the run says so on stderr rather than standing down quietly. A docstring is out of scope, as is Markdown, and a file whose Python does not parse is still lexed, its docstrings staying string text. Where the comment is wanted, --allow-comments stands the rule down and names the act on stderr, and PROSE_ALLOW_COMMENTS, set to anything but a false spelling, does the same for a caller with nowhere to pass a flag. false and 0 read as off, since the composite action's own input spells its off value false and a value mirroring it turned the rule off at every commit. The composite action clears that variable and passes the flag from its own allow-comments input, so what decides a pull request is whatever the calling workflow puts there. The fleet's own caller passes it for either of two reasons, the comments label declared in repo-config/labels.json, and the pull request being a release-model promotion. That is this repository's own develop branch into its default branch, and its diff was reviewed where it landed. On the operational model a promotion runs the rule, since any of its lines may have landed by direct commit and the promotion is then their first review. The caller names its model in validate-task.yml's workflow-model input, and one passing none is read as release. A repository calling the action from a workflow of its own passes whatever that workflow passes. The label reaches a repository through repo-config/configure.sh apply rather than through this change. Where the lint log shows "Read the pull request's labels live", a label added after a run fails applies when that job reruns. Otherwise, as in a private repository, it applies to the next push, since the event's snapshot misses it (ptr727/ProjectTemplate#2354).
The default set also carries issue-ref, which reports an issue or pull request reference in a comment, in a Python docstring, and in the instruction text an agent reads as law, meaning the Skills trees, the fleet's own rule documents, and .github/copilot-instructions.md. In instruction text it reports the URL spelling as well, an inline link destination, a reference definition, and a bare URL alike, read off the line as written rather than off the blanked copy the bare-form scan reads, since that blanking exists to hide the very destinations this half looks for. A URL is reported nowhere else, because in a code or workflow comment the rule carves out a URL naming an issue or a pull request on a public repository other than this one, while the bare spellings stay banned on every surface the rule reaches. A docstring and a documentation comment lie outside that carve-out's surface as well, so a URL is banned there too, and on every surface but instruction text the gate reads the bare form alone and a banned URL goes unreported, which is the same unreported half a commit reference already sits in. One forge's URL paths are read, and on its bare host and that host's www alias rather than any subdomain of it, since the tracker is served from the bare host and a subdomain is a different service on the same domain. No subdomain was measured to serve a tracker-shaped path, so the bound is the one that was measured rather than a hazard that was found, and a wildcard host is the wider claim. A URL naming a tracker the pattern does not know is banned by the rule text and goes unreported, which is the relationship the rule already holds with a commit SHA. One line naming one reference reports once however many spellings of it that line carries, the key being the number both patterns capture rather than the text that spelled it. That key costs one case, stated rather than left to be found: a line naming two different projects' references under one number reports once, and the second reports on the run after the first is fixed, so it is a round rather than a lost detection. A documentation comment is read like any other comment here, unlike in comment-added, since a reference in one is the same defect, and a Markdown comment is read only where the document itself is instruction text. How far a comment read reaches is a parser fact that differs by language, and rule_issue_ref in .github/actions/prose-gate/prose_lint.py is the one statement of it, since every restatement of it here was wrong in its own way. The ban the rule serves is wider than the rule, covering a commit reference as well, and a commit is not a shape a gate can read, since a short SHA carries the same shape as a blob id, a version fragment, and a fixture hash. So the rule text in the comment-and-doc-style Skill covers that half and this rule reads the pattern-detectable one. A string literal is not read, because a test builds the numbers it asserts against and reading those would report a fixture rather than a claim about the repository. A tracker, a history, a plan, and a README outside those trees are the repository's own narrative and keep their references, this file among them. The rule shipped with the scrub that cleared the tree's existing references rather than after it, because a file whose every rationale block ended in an issue number taught the house style the rule forbids, and no rule text competes with the surrounding file until that file changes.
A wide scan skips the trees this repo generates rather than authors, currently reports/, which spec/audit.py writes. A finding there is the audit engine's phrasing rather than an author's, so no edit to that tree can fix it, and leaving them in made the repo's own number mostly generated output. Naming such a path directly still reads it (prose_lint.py reports), so nothing becomes uncheckable.
The reusable prose gate reads optional exclusions from .github/prose-gate-excludes in the repository it scans. Each non-empty line that does not start with # is one repository-relative path fragment passed to --exclude. Use the contract only for vendored paths whose upstream body the repository preserves. Repository-authored provenance files and patches stay outside the excluded path so the gate continues to read them.
The file set is what git tracks plus what it is not ignoring, and the diff scope counts an untracked file as added in full, so a new file is read before it is staged rather than after. That is the file set where git can describe the tree, and where it cannot the run warns and walks the filesystem instead, which applies no ignore rules and skips the generated roots by name. Everything a verdict rests on is read from the repository named on the command line: the rule set, the file set, the diff, and the repository-relative keys that join the last two. Reading any of them from the directory the process happens to stand in is what produced every false clean on record, including an absolute path argument that reported clean over a tree it had read in full. Each run states its scope on stderr, because a gate that read nothing prints what a gate with nothing to report prints.
That same line names the copy that produced the verdict, so a finding is attributable to a commit rather than to the gate in general. A repository reaches this gate at whatever hub commit its own workflow pins, so a rule that changed after that pin is a rule no local run has, and a finding raised by the older copy then reproduces nowhere. The composite action passes its own owner/repo@ref through --provenance, being the only source that knows the pin, since a checked-out action carries no history to read it from. PROSE_GATE_PROVENANCE plays the same role for a caller invoking the script directly. The extraction recipe in GOVERNANCE.md "Running the Linters Locally (Known-Working Invocations)" sets it. hub_fetch_run.py also sets it, to ptr727/ProjectTemplate@<sha> (hub-fetch-run main), naming the main commit it fetched. When it cannot resolve that commit, hub_fetch_run.py sets ptr727/ProjectTemplate@main (hub-fetch-run, commit unresolved) instead. Failing both --provenance and that variable, the script's own checkout answers, and only where that checkout tracks the script. An untracked copy and a copy under no repository each report unknown rather than a plausible commit, and a tracked copy edited since its commit is named -dirty, because a well-formed wrong attribution sends the next investigation at a copy nobody ran.
In Markdown an HTML comment carrying no sentence punctuation is treated as a structural marker rather than commentary, so it takes neither a capital nor a sentence split. The reference-link group headers, the ToC-omit directive, and the agent-safety install markers are each matched verbatim by a tool, so rewriting one to satisfy the rule breaks whatever reads it. A Markdown comment that does punctuate a sentence is prose and is judged as prose.
The spelling rule covers the US English convention where cspell does not reach. That gate reads README and HISTORY only, deliberately, because gating every Markdown file would mean endlessly padding cspell.json with technical terms, so a British spelling anywhere else in the tree had nothing checking it. The banned words are generated from stems rather than listed one by one, since an inflected spelling is as wrong as its base and a hand-listed family drifts as soon as one form is added without the others. Two words are deliberately absent: analyses is the US plural of analysis as much as it is a British verb form, and cancelled is a GitHub Actions job status rather than prose.
Outside Markdown spelling and dupword read the comments, not the source lines, reusing the extraction the comment-wrap rule already does. An identifier, a string literal, or a lookup table is code, and judging it as prose would make this script report its own table of banned words. Each comment on a line is judged on its own rather than joined with its neighbors, because two comments are two sentences and joining them reads the second's opening word as a repeat of the first's last.
dupword gates CI, so its scope decides what a correct file is allowed to contain. A repeated token outside a comment is usually correct authoring rather than a typo: class="gallery gallery-cols-1" is the ordinary way two CSS class names share a prefix, and rel, srcset, sizes and the data-* attributes all take value lists of the same shape. There is no edit that satisfies the rule without changing the rendered page, so a blocking gate that reads those lines rejects correct work. The cost of the narrower scope is stated plainly rather than hidden: a duplicated word in HTML body text, or in a YAML or JSON string value, is no longer caught. Narrowing to the comment is preferred over exempting an attribute, since an exemption list covers only the attributes its author thought of.
Scope is every text file git tracks, binaries skipped by a NUL-byte check, with no extension allowlist: an allowlist covers what its author thought of and silently stops covering whatever is added next, which is the same reason the line-endings rule already requires git ls-files over a raw find. --list-files prints the discovered set for auditing.
A double-quoted span in Markdown is treated as a quotation and not scanned for prose rules, so a rule that states its own counter-example does not report the document that documents it. Outside Markdown a double quote is structural, so the prose inside it still counts.
The semicolon and dash rules ban a construction rather than a detectable subset of it, so each flags by default and the exceptions are the ones the rule names: a semicolon inside a list that already carries commas, and for the dash a compound word, a leading list marker, a range, and the - **Label** - explanation separator that opens a governed bullet. Where such a list is joined by a single semicolon, each of its two items carries a label.
The semicolon rule reads the list where it lives. The comma qualifies the list as a whole rather than one separator's position, so an enumeration whose commas fall in a later item keeps every semicolon it carries. Reading it positionally split one series in two, flagging the openers of the same list it then exempted the tail of, which would have restructured the enumerated guarantees the exemption exists to protect. A Markdown table row is judged one cell at a time, since a row is a record of fields and a comma in one column cannot excuse a semicolon in another, and a bullet's **Label**: is dropped before the line is read, because it opens the bullet rather than announcing a list, the same construct the label dash is exempted for. The colon is written inside the emphasis as often as outside it, so **Label:** is dropped on the same grounds, matching only one spelling having left the other announcing a list it never announced.
The sentence is the unit the exemption is judged on, because that is where a list lives. The whole bullet decided it once, so a colon anywhere before the first semicolon marked the bullet a list and exempted every semicolon after it, however plainly one joined two independent clauses, and the two did not have to be near each other or related at all. Measured over this repo when it was fixed, the exemption was covering 62 spans holding 120 semicolons across 9 files while the rule reported none of them, so the gate read as clean over the docs it exists to check. Scoping it to the sentence reported 43 further semicolons and silenced none, with a 44th from dropping the other spelling of the label colon, and the sentence boundary is the run-on rule's, so an initial or an abbreviation ends nothing and a terminator closing inside emphasis or a bracket (.**, .)) still ends a sentence. A lone semicolon separates a list only where the items on both sides of it open with a label, as in Inputs: a, b; outputs: c. No structure tells an explanatory colon from a list colon. A colon and a comma alone once exempted the one semicolon joining two clauses after them. A sentence with two or more semicolons and a comma stays a series whatever colon it holds.
Both are Markdown-only for now. A shell script carries 78 statement separators that are not prose at all, so telling a comment from code is a precondition for reaching source files. Until then a semicolon or dash in a code comment is missed, which reading the diff by eye still catches.
The comment-wrap rule covers comments in every syntax the fleet's project types carry, not only the hash ones: // and /* */ for C#, C, C++ and JSONC, /* */ alone for CSS, <!-- --> for XML, csproj and Markdown, <# #> for PowerShell, ; for INI, and # for Python, shell, YAML and TOML.
JSON is treated as JSONC, because that is what ships: VS Code tasks, launch, devcontainer and workspace files all carry comments under a plain .json name. A marker inside a string literal is not a comment, so each line is scanned with quoted spans blanked first, and Python uses tokenize so a trailing comment is seen exactly. A documentation comment (///, /**, a docstring) is left to CODESTYLE, which permits the paragraphs this rule forbids.
Each syntax also declares how its strings escape: the escape character, the quotes it works inside, and whether it works outside one, all read independently of whether a string embeds its delimiter by doubling it. Neither property implies the other: PowerShell's double-quoted string is escaped by a backtick and doubling at once, while a C# verbatim string is doubling and not escaped. Reading an escape a string does not have consumes its closing quote and blanks the rest of the line, and missing one it does have ends the string early on the escaped quote.
A string that spans lines carries its state onto the lines it covers, so a marker inside one is data rather than a comment. Each syntax declares the forms it carries: the C# verbatim string, an ordinary quoted string in shell and in PowerShell, a PowerShell here-string, a shell heredoc, and a YAML block scalar. A YAML run: scalar is deliberately not one of them, because it holds a script whose # lines are exactly the comments this rule governs. A form no syntax declares stays scanned a line at a time, which is where a false positive is still possible: a TOML triple-quoted string is the open case. The reverse direction is guarded too, since a form that carried where the language has none would blank the rest of the file and report nothing: a YAML plain scalar's apostrophe is not a string, so an ordinary quote does not carry there.
A comment sentence also has to start with a capital, which comment-case checks. A lowercase opening reads as the continuation of the line above it, so the two rules are read together: a wrapped sentence reports as comment-wrap, and a lowercase opening that is not a continuation reports as comment-case. Where the first word is a tool whose own casing is lowercase, the fix is to restructure rather than to capitalize the name against CODESTYLE's tooling-casing rule.
A comment body that is one token closing on a colon is a key or a heading rather than a sentence, and neither rule applies to it. A # ignore: heading a commented-out block is disabled configuration, so the capitalization comment-case asks for corrupts the key a reader uncomments, and the rule damages the file it was pointed at. The token count carries the test, because a colon ending real prose always has words before it. Measured over this repo, the shape matches exactly two bodies, both of them a key or a heading, where the broader reading of a body with no whitespace at all covers 99 and sweeps in every reference URI and version marker with them.
A comment body of one key, a colon and one value is configuration rather than a sentence, and neither rule applies to it. comment-case reads the first character, so a snippet pasted into a comment could not pass while its key opened lowercase, and the keys that did pass are the ones opening on something else, each changing what a reader pastes: a capitalized, quoted or underscored key changes the key, and a leading dash is a different YAML construct from the mapping it belongs to. The same snippet already passes inside a Markdown fence, which is what makes this a gap rather than a constraint. The shape is one value carrying no space, and a body ending as a sentence is judged as one, the exemption pairing a guard with its own anchor as is_tool_directive does and for the same reason. The exemption reads SNIPPET_END rather than SENT_END, whose set carries the colon: a colon ends configuration rather than a sentence here, which is why the lone-key exemption above exists, and reading it as a terminator refused address: fd00:: and image: ghcr.io/o/n:. The exemption costs two detections and the guard refuses one shape, all stated rather than left to be found. The common cost is a marker comment, since todo: refactor and note: obsolete are the same shape as a config line. The second is a two-token body with no terminator inside a wrapping sentence, which loses that sentence its wrap and reports the line under it for case instead, or loses it silently where the body ends the wrap. The refusal is not a lost detection but a finding the exemption fails to remove: a value ending in a full stop is judged as before, an FQDN such as origin: example.com. being the case. Dropping the full stop from SNIPPET_END would leave ! and ? guarded and re-exempt the wrapped sentence the guard exists for, so it stays. is_comment_prose deliberately does not carry this exemption, unlike its three siblings, since comment-added reads a body of any case and exempting the shape there would free Owner: alice from the label as well. Measured over this repository the exemption newly reaches one body, a command to paste, and that measurement bounds neither cost nor the refusal, this tree carrying no instance of any of them.
Two or more adjacent configuration lines are a block a reader pastes, and neither rule applies to any of them. The line shape read here is wider than the one above: a key, a separator, a space, and the rest of the line, accepting = beside :. The space after the separator is required, which is what keeps a bare URI from reading as a key and a value, and it is also why the unspaced key=value an environment file writes is not this shape. That width is what reaches a YAML tag value such as board: !include board.yaml, a quoted value carrying a space such as label: "Compressor 1", and a key-value line with a trailing comment, each of which splits on a space the narrow shape's single-token value cannot contain. The run is the configuration lines themselves rather than the comment block holding them, and a usage block is what decides that: three sentences of prose above two Run: lines are one comment block, and exempting the block would stop both rules reading the prose, so a line joins the run only by being configuration itself. A block's own non-key-value lines need no membership of their own wherever the block holds a run at all, since a PEP 723 block's # /// script opener draws a finding only because the key-value line under it reads as a continuation of it, and exempting that line clears the opener with it, while the closing # /// is already exempt as punctuation alone. A block declaring one key is not a run, so its opener still reports, which is what the count costs rather than a case the rule misses. Two is the count because the wider shape is also the shape of a marker comment, note: obsolete and todo: refactor being indistinguishable from a configuration line, and a marker comment is one line where a configuration block is not. The same guard the narrow shape pairs with its anchor applies here, so a body closing as a sentence does is judged as one. A trailing comment breaks a run and joins none, since it annotates the code on its own line. Three costs, stated rather than left to be found. A genuine single-line snippet in the wider shape, standing alone, still reports, which is what keeps every marker comment in the tree from going exempt. A run of two or more marker comments in the wider shape is exempt, which is the narrow shape's own documented cost reaching one line further. And the wrap cost the narrow shape's own paragraph above already states reaches further too: a sentence wrapping into a run loses its wrap silently, and one wrapping out of a run has the line under it reported for case instead, so the author is told to capitalize a word that opens no sentence. The wider shape makes that class more reachable, since a body becomes an anchor on the wider shape where it needed the two-token one before, though only ever inside a run of two or more, where the narrow shape's own exemption reaches a body standing alone. Measured over this repository the exemption reaches six lines in three files, two usage blocks and one invocation block, and no prose line among them, so none of the three costs is bounded by that measurement.
A label opening a definition is exempt from comment-case, because the lowercase word is the name being defined. A # publish - 'true' when ... documents an output named publish, and capitalizing it renames the output the workflow declares. This is the comment spelling of the - **Label** - text construct the dash rule already exempts, and it is the same reason a step marker is stripped before the sentence checks. The exemption costs a detection, stated rather than left to be found: a comment that genuinely opens a sentence on one word followed by a spaced dash is no longer read as a lowercase opening. It is scoped to a line that opens a definition and is never applied to one continuing a wrapped sentence, since a continuation whose first word takes a spaced dash is a parenthetical, which is the construction the dash rule exists to catch. Both live instances in this repo are continuations, so the corpus rather than the intuition chose the unit: the first design read any comment body and would have exempted both.
A comment whose whole body is a URI is a reference rather than a sentence, and neither rule applies to it. It cannot be capitalized or restructured without corrupting the address it exists to carry, so before the exemption every repo carrying a reference block inherited a finding no edit could answer. Consecutive reference lines are separate addresses rather than one sentence wrapping, which is why the exemption also stops the line below a URI from reading as its continuation. A URI inside a sentence is still prose, so the exemption requires the whole body to be the address and nothing else.
Every rule in the default set is clean tree-wide except comment-added, which reads a diff and so has no tree-wide reading at all. That is what lets a local sweep here mean something, rather than what the CI gate does, since the gate scopes to the lines a change touches. charset-unknown blocks there like any other default rule, and it is the warn-only one in .github/actions/validate's separate whole-tree step, for the reason the tier discussion above gives.
Deterministic checks:
sha-pin: every external action or reusable-workflowuses:reference is a 40-hex commit SHA, with the documenteddotnet/nbgv@masterexception. References under the scanned repository's owner are also resolved through GitHub. References under another owner are shape-checked only. Local (./) and self-repository ($/) references name no ref, so they take no pin, and a quoted one is recognized once its quotes are stripped. They resolve differently, though. A$/reference and a job-level./reusable-workflow call resolve at the calling workflow file's commit, while a./action reference resolves against whatever the job checked out, which is the caller's tree where a reusable workflow called from another repository checks out its caller. A reference starting with a bare.github/is reported, since GitHub reads it asowner/reporather than as a path.eol: every path pinned LF in.gitattributeshas the matching.editorconfigoverride the line-ending rule requires, with EditorConfig brace syntax expanded. One direction only: an.editorconfigLF glob with no git pin is legitimate, since.editorconfiggoverns what the editor writes where git enforces a class it must not guess at.eol-coverage: the same pins read against the tree instead. A tracked file opening#!that git does not resolve toeol=lfis an interpreter line a CRLF checkout breaks, and a pin matching no tracked file is dead unless its block is markedforward-declared.composite-actions: every trackedaction.ymloraction.yamlunder.github/actions/, at any depth, is shellchecked and schema-checked, because actionlint reads workflows only. Each non-emptyrun:body of a bash, sh, or unset-shell step goes to shellcheck. Every${{ }}expression is replaced by a placeholder first. A finding names the action file and the step. A pwsh or python step is skipped. The file set goes tocheck-jsonschema'svendor.github-actionsbuiltin throughuvx check-jsonschema@latest, orpipx runwhereuvxis absent. Shellcheck runs from themirror.gcr.io/koalaman/shellcheck:stableimage, so the check needs Docker. It reads the action through PyYAML, in process where it is importable and throughuvx --with pyyamlotherwise. Each half reports its own missing tool. Bodies run at warning severity without SC2154, so this is weaker than shellcheck over a.shfile. A tree with no action file is skipped with a note.
python3 scripts/repo_gate.py
python3 scripts/repo_gate.py --check sha-pineol and eol-coverage are separate checks because they answer different questions, and widening the first in place would have made three published descriptions of it wrong at once. eol compares the two line-ending documents with each other, which is worth asking on its own, and it is document-to-document by design rather than by omission. What it cannot see is the tree: both documents can agree perfectly and both be wrong about the repository they describe, and nothing mechanical ever asked whether a pin binds a file that exists. spec/files.json marks .gitattributes as intent, so what stood in that gap was an agent reading the file for meaning during an audit.
The gap is measured rather than argued. ptr727/Blog at 392de22 carried both shapes while --check eol reported 0 issue(s): ops/vps-backup-pull is an extensionless shebang script systemd runs unattended on a backup host, matched by *.sh and by no .py pin and left passive by * -text, and two pins named deploy/ paths that have never been tracked in that repository in any commit. Run against the same commit, eol-coverage reports all three. The repository is where the issue that raised this came from (ptr727/ProjectTemplate#633), and it fixed its own copy first in ptr727/Blog#69.
The dead pin is the worse half, and not because a no-op pin costs anything. The comment above Blog's two read "the deploy shell is an extensionless shebang script that matches no rule above", so the file asserted the extensionless case was handled while the one real instance sat unpinned twenty lines up. A dead pin does not merely fail to bind. It reads as coverage, which is what hid the live defect from every human and agent who opened that file.
A pin block marked forward-declared is exempt from the dead reading, because in a carried baseline a pattern matching nothing is a declaration for whichever consumer adds the file, not a dead pin. Three of this repo's pins are exactly that today (uv.lock, Dockerfile, *.Dockerfile), and each goes live the moment a derived repo adds a lockfile or a Dockerfile, which .gitattributes already said in prose before anything read it. The mark reaches to the next blank line, which is how that file already groups a pin with its rationale, and it travels with the carried copy so a python repo holding the baseline without a lockfile stays exempt too. Two alternatives were rejected. An exception list inside this script restates data the scanned repository already carries and goes stale in the one direction nobody checks. Diffing against the hub's own .gitattributes needs no convention at all, and it was rejected because it makes a repository gate change verdict when a file in another repository changes, and it cannot run standalone.
What the mark trades away is stated rather than left to be found: it reaches to the next blank line, so a pin appended directly under a marked block inherits an exemption nobody wrote for it, and that fails open. A case in the suite therefore names the three pins the marking is for and fails the moment a fourth arrives, rather than the parser growing a second rule.
Matching is gitattributes matching, not pathspec matching. git ls-files -- <pattern> looks like the cheap way to ask whether a pin binds anything and is a different language: there * crosses a /, so capture/*.py also matches capture/sub/x.py and a dead pin reads as live. The attribute side has no such risk and takes no such care, since it delegates to git check-attr and so cannot disagree with what a checkout actually applies.
The shebang floor lives in the suite rather than in the check. A source-only configuration repository shipping no scripts at all is legitimately clean, so a gate that failed on an empty scan would report a false finding in the common fleet case. What must not go unnoticed is this repository's own scan going quiet, and a case holds that instead. Every run still prints what it covered, for the reason sha-pin does.
eol gained one note: of its own. Where .editorconfig sets end_of_line = lf for [*], the matching override the check looks for is satisfied by the global default for any path, one that does not exist included, so the check is vacuously true for every pin it will ever read and its result carries no information about pin content. Blog is shaped that way, and so are the fleet repositories that declare lineEndings: lf in registry/repos.json. The check does not fail there, since nothing is wrong with such a repository, and it says what it did not read.
One question is deliberately open: the symmetric reading of .editorconfig, a path-specific section naming files that do not exist. It is not implemented here because this repo's own [.github/workflows/*] and [catalog/snippets/workflows/*] sections are legitimately broad, so the exemption needs measuring against the live corpus before a gate is built on it rather than after.
sha-pin resolves the pin as well as reading its shape, because forty hex characters is a format any fabricated string satisfies, and an agent hand-writing a plausible SHA into a workflow is a failure this repo has seen rather than a hypothetical one. The gh-write-guard hook cannot cover it: the hook watches Bash, and an editor tool writing the same string into a file never reaches it. Resolving also catches the neighboring case, a pin whose commit was reachable only from a branch since squashed and deleted, which breaks a downstream gate long after the change that caused it.
The resolution pass is scoped to the scanned repository's own owner, which is where the fleet's actions live and where that decay comes from, since a squash merge deletes the branch a pin was taken from and the pin outlives the commit. A third-party action's tag is stable by comparison, and reading one would make every local run of this gate depend on a stranger's repository answering. The cost is real and is stated rather than left to be found: a fabricated pin on a third-party action is still only shape-checked. Every run therefore prints what the pass actually covered, because a scope that resolves nothing prints the same 0 issue(s) a full sweep does. On the hub today it covers nothing, since this repo's own uses: refs are all under other owners and the fleet's ptr727 pins live in the downstream repos that consume prose-gate. Those are reached by running this gate from a hub checkout with --root, per the hosted-and-reached model above.
A pin is a finding only where GitHub answered that the commit is absent, meaning a 404 or a 422. An offline host, a missing gh, a rate limit and a narrow token all report as unread and leave the pin on its shape, so the gate stays usable with no network instead of failing a correct tree. A 404 is confirmed against the repository itself before it becomes a finding, since an absent commit and a repository the credentials cannot see are the same answer from here, and a repository-scoped CI token is narrower than the fleet. That second read runs only on the failing path, and each distinct pin is read once however often it repeats.
A note: line is how a check says it did less than its name. It prints under the check's own result, outside the issue count, and never changes the exit code, because nothing is wrong with the tree when the network is what is missing. It carries one fixed shape every run so a zero is as visible as a count, and it separates a pin under a known other owner from one whose owner could not be compared at all, since a checkout with no readable origin skips every pin including this owner's own and reporting those as somebody else's is the same false clean the note was added to prevent.
A stale-backticked-path check was built and rejected: a template repo legitimately references paths that live in downstream repos, so it produced 34 false positives on a clean tree with no way to separate those from real drift. Doc-to-doc drift is a review lens, not a regex.
The host contract in docs/host-setup.md as a check, reading the tool floors declared in spec/host-tools.json. It exists because presence is the weaker half of that contract: both host defects this fleet has hit are version facts on a tool that is installed, answers --version, and looks healthy.
A floor is either measured or a target, and its why says which, since a host failing one has a defect to point at where a host failing the other does not. A measured floor records the defect rather than a preference, and two exist today. A distribution gh in the 2.45.x / 2.46.x range is named broken by the GitHub CLI maintainers, and both gh limitations in OPERATIONS.md were observed on one. A git-restore-mtime before 2025.08 calls git whatchanged, which current git refuses, so it restores nothing, prints its ordinary statistics and exits 0. Everything else is presence-only, which is deliberate, since a floor nobody can justify becomes a host failure nobody can act on.
The three states a tool can be in are kept apart because their remedies differ: absent means install it, unreadable means the declared pattern is wrong and the fix is in this repo rather than on the host, and read means the floor applies. A probe that runs and exits non-zero is not an answer, which is what separates a tool that is missing from one this file cannot parse.
Every repository carries a host-tools.json at its root, an empty tools list where it adds nothing, and the gate layers it over the hub's, so a repo needing ffmpeg, or needing a tool the fleet calls optional, declares that where it is true. A bare run started in a subdirectory names an ancestor's overlay it did not apply, unless that overlay declares nothing, since every repository carries the file whether or not it adds anything. One it cannot parse is named too, the failed parse being what leaves the question open. Layering is tighten-only: a local entry may add a tool, raise a floor, or turn an optional tool required, and may not lower a floor or turn a required tool optional, because those retire a fleet check from inside the repository it protects. A rejected relaxation is reported rather than dropped.
A missing required tool prints its platform source and runnable remedy. An install record can derive that remedy through the host installer. The record names only an allowed package manager and package identifier, so repository-authored command text is never executed.
One compact interface to a pull request's Copilot review loop. status prints the digest, and wait runs the backoff in-process. comment posts a PR-conversation answer, while reply answers one thread, or each copy of a finding posted twice, and optionally resolves it. claims reads the description against the branch it describes. attest publishes a recorded local pass. cleanup forwards to pr_cleanup.py. The runbook is in .github/copilot-instructions.md.
uv run --no-project --script scripts/pr_review.py status 452 --repo ptr727/ProjectTemplate
uv run --no-project --script scripts/pr_review.py claims 452 --repo ptr727/ProjectTemplate
uv run --no-project --script scripts/pr_review.py wait 452 --repo ptr727/ProjectTemplate --timeout 2700
uv run --no-project --script scripts/pr_review.py comment 452 --repo ptr727/ProjectTemplate \
--body "Suppressed findings (1): **Disproven** - the target is checked before the write."
uv run --no-project --script scripts/pr_review.py comment 452 --repo ptr727/ProjectTemplate \
--fingerprint 0123456789ab --body "**Declined**: the collapsed note restates the title."
uv run --no-project --script scripts/pr_review.py reply 452 --repo ptr727/ProjectTemplate \
--match "retry count is off by one" --body "Fixed in abc1234: the loop now stops at n." --resolve
uv run --no-project --script scripts/pr_review.py attest 452 --repo ptr727/ProjectTemplate --checkout ../worktree
uv run --no-project --script scripts/pr_review.py cleanup 452 --repo ptr727/ProjectTemplateThe gate and its readers are separate files. This script holds the GraphQL and REST plumbing, the verdict, the wait loop, and the writes. The head's checks are read by pr_review_checks.py and its review rounds by pr_review_rounds.py, and pr_review_digest.py renders the status and wait digest from both. Each reads the payload the gate already fetched, and none makes a call or imports the gate. The digest's header is an ordered list of fields, and its fixed advisory lines are an ordered table. What a reviewer's body says is read by pr_review_copilot.py, whose result is a small typed reading rather than a body. The reviewer's wording has changed many times and has no programmatic API, so a change in it is a data edit in reviewers/ plus one fixture, and never reaches the gate. A part of a head round that the reader cannot place holds the gate, shapes=HELD and exit 50. A finding can sit in it where no count reaches. Its text prints under UNPLACED ON THE HEAD, each part cut to EXCERPT_LINES lines of EXCERPT_WIDTH characters. Beside it prints a fingerprint, the first FINGERPRINT_HEX hex characters of a SHA-256 over the set's text. An owner, member, or collaborator clears the hold, shapes=ACKNOWLEDGED, with written outcomes posted by comment --fingerprint. That comment carries <!-- fleet-shape-ack: fingerprint=<fp> --> as an HTML line of its own. A new head carrying new unplaced text has a new fingerprint and owes a new acknowledgment. A part on an earlier round prints under REVIEWER OUTPUT NOTED with a one-line note. So does a label nested in a list, a quote, or a placed part. It decides no exit code. shapes=NOTED shows only where no head hold takes the field.
A Copilot round is requested at defined moments rather than on every push. The one moment is the head of a pull request into the default branch, a promotion among them. A promotion re-reviews content its feature pull requests already carried, so the hosted review runs once per change, at the promotion. On a pull request into any other branch, every head is covered by the recorded local pass the push already owes. That pass lives in the checkout's git directory, where nothing on GitHub can read it, so attest publishes it as a comment carrying <!-- fleet-local-review: head=<sha> base=<branch> findings=<n> -->. It does so only after confirming the checkout is the pushed head, holds no other change, measures the pull request's own merge base, and has a covering pass in local_review.py status. status then reads that head as review_on_head=local with coverage=local, where the attestation comes from an owner, member, or collaborator, stands as an HTML line of its own outside any code, and no round on record states partial coverage with the whole history in view. A hidden attestation comment does not count. wait requests nothing on such a head and polls its checks instead, while they can still move the merge. Where it is not attested, wait exits 49, and --request asks for a round anyway. The comment's findings count prints beside the reading and gates nothing, since a local pass's findings are advisory. A default branch the payload does not name reads as a promotion, so an unreadable field costs a Copilot request rather than passing a head on a local pass alone.
--repo is required and carries no default. A default names one repository, and a run from anywhere else resolves its number there instead, with every field well-formed and nothing in the output disagreeing. The digest leads with repo=OWNER/NAME for the same reason. A value that is not OWNER/NAME is rejected by name.
Do not infer one field's spelling from another's. Several states print upper-case, merge= reproduces GitHub's own enum verbatim, and a field can arrive behind a prefix, so a pattern built by analogy with a neighboring field silently never fires. review_on_head renders yes against NO, so a pattern written for YES waits out its whole bound over a review that landed. Run the command once, read a real line, and anchor the pattern on the field's own name with its value. Give a watcher a branch that exits non-zero where the digest cannot be read at all, or a pattern that never fires and a command that never ran are equally silent.
| Code | Command | Meaning |
|---|---|---|
0 |
status, wait |
No Copilot review covers the head yet (status), or the review covering it states no partial coverage and its verdict counts what it flags. On a held head, an attested local pass whose checks no longer hold the poll, short of 45. Exit 0 on that review does not poll the checks, so it does not prove the required checks ran. |
30 |
wait |
Still pending at the timeout, which is pending rather than failed. On a held head, status=CHECKS_PENDING. A round past --min-rounds not yet landed. |
40 |
wait |
No Copilot review covers the head: the reviewer answered outside a formal review, the request returned success and recorded nothing, or the reviewer writes under a login no query filters on. |
41 |
wait |
The newest round is a refusal or a quota state, its kind printed in the status line, and in refusal= for a refusal on this pull request. |
42 |
status, wait |
The round covering the head states partial coverage. Or a non-refusal head round exists while an earlier round states partial, and one reads UNKNOWN or none states coverage. |
44 |
wait |
The review loop closed, the merge reads BLOCKED, and a required check is in a shape no wait clears. |
45 |
wait |
An open attested held head that nothing holds, its merge other than CLEAN, UNSTABLE, or HAS_HOOKS. Its rollup was unreadable, a DIRTY merge has none posted, or a DIRTY or BEHIND merge lacks a required check or has it from another app, judged on rules read and a whole rollup. |
49 |
wait |
No Copilot round covers the head and none was requested, on a pull request into a branch other than the default with no attestation. |
50 |
status, wait |
The round covering the head opens on a verdict other than the clean one while it counts no finding. Or a head round carries a part the reader cannot place, and nothing acknowledges its fingerprint. |
60 to 63 |
reply |
No thread matched, more than one did, the reply returned no comment url, or the resolve did not report the thread resolved. |
64 |
every write, wait |
The write scope could not be established or excludes the target. |
65 to 68 |
comment, attest |
The pull request could not be read, the response did not confirm the comment, the checkout is not the head, or no current local pass covers it. |
70, 71 |
claims |
A reference does not resolve, or none could be read. |
1 is a GraphQL read that failed and ended the run, and 2 is argparse's own usage error.
wait exits 30 with status=CHECKS_PENDING on an attested held head while its checks can still move the merge at the timeout. It reads them in this order. A pull request no longer open holds nothing, and a merge reading UNKNOWN holds. A BLOCKED merge ends the poll on the first read where a required check is stuck in a shape waiting does not clear. A check running long or gated is not such a shape. A failed one is the exception while a newer run of its workflow on the head has not finished, which the digest names CHECK SUPERSEDED. Next, a workflow run on the head that has not completed holds, where the read reaches it. The read reaches the head's last 50 check suites and those of its first CHECKS_WINDOW rollup contexts. A run's later jobs enter the rollup only as their needs: finish, so a green rollup proves nothing while one is open. The status line names those runs. Next, a merge reading CLEAN, UNSTABLE, or HAS_HOOKS ends the poll. Otherwise a required check still settling holds, one running long included, and so does any settling check while no required one has posted. A BLOCKED merge also holds while a check the base branch's rules require has not registered. That hold needs the base branch's rules read and a rollup that is not CHECKS TRUNCATED. Where the rollup holds a type the reader cannot parse, its status line says that check may sit in the unread part. An empty rollup holds under BLOCKED, BEHIND, or DRAFT. An open head released short of those holds exits 45 with status=CHECKS_MISSING where a check it is owed is missing from the read. Each such case falls under a merge other than CLEAN, UNSTABLE, or HAS_HOOKS. One is a payload with no commit matching the head, which the digest names CHECKS UNREADABLE and which reads no run and no check. Another is a rollup with no check posted, which only a DIRTY merge releases, since a conflicted pull request runs no pull_request workflow. The last is a DIRTY or BEHIND merge whose rollup lacks a context the base branch's rules require, or carries it from another app. That one needs the rules read and a rollup that is not CHECKS TRUNCATED. Where the rollup holds a type the reader cannot parse, which the digest names CHECKS PARTIALLY UNREAD, the status says the check may sit there. Exit 0 can then leave a required check unregistered only under unread rules, a cut rollup, a DRAFT merge, or a pull request not open. --min-rounds N counts what status prints as rounds=, every round on every head, refusals included, so N is the rounds= a status read before a re-request.
wait exits 40 when no Copilot review covers the head and the reviewer has said why in another shape. A plain comment newer than its newest review carries no commit, so it satisfies no coverage check whatever it says, and its body prints whole because its wording is the only thing separating a refusal from a remark. A comment older than the newest review is spent. Every connection reads the newest WINDOW nodes rather than the reviewer's own, so window_blind guards both sides: blind on comments reads as answered_outside_review=unknown, and blind on reviews reports nothing and lets the wait keep polling. A request that returns success and adds neither a pending reviewer nor a review-request event also exits 40, as status=REQUEST_NOT_RECORDED, as does a reviewer writing under a renamed login, status=REVIEWER_LOGIN_DRIFT.
wait does not classify a request from copilot_work_started, since GitHub can complete an effort-labeled review without that event. A request with no review or answer remains PENDING until the bounded timeout. Where a second bounded wait times out as well, read the pending set, clear it only where no human or team reviewer is requested alongside the bot, and rerun wait, which then requests afresh. The clear replaces that set rather than adding to it, so a stall on a pull request with a human or team reviewer requested goes to the maintainer, the mutation itself being in pr-reviewer-reference.md. --pickup-grace remains accepted for command compatibility and has no effect.
wait exits 41 when the newest round is a refusal or a quota state. A refusal is a formal review carrying the head and no threads, which is the clean pass byte for byte, so it is not coverage: review_on_head reports NO and refusal= carries its kind. A quota refusal reads refusal=QUOTA. An error refusal is the body the weekly rate limit posts, so the job log of the reviewer's own failed run on that commit is read, a logged rate limit reading as QUOTA with its reset time and anything else as ERROR. wait sends no request while the pull request's newest Copilot review is a quota or error refusal on any head, and none where the reviewer's newest activity elsewhere in the repository is a quota refusal nothing answered since. Pass --ignore-quota-signal once the quota is believed to have reset. A file-count refusal is cleared by splitting the pull request. status exits 0 over a refusal and carries it in refusal=.
The digest reports the completed head review's effective effort as lite, balanced, or max, with effort_source=default for Default (<level>) and explicit for a bare level, and unknown for both where the body states none. Effort never changes a verdict, and the workflow never selects or changes the user-controlled setting.
coverage= names how the head's diff is shown read, and only partial coverage is an exit. full and PARTIAL are the reviewer's own statement, the fleet-review marker the fleet's review instructions ask for, and UNKNOWN is a statement this cannot believe, such as one reading more files than changed, or a body the reader failed on, the reason printed beside it. A count Copilot states in a top-level paragraph that opens on it, never a list item and never inside a counted finding section, is read too, and a partial wins over any other reading beside it. Beside an earlier partial round, a head round reading UNKNOWN reads as PARTIAL, and so does a head with non-refusal rounds, none stating coverage. Where nothing states coverage, a round covering the head whose own file table names exactly the changed files reads as table, and the newest round on any commit whose table does reads as carried:table. A table stands in only where no round states partial coverage and the review history is not cut by its window, since a table names the whole changed set on a partial round too. The changed-file list is paged to GitHub's own ceiling of 3,000 files, and a list not read whole, or read empty, is read again before a table is matched against nothing. A table row matches a changed path exactly, or by a * or ? glob. Otherwise coverage=none prints with the reason no table stood in, status adds a note, and the exit stays 0, since Merge Gate item 2 in pr-review-conduct reads this field rather than an exit code. A partial round is reported and handed over rather than retried into, since every partial on record stayed partial at the identical ratio across every later round.
Findings no thread carries are counted and printed whole. suppressed=, previously_missed=, and cr_outside_diff= count the findings a review body collapses into a counted section, read over every round rather than the head's alone, since a push does not answer a finding nobody replied to. Each reads N (on_head=X earlier=Y) with a trailing + where the reviews window was cut. A tally holding a count the reader cannot believe reads ?. previously_missed= and cr_outside_diff= print only where one was raised or the window was cut. qodo_open= counts the entries in Qodo's findings comment carrying neither its Resolved nor Dismissed badge, unknown where that comment can sit behind the window. overview=T/M is the open findings the round covering the head states beside the threads it opened, ? where it states no count. A count it cannot believe reads ? too. FINDINGS WITH NO THREAD names findings the round names, in its count or its findings list, that no open entry links to a thread on the pull request. None of these decides an exit code, and each takes the triage a suppressed finding gets, answered with comment since no thread exists to reply on.
status and wait exit 50 where the round covering the head opens on a verdict other than the clean one while it states no open finding, lists none, opened no thread, and collapsed nothing, and no thread is open. Its headline prints under VERDICT WITH NO COUNTED FINDING, since it is the one place that round names what it flags. The code repeats on that head after the answer, since nothing here reads one, and where a check on that head has not concluded, wait names a bounded gh pr checks --watch to watch it with. A clean verdict on the same terms prints its headline under CLEAN VERDICT HEADLINE, advisory and gating nothing.
wait exits 44 when the review loop has closed but a required check sits in a shape no wait clears. The digest carries checks=N/M beside the merge word and names the shape as stuck=, because BLOCKED alone is worn by a red check, a check nothing is running, an unresolved thread, and a missing approval alike. NOT_POSTED is a required status nothing has posted, NOT_PICKED_UP a check queued with no runner past --check-grace, RUNNING_LONG a check running past --check-stall, and FAILED a verdict. GATED is a check its environment's protection rule holds past --check-grace. On the Copilot path, a check merely still running is none of these and exits 0. wait returns the moment the round lands, and the checks are nearly always still going then. A held head polls its checks instead. There a check running long or gated holds the poll to 30 rather than ending it at 44. 44 also requires that the stuck check be required, read from each rollup node's isRequired, and that the merge read BLOCKED. A rollup member of an unknown type is named on CHECKS PARTIALLY UNREAD, a rollup past CHECKS_WINDOW contexts on CHECKS TRUNCATED, and a payload with no commit matching the head on CHECKS UNREADABLE, rather than any of them reading as a clean checks=0/0. A CheckRun's PENDING is dispatched and not begun while a StatusContext's is under way, so the two are translated rather than renamed. --check-grace must be less than --check-stall, and a negative value for either is rejected by name.
comment posts one answer in the pull request conversation. A suppressed finding has no thread, so this is the path that records its disposition. The command reads the pull request node ID in the same run and exposes no ID argument. It confirms the returned comment URL and exact body before it reports success. A missing target exits 65, and an unconfirmed response exits 66, after which the conversation is inspected before any retry. With --fingerprint the body is the acknowledgment of an unplaced set, posted with its marker beneath it.
reply posts one answer to the thread its words select and resolves it on request. The selector is the finding's own words and there is deliberately no argument a PRRT_... id fits in, since a node id typed into a mutation resolves globally and writes to a real thread somewhere rather than failing. The words are matched case-insensitively against the thread's opening comment rather than a line number, since a fix push moves the line. Every failure is a stop rather than a fallback: no match exits 60, two matches exit 61 and print both, a reply with no url exits 62 without resolving, and a resolve not reporting isResolved exits 63. --all-identical answers each thread of a finding posted twice on one file. Exit 64 is the scope refusal comment, wait, and attest raise on the same terms: a target under an owner other than the one git remote get-url origin names against this script's own directory, or an owner that run could not determine. That refusal takes no environment-variable escape, since a grant the caller writes for itself is not a grant.
What this trades away is stated rather than glossed. A mutation spelled as a gh command in a shell is read by the gh-write-guard hook and one this script performs is not. The script removes that failure at its source instead: every node ID comes from a live query in the same run, and the owner check is enforced in-process. A test holds the review scripts to exactly the five mutation documents named addComment, requestReviews, addPullRequestReviewThreadReply, resolveReviewThread, and deleteRef.
claims checks that a description does not contradict its own branch. It reads the commits a body claims the branch carries and the uses: refs it quotes, confirms the head tree still carries each, and exits 70 where one does not. A commit counts only where the body says it was fixed, landed, shipped, added, introduced, corrected, resolved, carried, or amended in it, since a bare SHA scan raised only correct prose over the corpus it was measured on. That is an inclusion list, so a phrasing nobody thought of costs a detection rather than producing a finding. A commit passes on ancestry, so a description may cite one it inherited from the base branch. The uses: refs are checked against the whole tree at head, read as one archive. A reference GitHub did not answer for is left undecided, and where every reference is undecided the run exits 71, since stale=0 from a check that read nothing renders exactly like one that did. Prose claims stay out of scope, since judging those needs the similarity heuristic spec/section-model.md rejects.
The reader pr_review.py decides on. One entry point, read(body, reviewer, threads), takes a review body and returns a typed reading: the coverage statement (full, partial, none, unknown), the refusal kind (none, quota, file_count, error, unknown), the findings by section (threaded, suppressed, previously missed), the verdict class (clean, flagging, unknown) with its headline, the effort, the file table's paths, and a one-line note for each part it could not place. It never raises, a body it trips on reading as no refusal, unknown elsewhere, and a note.
uv run --no-project --script scripts/pr_review_copilot.py copilot < body.mdThe body is parsed with markdown-it-py rather than a hand-written reader, so a code span, a fence, or an indented block is a token of its own and a section quoted in one is never read as a section. A <details> section is placed by the HTML tokens that open and close it, each at its block's first line plus the line breaks before it. Only Copilot's current overview format is read, the one opening on the ccr-overview-v2 marker, and a Copilot body in any other layout is one note.
Every spelling the reader matches lives in reviewers/copilot.json, reviewers/coderabbit.json, and reviewers/qodo.json, each carrying the same keys and read by the same code path: refusal and quota wordings, collapsed-section spellings by kind, verdict phrases, vetted headings and labels, the effort line, the file table's header, and a rate-limit marker. A pattern is matched against the whole of a label once tags, emphasis, and anything outside ASCII are dropped and its case is folded, so the emoji a verdict heading wears is not part of its identity. A test holds every vocabulary file to the keys the adapter reads, so a missing key fails a test rather than a review.
cleanup and cleanup --sweep, moved out of pr_review.py, which forwards to this script. Call it through that forward, as below, since the allow rule the host-safety installer registers names pr_review.py and not this script.
uv run --no-project --script scripts/pr_review.py cleanup 452 --repo ptr727/ProjectTemplate --checkout ~/repos/ProjectTemplate
uv run --no-project --script scripts/pr_review.py cleanup --sweep --repo ptr727/ProjectTemplate --checkout ~/repos/ProjectTemplateIt deletes the head branch of a merged pull request, the verify-then-delete drive-pr and merge-and-release prescribe after a merge into develop. It is the one place holding the delete's checks, and no skill prescribes a hand-typed delete beside it, since the gh-write-guard hook denies git push origin --delete from a primary checkout and Claude Code's auto-mode classifier refuses the ref API delete on some days. A command matching a narrow permissions.allow rule is settled before that classifier runs, and the host-safety installer registers Bash(uv run --no-project --script scripts/pr_review.py:*). That rule approves any arguments, so every check lives here. It takes the pull request number and --repo, never a branch name, and reads the branch from the pull request. It refuses unless the pull request is merged, its base is develop, its head repository is --repo itself, and the head branch is neither develop, main, nor the default branch read live. Past the owner check pr_review.py makes, handoff.py's own write-scope check binds the owner to registry/repos.json and admits a repository only where it is registered or a fork. A branch any ruleset or branch protection covers is refused, and so is a branch another open pull request in --repo has as its head or base, each one named. The ref is read by its exact qualified name, and the delete is sent only where that read's tip is the pull request's headRefOid, by the node id the same read returned. The delete is confirmed by reading the ref back rather than by the mutation's own answer. That read can lag a landed delete, so a ref still at the deleted tip is read again for a few seconds first. Exit 0 is the branch deleted and confirmed gone, or already gone. Exit 1 is a refusal, a tip that moved after the merge among them. Exit 2 is a read that did not answer, the rule read included, a delete the read back could not confirm, or a usage error. Exit 64 is the owner check, run before the registry and before any read.
With --checkout set to a primary clone, a local half follows a remote half that exited 0. Every refusal is decided before anything is deleted, the remote branch included: the primary leaving the merged branch, a rebase or bisect paused on it elsewhere, and a tip that is neither the merged head nor behind it. A re-check after the delete can still refuse where a concurrent session changed the branch or a worktree in between, and the output names what already happened. The local half removes the worktrees holding the branch without force, deletes the local branch only at the merged head or an ancestor of it after a tip re-read shows it unchanged, and fast-forwards develop. cleanup --sweep --checkout <clone> takes no pull request. It reports what is provably finished and removes it only with --apply. An item must be clean, idle for --min-age-hours (default 24), and not mid-operation. A branch is finished when a pull request from --repo merged it at its exact tip or past it, or when origin/develop or origin/main contains it. The sweep never touches the primary except to fast-forward a tracked-clean develop, and never touches permanent branches, locked worktrees, or worktrees with submodules.
The session handoff chain, one standing issue per track in a repository, its body the current handoff. Its subcommands are reads plus the writes that move a track forward and end it. The chain closes each way a handoff written to a scratch file fails: the file is not found where the next session looks, more than one candidate is found and nothing says which is current, and there is no history, so a later round re-runs a path an earlier round already tried and already wrote down. The judgment half, what belongs in a handoff and what does not, is the session-handoff Skill in .agents/skills/, and this is only the mechanics.
python3 scripts/handoff.py current --repo ptr727/ProjectTemplate --track default
python3 scripts/handoff.py resume --repo ptr727/ProjectTemplate --track default --history 5
python3 scripts/handoff.py chain --repo ptr727/ProjectTemplate --track default --grep "vendored"
python3 scripts/handoff.py new --repo ptr727/ProjectTemplate --track default \
--title "What this round did" --body-file handoff.md --dry-run
python3 scripts/handoff.py close --repo ptr727/ProjectTemplate --track default \
--body-file outcome.md --dry-run
python3 scripts/handoff.py tracks --repo ptr727/ProjectTemplate
python3 scripts/handoff.py tracks --repo ptr727/ProjectTemplate --jsonA track's issue is created when the track starts and closed when its work is complete. Each session close appends the outgoing body to the same issue as a comment headed with its round and close date, then replaces the body in place and advances the round, so the track's history stays on one issue, in order. The issue is machine-readable from one HTML comment on the body's last line, <!-- handoff: v1 track=<slug> round=<n> previous=<issue number or none> -->, so a retitled or hand-edited issue still reads and the title is free to carry a human subject. previous= names the closed issue the track continues from and stays fixed while the round moves. Each archived round ends in <!-- handoff-round: v1 track=<slug> round=<n> closed=<YYYY-MM-DD> -->, which is how a reader tells a round from an ordinary comment. Nothing parses the title. The handoff label is the index, which is what makes the chain findable without a title search, and a track is a short kebab-case slug naming a lane of work, default where a session names none. Parallel lanes each name their own, so neither reads the other's state as current.
The invariant is one open handoff per track per repository, read from the metadata block rather than from the label, since an issue carrying the label and no block belongs to no readable track. Zero open is two states rather than one, a track that has never had a handoff and a track closed when its work was complete, and in the second new continues from the newest closed issue: reading the second as the first would file round 1 with previous=none beside a chain that already exists, orphaning every round already written. An open issue whose body was never replaced in place is a standing issue like any other, so the first new on it archives and edits it, and its previous= still reaches the closed issues behind it. Two or more open on one track is a defect to report rather than one to resolve by picking, so every subcommand that would have to choose refuses and names both. An open handoff carrying no metadata block refuses the same way, since its track is unknown and it could be the one the caller asked about: settling that is a hand edit, adding the block to the issue's body or taking the label off it, since no read can place an issue on a track until its own block says which track that is, and tracks surveys without refusing, which is what makes an abandoned lane visible rather than silently current.
chain lists one track's rounds newest first, the body, then each archived round, then the closed issue previous= names. chain --grep takes a regular expression rather than a literal, and it searches only the rounds it walks, up to --limit. A walk that stopped at the cap names the round it stopped short of, because a capped search reading exactly like an exhaustive one produces the false "not tried yet" this whole mechanism exists to prevent. A head the chain has moved past is named the same way. Another issue may already name it as predecessor, or hold a round at or above its own. current, resume, and chain then print a notice naming that issue, and new and close refuse onto it. A walk that ended cleanly also names each issue on the track it did not reach.
new is the one way a handoff is written, and it prints the issue number either way. On a track with no open issue it creates one. On a track with one it comments the outgoing body as an archived round, then replaces the title and body, in that order. Archiving first means a failure between the two leaves the outgoing round in both places rather than in neither. A rerun then finds the round archived and only replaces the body, and a rerun after a run that finished finds the body current and writes nothing, so no repair command exists beside it. close is the one way a track ends: it comments the outcome, then closes the issue, and a rerun after the comment landed only closes. Nothing suppresses a write's output or converts a failure into success, per GOVERNANCE.md "Repository Boundaries and Write Safety". Every identifier a write consumes comes from a read in the same run rather than being constructed, and the body is read again immediately before it is replaced, so an edit landing since the archive refuses rather than being overwritten. An edit and a close are each confirmed by reading the issue back, because a write that appears to have failed may have succeeded on the server.
--repo is required and carries no default, for the reason pr_review.py states about a pull request number: an issue number resolves in every repository, and a chain read out of the wrong one is well formed. --track defaults to default, and omitting it on a named lane does more than read the wrong chain, since new then archives and rewrites whatever default holds, and close closes it. A fleet repository missing the handoff label refuses, naming repo-config/configure.sh apply, never with a degraded empty answer, because decision was declared on the hub and applied nowhere else and every downstream session then enumerated an empty queue and reported it healthy. The one exception is a fork under the registry's owner that the registry does not list, such as one kept for an upstream contribution, where no issue can carry a label that does not exist: a read there warns and answers as an empty chain does, and new --create-label creates the one handoff label from repo-config/labels.json, confirms it, and files the track's issue, refusing first where the fork has issues turned off. A repository missing the label under another owner, or an unregistered one of the owner's that is not a fork, refuses before any write, the first because these calls run as subprocesses a write guard on the caller never sees and the second because it is registry drift a lone label would hide. The two writing subcommands, new and close, refuse both whatever the label state, checked before the label is read, so a label a stranger's repository happens to carry opens no write there. That is an in-process refusal of the kind pr_review.py makes, with the owner read from the registry rather than from origin, and it bounds no read. Exit 0 is success, 1 a refusal the caller can act on, and 2 the command not having run to an answer, so the two never share a code: a usage error is moved off argparse's own 2 onto 1 because it is a refusal, and an exception nobody modeled is caught onto 2 rather than reaching CPython's own 1. A body over 12 KB warns and one over 60 KB refuses, under GitHub's own 65536-character limit, and both are backstops against a body GitHub would reject: what bounds a handoff is the Skill's per-section rules.
--dry-run prints the calls a writing subcommand would make and sends none of them, which is the first thing anyone wants before trusting this to replace a body or close an issue. It sits on the one path every call goes through rather than on each write separately.
Prints a weekly backlog and review-cost report for the cataloged repositories to stdout, so a server timer can mail it. No workflow runs it. It reads through gh and writes nothing.
python3 scripts/backlog_metrics.py
python3 scripts/backlog_metrics.py --days 14 --format html --repo ExampleThe table has a row per repository and a totals row. The columns are issues opened and closed in the window, handoff issues created, and issues open now. Next come pull requests merged, issues opened per merged pull request (n/a without a merge), and workflow runs. Last come runs named Running Copilot Code Review and those review runs per merged pull request (n/a without a merge). Every issue count except the handoff column leaves out issues labeled handoff.
A line per repository follows with its open issues by label, and an issue with several labels counts under each. The labels the script names print first, a zero included, and every other label an open issue carries follows them. An issue labeled handoff is left out here too. The month's Actions minutes per repository and Copilot gross come from the user billing usage endpoint. They cover the calendar month to date rather than the window, so the report does not divide them by the window's merges. A failed call prints one line saying billing was unavailable rather than a zero. A list that reaches the item limit is noted in the report. A failed issue, pull request, or run call exits 1 with no report.
Records and verifies that a local review pass covered a branch's current content. AGENTS.md and the local-strict-review Skill hold the review-before-every-push rule itself, and this gives that rule something a capture point can actually check rather than restating it. A pass records a receipt keyed on the content it reviewed, and a capture point asks whether that receipt still covers what is about to be pushed. Two capture points call it. This repository's .husky/pre-push hook runs check and refuses a branch push that no recorded pass covers, and the local-strict-review Skill's own steps read status before the pass and record it afterwards. Neither is load-bearing alone: the Skill is the primary, agent-agnostic layer and the hook is a backstop under it, opt-in per clone and bypassable with --no-verify by design.
The key is over the content of the changed files, plus the merge-base commit that fixes what "changed" means. It is deliberately not over the diff text. Which paths count is read from three sides, the commit at HEAD, the index, and the working tree, and each path's recorded state is read from the last two alone. That split is what lets a review run before the commit while the check runs at the push: reviewing untracked work and then committing it unchanged leaves the key identical. Each path contributes the state it is in on each of those last two sides, both as git's own mode-and-blob identity so they compare directly. Where they agree the path reads as one state, which is what makes git add on an untracked file and git commit invisible. Where they diverge, because content is staged that the working tree no longer holds, or because a file mode changed, the key moves. Reading the index rather than the working tree alone is what stops content being staged that the key never saw. HEAD is read for membership and never for a state, which is what stops content already committed and then undone in the tree from leaving the changed set: git rm a file the branch added and the index, the working tree, and the merge base all agree it is absent while the commit a push delivers still carries it. That inclusion has one visible consequence worth stating rather than discovering. A path in the set only because HEAD disagrees with the base leaves it when that undo is itself committed, and the key moves, which is right, since before that commit a push delivers the change and after it a push delivers the undo. The key is still not the content a push delivers, and does not try to be. It moves where a push would carry nothing new, staging a modified tracked file being the plainest example, so git add is invisible for an untracked file rather than in general, and the ordering that follows is to record a pass over what is already committed. It also holds still while HEAD's own content changes under a fixed index and working tree, which is why a caller gating a push tests the working tree against HEAD before trusting this key to describe that push. .husky/pre-push is that test.
Every identity is computed by git rather than reconstructed, which matters more than it sounds: a blob id built by hashing raw working-tree bytes cannot equal the one git stores wherever a text attribute or any clean filter sits between them, and this repository applies one to every text file, so git add moved the key on any CRLF file until that was fixed. The working tree's side is therefore read by staging it into a throwaway index that git builds, with its object writes redirected into a throwaway object directory and the real one attached as an alternate. That redirection is not incidental: staging writes blobs, so without it a read would permanently deposit the content of every unignored untracked file, a stray secret among them, into the repository. Nothing about the real index, the working tree, or the object database is modified. One requirement this places on a repository is that a clean filter be deterministic, since the same content is staged once when a pass is recorded and again when it is checked.
What the key covers is the net content the branch introduces against its target, not the commit series that produced it. A branch that adds a file and later deletes it, or a rebase that rewrites only messages, keeps its key. That is the intended scope, since a feature branch lands as a squash and the net diff is what a reviewer reads, but a capture point built on this does not cover the commit series. The receipt lives in the worktree's own git directory rather than in the working tree, so it is per-task, can never be committed by accident, and needs no .gitignore entry of its own.
record requires --expect-digest, the digest status reported before the pass ran, and refuses when the content has moved since. It is required rather than optional because an optional guard is the one a caller in a hurry omits, and the omission looks exactly like a pass that was properly bound. Nothing else binds the moment a review finished to the moment it is recorded, so a format-on-save or a hook autofix in between would otherwise be stamped as reviewed content no reviewer saw, which is the one claim the receipt exists to make. A headless backend is recorded by running it rather than by hand, since recording it directly would bypass the completion check that makes its pass mean anything.
It holds no review logic. It drives backends: agent-skill is the local-strict-review subagent pass, which only a live agent session can run, so the engine records it rather than invoking it, and coderabbit-cli is headless and is the one backend a git hook can execute by itself. A CodeRabbit CLI run draws on the same hourly budget as this account's pull request reviews, so a run that is rate limited, errors, or ends without a completion event records no pass rather than a clean one. The invocation follows the CLI's documented flag semantics: --base-commit for the merge-base hash, since --base takes a branch name, and --include-untracked, since the CLI excludes untracked files by default while this receipt's scope includes them. A completion reporting review_skipped is refused too, that being the CLI saying it looked at nothing. None of it has been executed against a real CLI on this host, so the backend stays opt-in until someone runs it. A recorded pass means a review ran over exactly this content, never that the content is clean: disposing of what a review found is judgment, per the pr-review-conduct Skill's outcomes, so a pass is recorded whether it raised findings or none.
python3 scripts/local_review.py status # what covers the current content. 0 either way, 2 if it cannot run
python3 scripts/local_review.py record --reviewer agent-skill --target develop --findings 3 --expect-digest '<digest>'
python3 scripts/local_review.py check # 0 covered, 1 not, 2 could not run
python3 scripts/local_review.py run --backend coderabbit-cliThe exit code is three-valued on purpose, per AGENTS.md "Report an execution boundary separately from a check finding". 0 and 1 are findings a caller acts on, and 2 means the check itself never ran, whether because no git repository resolved, the target ref is absent from this checkout, the receipt could not be read off disk, or a backend binary is not installed. Any unexpected failure reports 2 as well, rather than falling through to the interpreter's own exit 1, which a caller would read as the "not covered" verdict. status reports rather than gating, so it exits 0 whether or not the content is covered, and only reports 2 where it could not run at all. check alone treats a branch holding no net content against its target as covered, there being nothing for a review to read and no push git would have anything to reject over, while status keeps counting recorded passes and reports that same branch as not covered with zero changed paths. The two answer different questions, and reading either as the other is the mistake that wording guards against.
Records and verifies that a full-content review pass covered each unit of the canonical content this hub authors and other repos carry. It is local_review.py's sibling under one rule, asking a different question: that engine asks whether a reviewer read this branch's diff, and this one asks whether a reviewer has read a canonical unit the way the repository carrying it next will, whole, as a new file, knowing nothing about what changed in it. GOVERNANCE.md "Verification Discipline" states the rule and the ordering defect behind it, filed as ptr727/ProjectTemplate#1138, and what follows is the mechanism rather than the rule.
A unit is what a reviewer reads whole, and spec/files.json decides which, so list is the authority on the set and the rules below are stated once, here, rather than paraphrased into the rule text that routes to them. Membership starts at fidelity: an entry declaring verbatim or intent, or a tree declaring verbatim-tree, contributes units, while one declaring interface or no fidelity at all contributes none, its body being the carrying repo's own. Within that, an entry marked whole, or carrying no section list, contributes each level-two section of a Markdown file plus the region before the first where there is one, keyed <path> > (preamble), and contributes the file entire where it carries no level-two heading. An entry carrying a section list and not marked whole contributes exactly those sections, which is spec/section-model.md's fidelity unit and takes spec/divergences.json's own <path> > <section> key. Anything that is not Markdown is one unit per file. A file the manifest carries by named sections contributes exactly those, so the two sections GOVERNANCE.md keeps for itself are not units: no downstream copy holds them, since the audit's undeclared-heading check is what stops one from appearing there, and demanding a carrier's read of one would invent an obligation the manifest does not state. An interface entry contributes nothing for the same reason, its body being the carrying repo's own. The skills tree is keyed at .agents/skills/, where a fix lands, rather than at the generated .github/skills/ the manifest names, and build_dist.py --check is what holds the two equal.
Coverage is over content, never over a commit. A unit is covered while a recorded pass names its current digest, so editing the unit retires its pass and editing a neighboring section does not. sweep names the units whose text has moved past the pass that read them, and beside them a bounded slice of the units nothing has read here at all, newest-committed first, which together are the work one scheduled run files. report renders the whole backlog, which is larger than any one sweep asks for. The ledger at reports/canonical-review.json holds one entry per unit, its most recent pass, sorted by unit key, and git keeps the history rather than the file.
The ledger is state, and the burn-down is a rendering of it. A rendering is never committed. report writes the burn-down to standard output, and the hub's .github/actions/validate hook writes that same rendering to the run's job summary on every event, so nothing writes it into the tree. The distinction is what lets concurrent branches merge: a ledger sorted by unit key puts two units' passes at two places, which git merges unless both are first-ever entries with no entry between them, where a rendering carrying global counts changes the same line to the same value on both sides, merges silently to a count one short of the ledger's, and fails any gate over it on the next unrelated pull request (ptr727/ProjectTemplate#1268, #1290).
Nothing here gates a push or a pull request. Two weeks of fleet review rounds measured the local passes a push owed as a large share of what a pull request spent, and the maintainer's call on that evidence (ptr727/ProjectTemplate#1631) was to sweep this read rather than gate it, so .husky/pre-push runs its sibling alone and the hub's own .github/actions/validate hook renders the burn-down without failing on what it shows, though a boundary still fails that step, since report reaches the same manifest and ledger reads every subcommand does. What calls sweep instead is .github/workflows/canonical-review-sweep.yml, weekly once the default branch carries it, which files one issue naming the units it asks for that round and closes it once none is owed, the backlog included, so the issue stands until every carried unit has been read once. A schedule fires from the default branch's copy of a workflow, so the first run comes at the promotion rather than at the merge that retires the gate. The read itself is an adversarial agent pass, so the workflow can only hand the list to a session that can run one, and the local-strict-review Skill's "The Carried-Content Sweep" is what that session follows.
uv run --no-project --script scripts/canonical_review.py list # every unit key and its digest. 0 either way
uv run --no-project --script scripts/canonical_review.py status # covered, stale, or never read here, as JSON. 0 either way
uv run --no-project --script scripts/canonical_review.py sweep # 0 nothing owed, 1 a unit owes a read
uv run --no-project --script scripts/canonical_review.py record --reviewer agent-skill --target develop --findings 2 --unit '<key>=<digest>'
uv run --no-project --script scripts/canonical_review.py report # renders the burn-down from the ledger to standard outputrecord holds a lock across its read, merge, and write of the ledger, the lock local_review.py guards its receipt with, since two overlapping records would otherwise each read a ledger without the other's pass and the second write would drop one (#1151). The lock lives in the worktree's own git directory rather than beside the ledger, so a record killed mid-write leaves its lock where no add can stage it. The ledger is tracked and the receipt above is not, so the two records sit on opposite sides of the commit that carries the ledger, and the local-strict-review Skill's "The Carried-Content Sweep" gives the order. record binds the digest to the read for --expect-digest's reason above: recording a unit by name alone would stamp whatever the file holds at record time, so an edit between the review and the record would be attested to by a reviewer who never saw it. It refuses an unknown unit, a digest the content has moved past, and a reviewer outside local_review.py's own backend vocabulary, since two spellings of one reviewer make the two records impossible to read together. A pass is recorded whatever it found, including nothing, for the same reason a receipt is: the record says a review ran over exactly this text, never that the text is clean.
Each entry's hubCommit is stamped as the merge-base against the target (--target, default develop). A branch's own tip is exactly what a squash merge discards and an amend moves, so a stamp anchored there resolves nowhere once the branch that recorded it is gone. The merge-base is ordinarily a commit the target's remote-tracking ref already holds, so it survives both (ptr727/ProjectTemplate#1222, #1210). A ledger entry can still name a commit unreachable from wherever a reader's own checkout stands. Such entries are left as recorded rather than rewritten, since inventing a commit for one would fabricate provenance nothing supports. Checking one by hand is git merge-base --is-ancestor <hubCommit> HEAD, which exits 0 when it resolves as an ancestor here and nonzero otherwise, whether the object is entirely absent from the checkout or exists but sits off this branch. An unresolvable target refuses the record rather than falling back to HEAD.
Every subcommand exits 2 on a boundary, which is why status earns a place in a local gate block that reads exit codes and nothing else. The exit code is three-valued on the same contract as its sibling. The boundaries it reports rather than answers are a manifest it cannot read, a ledger it cannot parse, a ledger holding two entries for one unit, a canonical that is not UTF-8, a document carrying two level-two sections of one name, and a record --target that does not resolve. A boundary read as a verdict is the failure the three values exist to prevent, since the scheduled sweep files or closes an issue on the answer and would otherwise report coverage it never measured.
Regenerates .github/skills/ and .claude-plugin/fleet-skills/ from .agents/skills/, the hub's own hand-authored fleet Skills. Codex and opencode read .agents/skills/ directly, GitHub Copilot reads .github/skills/, and Claude Code reads the generated plugin published through .claude-plugin/marketplace.json. .agents/skills/ stays the one place a skill is hand-edited, its include regions excepted, since those are generated from the files they name. Both generated trees are never hand-edited. The plugin also carries one digest stamp per skill under .claude-plugin/fleet-skills/.source-digests/, so two changes to two skills touch two stamp files and merge, where one stamp over every skill's bytes conflicts on every concurrent skill edit. The stamps sit under the plugin rather than under .github/skills/, which spec/files.json carries whole to every repository.
A skill has to read whole in isolation and a rule has one home, so the text a skill needs from that home is generated into it rather than copied. A region between <!-- include: <path> > <heading> --> and <!-- /include --> is filled with the body under that heading, the key being the root-relative path, then >, then the heading text at any level from two, the delimiter canonical_review.py also keys a unit on. A region sits in a file the generator walks, which is the Markdown under each .agents/skills/<name>/ directory carrying a SKILL.md, plus the files the INCLUDE_DESTINATIONS tuple declares. The fill lands in the authored tree itself, because Codex and opencode read it directly and a region left empty there is a skill with a hole in it, and the mirrors then carry the filled text. The tuple is how a surface the walk would otherwise miss stops restating a rule by hand, and it is empty today. A file it could name is .agents/skills/README.md, which sits in that tree and is no more walked than a file elsewhere is. A destination is declared one at a time rather than discovered, and one whose bytes spec/files.json carries to another repository is refused. A source is any regular file under the repository root outside the two generated trees, reached through no symlink and spelled as the tree spells it, and a region filled from a file carrying regions of its own reads that file's filled text. Each marker sits on a line of its own, indented at most three spaces, since a marker shown in a code block, fenced or indented, is content.
--check is the read-only mode: it exits 1 when an include region differs from what its source renders now, or when either generated tree differs from .agents/skills/, comparing a digest over every file rather than a file count or timestamp, and 2 on a real failure (an unreadable file, an include region it cannot render: a heading that no longer resolves or recurs, an empty body or one leaving a code fence open, a malformed or cyclic region, a line that begins like a marker and matches neither form, a region in a file the walk does not visit, a refused source path, a file mixing line endings or not UTF-8), and on a failure that is about no region at all (a symlink at or under a skill's own directory, an INCLUDE_DESTINATIONS entry that is stale or carried, a spec/files.json that cannot be read or is not the shape it declares while a destination is declared), so a caller reading the exit code can tell that apart from the stale finding. CI runs --check rather than trusting a contributor to have run the generator, the same reason spec/audit.py exists rather than trusting a hand-carried file.
Copies manifest-owned trees, and the verbatim ## heading regions inside a mixed file, from a freshly fetched ProjectTemplate main checkout into an isolated downstream feature worktree. check reports the canonical commit, repository identity, applicable declarations, path differences, and tree digests without changing the target. apply reports each write or removal and repeats the comparison before it succeeds.
uv run --no-project --script scripts/carry.py check PhotoCleaner --target /path/to/worktree
uv run --no-project --script scripts/carry.py apply PhotoCleaner --target /path/to/worktree
uv run --no-project --script scripts/carry.py check-sections PhotoCleaner --target /path/to/worktree
uv run --no-project --script scripts/carry.py apply-sections PhotoCleaner --target /path/to/worktreeThe tool validates the registry identity, target origin, feature-branch worktree, development-branch ancestry, unrelated changes, root containment, and symlink-free trees. A pruned declaration authorizes removal only beneath its target root.
The section modes close the gap between the two things that already existed: a tree mode owns a whole tree, and spec/audit.py names a stale section precisely without writing anything, so the one apply step in RESYNC.md with no tested tool behind it was the step the AGENTS.md-overwrite incident happened on. check-sections reports each declared verbatim section and which of them are stale, and exits 1 while any is. apply-sections replaces only those regions and then re-reads the file to assert three things: the level-two heading sequence is unchanged, every line outside the replaced regions is byte-identical to what was there before, and each declared section now matches the hub's canonical. The second of those is the one that earns its keep, since the defect it exists for, one blank line dropped between the preamble and the first heading, is invisible in a diff review, breaks no renderer, and no gate in this repository flags it.
Region boundaries come from spec/fidelity_text.py, which reads the document with markdown-it-py for the audit's region extraction too. So a level-two heading shown in a code sample cannot end a region here and not for the check that judges the result. The replacement carries the terminator the section's own heading line carried, so a CRLF repository keeps its endings and the file is never rewritten to one ending it may not use throughout.
Every refusal is raised while planning, before the first file is written, so a refused run leaves nothing re-vendored. What refuses: a section the manifest declares that the repository does not carry (where it belongs is a standup question), a file absent from the target, a file that is not UTF-8 or that carries a bare carriage return, which the fidelity comparison reads as a line break and this tool does not, a file declaring both placeholders and verbatim sections (a verbatim unit carries none, per spec/fidelity-model.md), a region holding content the fidelity comparison normalizes away, such as a Dependabot-owned action pin, whose bytes the repository rather than the hub owns, and two declared names resolving to one region. A failure after that point is the write itself or its assertion, and it names the file it was working on, because an earlier file may already hold its re-vendored content.
A region runs to the next level-two heading, so the blank line before that heading sits inside the region and a section ending its file has none. spec/fidelity_text.py's extract_section reads those two as the same content, since it splits the document on LF and joins the region back with LF, so a file-final newline and a blank line before a following heading both render as one trailing empty element. The comparison therefore uses that form, and the write reconciles the boundary to what the target document needs rather than copying it from the hub: a section that ends the hub's file keeps a blank line before whatever follows it downstream, and one that ends the target's file gains no trailing blank line.
The target is required to be clean, with no owned root exempted from the unrelated-changes check, unlike a tree carry. A tree's root holds nothing but hub-owned content, so exempting it gives up nothing, where these files hold the repository's own sections too and exempting one would let a write land on top of an uncommitted edit to a section the tool never touches.
Installs the fleet's Skills for the current machine, cross-platform and idempotent, mirroring host-setup/agent-safety/claude/install.py's shape: skills_install.sh and skills_install.ps1 are thin wrappers that locate a Python 3 interpreter and hand off, so every OS runs one tested code path. Two independent things happen on a run, since the three tools this fleet targets discover skills differently: .agents/skills/ is materialized (not symlinked) to $HOME/.agents/skills/, so Codex and opencode's global scan covers every repo on the machine rather than only the one that happens to be open, and this repo's marketplace is registered with the claude CLI (claude plugin marketplace add, claude plugin install) so Claude Code loads the same content the other two read directly. The marketplace/plugin registration goes through the claude CLI's own commands rather than writing its internal known_marketplaces.json by hand, because that file's shape is the CLI's state, not a documented contract, and a hand-written copy risks drifting from what the CLI expects on its next release. claude plugin marketplace add re-points an existing registration without failing, so the installer reads the CLI's listing first and moves a registration only where it is a directory source whose path is no longer a directory, saying so. A registration from this checkout needs no add, so the run skips it. Any other existing registration is left in place, and the run prints the command that moves it. The install is skipped too where claude plugin list --json shows the plugin installed at user scope. Each command that does run is judged by its exit status alone, never by the wording of its output. Where the CLI gives no listing, the run registers nothing and exits non-zero. --snapshot-only refreshes the Codex and opencode copy and its stamp without touching the registration at all.
The two channels hold different things: the Codex and opencode copy keeps the revision it was taken from, while the Claude Code marketplace is a directory source that loads the hub checkout in place and serves whatever it holds at read time. --report reads the stamp a prior run wrote ($HOME/.agents/skills-install-stamp.json, naming the hub commit installed) and answers each channel by name, without installing anything. The copy is judged against the promoted main as last fetched, or --intended <rev> in a git checkout, or on a bootstrapped tarball tree the commit its loader resolved, where --intended is refused. The live channel reports the branch, commit, and dirty state of the checkout the marketplace registers. Where the marketplace is registered it also carries plugin, installed and enabled for the user-scope install the installer makes, read from claude plugin list --json (each null where that listing cannot be read). A project- or local-scope install does not count, and enabled is as Claude Code reports it for the directory the report runs in. The exit code is the copy's verdict alone. Where no intended revision resolves, the copy is not asserted current and the snapshot's reason says it cannot be judged, which a re-install does not fix. A missing, unreadable, or unrecognized stamp still emits the same snapshot keys, installedFrom and installedDirty null and intended resolved, so a parser reads one shape. A repository whose AGENTS.md keeps needing a rule restated is usually this: the machine was never installed, or was installed from an older commit.