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. python3 scripts/prose_lint.py . --diff HEAD is diff-scoped, at about 0.13 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. The hook reads the working tree rather than the index, so a partially staged file is judged on all of its edits, which CI's whole-tree run is the backstop for.
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, standard library only, no third-party packages. Every check script is read-only and exits non-zero on a finding. build_dist.py and skills_install.py below are the two exceptions, since a generator and an installer both exist to write, and each still offers a read-only mode (--check, --report) for CI and for asking without changing anything.
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: build_dist.py, pr_review.py, skills_install.py. The unit tests live apart under scripts/tests/, one test_<script>.py per script, driving the canonical implementations against input they must reject. 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 scripts/tests/test_prose_lint.py
python3 scripts/tests/test_repo_gate.py
python3 scripts/tests/test_pr_review.py
python3 scripts/tests/test_build_dist.py
python3 scripts/tests/test_skills_install.py
python3 -m unittest discover -s scripts/tests # all of them, and exits 5 if the suite vanishes
uvx coverage@latest run --source=scripts,spec,host-setup -m unittest discover -s scripts/tests && uvx coverage@latest reportEnforces the GOVERNANCE.md "Documentation Style Conventions" rules that no linter checks: non-ASCII judged against the charset rule's three 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. It is the one rule a checkout can turn off: an operational repository's runbook carries the literal path an operator types, so a run there drops home-path and says so on stderr rather than going quiet. A repository states its own model by which configuration payload it carries, and the hub carries both, so it reads as a release repository and the rule gates here.
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. Developer-typed characters such as emoji are preserved regardless of tier, and an un-tiered one 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. Classifying one is a fleet-law edit, so CI surfaces it without blocking on it.
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. CI gates every rule in the default set on that basis, except charset-unknown, which reports warn-only because classifying a character is a fleet-law edit rather than a prose fix.
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.
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 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.
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.
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. The colon arm was measured before being kept rather than dropped: dropping it flagged 14 further lines, and those were genuine colon-introduced lists whose items carry commas, which is the standard use the rule names.
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 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, which is what lets the CI gate read the whole tree rather than a diff. charset-unknown is the one that reports instead of blocking, for the reason the tier discussion above gives.
Three 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 run at the workflow commit, so they need no separate pin.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.
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.
A repository adds its own host-tools.json at its root 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. 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.
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 and optionally resolves it. claims reads the description against the branch it describes. The runbook is in .github/copilot-instructions.md.
python3 scripts/pr_review.py status 452 --repo ptr727/ProjectTemplate
python3 scripts/pr_review.py claims 452 --repo ptr727/ProjectTemplate
python3 scripts/pr_review.py wait 452 --repo ptr727/ProjectTemplate --timeout 2700
python3 scripts/pr_review.py comment 452 --repo ptr727/ProjectTemplate \
--body "Suppressed findings (1): **Disproven** - the target is checked before the write."
python3 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--repo is required and carries no default. A default names one repository, and a run from anywhere else resolves its number there instead: the digest renders, every field is well-formed, and nothing in the output disagrees. Two runs read this repository's pull requests while their own was the subject, each caught by the maintainer rather than by the run. The digest leads with repo=OWNER/NAME for the same reason, since a number alone reads as correct in any repository. A value that is not OWNER/NAME is rejected by name rather than raised as an unpacking traceback, that being the near-miss a required argument still admits.
wait exits 30 when the review is still pending at the timeout, which is pending rather than failed. Its failure mode is a wrong answer rather than a crash, so the cases feed crafted GraphQL payloads: a review attributed to the wrong login, a review counted against a stale head, a maintainer's own thread read as a finding, and a wait that returns success while nothing landed. One case reads the reviewer login out of the runbook rather than restating it, since GraphQL drops the [bot] suffix REST carries, and another holds the script to exactly the two mutations reply owns, so a third arriving is a write nobody reviewed as one.
wait exits 40 when Copilot answers the request with a plain comment rather than a review, meaning a comment of its own that postdates its newest review on the pull request. The test is the shape of that answer and not its cause, which the script reads nothing of: a comment carries no commit, so it satisfies no coverage check whatever it says, and a wait reading formal reviews alone treats it as an unmet condition and then polls out its whole timeout against an answer that already arrived. A refusal is the case that makes this worth catching, a quota or rate-limit message among them, and 40 neither asserts nor detects one. The comment prints whole because its wording is the only thing separating a refusal, which is terminal since no review follows it and re-requesting does not clear it, from an ordinary remark that is not, so 40 ends the wait and hands the text to the reader who can tell them apart. A comment older than the newest review is spent rather than terminal, because the review it preceded did land. Every connection reads the newest WINDOW nodes rather than the reviewer's own, since GraphQL offers no author filter, so ordinary traffic is what pushes theirs out of reach. window_blind is the one guard over both sides, and each side fails differently. Blind on comments means an answer could be back there unseen, which reads as answered_outside_review=unknown rather than no. Blind on reviews is worse, because the newest review in view is then not the newest there is, and an empty baseline dates every comment as newer so each one reads as an answer: a false 40 that stops the loop on a pull request whose review actually landed. That case reports nothing and lets the wait keep polling, since a wait that runs on is visible where a wrong terminal is not.
Everything else is decidable and says so. One of the reviewer's own nodes in view, even a spent one, settles the question, because nodes arrive in creation order, so anything behind the window is older than everything inside it. A window holding every node the pull request has is settled too, which is why the guard reads pageInfo.hasPreviousPage rather than the node count: a full window and a complete one are the same length, so length alone would report a gap where none exists. Cases hold WINDOW equal across all four windows and hold all four to asking for hasPreviousPage, since a connection that stops asking reports no instead of unknown, the silent narrowing one level up. wait exits 50 when the reviewer sits in the pending request set and no copilot_work_started follows the newest request, meaning nothing is acting on it and waiting on will not start it. That state is invisible from the reviews alone and indistinguishable from patience: one request sat thirteen and a half hours while the pull request read as waiting on the reviewer. Elapsed time cannot separate it from a slow round either, so the pickup event decides. It is the one thing here read over REST, since no GraphQL timeline item carries it, and it runs on its own interval rather than per poll: the first read comes after --pickup-grace (default five minutes), because inside that window a pending request is simply a review being worked on, and each later read waits another interval. One reading settles the request in front of it, and the next covers a request a push raises mid-wait, so a long wait costs a handful of REST calls instead of one per poll. The pickup is checked before the timeout, so the stall reports as itself instead of as PENDING once the clock runs out. Recovery stays out of this script: the digest names the state and the runbook carries the two mutations that clear and re-raise the request, neither of which has an id to hide and so neither of which is worth wrapping. The pending set is read through GraphQL rather than gh pr view --json reviewRequests, which omits a Bot reviewer outright and reports an empty set while Copilot sits in it.
wait exits 41 when the review carrying the head is a refusal, meaning its body opens by saying it did not review. That answer arrives as a formal review, state: COMMENTED, with the correct commit and zero inline threads, so it satisfies every coverage check a clean pass does and renders a digest byte for byte identical to one. The 40 reasoning does not reach it, because that reasoning rests on a comment carrying no commit, and this carries the right one. A pull request of 301 changed files, one over the reviewer's limit of 300, read as rounds=1 review_on_head=yes threads=0 unresolved=0 merge=CLEAN and was one command from merging on a review that never ran. A refusal is therefore not coverage: review_on_head reports NO, the summary line carries a refusal=YES of its own, since rounds=1 review_on_head=NO is equally what a stale round looks like and the two want opposite responses, and the body prints whole because its wording is the only thing separating a file-count refusal, cleared by splitting the pull request, from a quota one, cleared by waiting. The script reads neither cause, only that the round declined. The match is on the body's opening line, since a refusal is the whole body where a review that merely quotes the wording carries it below its own overview, and this script and this file are exactly that quotation. One line rather than two, because a review's first line is its heading and its second is the overview prose: reading two passed every case except the review describing this check, which reported itself as a refusal of itself. The cost is the other direction, that a refusal introduced by a heading would sit below the opening and be missed, and answering that shape means telling a refusal from an overview rather than reading one line further. It is an alternation over the runbook's phrasings for the same reason the suppressed heading is, and a case asserts the script's pattern is the one the runbook publishes. The reading is head-scoped, unlike a suppressed finding, because a refusal is a statement about one commit that a push retires, and a genuine review of that same head outranks it, coverage that landed being coverage. The field is spent by that coverage as well as the exit code is, or the summary line reads review_on_head=yes refusal=YES and tells a reader to split a pull request the reviewer has just reviewed. The liveness query carries no bodies, so a refusal reads there as ordinary coverage. That is deliberate: it ends the wait, which is what a terminal outcome should do, and the full read every wait finishes with is what tells the two apart, so no exit code comes from the cheaper reading.
status and wait both exit 42 where the round covering the head read fewer files than the pull request changed, and 43 where it states its coverage in a wording this script does not read. Coverage of the head was the only coverage anything checked, and coverage of the diff is a second reading stated in a line nothing parsed: a partial round carries the right commit.oid, raises no threads, and reports "generated no comments", so it is the clean pass byte for byte in everything read. Over 332 Copilot review bodies on this repository, five rounds across three pull requests reported reading fewer files than were changed and all three merged, one of them leaving a file of three unread across both its rounds. This is the third instance of the shape refusal and suppressed are the first two, and the only one nothing was reading.
The reading fails closed, so a coverage-shaped line that parses to no counts is a failure whose remedy is stated as fixing this script rather than reading past it, which is what keeps the vetted spellings honest as the wording drifts, as it has once for each of the other two patterns. The tail of the sentence is deliberately outside the unit: it says how many comments the round raised, which is not coverage, and reading it would fail every merge over a sentence ending. There are two exemptions, and the first is the one that decides the design. A body stating no coverage at all reads as unstated, never as a pass and never as a failure: 28 of those 332 bodies are an overview and a change list and nothing more, that shape is current rather than historical and interleaves with the counted one throughout, and one pull request carries both across its two rounds, so failing on it would cry wolf on about one review in twelve and a guard an agent learns to work around is worse than none. A refusal is exempt because it states no coverage by design and is already classified, and reading it as a round would grow a spurious second failure on top of the one naming its remedy.
The line is matched at its start rather than anywhere in the body, since both spellings are structural: across those bodies every coverage statement opens its line, 272 with the reviewer's own name and 32 as the Review details bullet, and none sits mid-sentence. A body-wide match reports the pull request that adds this check as a partial round, which is the false positive the suppressed matcher and the refusal matcher have each had once already, and fenced blocks are dropped for the same reason, 131 of the bodies carrying one and this change putting both spellings into the diff a review of it quotes. The cost is named rather than hidden: a wording that moves the statement off the line start reads as no statement rather than as one this cannot parse. The reading is head-scoped, unlike a suppressed finding and like a refusal, because a partial round describes one commit's diff and the push that changes that diff raises a round reading the whole of the new one, and where one head carries two rounds through a re-request the worst of them reports, since the one naming files it did not read is the one to answer. A case reads the vetted spellings out of the runbook and hands them to this script's own parser, so the pair stays in step in both directions.
A partial round is reported and handed over rather than retried into. Measured over four pull requests and seven rounds, every partial stayed partial at the identical ratio and no round ever recovered, and the diff size does not predict it: fully covered pull requests here run to 33 files and 2,219 changed lines, well past every partial. The stated denominator matches the API's own changed-file count on 103 of 104 pull requests, so the reviewer counts the file and does not read it. Which file that is usually cannot be recovered, and the file table many bodies carry is why it is worth saying "usually" rather than "never": measured over 348 review bodies here and 121 on another repository in this fleet, that table names the whole changed set on partial and fully covered rounds alike, so a table naming every file is exactly what a fully covered round carries too and separates a miscount from a file nobody read exactly never. One round of the seven is the exception, stating 16 of 17 and naming 16, omitting GOVERNANCE.md, and it is the only evidence on record that the unread file is a real file rather than an artifact of counting. status therefore prints what the table says beside the counts and takes no verdict from it. It names the omitted file only where the table is short by exactly what the counts leave unread and names nothing outside the diff, one round here naming GOVENANCE.md, the reviewer's own spelling and a path no diff carries, which is enough of a reminder that the table is prose the reviewer writes rather than a list from the API. The comparison is head-scoped like the counts are, so a table from before a push describes the diff that push replaced and reports as no table rather than as a list of unreviewed files, which is the shape three of the four partials here carry. wait and status therefore report 42 as a state for the maintainer rather than one to re-request into.
status and wait both exit 43 where the reviewer sent a shape this script has no reader for, and that outcome outranks every other reading here, because a reader that does not understand the output cannot be believed about what it found in it. Every rule in this script keys on a structural marker, so a marker that changes spelling is a section the reader stops finding and reports as absent. All three failures on record have that exact shape: the suppressed heading reworded and the count went to zero, the suppressed section moved inside another wrapper and the count went to zero again, and the coverage line was never parsed at all. Each reported a clean pass over a review it had misread, and each was found by the maintainer afterwards rather than by the gate. The digest carries shapes=UNRECOGNIZED, lists each shape it could not place, and names the remedy in two parts: file an issue on the hub repository that hosts this script, quoting the body the shape came from, and leave the merge decision to the maintainer, since an unrecognized shape does not say the pull request is bad, only that nothing here can vouch for the review of it.
The vetted inventory is measured rather than imagined, and it is small because the output is regular. Across those 332 bodies, with fenced blocks dropped and text reduced to ASCII, the whole corpus is seven headings, six <summary> texts and three metadata labels. Counts are normalized to (N) and the verdict headings' colored circle is dropped before comparing, since both change on every review without the section having changed, and dropping the emoji is also what keeps this repository's charset rule satisfied. A body carrying no heading at all is itself unrecognized, which is what catches a rewrite that changes every marker at once, and a refusal is exempt because it is a bare paragraph by design and REFUSAL is its vetted spelling. That exemption is the pattern rather than a carve-out, so a refusal reworded stops being exempt and blocks, which is the refusal check's own failure mode caught one rewording later. The last reading is the quietest: a reviewer login that reads as this reviewer without being the spelling every query filters on, since a rename leaves every filter matching nothing and the digest then reports a review that landed as no review at all. A case runs the whole inventory over the measured corpus, where it raises 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 mergeStateStatus reports one word, BLOCKED, for a red check, a check nothing is running, an unresolved thread, and a missing approval alike, and a run in this repository spent twenty-five minutes polling that word on a pull request whose only unfinished check was an aggregator job no runner ever took. The cause came from the maintainer rather than from any field here, which is the whole defect: the digest named the state it could not explain and stopped there. Four shapes are told apart because each wants a different response. NOT_POSTED is a required status whose poster has not spoken, which is a StatusContext's EXPECTED and only ever that. It is deliberately not folded in with the starved shape, since no runner is owed a status nothing has posted, so re-running a workflow clears nothing and the starved wording would send a reader at the runner pool over a missing poster. NOT_PICKED_UP is a check GitHub dispatched and assigned no runner, read from the queued state rather than from a runner name GraphQL does not carry, and the state suffices because a job held behind a needs: dependency does not enter the rollup until that dependency finishes, so there is no dependency-blocked queue to mistake for a starved one. Nothing agent-side starts it, since the pool is GitHub-hosted, so the remedy is a re-run or that capacity. RUNNING_LONG is deliberately the weaker reading and its wording says so, because duration alone cannot separate a hung job from a slow one: this repository's lint job legitimately runs nine to eleven minutes while its aggregator is a single shell conditional, so the threshold is generous, the elapsed time prints for the reader to judge against what the job costs, and nothing asserts a fault. FAILED is a verdict rather than a stuck check, reported so no reader deduces a red check from BLOCKED.
A check merely still running normally is not any of these and exits 0. That boundary is the whole design, because wait returns the moment coverage lands and on almost every pull request the checks are still going at that instant, so taking 44 for a pending check would make 44 the ordinary outcome and a code that fires always carries nothing. 44 additionally requires mergeStateStatus: BLOCKED, which the module docstring's own list of shapes has to name rather than only implying, because a reader who sees stuck=FAILED and exit 0 on a merge that is UNSTABLE should find the condition written down rather than infer the field is unreliable. It is because a rollup carries checks the ruleset does not require and four of the six on a green pull request here are exactly that, so the code borrows GitHub's own reading of which checks gate a merge rather than reading the ruleset's contexts over another call. CLEAN proves no required gate is outstanding whatever else the rollup is doing, and without that condition a stuck check nothing requires returns 44 on a mergeable pull request. The digest names the check either way, so the narrower code costs the reader nothing. Both of those came out of this change's own review.
A rollup member that is neither a CheckRun nor a StatusContext is skipped rather than forced into one of those shapes, since forcing reads a label under a key the node does not use and a state that is not there, so an unknown member would arrive as an anonymous failure. Skipping it quietly would be the other half of the same mistake, because a check absent from the tally and the stuck reading renders as a clean pass over something never seen, which is this script's own core failure and not a case its newest field gets an exception from. So the member is carried as a marker, counted by neither reader, and named on a CHECKS PARTIALLY UNREAD line. CHECKS_WINDOW is substituted into Q_FULL rather than sitting beside a hard-coded 100, since a constant that does not drive the query only documents the literal, and the case asserting the two agree holds only where someone runs it. The substitution is a .replace rather than an f-string because GraphQL is braces from end to end and interpolation would need every one of them doubled. The rendering path reads every field with .get and clamps a negative age to zero, for the reason age catches two exceptions: a caller handing the digest an odd node shape, or a clock behind GitHub's, should cost a field rather than the one call whose job is to report the state. Through the CLI the negative age is unreachable, since a negative age cannot exceed a non-negative threshold and the parser refuses a negative one, so that clamp is hardening on the library path rather than a live defect.
The contexts connection is guarded the way the review and comment windows are, since it has the same failure. A rollup past a hundred contexts would drop the rest silently, so a required check among them would be absent from the tally and the stuck reading alike and the digest would render a clean pass over a check it never saw, which a fleet repository with a large matrix build reaches long before this one does. CHECKS TRUNCATED says so instead. The rollup is normalized once per digest and the list handed to both readers, since the parse is what this script exists to spend once, and the two readers calling it separately was invisible in the output, which is what let it pass. The 44 message is worded as a coincidence rather than a cause, because nothing here proves the stuck check is the blocker: BLOCKED is also worn by an open thread or a missing approval, so naming the check as the blocker would assert a link this cannot read. The normalized stamp key is since rather than started, since it holds a CheckRun's startedAt for one shape and a StatusContext's createdAt for the other, and one name over two different meanings reads like a comparison of equivalents.
The rollup is selected by matching headRefOid rather than taken as the connection's first node, and where no commit matches, the digest says CHECKS UNREADABLE instead of reading another commit's rollup or letting checks=0/0 pass for a fact about the head. A fallback to the newest node is the stale reading reached by a different route, and a silent 0/0 is the narrowing this whole script is built against, which its own newest field does not get an exception from. The case that was supposed to hold this asserted the fixture's commit equalled the head, which tests the payload rather than the code, and the code was reading position regardless. The reading of an age is guarded the same way: age catches TypeError as well as ValueError, because a stamp carrying no zone parses and yields a naive datetime that will not subtract from an aware now, so catching one and not the other lets a crash out of the call whose whole job is to report the state. --check-grace must be less than --check-stall, since asserting the two constants are ordered while leaving the flags free to invert them is the gap between a rule and its check one level down. All four came out of this change's own review, as low-confidence findings carrying no thread.
The two rollup node shapes need translating rather than merely renaming, because PENDING is a member of both enums and means the opposite thing in each. A CheckRun's PENDING is dispatched and not begun, so it belongs with the queued states, while a StatusContext's reports a run the posting system says is under way, so it is judged against the stall rather than the grace. Read as unstarted, a long external build reports as queued with no runner assigned, naming a cause it does not have on a system that did pick it up. EXPECTED is the StatusContext state that genuinely means unposted, and leaving it out of the unstarted set sends it to the conclusion branch where it matches no pass and reports a required status nobody has posted as a red check. The grace is the pickup grace's five minutes for the reason that one is, as inside it a queued check is a check starting, and the stall is thirty minutes (1800) because a fleet repository building and testing .NET runs longer than this one does. Both are --check-grace and --check-stall, and a negative value for either is rejected by name rather than rendering a digest that reports every check stuck from its first read. A finished check carrying no conclusion yet reports nothing, since reading an absent verdict as a failure invents a red check out of a race in the API, and that case was caught by the suite rather than in review. An unrecognized conclusion is reported, because a new enum member read as a pass is a red check rendering as a green digest. SKIPPED and NEUTRAL are passes, as this fleet's aggregator pattern skips the conditional jobs and four of the six checks on a green pull request here are skips. The rollup rides the existing full query rather than a REST call, so the reading costs no extra round-trip, and it is asked of the last commit because a rollup hangs off a commit object, with a case holding that commit equal to headRefOid since a rollup read a push ago still renders every field.
The timeout path prints the full digest for the same reason, as a bare PENDING line reports a slow reviewer and a broken poll identically, which is the reading that turns a stalled watcher into a watcher nobody notices is stalled.
The digest also reports the suppressed findings a review body collapses into a <details> block. Those reach no review thread, so a loop that polls threads alone reports a clean pass while they stand, and the merge gate counts them as outstanding findings either way. suppressed=N counts findings rather than blocks, reading the (N) the heading carries, since one body holds one block per round and counting blocks reports two findings as one. It covers every round rather than the current head, because a suppressed finding has no resolved state for a push to retire: head-scoping read "superseded by a push" as "answered", and a finding nobody replied to left the digest the moment the branch moved, so the run reported zero. That is how four rounds went unanswered across three pull requests in one day, each found by the maintainer rather than by this script. The summary line splits the count as suppressed=N (on_head=N earlier=N) and each block is marked with the round that raised it, since a finding on an older round may since be moot and deciding that is the reader's call rather than one the count should make for them. Each block prints whole where a thread body truncates, because a thread can be re-read at its id and a suppressed finding cannot, and it prints under a marker naming what closing it takes: no thread exists to reply on or resolve, so the answer goes in the PR conversation.
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. An unconfirmed response may still have landed, so its output tells the reader to inspect the conversation before retrying.
reply posts one answer and resolves one thread, and it exists because the hand-run form keeps failing the same way rather than because a wrapper is tidier. Three instances are on record, each an agent that had read the rule against hand-typing a node id and reached for the literal regardless, the last of them refused by the gh-write-guard hook an hour after quoting that same rule in a pull request body. A shape that fails while the reader knows the rule is a shape to remove rather than a rule to restate, so the selector is the finding's own words and there is deliberately no argument a PRRT_... id fits in: the id is read from the query in the same run and passed straight to the mutation, and a case asserts the id the mutation carries is the one that run just read. The words are matched against the thread's opening comment rather than against a line number, because a fix push moves the line and every lookup keyed to one then misses, which is how three replies posted against nothing while the resolves still succeeded and closed the threads carrying no answer. Matching is case-insensitive, since the text is quoted back out of a digest by a reader.
Every failure is a stop rather than a fallback, because each alternative closes a finding while leaving it unanswered, which is the state a reviewer reads as addressed. No match exits 60 and prints the open threads, since a no-match and an already-answered thread read identically from here. Two matches exit 61 and print both candidates rather than taking the first, head -n 1 being how a reply lands on the wrong finding. A reply whose response carries no url, or a body that came back empty, exits 62 without resolving. A resolve that does not report isResolved exits 63, with the reply already posted, so the thread is open behind an answer rather than silently assumed closed. --resolve is opt-in rather than the default, because a decline is resolved only once its evidence is in the thread. A target under an owner other than this checkout's exits 64 before anything is read at all, and that refusal takes no environment-variable escape: a grant this process can be handed is one the caller sets on the command that runs it, and a grant the caller writes for itself is not a grant, so the cross-owner case goes through the runbook's explicit gh path where the hook reads the maintainer's grant from the session instead.
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. The owner check is also enforced in-process. The whole-source test guards against every other state-changing call and counts each reviewed mutation document.
claims checks that a description does not contradict its own branch, and it exists for the same reason sha-pin now resolves: a reference that points at nothing is a silent failure caught by a reviewer or not at all. Three stale descriptions in one session generated six review findings between them, each a reviewer noticing that the body named a commit or a behavior the branch no longer carried. 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. Prose claims stay out of scope, since judging those needs a similarity heuristic, which spec/section-model.md rejects for exactly the reason it would fail here.
Scanning for bare SHAs was built first and the corpus rejected it. Over the 25 most recent merged pull requests it raised four findings, and every one was correct prose: a develop commit named as history, a SHA inside a pasted digest, and two commits in ptr727/Blog written without a URL. Nothing in the form of a bare SHA separates those from a real claim, and separating them by meaning is the heuristic already ruled out above. A path arm was measured on the same corpus and is worse, flagging 54 of 215 backticked candidates, nearly all of them bare basenames, origin/develop, and other repositories. What survives is the verb: a commit counts only where the body says it was fixed, landed, shipped, added, introduced, corrected, resolved, carried or amended in it. That alternation raises exactly one reference over the same 25, and that one is true. It is an inclusion list, so a phrasing nobody thought of costs a detection rather than producing a finding, which is the direction to be incomplete in, and a claimed SHA still has to carry a digit as a backstop on the list growing later, since accede and defaced inflect into all-hex English words.
A commit passes on ancestry rather than on membership of the branch's own commits, so a description may cite a commit it inherited from the base branch. A commit the repository does not carry at all and a commit this head does not descend from are both findings, and each names which it is, since the first is an amended-away SHA and the second is a branch cut elsewhere. The uses: refs are checked against the whole tree at head rather than a guessed set of workflow paths, because this repo carries uses: lines in catalog snippets and in documentation as well as under .github/, and a narrower surface would report a ref absent because it looked in the wrong place. One archive is also one request, where walking a listing costs a request per file and grows with the repository, and the match is on bytes so a file that does not decode is searched rather than skipped.
Undecided is a third answer here for the reason it is one in repo_gate.py. A reference GitHub did not answer for is left undecided rather than reported stale, and where every reference is undecided the run exits 71 and says so, because stale=0 from a check that read nothing renders exactly like stale=0 from one that did.
The match is on the block's heading rather than anywhere in the body, and on the runbook's alternation rather than on one phrasing, since the wording has already appeared two ways. A case asserts the script's pattern is the one the runbook publishes rather than a copy of it that can drift. Reading the whole body was the first implementation and its own review caught it: a review whose overview prose discusses suppressed findings carries none, and reporting that as a finding trains the reader to skim the field. A heading outside any <details> wrapper is still read, because reporting zero when the markup moves is the same false clean one level up, and that fallback takes a count so ordinary prose does not become one.
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. Both generated trees are never hand-edited.
--check is the read-only mode: it exits 1 when either generated tree differs from .agents/skills/, comparing a digest over every file rather than a file count or timestamp. 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 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.
python3 scripts/carry.py check PhotoCleaner --target /path/to/worktree
python3 scripts/carry.py apply 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.
Installs the fleet's Skills for the current machine, cross-platform and idempotent, mirroring host-setup/agent-safety/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.
--report reads the stamp a prior run wrote ($HOME/.agents/skills-install-stamp.json, naming the hub commit installed) against the current checkout and says whether the machine is current, without installing anything. 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.