From daace82cbe495cb2a31bc3cd7ed0adf5bce487cc Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Mon, 9 Nov 2026 09:19:50 -0800 Subject: [PATCH] Re-vendor GOVERNANCE.md's Verbatim Sections From the Hub Part e of the hub resync at hub main c175d557, split out so it gets one full Copilot review. The content is what #475's head (c1193c97) carried. Co-Authored-By: Claude Opus 5.5 --- GOVERNANCE.md | 273 ++++++++++++++++++++++++++++---------------------- 1 file changed, 156 insertions(+), 117 deletions(-) diff --git a/GOVERNANCE.md b/GOVERNANCE.md index 6cc045e..8a8b2ac 100644 --- a/GOVERNANCE.md +++ b/GOVERNANCE.md @@ -6,67 +6,67 @@ Read the one section a task needs rather than the whole file. `grep -n '^## ' GO ## Foundational Principles -The specific rules in this file implement a few governing principles. Read these first: they are the reason the branching, release, and versioning rules are shaped the way they are, and every rule below serves one of them. +The specific rules in this file implement a few governing principles. Read these first. They are the reason the branching, release, and versioning rules are shaped the way they are, and every rule below serves one of them. -- **Distribution respects the user: pull by default, push only where the channel forces it.** Docker images, GitHub Releases, and NuGet/PyPI packages are **pull**: the user decides when to consume them. A few channels are **push**: HACS surfaces a new release to every installed user as a pending update they did not go looking for, and a consumer that vendors from `main` picks up its current state. Because a release can reach users who did not ask for it, releasing is a deliberate act that marks a real functional change, never mechanical churn. This is why a **human merge never auto-publishes**: a release is a deliberate `workflow_dispatch`, or a conditional auto-release when the App merges a code-affecting Dependabot/codegen PR to `main` (Docker also refreshes on a weekly schedule). That rule, the no-op republish guarantee, and maintainer-gated version bumps all hold the same line: a needless release spends the user's attention and, on a push channel, acts on their machine. -- **Both branches stay in sync, so a promotion never needs a back-merge.** Dependabot and codegen target `develop` and `main` in parallel, so neither branch drifts and a `develop -> main` promotion stays a clean forward merge by default. That is exactly what lets the model be **signed, linear, and free of back-merges**: forward sync removes any need to merge `main` back into `develop`, which the rules forbid. If sync is ever broken (a change lands on one branch only, or normalizes a file on one side), restore it forward-only, never back-merge. See "Branching Model". (These auto-publish rules describe `release` repos. **Operational** repos differ, with direct-to-`develop` commits and a dispatch-only release. See "Operational Repositories".) -- **Two version numbers, two jobs.** The 2-digit `major.minor` in `version.json` carries human meaning: the maintainer raises it only for a functional change (feature, behavior or API change, breaking change), at their discretion, while NBGV owns the patch position and always increments with git height, so every build is uniquely versioned with no edit. Human-facing docs name the 2-digit line, and the toolchain guarantees monotonic builds. See "Release Model". -- **Contracts state what, not how, and favor reuse.** [`WORKFLOW.md`](./WORKFLOW.md) fixes required outcomes, not a required implementation, so two repos may satisfy a guarantee with different YAML. Within that freedom, apply good engineering practice: minimize duplication and maximize reuse, which is why the pipeline splits a carried, generic orchestration layer from a repo-owned build layer. +- **Distribution respects the user: pull by default, push only where the channel forces it.** Docker images, GitHub Releases, and NuGet/PyPI packages are **pull**: the user decides when to consume them. A few channels are **push**: HACS surfaces a new release to every installed user as a pending update they did not go looking for. And a consumer that vendors from `main` picks up its current state. A release can reach users who did not ask for it. So releasing is a deliberate act that marks a real functional change, never mechanical churn. This is why a **human merge never auto-publishes**. A release is a deliberate `workflow_dispatch`, or a conditional auto-release when the App merges a code-affecting Dependabot/codegen PR to `main`. Docker also refreshes on a weekly schedule. That human-merge rule, the no-op republish guarantee, and maintainer-gated version bumps all hold the same line. A needless release spends the user's attention and, on a push channel, acts on their machine. +- **Both branches stay in sync, so a promotion never needs a back-merge.** Dependabot and codegen target `develop` and `main` in parallel, so neither branch drifts and a `develop -> main` promotion stays a clean forward merge by default. That is exactly what lets the model be **signed, linear, and free of back-merges**. Forward sync removes any need to merge `main` back into `develop`, which the rules forbid. If sync is ever broken (a change lands on one branch only, or normalizes a file on one side), restore it forward-only, never back-merge. See "Branching Model". (These auto-publish rules describe `release` repos. **Operational** repos differ, with direct-to-`develop` commits and a dispatch-only release. See "Operational Repositories".) +- **Two version numbers, two jobs.** The 2-digit `major.minor` in `version.json` carries human meaning. The maintainer raises it only for a functional change (feature, behavior or API change, breaking change), at their discretion. NBGV owns the patch position and always increments with git height, so every build is uniquely versioned with no edit. Human-facing docs name the 2-digit line, and the toolchain guarantees monotonic builds. See "Release Model". +- **Contracts state what, not how, and favor reuse.** [`WORKFLOW.md`](./WORKFLOW.md) fixes required outcomes, not a required implementation, so two repos may satisfy a guarantee with different YAML. Within that freedom, apply good engineering practice: minimize duplication and maximize reuse. That is why the pipeline splits a carried, generic orchestration layer from a repo-owned build layer. All code favors reuse the same way, per "Design Before Code". ## Durable Knowledge and Self-Improvement -- **Durable knowledge lives in the committed docs, not in agent memory.** Anything a future agent must honor (a rule, a contract, a hard-won gotcha, a pattern worth repeating or one to avoid) belongs in a committed governance file (`GOVERNANCE.md` for a cross-cutting rule, `AGENTS.md`, `CODESTYLE.md`, `WORKFLOW.md`, or a committed backlog the repository already keeps). Agent memory does not survive a new session, a new machine, or a new environment, so it holds only environment-specific nuance and in-flight session state, never anything whose loss on reset would matter. A durable lesson left only in memory is lost to the next agent. -- **Keep the governance current as you work.** When work surfaces something durable (a rule worth enforcing, a recurring gotcha, a positive pattern to repeat, a negative one to design out), record it in the governance docs as part of that change, rather than leaving it in a local note or routing around it with a one-off workaround. Where the governing doc is carried from a template this repo cannot edit directly, propose the change upstream rather than patching the local copy. A local patch leaves every sibling repo with the same trap. Governance is not static: it improves by agents folding good patterns in and designing bad ones out. -- **A blocker filed in another repository is recorded in the repository whose work it blocks.** The binding moment is the one where the upstream issue is filed or, where it already exists, found, because that session is the one that knows what stopped and why. It owes a second issue in the repository that is waiting rather than only the first, and where the upstream issue already exists the local issue names that one and no second upstream issue is filed. The local issue states what this repository cannot do and why, in its own terms rather than as a pointer to read elsewhere, since a reader who has to open the upstream issue to learn whether it affects them opens every one of them. The local issue names the upstream one as its blocker, carries the `blocked` label, and carries whatever labels its own work would carry anyway. A comment on the upstream issue then names the local one in return, a comment rather than an edit to the body because a second repository may join the same blocker later and because the body is often not this session's to rewrite. That order, the upstream issue and then the local issue and then the backlink comment, leaves a partial failure as a record naming its blocker rather than as a blocker naming a record nobody wrote. **A handoff does not do this job.** It carries the blockers a round met, and it belongs to one track and closes with its successor, where the wait outlives every session that met it and belongs in the backlog the whole repository reads. -- **The blocker record is written under the ordinary write rules, and the label on it is taken off deliberately.** Filing an issue and commenting on another are state-changing calls, so "Repository Boundaries and Write Safety" binds each of them exactly as it binds any other write, which keeps the upstream issue inside this owner and makes a blocker under a different owner a matter of explicit permission rather than of this rule. The `blocked` label is what every reader of this record selects on, so a repository not carrying it cannot host one until the fleet label set is applied there, which is a change to that repository's configuration rather than anything this rule writes, and a session that finds it missing reports that. The label comes off when the blocker clears, and the session closing the upstream issue is best placed to take it off, since the backlink comments naming every waiting repository are on the issue it is closing, while any later session that finds it cleared takes it off instead. A fix can land well before either of those, so the label lags the fix rather than tracking it, which is why a session meeting a `blocked` issue reads the state of the issue that issue's body names rather than the label. The local issue stays open when the label comes off, because the work it records still has to be done and is ordinary backlog from that moment on. -- **A handoff parked on a maintainer decision carries the `blocked` label too.** Its blocker is a `decision` issue in this same repository rather than an issue elsewhere, and the parking comment on the handoff names it. A reader reads the named issue rather than the label, and the blocker clears when that issue loses its `decision` label. Unlike a cross-repository blocker, a session finding it cleared does not take the label off. It comes off only when a session hands the link back to be worked, so a loop running meanwhile never takes a link a present maintainer is still working. -- **A durable rule earns a mechanical hook only where a hook can actually decide it, otherwise it stays prose.** Three conditions together, not any one alone. The failure recurs even after the governing prose was demonstrably read and understood, so it is not a discovery or loading problem a structural fix (getting the rule into context at all) would already solve. The triggering shape is decidable from the tool call's own text, arguments, and working directory alone, with no semantic or contextual judgment required. And the failure is destructive or hard to reverse rather than a quality miss. A worktree-isolation lapse met all three (it recurred under prose the agent had already read, "is this command's target a primary checkout" is a plain directory comparison, and the harm is another task's swept or reverted work), so it was promoted to a `gh-write-guard` hook rule. A skill's own trigger going unread by the session at all, by contrast, is a loading problem, fixed by getting the rule into context (the `CLAUDE.md` importing `AGENTS.md`), not by a hook. And "was this review finding actually evidence-backed" fails the second condition outright: a hook sees only the command text, never the judgment call itself, so it can only ever nag, not decide, and that class of rule stays prose and a chained Skill trigger. Those three conditions gate promotion to a **host** hook, the involuntary layer that fires in every session under the maintainer's own credentials and that only the maintainer can grant an exemption from, which is why the bar there is destructive harm. A **committed** hook in the repository's own tree is a third layer between prose and that one, and it is earned on weaker grounds: it is opt-in per clone, visible in the tree, bypassable by design, and it therefore fits a rule whose harm is a quality miss rather than a destruction. The second condition still binds it, since a hook that cannot decide its own trigger is a hook that nags, so what earns the layer is finding the decidable half of a rule whose other half is judgment. The local-review rule under `GOVERNANCE.md` "Verification Discipline" is the worked example: whether a review's findings were rightly disposed of is judgment no hook can decide and stays prose, while whether a review pass ran over exactly the content being pushed is a receipt comparison, which the hub's own `.husky/pre-push` decides. +- **Durable knowledge lives in the committed docs, not in agent memory.** Anything a future agent must honor belongs in a committed governance file. That covers a rule, a contract, a hard-won gotcha, and a pattern worth repeating or one to avoid. The file is `GOVERNANCE.md` for a cross-cutting rule, or `AGENTS.md`, `CODESTYLE.md`, `WORKFLOW.md`, or a committed backlog the repository already keeps. Agent memory does not survive a new session, a new machine, or a new environment. So it holds only environment-specific nuance and in-flight session state, never anything whose loss on reset would matter. A durable lesson left only in memory is lost to the next agent. +- **Keep the governance current as you work.** When work surfaces something durable, record it in the governance docs as part of that change. Something durable includes a rule worth enforcing, a recurring gotcha, a positive pattern to repeat, or a negative one to design out. Record it rather than leaving it in a local note or routing around it with a one-off workaround. Where the governing doc is carried from a template this repo cannot edit directly, propose the change upstream rather than patching the local copy. A local patch leaves every sibling repo with the same trap. Governance is not static: it improves by agents folding good patterns in and designing bad ones out. +- **A blocker filed in another repository is recorded in the repository whose work it blocks.** The binding moment is when the upstream issue is filed or, where it already exists, found. That is because the session filing or finding it is the one that knows what stopped and why. It owes a second issue in the repository that is waiting rather than only the first. Where the upstream issue already exists, the local issue names that one and no second upstream issue is filed. The local issue states what this repository cannot do and why, in its own terms rather than as a pointer to read elsewhere. That is because a reader who has to open the upstream issue to learn whether it affects them opens every one of them. The local issue names the upstream one as its blocker, carries the `blocked` label, and carries whatever labels its own work would carry anyway. A comment on the upstream issue then names the local one in return. It is a comment rather than an edit to the body because a second repository may join the same blocker later. It is also because the body is often not this session's to rewrite. That order is the upstream issue, then the local issue, then the backlink comment. It leaves a partial failure as a record naming its blocker rather than as a blocker naming a record nobody wrote. **A handoff does not do this job.** It carries the blockers a round met, but it belongs to one track and closes when that track's work is done. The wait can outlive that track, so it belongs in the backlog the whole repository reads. +- **The blocker record is written under the ordinary write rules, and the label on it is taken off deliberately.** Filing an issue and commenting on another are state-changing calls. So "Repository Boundaries and Write Safety" binds each of them exactly as it binds any other write. That keeps the upstream issue inside this owner. That binding also makes a blocker under a different owner a matter of explicit permission rather than of this rule. The `blocked` label is what every reader of this record selects on. So a repository not carrying it cannot host one until the fleet label set is applied there. That is a change to that repository's configuration rather than anything this rule writes. A session that finds it missing reports that. The label comes off when the blocker clears. The session closing the upstream issue is best placed to take it off. That is because the backlink comments naming every waiting repository are on the issue it is closing. Any later session that finds it cleared takes it off instead. A fix can land well before either of those, so the label lags the fix rather than tracking it. That is why a session meeting a `blocked` issue reads the state of the issue that issue's body names rather than the label. The local issue stays open when the label comes off. That is because the work it records still has to be done and is ordinary backlog from that moment on. +- **A handoff parked on a maintainer decision carries the `blocked` label too.** Its blocker is a `decision` issue in this same repository rather than an issue elsewhere, and the parking comment on the handoff names it. A reader reads the named issue rather than the label, and the blocker clears when that issue loses its `decision` label. Unlike a cross-repository blocker, a session finding it cleared does not take the label off. It comes off only once a session writes the next round, so a running loop never takes a handoff a present maintainer is still working. +- **A durable rule earns a mechanical hook only where a hook can actually decide it, otherwise it stays prose.** The conditions below must hold together, not any one alone. The failure recurs even after the governing prose was demonstrably read and understood. So it is not a discovery or loading problem that a structural fix would already solve. A structural fix here means getting the rule into context at all. The triggering shape is decidable from the tool call's own text, arguments, and working directory alone, with no semantic or contextual judgment required. And the failure is destructive or hard to reverse rather than a quality miss. A worktree-isolation lapse met all of them, so it was promoted to a `gh-write-guard` hook rule. It recurred under prose the agent had already read. The question "is this command's target a primary checkout" is a plain directory comparison. And the harm is another task's swept or reverted work. A skill's own trigger going unread by the session at all, by contrast, is a loading problem. It is fixed by getting the rule into context (the `CLAUDE.md` importing `AGENTS.md`), not by a hook. And "was this review finding actually evidence-backed" fails the second condition outright. A hook sees only the command text, never the judgment call itself, so it can only ever nag, not decide. That class of rule stays prose and a chained Skill trigger. Those conditions gate promotion to a **host** hook. That is the involuntary layer that fires in every session under the maintainer's own credentials, and only the maintainer can grant an exemption from it. That is why the bar there is destructive harm. A **committed** hook in the repository's own tree is a third layer between prose and that one, and it is earned on weaker grounds. It is opt-in per clone, visible in the tree, and bypassable by design. It therefore fits a rule whose harm is a quality miss rather than a destruction. The second condition still binds it, since a hook that cannot decide its own trigger is a hook that nags. So what earns the layer is finding the decidable half of a rule whose other half is judgment. The local-review rule under `GOVERNANCE.md` "Verification Discipline" is the worked example. Whether a review's findings were rightly disposed of is judgment no hook can decide, and it stays prose. Whether a review pass ran over exactly the content being pushed is a receipt comparison, which the hub's own `.husky/pre-push` decides. -`GOVERNANCE.md` "Durable Knowledge and Self-Improvement" keeps the full rules, and the `agent-conduct` Skill at `.agents/skills/agent-conduct/SKILL.md` in the hub, not a repo-relative link since that path is hub-local and not carried into every fleet repo, carries it whole as a generated include and surfaces it at its decision moments. +`GOVERNANCE.md` "Durable Knowledge and Self-Improvement" keeps the full rules. The `agent-conduct` Skill at `.agents/skills/agent-conduct/SKILL.md` in the hub carries it whole as a generated include and surfaces it at its decision moments. That path is not a repo-relative link, since it is hub-local and not carried into every fleet repo. ## Repository Boundaries and Write Safety -A state-changing GitHub call is the highest-blast-radius thing an agent does here: it runs under the maintainer's identity, so one wrong target writes to another owner's repository as the maintainer, an outward-facing and hard-to-reverse act. These rules bound every write (a git push, an API mutation, a comment, a label, a merge) on any platform, and they bound a write to a checkout on disk as well, since a blanket add or a hard reset in a working tree another task is using destroys work without ever reaching GitHub. Reads are unrestricted, and how far a local read can be trusted is governed under "Verification Discipline" rather than here. The bounds below are on writes. +A state-changing GitHub call is the highest-blast-radius thing an agent does here. It runs under the maintainer's identity. So one wrong target writes to another owner's repository as the maintainer, an outward-facing and hard-to-reverse act. These rules bound every write (a git push, an API mutation, a comment, a label, a merge) on any platform. They bound a write to a checkout on disk as well. That is because a blanket add or a hard reset in a working tree another task is using destroys work without ever reaching GitHub. Reads are unrestricted, and how far a local read can be trusted is governed under "Verification Discipline" rather than here. The bounds below are on writes. -- **Write only within the owner of the current project's repository.** Every state-changing call targets this project's `origin` or another repository under the same owner, which is the fleet the maintainer already administers. A broad or logged-in identity is capability, not permission: a token that *can* reach another owner's repository does not authorize writing to it. Writing under a **different owner** needs explicit human permission naming that repository, granted deliberately rather than assumed from a token's reach, and a "harmless test" write is still a write, so there is no probe exception. That boundary is where the harm sits, since the incident this rule exists for was a stray comment on a stranger's repository, not work across the maintainer's own projects. Reads from anywhere are fine. +- **Write only within the owner of the current project's repository.** Every state-changing call targets this project's `origin` or another repository under the same owner, which is the fleet the maintainer already administers. A broad or logged-in identity is capability, not permission: a token that *can* reach another owner's repository does not authorize writing to it. Writing under a **different owner** needs explicit human permission naming that repository, granted deliberately rather than assumed from a token's reach. A "harmless test" write is still a write, so there is no probe exception. That boundary is where the harm sits. That is because the incident this rule exists for was a stray comment on a stranger's repository, not work across the maintainer's own projects. Reads from anywhere are fine. - **Provider connectors are read-only for fleet work.** Use a provider's GitHub connector for reads where it helps. Perform each GitHub mutation through the documented hub tool, or through authenticated `gh` where no tool owns the operation. This gives Codex, Claude, opencode, and a terminal session one write path with the same checks. It also avoids a connector mutation that predictably lacks repository authorization while the verified `gh` session already has it. A provider-specific instruction may explain how to reach the common path. It never replaces that path with its own mutation surface. - **Never fabricate, guess, or reuse an identifier passed to a write.** Capture every identifier a state-changing call consumes from a live query in the **same** session. This includes node, numeric, thread, and comment ids. Pass the captured value directly. Do not hand-type an id, recall it from another session, or copy it from documentation or an example. Ids commonly resolve **globally**, so a wrong-but-valid id does not fail. It writes to the wrong target, in someone else's repository. Apply the same rule to an identifier embedded in outward-facing text. Read the complete URL from the live object. Never construct a plausible link from an unverified id. If a query returns no id or URL, stop rather than invent one to proceed. -- **A write is never a probe, and a write's output is never suppressed.** Never fire a state-changing call to see whether it works: decide it should happen, make it happen, and read the result. Never append output-discarding redirection or a force-success tail to a mutation (for example `>/dev/null`, `2>/dev/null`, `&>/dev/null`, `|| true`, `|| :`, `|| echo`), because the write's output is exactly what must be read. A write that appears to fail is **verified, not assumed harmless**, because the operation may have succeeded on the server while the client reported an error, so confirm the actual state before retrying or moving on. The ban targets hiding a *failure*. An ad-hoc call's response is the only signal you get, so `>/dev/null 2>&1`, `|| true`, and `|| echo`, which swallow the error stream or force success, are never acceptable on one. A committed script under `set -e` is a narrow exception: it may send a write's *stdout* to `/dev/null` to drop the success-response noise, because stderr stays visible and a failed write still aborts loudly (the hub's own `repo-config/configure.sh` does exactly this, and a repository reaches it there rather than carrying a copy). The exception is stdout-only suppression inside a reviewed, fail-loud script, never `2>&1` or a force-success tail, and never an ad-hoc command. -- **A refused write is reported, never re-shaped, and the maintainer's say-so does not lift a refusal by the harness.** These are two different permissions and only one of them is the maintainer's to give. When the agent harness refuses a write, the maintainer authorizing it in conversation does not change the outcome, and the identical call is refused again, so a second attempt is not worth making and reading the second refusal as a flake is how an agent starts hunting for another shape of the same request. **That hunt is the failure this rule exists to stop.** Re-expressing a refused `gh` command as a raw `gh api -X POST` reaches the same endpoint with the same identity and the same blast radius, having defeated the one control that stopped it, and it is the more dangerous version because the agent believes it has permission. So a refused write is never re-attempted through a different API surface, a different tool, or a rephrasing, and it is never routed around by the agent writing itself a permission rule, which is self-authorization whatever the maintainer said. Two routes remain, both of them the maintainer's: they add the permission rule themselves, or they run the command themselves. Raise it as a blocked decision naming those two (see "Communicating with the User"), and where the work needs the result rather than the call, say what the agent will verify once the maintainer has run it. **A refusal is also a fact about the contract, not just about the session**: where a required verification can only be performed by a write the agent is refused, the document requiring it says so and names who runs it, since a check that is mandatory and unperformable is quietly dropped and then reported as done. -- **Each task runs in its own checkout, in its own directory, on its own feature branch.** The unit is the task rather than the agent, since one agent moving between two repositories meets the same hazard as two agents sharing one tree, and a rule written per agent permits exactly the case that goes wrong. The commands that cross the boundary are the ordinary ones rather than the reckless ones, and each is correct in isolation: a blanket `git add -A` sweeps another task's uncommitted work into the commit, a `git reset --hard` deletes it, and a branch switch carries it into an unrelated change. The mechanical habit that holds the rule up is that a mutating command takes an absolute path, or a `cd` to one in the same invocation, rather than the working directory it inherited, because a read in the wrong directory is a wasted call and a write there is damage. -- **A task isolates into its own worktree before its first file edit, and a continuation re-isolates.** All new work begins by creating a unique git worktree (or clone) on its own feature branch, based on the branch work starts on for the repository's model per "Branching Model", which is `develop` unless the task is explicitly about `main`-only content. The primary checkout is the maintainer's own surface, so a session launched there isolates before writing rather than after noticing contention, and a session resuming a prior task creates a fresh worktree rather than resuming wherever its branch happens to be checked out, since a branch sitting checked out in a shared tree is exactly how two sessions end up in one checkout. The moment this rule binds is the first file edit, because the commit-time and review-time checks all run after another task's uncommitted work can already be swept. The worktree mechanics, the layout convention, and the cleanup are packaged as the `repo-worktree` Skill at `.agents/skills/repo-worktree/SKILL.md` in the hub, not a repo-relative link since that path is hub-local and not carried into every fleet repo, and this section keeps the rule. For Claude Code sessions, the `gh-write-guard` hook now backstops a mechanical subset of this rule directly, denying a mutating git operation (`reset`, `add`, `commit`, and most of the rest, a documented handful of exemptions such as a fast-forward-only pull kept aside) run against a primary checkout rather than a worktree. Every other agent, and everything about isolation a hook cannot see (which checkout a read happens in, whether another task is live in the tree), still relies on this prose alone. The parenthetical "(or clone)" above names `repo-worktree`'s own standalone-clone fallback, used when a linked worktree is unavailable, and that fallback is itself structurally a primary checkout to the hook's own primary-vs-worktree test, so a session using it on Claude Code sets the hook's `GH_WRITE_GUARD_ALLOW_PRIMARY_CHECKOUT` grant for that session, per `repo-worktree`'s own instructions, rather than being silently denied the commits the fallback exists to make. -- **A checkout another task is live in is left rather than shared, and a footprint already left there is undone deliberately.** Two signals say someone else is in the tree, a branch that changes when nothing you did changed it, and an edit of yours reverted with no conflict, and the response to either is to stop rather than to re-apply the edit, which is the instinct and the wrong one. Leaving and cloning your own costs about a minute against an incident that costs the better part of an hour, so it is the cheap move rather than the cautious one. Once you have written there, leaving it alone arrives too late, so save your work aside, restore only the files you touched, verify the tree is clean, delete your branch from that clone, and then say plainly what was touched, since a regenerated report left behind reads as the other task's own and is committed by whoever runs the next blanket add. +- **A write is never a probe, and a write's output is never suppressed.** Never fire a state-changing call to see whether it works: decide it should happen, make it happen, and read the result. Never append output-discarding redirection or a force-success tail to a mutation (for example `>/dev/null`, `2>/dev/null`, `&>/dev/null`, `|| true`, `|| :`, `|| echo`). The reason is that the write's output is exactly what must be read. A write that appears to fail is **verified, not assumed harmless**. That is because the operation may have succeeded on the server while the client reported an error. So confirm the actual state before retrying or moving on. The ban targets hiding a *failure*. An ad-hoc call's response is the only signal you get. So `>/dev/null 2>&1`, `|| true`, and `|| echo`, which swallow the error stream or force success, are never acceptable on one. A committed script under `set -e` is a narrow exception. It may send a write's *stdout* to `/dev/null` to drop the success-response noise, because stderr stays visible and a failed write still aborts loudly. The hub's own `repo-config/configure.sh` does exactly this, and a repository reaches it there rather than carrying a copy. The exception is stdout-only suppression inside a reviewed, fail-loud script, never `2>&1` or a force-success tail, and never an ad-hoc command. +- **A refused write is reported, never re-shaped, and the maintainer's say-so does not lift a refusal by the harness.** These are two different permissions and only one of them is the maintainer's to give. When the agent harness refuses a write, the maintainer authorizing it in conversation does not change the outcome. The identical call is refused again, so a second attempt is not worth making. Reading the second refusal as a flake is how an agent starts hunting for another shape of the same request. **That hunt is the failure this rule exists to stop.** Re-expressing a refused `gh` command as a raw `gh api -X POST` reaches the same endpoint with the same identity and the same blast radius. It has defeated the one control that stopped it. And it is the more dangerous version because the agent believes it has permission. So a refused write is never re-attempted through a different API surface, a different tool, or a rephrasing. It is also never routed around by the agent writing itself a permission rule, which is self-authorization whatever the maintainer said. The routes that remain are the maintainer's: they add the permission rule themselves, or they run the command themselves. Raise it as a blocked decision naming those routes (see "Communicating with the User"). Where the work needs the result rather than the call, say what the agent will verify once the maintainer has run it. A gate-checked subcommand that the maintainer's own installer allows, such as `pr_review.py merge`, is a shape of call the maintainer sanctions in advance. It sanctions how the write is made, never whether it is, so the write still needs the maintainer's go-ahead as before. A skill prescribes it as the first call for its write. It is not among the routes left after a refusal. So it is never reached for once a hand-typed form of the same write was refused. **A refusal is also a fact about the contract, not just about the session.** Where a required verification needs a write the agent is refused, the document requiring it says so and names who runs it. That is because a check that is mandatory and unperformable is quietly dropped and then reported as done. +- **Each task runs in its own checkout, in its own directory, on its own feature branch.** The unit is the task rather than the agent. That is because one agent moving between two repositories meets the same hazard as two agents sharing one tree. It is also because a rule written per agent permits exactly the case that goes wrong. The commands that cross the boundary are the ordinary ones rather than the reckless ones, and each is correct in isolation. A blanket `git add -A` sweeps another task's uncommitted work into the commit, and a `git reset --hard` deletes it. A branch switch carries it into an unrelated change. And a `git stash pop` can pop another task's entry, since the stash is one list per repository shared by every worktree. The mechanical habit that holds the rule up: a mutating command takes an absolute path, or a `cd` to one in the same invocation. It does not take the working directory it inherited. That is because a read in the wrong directory is a wasted call and a write there is damage. No working directory scopes the shared stash list, so a task leaves the stash alone. +- **A task isolates into its own worktree before its first file edit, and a continuation re-isolates.** All new work begins by creating a unique git worktree (or clone) on its own feature branch. Its base is the branch work starts on for the repository's model per "Branching Model", which is `develop` unless the task is explicitly about `main`-only content. The primary checkout is the maintainer's own surface, so a session launched there isolates before writing rather than after noticing contention. An operational repository's live checkout on `develop` is the one exception, since it takes a direct commit in place per "Operational Repositories". A session resuming a prior task creates a fresh worktree rather than resuming wherever its branch happens to be checked out. That is because a branch sitting checked out in a shared tree is exactly how two sessions end up in one checkout. The moment this rule binds is the first file edit. That is because the commit-time and review-time checks all run after another task's uncommitted work can already be swept. The worktree mechanics, the layout convention, and the cleanup are packaged as the `repo-worktree` Skill at `.agents/skills/repo-worktree/SKILL.md` in the hub. That path is not a repo-relative link, since it is hub-local and not carried into every fleet repo. This section keeps the rule. For Claude Code sessions, the `gh-write-guard` hook now backstops a mechanical subset of this rule directly. It denies a mutating git operation run against a primary checkout rather than a worktree. That covers `reset`, `add`, `commit`, and most of the rest, a documented handful of exemptions such as a fast-forward-only pull kept aside. An operational repository's in-place commit is one of them. The hook allows it only where the repository's `.github/agent-policy.json` on `main` lists the checked-out branch. Every other agent, and everything about isolation a hook cannot see, still relies on this prose alone. What a hook cannot see includes which checkout a read happens in and whether another task is live in the tree. The parenthetical "(or clone)" above names `repo-worktree`'s own standalone-clone fallback, used when a linked worktree is unavailable. That fallback is itself structurally a primary checkout to the hook's own primary-vs-worktree test. So a session using it on Claude Code sets the hook's `GH_WRITE_GUARD_ALLOW_PRIMARY_CHECKOUT` grant for that session, per `repo-worktree`'s own instructions. That keeps it from being silently denied the commits the fallback exists to make. +- **A checkout another task is live in is left rather than shared, and a footprint already left there is undone deliberately.** These signals say someone else is in the tree: a branch changing when nothing you did changed it, and your edit reverted with no conflict. The response to any of them is to stop rather than to re-apply the edit, which is the instinct and the wrong one. Leaving and cloning your own costs about a minute against an incident that costs the better part of an hour. So it is the cheap move rather than the cautious one. Once you have written there, leaving it alone arrives too late. So save your work aside in a scratch directory of your own, and restore only the files you touched. Verify the tree is clean, delete your branch from that clone, and then say plainly what was touched. That is because a regenerated report left behind reads as the other task's own and is committed by whoever runs the next blanket add. ## Representative Data in Agent-Authored Text -Agent-authored text illustrates with data the agent constructed, never with data it observed in the maintainer's environment. This binds every surface an agent writes: pull request and issue comments, review replies, commit messages, code, tests, fixtures, and docs. Reading real data is unrestricted, and what is bounded is what an agent copies out of the environment into text that is committed or posted. The rule holds for a private repository as much as a public one, since a repository's audience changes with one settings toggle while the text stays exactly where it was written, and it holds where the data is the maintainer's own, since the exposure happens on their behalf before they can weigh it. +Agent-authored text illustrates with data the agent constructed, never with data it observed in the maintainer's environment. This binds every surface an agent writes: pull request and issue comments, review replies, commit messages, code, tests, fixtures, and docs. Reading real data is unrestricted, and what is bounded is what an agent copies out of the environment into text that is committed or posted. The rule holds for a private repository as much as a public one. That is because a repository's audience changes with one settings toggle while the text stays exactly where it was written. It also holds where the data is the maintainer's own, since the exposure happens on their behalf before they can weigh it. -- **Synthetic evidence is the better evidence, not a weaker substitute.** A case constructed to carry the defect demonstrates it exactly and any reader can re-run it, where observed data proves the same thing and can never be reproduced by anyone else. A filename built to contain a newline is a complete proof of a newline-handling defect, and the real directory it was found in adds nothing the proof needed. Reaching for observed data to make a finding more convincing inverts which of the two is the stronger evidence. Where observed data is what revealed the defect, name its shape, meaning the property that triggers the fault, and construct a case that carries that property. -- **The exposure is one-way.** A public comment is fetched, cached, and indexed the moment it posts, so editing it afterwards is mitigation rather than a fix, and the edit leaves the original readable in the comment's edit history to anyone who can read the repository. Text that has already landed is reported to the maintainer rather than quietly rewritten, since the decision on what to do about it, deletion included, is theirs. Do not quote the exposed data again while reporting or investigating it, because a transcript, an issue, or a commit message written about the exposure reproduces it somewhere new. -- **No checker closes this.** A pattern finds an absolute home path or a drive letter, and gating that subset is worth doing as a floor. The exposure this rule exists for was name-shaped, and a name is not pattern-detectable, so a search of the offending text for path-shaped strings returns nothing while the names sit in plain sight. A gate here catches the easy half, and mistaking it for the answer is what stops anyone looking at the other half, which is why this is a judgment an agent applies rather than a check it waits for. +- **Synthetic evidence is the better evidence, not a weaker substitute.** A case constructed to carry the defect demonstrates it exactly, and any reader can re-run it. Observed data proves the same thing and can never be reproduced by anyone else. A filename built to contain a newline is a complete proof of a newline-handling defect. The real directory it was found in adds nothing the proof needed. Reaching for observed data to make a finding more convincing inverts which of the two is the stronger evidence. Where observed data is what revealed the defect, name its shape, meaning the property that triggers the fault. Then construct a case that carries that property. +- **The exposure is one-way.** A public comment is fetched, cached, and indexed the moment it posts, so editing it afterwards is mitigation rather than a fix. The edit leaves the original readable in the comment's edit history to anyone who can read the repository. Text that has already landed is reported to the maintainer rather than quietly rewritten. That is because the decision on what to do about it, deletion included, is theirs. Do not quote the exposed data again while reporting or investigating it. That is because a transcript, an issue, or a commit message written about the exposure reproduces it somewhere new. +- **No checker closes this.** A pattern finds an absolute home path or a drive letter, and gating that subset is worth doing as a floor. The exposure this rule exists for was name-shaped, and a name is not pattern-detectable. So a search of the offending text for path-shaped strings returns nothing while the names sit in plain sight. A gate here catches the easy half, and mistaking it for the answer is what stops anyone looking at the other half. That is why this is a judgment an agent applies rather than a check it waits for. ## Git and Commit Rules -The fleet's mechanical git rules: default to staging rather than committing, stage by explicit path only and never with a blanket add, commit means commit and push, every commit is signed and carries the committer's own verified GitHub `noreply` identity, never force push, a history rewrite re-identifies only the commits it touches that aren't yours, and destructive git commands run only on explicit instruction. +The fleet's mechanical git rules follow. Default to staging rather than committing, and stage by explicit path only, never with a blanket add. Commit means commit and push. Every commit is signed and carries the committer's own verified GitHub `noreply` identity. Never force push. A history rewrite re-identifies only the commits it touches that aren't yours. And destructive git commands run only on explicit instruction. The `git-commit-conventions` Skill names the narrow cases. One is `git branch -D` on a squash-merged task branch. Another is a `backlog-burndown` orchestrator discarding the uncommitted edits in the linked worktree of a worker it told to abandon, once that worker has ended. This is packaged as the `git-commit-conventions` Skill at -`.agents/skills/git-commit-conventions/SKILL.md` in the hub, not a repo-relative link since that -path is hub-local and not carried into every fleet repo. The summary above sketches the contract. +`.agents/skills/git-commit-conventions/SKILL.md` in the hub. That path is not a repo-relative link, +since it is hub-local and not carried into every fleet repo. The summary above sketches the contract. Read the skill for the full rules. ## Branching Model Two workflow models, set per repo by the registry `workflowModel` field. Most repos are `release`: squash-only feature branches into `develop`, merge-commit-only `develop -> main` -promotions, forward-only with no back-merges, and two promotion traps worth knowing before the +promotions, and forward-only with no back-merges. That model has promotion traps worth knowing before the first one (never delete `develop`, resolve an EOL-only conflict by taking `develop`'s side). -**GitHub's own "default branch" repository setting reads `main`, but `develop` is where work starts and where in-flight content lives**, so a worktree or clone that defaults to "the default branch" lands on `main` and can silently miss content already merged to `develop` but not yet promoted. Branch from `develop`, on either workflow model, unless the task is explicitly about `main`-only content. +**GitHub's own "default branch" repository setting reads `main`, but `develop` is where work starts and where in-flight content lives.** So a worktree or clone that defaults to "the default branch" lands on `main`. It can silently miss content already merged to `develop` but not yet promoted. Branch from `develop`, on either workflow model, unless the task is explicitly about `main`-only content. **Operational** repos differ substantially (direct-to-`develop`, advisory CI, dispatch-only release), covered as a delta rather than a separate model. This is packaged as the `branching-and-release-model` Skill at -`.agents/skills/branching-and-release-model/SKILL.md` in the hub, not a repo-relative link -since that path is hub-local and not carried into every fleet repo. The summary above sketches +`.agents/skills/branching-and-release-model/SKILL.md` in the hub. That path is not a repo-relative link, +since it is hub-local and not carried into every fleet repo. The summary above sketches the contract. Read the skill for the full rules, including branch protection configuration, the dual-target bot wiring, and the operational-repo delta in full. @@ -75,42 +75,48 @@ dual-target bot wiring, and the operational-repo delta in full. The **two-phase model is the default**: PRs build fast, publishing is batched, a human merge never auto-publishes on its own. See [`WORKFLOW.md`](./WORKFLOW.md) for the full CI/CD contract. Publishing fires on a manual dispatch, a code-affecting bot push to `main`, or a `main`-only -weekly schedule (Docker), and versioning is semantic and maintainer-controlled (NBGV owns the build number, +weekly schedule (Docker). A publish overtaken by a release bot's workflow merge re-dispatches itself +per `WORKFLOW.md` D4.7. Versioning is semantic and maintainer-controlled (NBGV owns the build number, the maintainer owns the `major.minor` floor). **Operational** repos differ, with a dispatch-only release and no auto-publish bots. See "Operational Repositories" below. This is packaged as part of the `branching-and-release-model` Skill at -`.agents/skills/branching-and-release-model/SKILL.md` in the hub, not a repo-relative link -since that path is hub-local and not carried into every fleet repo. The summary above sketches -the contract. Read the skill for the full rules, including the release-target build layer, the -no-op republish guarantee, the recovery routes for a package push that fails after the release is +`.agents/skills/branching-and-release-model/SKILL.md` in the hub. That path is not a repo-relative link, +since it is hub-local and not carried into every fleet repo. The summary above sketches +the contract. Read the skill for the full rules, including the release-target build layer and the +no-op republish guarantee. They also cover the recovery routes for a package push that fails after the release is already cut, and wrapper-repo upstream-version tracking. ## Operational Repositories The registry `workflowModel` field is `release` (the default) or `operational`. **Operational** -repos track a live service's running state rather than shipping versioned units of delivery -(live-service config such as Home Assistant, ESPHome, Vantage, and home automation): commits go -directly to `develop`, CI runs on the push as advisory feedback only, a PR still exists for a -change worth reviewing, the `main` promotion gate is unchanged, and release happens only by manual +repos track a live service's running state rather than shipping versioned units of delivery. +They hold live-service config such as Home Assistant, ESPHome, Vantage, and home automation. Commits go +directly to `develop`, and CI runs on the push as advisory feedback only. The maintainer and an agent alike commit in place, in the live checkout the running service reads. A worktree or a pull request into `develop` stays available to an agent and is never required. The host write guard allows that in-place commit only once the repository's `.github/agent-policy.json` on `main` lists `develop` in its `guard` section. The maintainer's merge to `main` is therefore what grants it, and until then the guard denies it. A direct push names +the issues it fixes in one `Closes on promotion:` line in its commit message, such as +`Closes on promotion: #N, #M`. The promotion pull request carrying that push takes a closing +keyword in its body for each issue the line names. Its body takes no closing keyword for a push +without that line. A PR still exists for a +change worth reviewing, and the `main` promotion gate is unchanged. Release happens only by manual dispatch. This is packaged as part of the `branching-and-release-model` Skill at -`.agents/skills/branching-and-release-model/SKILL.md` in the hub, not a repo-relative link -since that path is hub-local and not carried into every fleet repo. The summary above sketches +`.agents/skills/branching-and-release-model/SKILL.md` in the hub. That path is not a repo-relative link, +since it is hub-local and not carried into every fleet repo. The summary above sketches the contract. Read the skill for the full rules, including when a config change still earns a pull request. -Line-ending governance for an operational repo is in [Line Endings](#line-endings), where its `[*]` default follows the consuming app's native platform per the registry `lineEndings` field, not the fleet LF default. +Line-ending governance for an operational repo is in [Line Endings](#line-endings). There its `[*]` default follows the consuming app's native platform per the registry `lineEndings` field, not the fleet LF default. ### Repo-Scoped Secrets -A repo whose own stacks or scripts read local runtime credentials from disk, most commonly an operational repo's Docker Compose stack, keeps them in a dotted `.secrets/` directory at the repo root. This is the repo-scoped counterpart to the host-scoped `~/.secrets/` convention a repo's own `OPERATIONS.md` may document, and it is a different thing from `spec/secrets.json`, the CI/GitHub Actions secret-name registry `spec/audit.py` cross-checks. `spec/secrets.json` governs what a workflow reads from GitHub Actions. This convention governs what a repo's own process reads from its own checkout. +A repo whose own stacks or scripts read local runtime credentials from disk keeps them in a dotted `.secrets/` directory at the repo root. The most common case is an operational repo's Docker Compose stack. This is the repo-scoped counterpart to the host-scoped `~/.secrets/` convention a repo's own `OPERATIONS.md` may document. It is a different thing from `spec/secrets.json`, the CI/GitHub Actions secret-name registry `spec/audit.py` cross-checks. `spec/secrets.json` governs what a workflow reads from GitHub Actions. This convention governs what a repo's own process reads from its own checkout. - **The directory is named `.secrets/`, dotted, never a bare `secrets/`.** +- **A secret file's name is lowercase fields joined by `_`** (`homeassistant_db_password`, `mail_relay.env`), per [`CODESTYLE.md` "Script and Utility File Names"](./CODESTYLE.md#script-and-utility-file-names). - **A single opaque credential file carries no extension** (`homeassistant_db_password`, not `homeassistant_db_password.txt`), the same reason `README` and `LICENSE` carry none. It is a security property, read but never sourced, not a formatting preference. - **A structured credential keeps its format's extension** (`.json` for structured config). -- **The shared env file is named for what it configures**, not a bare `.env` (`docker.env` for a repo whose stacks are Docker Compose), so a second env-shaped file added later stays unambiguous. +- **The shared env file is named for what it configures**, not a bare `.env` (`docker.env` for a repo whose stacks are Docker Compose). A second env-shaped file added later then stays unambiguous. - **Every real secret file has a tracked `.example` beside it**, and only the `.example` files plus a `README.md` catalog are un-ignored: ```gitignore @@ -120,35 +126,38 @@ A repo whose own stacks or scripts read local runtime credentials from disk, mos ``` A fresh checkout then documents its own required shape without ever exposing a real value. This negation keeps a real secret file out of a **new** commit. It does not remove one already tracked: `.gitignore` has no effect on a path git already follows. A real secret file found tracked is removed from the index (`git rm --cached `) and its credential is rotated, not just added to `.gitignore` going forward. -- **`.secrets/README.md` is a catalog**, one row per file naming what it holds and what consumes it, plus a short note on how the directory relates to `~/.secrets/` where the repo also touches that. +- **`.secrets/README.md` is a catalog**, one row per file naming what it holds and what consumes it. It adds a short note on how the directory relates to `~/.secrets/` where the repo also touches that. +- **A repository whose shell scripts read a credential from disk declares the `host-secrets` type, and reads each one through the carried readers.** A credential is a secret file or an env file holding one, in `.secrets/` or in the host-scoped `~/.secrets/`, and this bullet reaches both. A single inline read counts, since it is the read a bare existence check and a sourced file get wrong. A script sources its carried `lib/fleet/sh/secrets.sh`, per "Hub-Hosted Tooling", and checks the file before reading it. A private file takes `require_secret` or `secret_set_verdict`, and one a container reads as a Docker secret takes `secret_verdict` with `shared`. It reads one value from an env file with `read_env_value` rather than sourcing the file into its own shell. ## Hub-Hosted Tooling -The fleet's tooling lives in the hub once and a repository runs it from there rather than holding a copy. A carried script is current only until the next fix to it, and a repository that misses the sweep does not fail loudly, it audits itself with an older gate while reporting the same command in its output. Removing the copy removes the sweep, the stale-copy detection, and the disposition each stale copy earns, all at once. The hub is the repository [`AGENTS.md`](./AGENTS.md) "Fleet Bootstrap" names, and that section is the entry point whenever nothing else present says where it is. +The fleet's tooling lives in the hub once and a repository runs it from there rather than holding a copy. A carried script is current only until the next fix to it. A repository that misses the sweep does not fail loudly. It audits itself with an older gate while reporting the same command in its output. Removing the copy removes the sweep, the stale-copy detection, and the disposition each stale copy earns, all at once. The hub is the repository [`AGENTS.md`](./AGENTS.md) "Fleet Bootstrap" names, and that section is the entry point whenever nothing else present says where it is. -**What a repository carries and what it reaches is decided by what the content is.** It carries the content it is audited against and the configuration that describes it, meaning its rule text and the files the manifest declares. It reaches machinery whose content is identical in every repository, meaning the prose and repository gates, the review digest, and the configuration script, because a file holding no per-repo content is a copy whose only future is to go stale. A tool named in a carried rule is therefore named as the hub's, since the alternative is a pointer to a path the reader does not have, and a pointer that resolves nowhere teaches the reader that a pointer in carried text is decorative. +**What a repository carries and what it reaches is decided by what the content is.** It carries the content it is audited against and the configuration that describes it, meaning its rule text and the files the manifest declares. It reaches machinery whose content is identical in every repository, meaning the prose and repository gates, the review digest, and the configuration script. That is because a file holding no per-repo content is a copy whose only future is to go stale. A tool named in a carried rule is therefore named as the hub's. The reason is that the alternative is a pointer to a path the reader does not have. It is also because a pointer that resolves nowhere teaches the reader that a pointer in carried text is decorative. -**Reaching it is a checkout of the hub rather than a copy of one file.** A tool reads the payloads, tables, and sibling modules beside it, so a single file lifted out of the tree runs against whatever the caller happens to have, which is the copy problem again in a shorter loop. Read `main`, the promoted and gated state, and fetch immediately before running, because a clone is whatever it last fetched rather than the branch it names, and a stale clone answers confidently instead of failing. Name the tool by its path in that checkout and name the target explicitly, since a tool that defaults to the current directory or the current repository resolves somewhere either way, and a result computed against the wrong repository is well-formed. Which directory the command runs in is the tool's own contract rather than a rule here, so a gate reading a working tree runs in the repository under test while a tool taking its target as an argument runs anywhere and is given one. What the rule forbids is letting a default decide which repository the answer is about. +**Code a repository's own runtime executes is the exception, and it is carried.** Such a library runs on a host the project owns, often unattended and sometimes as root. Fetched from the hub at run time, it would let a GitHub outage or a bad `main` commit stop a scheduled host job. It would also run code the repository never reviewed against its own secrets. So offline operation and review in the repository that runs it outweigh the staleness of a copy. A gate differs, since "the tool did not run" is an acceptable answer there and CI is the backstop. The hub hosts the library once. A repository whose declared types select it carries it byte-for-byte, as a tree the hub's `spec/files.json` declares. The hub owns the carried directory whole. The repository extends the library in a file of its own outside it that sources it, never by editing the copy. The staleness is made visible rather than accepted. The repository gate compares each carried tree with the hub tree its pinned action holds. It reports a difference as a warning that never fails the gate. A failure would break every downstream pull request on each hub fix. The audit reports a stale copy, and the resync refreshes it. -**A loader is outside this section rather than exempt from it.** The rule above governs a tool that reads hub content, because a tool reads the payloads, tables, and sibling modules beside it. A loader reads none of them: it obtains a tree and hands control to a tool inside that tree, on a host that cannot yet obtain one. The bound is what it may contain rather than who runs it, and it is one line: a loader references no path inside the tree it fetches except the single entry point it hands control to, and everything else it touches is the machine or the network. A loader that grows a second path into that tree has become a tool and is governed above. +**Reaching it is a checkout of the hub rather than a copy of one file.** A tool reads the payloads, tables, and sibling modules beside it. So a single file lifted out of the tree runs against whatever the caller happens to have. That is the copy problem again in a shorter loop. Read `main`, the promoted and gated state, and fetch immediately before running. That is because a clone is whatever it last fetched rather than the branch it names, and a stale clone answers confidently instead of failing. Name the tool by its path in that checkout and name the target explicitly. That is because a tool that defaults to the current directory or the current repository resolves somewhere either way. It is also because a result computed against the wrong repository is well-formed. Which directory the command runs in is the tool's own contract rather than a rule here. So a gate reading a working tree runs in the repository under test. Likewise, a tool taking its target as an argument runs anywhere and is given one. What the rule forbids is letting a default decide which repository the answer is about. -**A report or finding a hub tool produces names the hub commit it ran from.** The tool moves independently of the repository it measures, so a verdict carrying no hub commit cannot be re-run, and two runs that disagree cannot be attributed to the tree or to the tool. The obligation is the runner's rather than the tool's, since a tool reports on the repository it measures rather than on itself, so the commit is read from the hub checkout and written into the report beside the verdict. This is the same requirement "Verification Discipline" places on any claim that gets acted on. +**A loader is outside this section rather than exempt from it.** The rule above governs a tool that reads hub content, because a tool reads the payloads, tables, and sibling modules beside it. A loader reads none of them. It obtains a tree and hands control to a tool inside that tree, on a host that cannot yet obtain one. The bound is what it may contain rather than who runs it, and it is one line. A loader references no path inside the tree it fetches except the single entry point it hands control to. Everything else it touches is the machine or the network. A loader that grows a second path into that tree has become a tool and is governed above. -**CI reaches the same tooling as a pinned action or reusable workflow.** A runner holds no hub checkout, so a workflow consumes the hub's composite action or reusable workflow and pins it to a commit SHA, per the action-pinning rule under "Workflow YAML Conventions". A standard workflow whose job graph is identical across repos of a type is reached the same way, as a `workflow_call` task the hub hosts once, and the repository carries only the caller stub and a composite-action hook for what is genuinely its own. The pin is what makes a released repository's gate reproducible, since an unpinned consume lets a later hub commit fail a re-run of a change that already passed. Branch-dependent behavior belongs inside the consumed action, because `uses:` takes no expressions and a per-branch ref therefore cannot be selected in the workflow file. +**A report or finding a hub tool produces names the hub commit it ran from.** The tool moves independently of the repository it measures. So a verdict carrying no hub commit cannot be re-run, and two runs that disagree cannot be attributed to the tree or to the tool. The obligation is the runner's rather than the tool's, since a tool reports on the repository it measures rather than on itself. So the commit is read from the hub checkout and written into the report beside the verdict. This is the same requirement "Verification Discipline" places on any claim that gets acted on. -**An unreachable hub means the tool did not run, and that is the result reported.** A carried copy still works offline and a reached one does not, which is the cost this model trades away and the reason to state the failure rather than route around it. A check that cannot run reports itself as not run, never as clean, which is the silent-narrowing failure "Verification Discipline" names. A hand-rolled substitute is not the tool either: a reconstructed gate encodes its author's reading of the rule rather than the rule, agrees with no other repository, and is the duplicated effort this model exists to end, so an agent that cannot reach the hub says so and stops. +**CI reaches the same tooling as a pinned action or reusable workflow.** A runner holds no hub checkout. So a workflow consumes the hub's composite action or reusable workflow and pins it to a commit SHA, per the action-pinning rule under "Workflow YAML Conventions". A standard workflow whose job graph is identical across repos of a type is reached the same way. It is a `workflow_call` task the hub hosts once. The repository carries only the caller stub and a composite-action hook for what is genuinely its own. The pin is what makes a released repository's gate reproducible. That is because an unpinned consume lets a later hub commit fail a re-run of a change that already passed. A doc gate fetched from `main` stays unpinned because a pin nothing keeps current goes stale. Dependabot keeps this `uses:` pin current, so that reason does not reach it. Branch-dependent behavior belongs inside the consumed action, because `uses:` takes no expressions and a per-branch ref therefore cannot be selected in the workflow file. + +**An unreachable hub means the tool did not run, and that is the result reported.** A carried copy still works offline and a reached one does not. That is the cost this model trades away, and the reason to state the failure rather than route around it. A check that cannot run reports itself as not run, never as clean, which is the silent-narrowing failure "Verification Discipline" names. A hand-rolled substitute is not the tool either. A reconstructed gate encodes its author's reading of the rule rather than the rule, and agrees with no other repository. It is the duplicated effort this model exists to end, so an agent that cannot reach the hub says so and stops. ## Pull Request Title and Commit Message Conventions -A PR title and a commit message share one contract: an imperative subject, 72 characters or fewer, no trailing period, no vague titles like `update stuff` or `wip` (Dependabot's `Bump X from Y to Z` is fine as-is), no unsolicited `Co-Authored-By:` lines, and no release-bump magnitude in the title, since Nerdbank.GitVersioning computes the next version from `version.json` and git history. +A PR title and a commit message share one contract. The subject is imperative, 72 characters or fewer, and has no trailing period. Vague titles like `update stuff` or `wip` are out, while Dependabot's `Bump X from Y to Z` is fine as-is. Unsolicited `Co-Authored-By:` lines are out. So is any release-bump magnitude in the title, since Nerdbank.GitVersioning computes the next version from `version.json` and git history. -This is packaged as the `comment-and-doc-style` Skill at `.agents/skills/comment-and-doc-style/SKILL.md` in the hub, not a repo-relative link since that path is hub-local and not carried into every fleet repo. The summary above sketches the contract. Read the skill for the full format, rules, and examples. +This is packaged as the `comment-and-doc-style` Skill at `.agents/skills/comment-and-doc-style/SKILL.md` in the hub. That path is not a repo-relative link, since it is hub-local and not carried into every fleet repo. The summary above sketches the contract. Read the skill for the full format, rules, and examples. ## Documentation Style Conventions -The fleet's prose and formatting contract, applied to docs and code/workflow comments alike. It governs what a carried file may reference, Markdown link, heading, and tense structure, and the comment philosophy. It also holds the ASCII character-set tiers, the line-ending policy, the sentence-structure house style, the ban on naming an issue, a pull request, or a commit in a comment, a docstring, or an instruction document, and the rule keeping a quantitative claim honest. +The fleet's prose and formatting contract, applied to docs and code/workflow comments alike. It governs what a carried file may reference, Markdown link, heading, and tense structure, and the comment philosophy. It also holds the ASCII character-set tiers, the line-ending policy, and the sentence-structure house style. It bans naming an issue, a pull request, or a commit in a comment, a docstring, or an instruction document. And it holds the rules keeping a quantitative claim honest and an incidental count out of the text. -This is packaged as the `comment-and-doc-style` Skill at `.agents/skills/comment-and-doc-style/SKILL.md` in the hub, not a repo-relative link since that path is hub-local and not carried into every fleet repo. The summary above sketches the contract. Read the skill for the full rules. +This is packaged as the `comment-and-doc-style` Skill at `.agents/skills/comment-and-doc-style/SKILL.md` in the hub. That path is not a repo-relative link, since it is hub-local and not carried into every fleet repo. The summary above sketches the contract. Read the skill for the full rules. ### Comments @@ -156,94 +165,124 @@ The full comment philosophy, what earns one, structure, capitalization, growth d ### References -No comment, no docstring, and no instruction document names an issue, a pull request, or a commit, and a commit message and a pull request body each carry theirs as usual. Two carve-outs. In a code or workflow comment, a URL naming an issue or a pull request on a public repository other than this one is a source citation and is permitted. And a record whose subject is the revision itself keeps it, which is what lets a disproved-claims entry in `.github/copilot-instructions.md` name the revision its proof was read against. Whatever neither carve-out affirmatively permits is banned by the sentence above them, which is the whole of the test. The reasons, the surfaces the ban reaches, the narrative files it leaves alone, and what to write instead are in the `comment-and-doc-style` Skill referenced above. Separately, `AGENTS.md`, `GOVERNANCE.md`, `CODESTYLE.md`, and `WORKFLOW.md` carry no three-part version and no commit SHA, full or abbreviated, whether it is a pin's value, an illustrative example, a minimum version, or a fixed constant. A pin lives in the workflow or manifest that uses it, where Dependabot moves it, so a copy in prose is stale at the next bump, and every other kind reads exactly like one, which is why a check cannot tell them apart. A two-part language or runtime version, such as a minimum Python minor, is how such a rule is stated and stays. Each file names the mechanism instead, such as a reference SHA-pinned to a hub release, writes an example with a placeholder, such as `1.0.N`, names the manifest or skill that holds a minimum version rather than the number, and describes a fixed constant, such as the all-zero placeholder version, rather than quoting it. Neither carve-out above lifts this ban. +No comment, no docstring, and no instruction document names an issue, a pull request, or a commit. A commit message and a pull request body each carry theirs as usual. The carve-outs follow. A URL naming an issue or a pull request on a public repository other than this one is a source citation. In a code or workflow comment, such a citation is permitted. And a record whose subject is the revision itself keeps it. That is what lets a disproved-claims entry in `.github/copilot-instructions.md` name the revision its proof was read against. Whatever no carve-out affirmatively permits is banned by the section's opening sentence, which is the whole of the test. The reasons, the surfaces the ban reaches, the narrative files it leaves alone, and what to write instead are in the `comment-and-doc-style` Skill referenced above. Separately, `AGENTS.md`, `GOVERNANCE.md`, `CODESTYLE.md`, and `WORKFLOW.md` carry no three-part version and no commit SHA, full or abbreviated. That holds whether it is a pin's value, an illustrative example, a minimum version, or a fixed constant. A pin lives in the workflow or manifest that uses it, where Dependabot moves it. So a copy in prose is stale at the next bump. Every other kind reads exactly like one, which is why a check cannot tell them apart. A two-part language or runtime version, such as a minimum Python minor, is how such a rule is stated and stays. Each file names the mechanism instead, such as a reference SHA-pinned to a hub release. It writes an example with a placeholder, such as `1.0.N`. It names the manifest or skill that holds a minimum version rather than the number. And it describes a fixed constant, such as the all-zero placeholder version, rather than quoting it. No carve-out above lifts this ban. ### Character Set -The full ASCII tier system (never legitimate, legitimate next to a number, always legitimate, developer-typed Unicode) and the semicolon and spaced-hyphen rules are in the `comment-and-doc-style` Skill referenced above. +The full character-set tier system is in the `comment-and-doc-style` Skill referenced above. That tier system covers what is never legitimate, legitimate next to a number, or always legitimate. It also covers a fixed set of emoji callout markers and developer-typed Unicode. The Skill also holds the rule that the letters of a recorded name keep their actual spelling, and the semicolon and spaced-hyphen rules. ### Line Endings -The full CRLF/LF policy (`.editorconfig` and `.gitattributes` defaults and pins, choosing an ending for a new file type, operational-repo overrides, editing discipline, and auditing) is in the `comment-and-doc-style` Skill referenced above. +The full CRLF/LF policy is in the `comment-and-doc-style` Skill referenced above. It covers `.editorconfig` and `.gitattributes` defaults and pins, choosing an ending for a new file type, operational-repo overrides, editing discipline, and auditing. ### Sentence Structure -ASD-STE100's structural half is the adopted house style: short sentences, one instruction per sentence, active voice, and imperative mood for procedure steps. Its controlled dictionary is deliberately not adopted. The full rules, the sentence word cap, and the opt-in `sentence-length` check that enforces the cap are in the `comment-and-doc-style` Skill referenced above. +ASD-STE100's structural half is the adopted house style: short sentences, one instruction per sentence, active voice, and imperative mood for procedure steps. Its controlled dictionary is deliberately not adopted. The full rules, the sentence word cap, and the default `sentence-length` check that enforces the cap are in the `comment-and-doc-style` Skill referenced above. + +### Incidental Counts + +Text refers to an enumeration rather than stating its size, so it writes "the following checks", "each case", or "the rows below". This holds in every fleet repository, for docs, code and workflow comments, and docstrings alike. A stated count is a second copy of what the list or table already shows, and nothing keeps the two copies in sync. Every later edit to the enumeration then owes an edit to the sentence, and a missed one surfaces as a review finding. A word such as "both" or "all three" is a count too, where the list it counts can grow. A count is written only where the number is what the reader needs, such as a limit, a threshold, or a measured result. Such a count names its source, such as the constant or the measurement, so a reader can re-derive it. A wrong incidental count is fixed by removing it rather than by correcting it, since a corrected number goes stale the same way. ## Verification Discipline The checks that separate work actually done from work that merely reports success. A pattern that matches less still exits zero, and a gate that stops gating still reports success. -- **Locate every check a change owes before running any of them, and CI's coverage is not that list.** The checks are read from what the repository declares, meaning its `OPERATIONS.md` "Local Verification" section alongside the workflows, rather than inferred from whatever the pipeline happens to run. Part of a repository's contract is routinely unreachable from a runner, a redirect no build serves, a deploy no pull request performs, hardware no runner holds, so the check covering that part lives in a document rather than in a workflow and is run by hand before the pull request opens. Green is then the precise signal that it was skipped, because the pipeline reports success over the half it reaches while saying nothing about the half it cannot. Reading a document's own description of itself is not how such a check is found, since a topical document is named for its most visible function, usually a post-merge one, and an accurate description of that function routes a pre-merge task away from the file holding the gate. The destination is declared fleet-wide for that reason, rather than left to how well each repository worded a pointer to it. A repository whose `OPERATIONS.md` carries no such heading, or carries no such file, is missing content it owes: read that file whole where it exists and the workflows beside it either way, and report what is absent rather than reading its absence as an answer that no local check applies. -- **A test runner failing to spawn is not evidence that no test coverage applies here.** `uv run pytest` failing to spawn in a lint-only Python Scripts profile is that profile working as intended, not a missing dependency, per the `python-codestyle` Skill's Two Profiles. Read the actual invocation from the same `OPERATIONS.md` "Local Verification" section the bullet above names, rather than guessing a generic test-runner command, and report that document's own command result, not the guessed command's failure. -- **A test must assert the mechanism it names, and a gate has to be watched failing.** Label each case by the behavior it proves, then write the case that reintroduces the fault and confirm the gate objects to it. A case that passes for an incidental reason, the right answer reached by the wrong path, is worse than no case, because it is later cited as evidence. A proof that restates the gated data instead of reading it proves only that the function works, so drive the real table or the real config. And a gate that finds nothing is indistinguishable from a gate with nothing to find, so assert a floor on what a healthy run covers. +- **Locate every check a change owes before running any of them, and CI's coverage is not that list.** The checks are read from what the repository declares, rather than inferred from whatever the pipeline happens to run. What it declares means its `OPERATIONS.md` "Local Verification" section alongside the workflows. Part of a repository's contract is routinely unreachable from a runner. Examples are a redirect no build serves, a deploy no pull request performs, and hardware no runner holds. So the check covering that part lives in a document rather than in a workflow. It is run by hand before the pull request opens. Green is then the precise signal that it was skipped. That is because the pipeline reports success over the half it reaches while saying nothing about the half it cannot. Reading a document's own description of itself is not how such a check is found. That is because a topical document is named for its most visible function, usually a post-merge one. It is also because an accurate description of that function routes a pre-merge task away from the file holding the gate. The destination is declared fleet-wide for that reason, rather than left to how well each repository worded a pointer to it. A repository whose `OPERATIONS.md` carries no such heading, or carries no such file, is missing content it owes. In that case, read that file whole where it exists, and the workflows beside it either way. Report what is absent rather than reading its absence as an answer that no local check applies. +- **A test runner failing to spawn is not evidence that no test coverage applies here.** `uv run pytest` failing to spawn in a lint-only Python Scripts profile is that profile working as intended, not a missing dependency. The `python-codestyle` Skill's Two Profiles section states this. Read the actual invocation from the same `OPERATIONS.md` "Local Verification" section the bullet above names, rather than guessing a generic test-runner command. Report that document's own command result, not the guessed command's failure. +- **A test must assert the mechanism it names, and a gate has to be watched failing.** Label each case by the behavior it proves, then write the case that reintroduces the fault and confirm the gate objects to it. A case that passes for an incidental reason, the right answer reached by the wrong path, is worse than no case. That is because it is later cited as evidence. A proof that restates the gated data instead of reading it proves only that the function works. So drive the real table or the real config. And a gate that finds nothing is indistinguishable from a gate with nothing to find, so assert a floor on what a healthy run covers. - **Gates, filters, and gate-like watchers fail loud, never narrow quietly.** A pattern that silently matches less, an allowlist that silently stops matching, or a gate that silently stops gating all report success while doing nothing. When a construct exists to notice something, make the not-noticing case produce an error or an annotation. An identity allowlist used as a gate, for one, must raise an error when its list stops matching, not silently pass everything through. -- **Config with a uniqueness rule is validated on read, and its consumers assert what it promised.** A repeated key in a lookup table is not a precedence question to settle quietly, it is two answers to one question, and keeping whichever came last picks one of them where the reader sees no choice being made. Fail on the duplicate at the point the config is read, so the code downstream can rely on the invariant instead of re-deriving it. -- **Validate and read on the same normalized key.** A guard that compares stripped names while the join looks up the raw one passes a padded key and then matches nothing, so the exact fault the guard exists to stop is sitting inside the guard. Normalize once at the boundary and use that one value for both the check and the lookup. -- **Every push toward a pull request is preceded by a local adversarial review of the branch's whole diff, and the pass is recorded.** The rule binds every push rather than the first one, so a fix push answering a reviewer's finding owes a pass exactly as the branch's first push did, and that is the round it is actually skipped on: the fix looks small, the branch was reviewed once already, and what goes up is content no review has read. Skipping it does not save the round, it moves it, into the fix-commit and review-comment cycle that spends wall-clock, Actions runtime, and agent tokens finding what a local pass would have. The pass itself, its delegation shape, and its model tier are the `local-strict-review` Skill's, and `scripts/local_review.py` records it keyed on the content the reviewer actually saw, so a capture point can ask whether a receipt still covers what is about to be pushed rather than trusting the rule to have been remembered. The pass is mandatory and its findings are advisory, which are opposite claims worth keeping apart: a pass is recorded whether it raised ten findings or none, and disposing of each one is judgment, per `GOVERNANCE.md` "PR Review Etiquette". -- **Canonical content one repo authors and others carry is read the way a carrier reads it, whole, in the repo that can fix it, and that read is swept periodically rather than owed by a push.** Such content is written and merged against a diff of a few lines, and reaches a reviewer as a new file, in full, only when a repo carries it for the first time, so the first real read of a rule happens where nothing can be done about the result: the tree is manifest-owned, the copy is compared against the authoring repo's, byte for byte wherever the declared fidelity is verbatim, and a local edit there is drift on the next fidelity check. Where the fidelity is intent the carrier may adapt its own copy, and the defect still has to be fixed at the source, since every other carrier holds it too. Every carrier after that re-discovers the same defect, and the finding arrives in a session holding no checkout of the authoring repo and no standing to test the claim. The unit is what a reviewer reads whole, and the carry manifest, `spec/files.json` in the hub, rather than the document decides which, down to which files carry units at all, so the engine that reads that manifest is the authority on the set rather than any restatement of its rules. In the ordinary case a unit is one level-two section of a carried Markdown canonical, which is the fidelity unit `spec/section-model.md` declares. The read is of the unit's whole current text rather than of the diff that moved it, and the pass itself, its delegation shape, and its model tier are the `local-strict-review` Skill's, exactly as they are for the pass above. **The read is swept because owing it at every push cost too much to keep owing it there.** Measured across this fleet's review rounds, the passes a push owed were a large share of what a pull request spent, and what they returned was never measured against that, so the read moves to a schedule on the cost alone rather than being owed by whichever change happens to touch a unit. A change that moves a unit is no longer refused over one, and no capture point asks a change for a pass of this kind, the diff pass the bullet above requires being owed by every push exactly as before. What replaces it is a schedule in the authoring repo, which gathers the work into one piece and files it where an agent session can run the passes and fix what they find. That work is every unit whose text has moved past the pass that read it, plus a bounded slice of the units nothing has read there at all, taken newest-committed first. The slice is what keeps a newly authored unit from waiting on a volunteer, since such a unit has no earlier pass to move past and would otherwise reach a carrier with nothing having asked to read it, which is the case this whole rule is about. A unit newly carried by widening the manifest alone is not reached that way, the order reading the unit's own file rather than the manifest, so it joins the backlog at that file's age, where a section written and declared in one commit leads like any other newly authored one. Bounding it is what keeps a long backlog from arriving as one week's work. `scripts/canonical_review.py` records each pass keyed on the content the reviewer saw and names both sets, so the sweep's list is read off that record rather than remembered, and its ledger is tracked content the change carrying it commits like any other. Like the pass above, a pass the sweep asks for is mandatory and its findings are advisory. -- **Another round of edits after either pass is owed only while a defect this change introduced is open, never by a finding count.** Which findings count as introduced, what each class owes, and how many rounds a push may spend are the `local-strict-review` Skill's. -- **Run the repo's whole lint gate before every push, not the parts that look relevant.** CI runs all of them, so a partial local run only defers the failure, and the tool most likely to catch a given change is often the one it seems least about (an edit that manipulates line endings is exactly when `editorconfig-checker` matters). The repo documents each linter's known-working invocation, and this rule is that **all** of them run. -- **Editing CRLF files programmatically: `.` matches `\r` in a regex**, so a captured line keeps its carriage return and rejoining with `\r\n` yields `CRCRLF`. Prefer literal replacement over regex reassembly. In Python the *default* path is a text-mode rewrite, which has the mirror failure: `Path.read_text()` decodes through universal newlines and `write_text()` translates each `\n` back to `os.linesep`, so a read-edit-write round trip rewrites every line ending in the file to the host's own while the edit itself looks correct. Work in bytes, or open the file explicitly with `newline=''` on both the read and the write, since a read that preserves the endings still hands them to a write that translates them. Use `open()` rather than `Path.read_text()`, which accepts that argument only on Python 3.13 and newer and raises `TypeError` below it. The corruption is worth naming because it is invisible in a rendered diff. -- **Scope a check by what the project declares, not by the file that prompted it.** A check written while editing one file tends to cover that file's language and stop, and then reports success on every other surface the rule governs. Read the declared types, or the config that enumerates them, and cover each one, then assert a floor per surface so a table that narrows fails loudly instead of passing quietly. A rule about comments means every comment syntax the project ships, and a format that carries comments in practice counts even where its specification says otherwise. -- **Never write source text carrying backslash escapes through a shell construct that interprets them.** A `printf` format string, a `printf` argument consumed by `%b`, `echo -e`, POSIX `sh`'s builtin `echo`, and `$'...'` each consume the escape and write an invisible control character in its place, so a `\b` inside a regex becomes a backspace and the pattern silently matches nothing while every test still passes. A quoted heredoc, `<<"EOF"`, is not one of those constructs and writes every backslash literally. An unquoted `<///` returns an indistinguishable 404 whether the repository is private, the ref does not exist, or the path is wrong, so an agent that treats that response as "the content does not exist" has made the same unstated-branch mistake the bullet above names, only over visibility instead of branch. Where a repository's visibility is not confirmed public, read its content through the contents API with the raw media type instead, which hands back the bytes themselves and leaves no decode step to fail quietly: `gh api -H "Accept: application/vnd.github.raw" "repos///contents/?ref="`. Take the base64 `.content` field only where something needs the JSON around it, and then read `.encoding` alongside it, because a blob over 1 MB comes back with `content` empty and `encoding` set to `none`: the call succeeds, `base64 -d` decodes the empty string successfully, and the result is the failed-fetch-read-as-an-empty-success this bullet exists to prevent. Either form is its own command whose exit status is read before its output is used, never a producer piped straight into a consumer that reports only its own status. `gh api` writes a failed call's error body to standard output, so an unchecked capture or redirect stores that error where the content was supposed to go, and merging the error stream in with `2>&1` puts it inside the payload rather than beside it. Verify the ref resolves (a commit SHA is unambiguous where a branch name may have moved, been deleted, or never existed on the remote) before reading either failure as an answer about the content itself. -- **A launched process is not a result, and a cause nobody observed is not a diagnosis.** "The watcher is armed" names a process rather than a finding, so what gets reported is the output that process produced, and where it produced none, that absence is the report. The failure it prevents is an agent standing still on a condition that was met half an hour earlier, having announced the wait and never read it. Naming an external cause for such a stall afterwards, a throttle or a quota that appears nowhere in the record, turns a local defect into a story about someone else and closes the investigation on the wrong party, so read the record for the cause before naming one, and where the record does not carry it, report the cause as unknown. -- **A workflow change is only fully exercised by CI.** Extracting a `run:` block and executing it locally validates the script and nothing else, because `secrets: inherit`, `permissions:`, `needs:` wiring, and reusable-workflow inputs resolve only in a real run. -- **Platform-specific code is "verified" only on the platform it runs on.** PowerShell on Windows, a macOS-only `mktemp`/`ssh-agent` behavior, a WSL-specific path quirk: an agent reasoning about such code from a different host, however carefully, has not executed it, and reasoning by structural analogy to an already-tested equivalent on another platform ("the POSIX version works, so the PowerShell version should too") is a plausible first pass, not verification. State it as exactly that, an unverified structural match, and never in the same words used for a tested fact. When no agent in the loop has access to the target platform, say so, and either defer the platform-specific portion to a human or an agent that has that access, or ship it clearly labeled unverified. -- **A review flags an instance, so a fix covers the class, bounded to what this change touched or broke.** When a reviewer cites one stale claim, one silent-narrowing pattern, or one mis-worded contract, and the finding is being fixed, sweep for its siblings before replying, since reviewers sample rather than enumerate, and fix each sibling that sits in a file the diff already touches. A sibling the change itself put in disagreement is this change's to fix wherever it sits, because the change made it wrong. A sibling that was wrong before the change and sits in a file the diff does not touch is filed rather than folded in, because every file the diff grows into is one more that each round reads again, so a sweep that widens the diff widens the loop it was meant to close. +- **A local clone is not the branch it names, it is whatever that clone last fetched.** Reading a checkout on disk answers what that clone last saw, so a finding taken from one carries a date nobody stated. Failures of exactly that shape are on record from one session. A repository was reported as still drifted on a file whose fix had already merged. Another was reported as missing a file it carries, because the checkout sat on an older branch. Read the live ref through the API where the claim will be acted on, or fetch immediately before reading. Name the ref and the commit in any finding a local read produced. A clone stays the right tool for anything needing history or a build, which an API read cannot give. +- **A checkout already sitting on disk is not yours to trust for being there.** A clone or worktree this session did not create, found while looking around a machine, may belong to another concurrent session's task. It may sit on a stale fetch or a branch nobody expects, or hold uncommitted edits nobody has reviewed. None of that is visible from the directory listing that found it. Running `git status`, `git remote -v`, or `git branch --show-current` against it, or reading a file inside it, answers for whatever that checkout happens to hold at that moment. It does not answer for the repository. And the found checkout is not the "local clone" the bullet above means. That is because this session never fetched it and has no basis for trusting what it last saw. Clone the repository fresh into a location this session controls, or read the live state through the GitHub API. Do not adopt a pre-existing checkout as ground truth. +- **A "does not exist" claim names the branch it was checked against.** A worktree or checkout answers for whichever ref it was built from, and that ref is not necessarily the one the content lives on. A `release`-model repo carries in-flight content on `develop`, per `GOVERNANCE.md` "Branching Model", well before it reaches `main`. So a worktree defaulted to the fleet's default branch can hold nothing while the repository holds everything. Before reporting a file, a directory, or a piece of content as absent anywhere in a repo, check it against the designated branch. That is the branch the repo's own model designates as current for that kind of content. Checking only whichever branch a worktree or checkout happened to default to is not enough. Name the branch the negative claim was checked against in the finding itself. +- **A raw-file fetch 404s the same way for a private repository as for a genuinely missing file.** `curl`ing `raw.githubusercontent.com////` returns an indistinguishable 404 whether the repository is private, the ref does not exist, or the path is wrong. So an agent that treats that response as "the content does not exist" has made the same unstated-branch mistake the bullet above names. The mistake is only over visibility instead of branch. Where a repository's visibility is not confirmed public, read its content through the contents API with the raw media type instead. That hands back the bytes themselves and leaves no decode step to fail quietly: `gh api -H "Accept: application/vnd.github.raw" "repos///contents/?ref="`. Take the base64 `.content` field only where something needs the JSON around it, and then read `.encoding` alongside it. That is because a blob over 1 MB comes back with `content` empty and `encoding` set to `none`. The call succeeds, and `base64 -d` decodes the empty string successfully. The result is the failed-fetch-read-as-an-empty-success this bullet exists to prevent. Either form is its own command whose exit status is read before its output is used. It is never a producer piped straight into a consumer that reports only its own status. `gh api` writes a failed call's error body to standard output. So an unchecked capture or redirect stores that error where the content was supposed to go. Merging the error stream in with `2>&1` puts it inside the payload rather than beside it. Verify the ref resolves before reading either failure as an answer about the content itself. A commit SHA is unambiguous where a branch name may have moved, been deleted, or never existed on the remote. +- **A launched process is not a result, and a cause nobody observed is not a diagnosis.** "The watcher is armed" names a process rather than a finding. So what gets reported is the output that process produced. Where it produced none, that absence is the report. The failure it prevents is an agent standing still on a condition that was met half an hour earlier. The agent announced the wait and never read it. Naming an unrecorded external cause for such a stall afterwards, a throttle or a quota, turns a local defect into a story about someone else. Doing so also closes the investigation on the wrong party. So read the record for the cause before naming one. Where the record does not carry it, report the cause as unknown. +- **A workflow change is only fully exercised by CI.** Extracting a `run:` block and executing it locally validates the script and nothing else. That is because `secrets: inherit`, `permissions:`, `needs:` wiring, and reusable-workflow inputs resolve only in a real run. +- **Platform-specific code is "verified" only on the platform it runs on.** Examples are PowerShell on Windows, a macOS-only `mktemp`/`ssh-agent` behavior, and a WSL-specific path quirk. An agent reasoning about such code from a different host, however carefully, has not executed it. Reasoning by structural analogy to an already-tested equivalent on another platform ("the POSIX version works, so the PowerShell version should too") is a plausible first pass, not verification. State it as exactly that, an unverified structural match, and never in the same words used for a tested fact. When no agent in the loop has access to the target platform, say so. Then either defer the platform-specific portion to a human or an agent that has that access, or ship it clearly labeled unverified. +- **A review flags an instance, so a fix covers the class, bounded to what this change touched or broke.** Where a reviewer's finding cites one stale claim, one silent-narrowing pattern, or one mis-worded contract, and is being fixed, sweep for its siblings before replying. That is because reviewers sample rather than enumerate. Fix each sibling that sits in a file the diff already touches. A sibling the change itself put in disagreement is this change's to fix wherever it sits, because the change made it wrong. The doc, manifest entry, or rule sentence in this repository describing behavior the change altered is such a sibling. So it is fixed in this change rather than filed. A sibling that was wrong before the change and sits in a file the diff does not touch stays out of the diff. That is because every file the diff grows into is one more that each round reads again. So a sweep that widens the diff widens the loop it was meant to close. Such a sibling takes the outcome a `pre-existing` finding takes under the `pr-review-conduct` Skill's "Every finding ends in a listed outcome" item 4. + +`GOVERNANCE.md` "Verification Discipline" keeps the full rules. The `agent-conduct` Skill at `.agents/skills/agent-conduct/SKILL.md` in the hub carries it whole as a generated include and surfaces it at its decision moment. That path is not a repo-relative link, since it is hub-local and not carried into every fleet repo. + +## Design Before Code + +A change is cheapest to shape before it is written. Hardening nothing calls for, and a near-duplicate of an existing helper, each cost a review round to find and remove once the code exists. So the design questions are answered before the first edit. + +- **Model the threat before the first edit.** Name the change's inputs, the class of each under "Trust Boundaries and Hardening Effort", and the deployment the code runs in. That model sets the hardening budget, so hardening it does not call for is never written, rather than written and then declined in review. +- **Reuse before building.** Search this repository and the hub for a primitive that already does the job. Start from the list of consolidated primitives this repository's `OPERATIONS.md` keeps under `Configuration Layout` once it has consolidated any. Extend one that exists rather than adding a sibling that does almost the same thing. A near-duplicate splits every later fix between two copies. +- **A defect class seen a second time gets one primitive, and every site moves onto it.** A check written at each site repeats its own platform and edge-case defects at every site. A read that can block on a named pipe, for one, meets a new platform difference at each site that guards it alone. One reader that opens without blocking and judges the open descriptor closes that class everywhere. Sites in files a change does not touch move in a change of their own, filed as one issue naming each. +- **Complexity is a cost that needs a reason.** Code that answers no requirement and no realistic threat is removed rather than maintained, since each later review and each later change reads it again. + +`GOVERNANCE.md` "Design Before Code" keeps the full rules. The `fleet-code-review` Skill at `.agents/skills/fleet-code-review/SKILL.md` in the hub carries it whole as a generated include and surfaces it wherever a change is reviewed. That path is not a repo-relative link, since it is hub-local and not carried into every fleet repo. + +## Trust Boundaries and Hardening Effort + +Hardening is spent where a failure can actually occur. A defense against a case that cannot happen still costs a review round, a test, and the upkeep of both. So the effort an input gets is set by where it comes from and what its failure would do. + +- **Every input is trusted or untrusted, and the class sets the effort.** Trusted input is the maintainer's and what the maintainer controls. That covers files, images, and data handed to a session, settings and secrets files the maintainer writes, and command-line arguments the maintainer types. It covers this repository's content and the hub's, and the maintainer's own hosts with the output read back from them. It covers state files the repository's own scripts write. Untrusted input arrives from outside. That covers a request from an internet client and every field it controls, and upstream release metadata and downloads. It covers a third-party API response, content from an outside contributor, and a bot-authored pull request. +- **The class follows where data originates, not the channel carrying it.** Any channel holding content an outsider supplied is untrusted, whatever the list above names. That covers an argument or a checked-out tree holding a pull request's content. It covers a host's log line recording an outside request, and a state file caching a third-party response. The same channel carrying the maintainer's input stays trusted. +- **Against trusted input the only threat is a maintainer's mistake.** Guard a realistic mistake, such as a typo, CRLF line endings, a missing file, or a wrong unit. Guard it only where it would fail silently or destructively, with one check at the point of read that says what is wrong. Do not build a parser for adversarial shapes or test injection through a trusted file. Do not sanitize trusted text read back from the maintainer's own hosts against injection, or file an issue for an attack on trusted input. What agent-authored text may quote from those hosts is still "Representative Data in Agent-Authored Text"'s to decide. +- **Untrusted input is validated once, at the boundary where it enters**, in proportion to what it can reach from there. +- **Effort follows likelihood times consequence, for robustness as much as for trust.** Code that fails on its ordinary path in its deployment is a bug rather than a hardening case, and is fixed. A hardening case earns a fix or an issue only where it can realistically occur in the code's actual deployment. Its failure must also be silent or destructive, or be one an outsider can trigger. Every other case is declined with that reason, neither fixed nor filed. +- **A review finding asking for hardening the bullets above rule out is itself declined**, with no fix and no issue. The decline cites this section and the input or deployment that rules the case out. This binds the reviewer as much as the author, so a reviewer does not raise such a finding. A review brief asks after races, coercions, and platform differences only where the code's deployment can produce one. +- **A repository names its own boundaries where the classes above leave them unclear.** Its `OPERATIONS.md` lists which concrete inputs are trusted and which are not, under `Configuration Layout`. A session or a reviewer then applies this section without re-deriving it. A repository naming none uses the classes above. -`GOVERNANCE.md` "Verification Discipline" keeps the full rules, and the `agent-conduct` Skill at `.agents/skills/agent-conduct/SKILL.md` in the hub, not a repo-relative link since that path is hub-local and not carried into every fleet repo, carries it whole as a generated include and surfaces it at its decision moment. +`GOVERNANCE.md` "Trust Boundaries and Hardening Effort" keeps the full rules. The `fleet-code-review` Skill at `.agents/skills/fleet-code-review/SKILL.md` in the hub carries it whole as a generated include and surfaces it wherever a change is reviewed. That path is not a repo-relative link, since it is hub-local and not carried into every fleet repo. ## PR Review Etiquette -The provider-agnostic review-loop contract every fleet repo follows starts when a pull request opens. Open every fleet-owned pull request ready for review. Draft state is reserved for the separately documented upstream contribution workflow while a third-party contribution is still being prepared. Creating the pull request is not a terminal handoff. Run the review status once in the foreground. Then start the bounded review wait in a background process. Request a review on every push. Confirm it covers the current head SHA and the full diff rather than only part of it. Where the round covering the head states no coverage at all, read the newest round that does state some as covering this head only where the pull request changes the same set of files at both commits, a head round's own statement always winning over a carried one. Triage every finding, including low-confidence findings collapsed into the review body rather than threads. Reply to and resolve every addressed finding. Repeat after every fix until the checks are green and the current-head review leaves no finding open. Only an explicit maintainer instruction may stop, defer, or alter this default. Silence or a request that says only "open a PR" is not such an instruction. Never merge on a green or CLEAN merge state alone. That state does not prove the review covered the current head SHA and full diff. It also does not expose unanswered low-confidence findings that opened no thread. +The provider-agnostic review-loop contract every fleet repo follows starts when a pull request opens. Open every fleet-owned pull request ready for review. Draft state is reserved for the separately documented upstream contribution workflow while a third-party contribution is still being prepared. Creating the pull request is not a terminal handoff. Run the review status once in the foreground. Then start the bounded review wait in a background process. Which bots review which pull requests, when, and how often is the repository's review policy. That policy is the `review` section of `.github/agent-policy.json`, or the hub's `spec/agent-policy.default.json` where the repository states none. The review wait reads that policy from the pull request's base branch. It sends each request the policy asks it to send. A bot the policy marks automatic is left to start on its own, except a gated Copilot, whose missed start it fills. So the loop below runs the same whether a repository's reviews arrive automatically or on request, and nothing here names which. Under the hub default, a Copilot review is requested at one defined moment rather than on every push. That moment is the head of a pull request into the default branch, a promotion among them. The review wait also requests one on any other pull request where a partial round or a truncated review history is on record. On a pull request into any other branch, every head is covered instead by the recorded local pass the push already owes. That pass is published to the pull request so the review status can read it. The `pr-review-conduct` Skill's Merge Gate states when that published pass counts as coverage. Confirm that the current head SHA is covered by a review or, on such a pull request, by that published pass. Also confirm that a review covers the full diff rather than only part of it. Where no round covering the head states coverage, a round covering it whose own file table names exactly the changed files covers it. That is the reading a Copilot round at Balanced review effort gives. Where no round covering the head carries its own table, the newest round that does stands in where its table names exactly the changed files. Either table may leave out an added, modified, or deleted file with no changed line, such as a binary. That holds while some changed file has one. A table stands in only where no Copilot round on the pull request, on any commit, states partial coverage. So a pull request that ever had a partial round goes to the maintainer. A develop -> main promotion whose head round states no coverage has one further way to be covered, which the `pr-review-conduct` Skill's Merge Gate states. Triage every finding, including low-confidence findings collapsed into the review body rather than threads. Reply to and resolve every addressed finding. Repeat after every fix until the checks are green, the current head is covered, and no finding is left open. Only an explicit maintainer instruction may stop, defer, or alter this default. Silence or a request that says only "open a PR" is not such an instruction. Never merge on a green or CLEAN merge state alone. That state does not prove the review covered the current head SHA and full diff. It also does not expose unanswered low-confidence findings that opened no thread. -This is packaged as the `pr-review-conduct` Skill at `.agents/skills/pr-review-conduct/SKILL.md` in the hub, not a repo-relative link since that path is hub-local and not carried into every fleet repo. The summary above sketches the contract. Read the skill for the merge gate, the expected loop, and how a finding is closed. +This is packaged as the `pr-review-conduct` Skill at `.agents/skills/pr-review-conduct/SKILL.md` in the hub. That path is not a repo-relative link, since it is hub-local and not carried into every fleet repo. The summary above sketches the contract. Read the skill for the merge gate, the expected loop, and how a finding is closed. -The provider-specific mechanics this contract needs to actually drive GitHub Copilot, how to request a review, poll for it, read and answer the suppressed findings, verify coverage, and reply to or resolve a thread without a hand-typed id, are implemented by the hub's `scripts/pr_review.py`, per "Hub-Hosted Tooling" above. The runbook a repository carries for the same loop is [`.github/copilot-instructions.md`](./.github/copilot-instructions.md) "GitHub Copilot Review Runbook". +The hub's `scripts/pr_review.py` implements the provider-specific mechanics this contract needs to actually drive GitHub Copilot, per "Hub-Hosted Tooling" above. They cover requesting a review, polling for it, and reading and answering the suppressed findings. They also cover verifying coverage, and replying to or resolving a thread without a hand-typed id. The runbook a repository carries for the same loop is [`.github/copilot-instructions.md`](./.github/copilot-instructions.md) "GitHub Copilot Review Runbook". ## Communicating with the User -- **Reference every pull request as a clickable link.** When you mention a PR on a surface that renders Markdown (chat, a summary, a report), render it as a Markdown link to the PR (`[#N](https://github.com/OWNER/REPO/pull/N)`), never a bare `#N`. The same applies to issues and commits. **The form follows the surface.** Some surfaces link neither a Markdown link nor a bare URL, an interactive prompt's question and option text among them, and pasting a full URL into one of those does not rescue it, since the reader gets a string to copy, which is the outcome this rule exists to prevent. There the reference is a bare `#N`, and the clickable link goes in the message that comes **before** the prompt rather than merely alongside it, because the prompt blocks on an answer and a message emitted after it is read once that answer is already given, which is the one moment the link is no longer any use. The test is whether the reader can click it where it is read, not whether it was written in the syntax that works elsewhere. -- **Ask every question through the interface's prompt, never in prose.** When you need the user to decide, answer, or approve anything, put it to them through the interface's own prompt mechanism, whether or not work is blocked on it, and an offer to do more work ("want me to file that?") is a question like any other. A question written into a message is lost in the report around it however short it is, and one closing a long report sits where the reader is least likely to reach it. Where no prompt mechanism is available, the questions go as a numbered list opening the message rather than closing it, so the user can reply per number. This bullet settles how a question is put once it is asked, and whether a question is asked now or recorded for later is the parking bullet's to settle, the one opening "A question filed as an issue is parked rather than asked". -- **Raise work blocked on the user as a direct interactive prompt.** When progress needs a decision, an authorization, or an answer only the user can give, ask for it through the interface's own prompt mechanism, at the point the work stops. Never leave it as prose in a summary: a handoff buried in a paragraph is a handoff that did not happen, because a summary reads as a report of finished work and the one line still waiting on the user is the easiest in it to skim past. The blocked item is the message, not a closing remark on a message about something else. **The options offered are the actions themselves**, and the one that unblocks the work names the action it authorizes ("squash and merge it"), so selecting it is the go-ahead rather than a note to act on later. Offering only ways to wait is the same failure in interactive clothing, since a prompt whose every choice is inaction reports the block rather than clearing it, and where the agent may not perform the authorized action itself, the option says who does it. Where no interactive prompt is available, the numbered list the bullet above names is the fallback. -- **Lead every choice with a recommendation and its reason.** Wherever the user is asked to choose, in a prompt or in a numbered list, the option the agent recommends comes first and is marked as the recommendation, and every option states the reason for it. A user who takes the recommendation then reads one line, and one who does not sees what the other options trade away. A question with no defensible recommendation says so rather than inventing one. Where an open question has no options and the agent does have an answer, that answer and its reason go in the question's text, never as an invented option. -- **A question filed as an issue is parked rather than asked, and it stays owed.** Where the work cannot continue without the answer, the interactive-prompt bullet governs and the question is asked at the point the work stops. Wherever the question is recorded rather than asked, whether because the work can continue without the answer or because the question was asked once and deferred, recording it is the right thing to do and recording it is still not asking it, so the issue carries the fleet's `decision` label and, when the filing session knows the choices the question is between, states them, which is what lets a later session, one that was not there when the issue was filed, find the question and put it to the user without inventing its answers. **The parked queue is the failure, not the parking.** An issue holding a question the user has never seen reads to every later session as tracked work rather than as a block, so each session files correctly and moves on, and presenting the accumulation is the step nobody owns. -- **The session that writes a handoff presents the parked queue in the same act.** This binds at the moment the session writes the handoff that `AGENTS.md` "Session Scope" defines, rather than at the moment the session ends, because a session also ends by interruption, where no agent acts at all and no rule reaches it. The session enumerates this repository's open issues carrying that label, states how many issues the queue holds, and puts the highest-ranked of them to the user as questions, **one question per parked decision, carrying that decision's own answers as its options**, which is the interactive-prompt bullet's own shape applied per decision rather than a single prompt whose options are topics. An issue that states no choices is put as the open question it is, never as invented options, since the label marks every decision waiting on the user rather than only the ones filed under this rule, and the issues that already carry the label predate the rule. Rank them longest-waited first. Every issue records how long it has waited, so any session can reproduce that order. Promote one ahead of that order where it blocks work in flight, and say in the handoff that it was promoted, since two sessions order the same queue differently where the key is subjective and nobody records it. Where more are parked than one round of questions can carry, the stated count still covers every one of them and the handoff names the remainder by issue number in that order, a list of numbers rather than of questions, which is what keeps the handoff inside the size rule that `AGENTS.md` "Session Scope" sets for it. **A count nobody states is a queue nobody can see**, which is how a backlog reported as healthy hides the questions inside it. Where a user is present but no prompt mechanism is available, the questions go as the numbered list the interactive-prompt bullet already names as its fallback, which reaches a reader who is there to read it. Where no user is present at all, the session asks nothing, names the whole queue by issue number in the handoff, and leaves the asking to the next session that has one. An item leaves the queue when the user answers it, and also at triage where a later change has already answered or overtaken its decision. Either way it leaves by losing the label, with the answer or the reason recorded on the issue, and the issue itself closes only where it held nothing but the question, since the queue holds issues that outlive their decision, and closing such an issue just to clear the queue discards the other work that issue tracks. +- **Reference every pull request as a clickable link.** When you mention a PR on a surface that renders Markdown (chat, a summary, a report), render it as a Markdown link to the PR. Write it as `[#N](https://github.com/OWNER/REPO/pull/N)`, never a bare `#N`. The same applies to issues and commits. **The form follows the surface.** Some surfaces link neither a Markdown link nor a bare URL, an interactive prompt's question and option text among them. Pasting a full URL into one of those does not rescue it, since the reader gets a string to copy. That is the outcome this rule exists to prevent. There the reference is a bare `#N`. The clickable link goes in the message that comes **before** the prompt rather than merely alongside it. That is because the prompt blocks on an answer, and a message emitted after it is read once that answer is already given. That is the one moment the link is no longer any use. The test is whether the reader can click it where it is read, not whether it was written in the syntax that works elsewhere. +- **Put a command the user must run where the user can run it.** This session's input line is where the user types to the agent. A command runs outside this session where this session's input line cannot run it or cannot hold its effect. One waiting on typed input, such as a password or a confirmation, is such a command. So is one meant for another host, or one setting a variable the session reads at launch. Every other one runs at this session's input line, where that line can run a command, and outside the session otherwise. **A command the user runs outside this session goes in the question's own text.** Where a question asks the user to run one, the question carries the exact command, complete and ready to paste. A message before the prompt may repeat the command, but the question never points at that message. A question pointing at it can leave the reader to find the command among a long run of earlier output. Such a command stays in the prompt, where the turn opens one, although the clickable-link bullet above moves a link out of one. The command needs no rendering, since the prompt shows it as plain text the reader can copy. **A command the user runs at this session's input line never goes in an interactive prompt.** On Claude Code such a command is a `!` line, typed at the session's input line so its output lands in the session. While an interactive prompt is open, the user cannot type at the session's input line. So a command placed in one sits in the one surface that blocks running it. It goes in a plain message instead, as a fenced block holding the `!` line itself, complete and ready to paste. Several commands join on that one line with `&&`, which runs them in order and stops at the first that fails. The block opens the message. Any question the same turn owes follows it as a numbered list, and any report comes after both. The message asks the user to reply once the command has run, and the turn ends with no interactive prompt open. The agent then checks the result on the user's next message. It reads back the state the command was meant to change, or the output it left in the session. An answer saying the command ran is not that check. +- **Ask every question through the interface's prompt, never in prose.** When you need the user to decide, answer, or approve anything, put it to them through the interface's own prompt mechanism. Do so whether or not work is blocked on it. An offer to do more work ("want me to file that?") is a question like any other. A question written into a message is lost in the report around it, however short it is. One closing a long report sits where the reader is least likely to reach it. Where no prompt mechanism is available, the questions go as a numbered list opening the message rather than closing it. The user can then reply per number. A turn asking for a command the user runs at this session's input line is an exception, per the command bullet above. It puts every question it owes in that message, since a prompt open beside the command would block the place it runs. This bullet settles how a question is put once it is asked. Whether a question is asked now or recorded for later is the parking bullet's to settle. That is the one opening "A question filed as an issue is parked rather than asked". +- **Raise work blocked on the user as a direct interactive prompt.** When progress needs a decision, an authorization, or an answer only the user can give, ask for it at the point the work stops. Ask through the interface's own prompt mechanism. Never leave it as prose in a summary. A handoff buried in a paragraph is a handoff that did not happen. That is because a summary reads as a report of finished work. It is also because the one line still waiting on the user is the easiest in a summary to skim past. The blocked item is the message, not a closing remark on a message about something else. **The options offered are the actions themselves.** The one that unblocks the work names the action it authorizes ("squash and merge it"). Selecting it is then the go-ahead rather than a note to act on later. Offering only ways to wait is the same failure in interactive clothing. That is because a prompt whose every choice is inaction reports the block rather than clearing it. Where the agent may not perform the authorized action itself, the option says who does it. Where no interactive prompt is available, the numbered list the bullet above names is the fallback. In a turn asking for a command the user runs at this session's input line, the command bullet above replaces the interactive prompt. +- **Lead every choice with a recommendation and its reason.** Wherever the user is asked to choose, in a prompt or in a numbered list, the option the agent recommends comes first. It is marked as the recommendation, and every option states the reason for it. A user who takes the recommendation then reads one line, and one who does not sees what the other options trade away. A question with no defensible recommendation says so rather than inventing one. Where an open question has no options and the agent does have an answer, that answer and its reason go in the question's text. They never go in as an invented option. +- **A question filed as an issue is parked rather than asked, and it stays owed.** Where the work cannot continue without the answer, the interactive-prompt bullet governs and the question is asked at the point the work stops. Wherever the question is recorded rather than asked, recording it is the right thing to do, and recording it is still not asking it. That holds whether the work can continue without the answer or the question was asked once and deferred. So the issue carries the fleet's `decision` label. When the filing session knows the choices the question is between, the issue states them. The label and the stated choices are what let a later session, one that was not there when the issue was filed, find the question. That session can then put the question to the user without inventing its answers. **The parked queue is the failure, not the parking.** An issue holding a question the user has never seen reads to every later session as tracked work rather than as a block. So each session files correctly and moves on. Presenting the accumulation is the step nobody owns. +- **The session that writes a handoff presents the parked queue in the same act.** This binds at the moment the session writes the handoff that `AGENTS.md` "Session Scope" defines, rather than at the moment the session ends. That is because a session also ends by interruption, where no agent acts at all and no rule reaches it. The session enumerates this repository's open issues carrying that label, and states how many issues the queue holds. It puts the highest-ranked of them to the user as questions, **one question per parked decision, carrying that decision's own answers as its options**. That is the interactive-prompt bullet's own shape applied per decision, rather than a single prompt whose options are topics. An issue that states no choices is put as the open question it is, never as invented options. That is because the label marks every decision waiting on the user, rather than only the ones filed under this rule. It is also because the issues that already carry the label predate the rule. Rank them longest-waited first. Every issue records how long it has waited, so any session can reproduce that order. Promote one ahead of that order where it blocks work in flight, and say in the handoff that it was promoted. That is because two sessions order the same queue differently where the key is subjective and nobody records it. Where more are parked than one round of questions can carry, the stated count still covers every one of them. The handoff names the remainder by issue number in that order, a list of numbers rather than of questions. That is what keeps the handoff inside the size rule that `AGENTS.md` "Session Scope" sets for it. **A count nobody states is a queue nobody can see**, which is how a backlog reported as healthy hides the questions inside it. Where a user is present but no prompt mechanism is available, the questions go as a numbered list. That is the fallback the interactive-prompt bullet already names, and it reaches a reader who is there to read it. They go as a numbered list too in a turn asking for a command the user runs at this session's input line. Where no user is present at all, the session asks nothing and names the whole queue by issue number in the handoff. It leaves the asking to the next session that has one. An item leaves the queue when the user answers it, and also at triage where a later change has already answered or overtaken its decision. Either way it leaves by losing the label, with the answer or the reason recorded on the issue. The issue itself closes only where it held nothing but the question. That is because the queue holds issues that outlive their decision. It is also because closing such an issue just to clear the queue discards the other work that issue tracks. -`GOVERNANCE.md` "Communicating with the User" keeps the full rules, and the `agent-conduct` and `session-handoff` Skills at `.agents/skills/agent-conduct/SKILL.md` and `.agents/skills/session-handoff/SKILL.md` in the hub, not repo-relative links since those paths are hub-local and not carried into every fleet repo, each carry it whole as a generated include and surface it at its own decision moment. +`GOVERNANCE.md` "Communicating with the User" keeps the full rules. The `agent-conduct` and `session-handoff` Skills at `.agents/skills/agent-conduct/SKILL.md` and `.agents/skills/session-handoff/SKILL.md` in the hub each carry it whole as a generated include. Each surfaces it at its own decision moment. Those paths are not repo-relative links, since they are hub-local and not carried into every fleet repo. ## Workflow YAML Conventions -These conventions bind every workflow. Several of them [`WORKFLOW.md`](./WORKFLOW.md) section 4 also states as guarantees, and not only at D9, so where it does, a violation of an *applicable* one is a defect that makes the workflow **not operational**, on the same terms as any other. Each D-item names its own constructs, so read section 4 for which rule binds where rather than a mapping kept here. The target-state framing below settles *when* an unswept workflow is fixed rather than *whether* its violation counts: new and modified workflows respect these rules now, and the rest of the repo is brought up to the same standard. Sweep PRs that apply a rule everywhere are welcome when a rule changes. - -An overlap with `WORKFLOW.md` resolves **by subject**, never by blanket precedence. This section keeps the full style rules and wins on them, stating each in more detail than the guarantee that carries it, while `WORKFLOW.md` wins on the architecture, the contract, and the test methodology. `WORKFLOW.md` section 2 points at this section rather than restating it. Throughout, a job is named by its id and a step by its `name:`. The `workflow-ci-contract` Skill at `.agents/skills/workflow-ci-contract/SKILL.md` in the hub, not a repo-relative link since that path is hub-local and not carried into every fleet repo, surfaces it. - -- **Action pinning**: pin **every** action, first-party (`actions/*`) and third-party alike, to a commit SHA with a trailing `# vX.Y.Z` comment, so Renovate / Dependabot can still bump it but a tag swap can't change the executed code. This binds a `uses:` wherever it appears, in a workflow and in a composite action under `.github/actions/**` alike, except a local (`./`) or self-repository (`$/`) reference, which names no ref to pin. Use `# vX` (major-only) only when the upstream's floating major tag doesn't correspond to a specific patch/minor release SHA, since pinning to the floating-tag SHA still gives the SHA guarantee, the version comment just records the major line. Documented exception (no SHA pin at all): `dotnet/nbgv` is consumed via `@master` because the upstream tag stream lags `master` substantially and Dependabot's tag-tracking would propose a downgrade. **This applies to repo-owned build-layer leaves too**, since a leaf owning its build specifics is not a reason to use floating tags, and Dependabot still bumps SHA pins (updating the SHA + version comment). -- **Filename**: a workflow declaring `on: workflow_call` ends in `-task.yml`, **whatever else it is also triggered by**, since that is the half the suffix is about. A workflow without `workflow_call` is an entry point (`push`, `pull_request`, `pull_request_target`, `schedule`, `workflow_dispatch`) and takes no `-task` suffix, ending instead with what it does: `-pull-request.yml`, `-release.yml`. The suffix says the file is meant to be `uses:`-d, which stays true of a file that is also dispatchable. Composite actions are named by their path (`.github/actions//action.yml`), so these suffix rules do not reach them. -- **Workflow `name:`** (the top-level `name:` field): a workflow declaring `workflow_call` takes a name ending in **"task"** (e.g. `Build project release task`), matching the filename rule above and covering a file that is also dispatchable, and every other workflow takes one ending in **"action"** (e.g. `Publish project release action`, `Test pull request action`). The suffix tells an orchestrator from a callee while reading the source tree, and on the runs list for an entry point. It does not do that in the Actions UI for a callee: a called reusable workflow's jobs appear nested inside the caller's run as ` / `, and the runs list shows the caller's workflow name rather than the callee's own. -- **Job and step `name:` suffixes**: every job's `name:` ends in **"job"** and every step's `name:` ends in **"step"**, including the PR-gate aggregator, whose `name:` is a required-status-check `context:` in a branch ruleset (`Check pull request workflow status job` in `test-pull-request.yml`). A trailing parenthetical qualifier after the suffix is allowed and is the only exception (`Upload coverage to Codecov step (Python)`), and nothing enforces the rule mechanically. A ruleset-bound job's `name:` and its ruleset `context:` are the **same string**: rename them **together**, or required-status-check enforcement silently breaks. Every surface whose staleness breaks that enforcement moves in the same change, never one without the others. In a repository the surfaces are the live ruleset and its own workflow. A rename of the fleet-wide string additionally moves the hub's `repo-config/` payloads, its `spec/files.json` `requiredCheckName`, and each adopter-facing stub in its `catalog/` and `docs/reusable-workflows.md`, which exist only in the hub. Prose naming the old string goes stale rather than breaking, and follows behind. -- **Concurrency**: top-level workflows declare `concurrency: { group: '${{ github.workflow }}-${{ github.ref }}', cancel-in-progress: true }` so a fresh push supersedes an in-flight run on the same ref. `cancel-in-progress: false` queues instead of cancelling, and queuing is not ordering: GitHub holds at most one pending run per group and cancels the previously pending one when a newer run queues, so the guarantee it buys is that a **running** job finishes rather than that every event runs in arrival order. **Documented exceptions**, each recording its rationale inline in its own header comment: (1) a merge-bot workflow keys the group on the **PR number** (`-${{ github.event.pull_request.number }}` rather than `-${{ github.ref }}`, which under `pull_request_target` is the base branch and would serialize every bot PR against it) and takes `cancel-in-progress: false`, because cancelling mid-flight would leave auto-merge enabled or disabled inconsistently (`.github/workflows/merge-bot-pull-request.yml`). (2) A publisher uses a **global, ref-independent group** (`group: ${{ github.workflow }}`, dropping the usual `-${{ github.ref }}`) with `cancel-in-progress: false`, because it publishes shared ref-independent outputs (both branches' Docker tags and caches, and GitHub releases) and its triggers need not agree on a ref, so a ref-scoped group would let two runs double-push, and cancelling one can leave a partially pushed tag set or a half-created release (`.github/workflows/publish-release.yml`). (3) A deploy workflow keys the group on the **environment** it deploys with `cancel-in-progress: false`, because a cancelled deploy leaves a release uploaded and the pointer unflipped. No workflow in this repository implements it, the deploy task being reusable rather than top-level, so the rationale lives here rather than in a header comment. (4) A workflow whose only write is one repository-scoped issue keys on the **workflow alone** with `cancel-in-progress: false`, because nothing it writes varies by ref, so a ref-scoped group would separate a scheduled run, which always runs on the default branch, from a dispatch made anywhere else, and let each read no open issue and each file one, and a cancel between two writes can leave the first landed and the second unmade. The one workflow implementing it is hub-only, so a repository carrying this section holds no such file and finds the rationale here rather than in a header comment. -- **Shells**: every bash surface, a multi-line `run:` block and every committed bash script alike, starts with `set -Eeuo pipefail`: fail fast, fail on undefined vars, fail on a failed pipe segment, and let an `ERR` trap inherit into functions, subshells, and command substitutions (`-E`). A shebang naming bash makes a script one whatever its extension. A single-line `run:` block is outside the rule. A one-liner that pipes or chains commands takes a multi-line block instead, since without `pipefail` a failed producer reads as success. The `-E` is defense in depth: the fleet ships no `ERR` trap today, so a script that later adds one inherits the behavior instead of silently losing it. A deliberately POSIX `#!/bin/sh` surface, a git hook that must run before any toolchain exists being the case in practice, is not a bash surface: it takes `set -eu`, dropping `-E` and `pipefail`, which `sh` does not carry. -- **Conditionals**: multi-line `if:` uses the folded scalar `if: >-`, which joins the wrapped source lines back into one line. `WORKFLOW.md` D9.3 requires it. A literal block (`if: |`) evaluates the same, the expression lexer skipping newlines along with other whitespace, so this is a legibility rule rather than a correctness one, and it binds as a guarantee regardless. -- **Boolean inputs**: a workflow triggered both via `workflow_call` and `workflow_dispatch` declares each boolean input in *both* trigger blocks, since one declaration does not propagate to the other. Which context reads it then decides the comparison, and the two are not the same. The `inputs` context **preserves the declared boolean** on both paths, so `if: ${{ inputs.foo }}` is read directly. The `github.event.inputs` context delivers **every** input as a string whatever its declared type, so a read through it is compared against `'true'`. Comparing a `github.event.inputs` read against the boolean `true` as well is dead rather than defensive: an operand-type mismatch casts each side to a number, a non-numeric string casts to `NaN`, and `NaN` compares equal to nothing, so `github.event.inputs.foo == true` is false even on the run where the input arrived as `true`. A both-forms comparison on an `inputs` read is merely redundant. `WORKFLOW.md` D7.3 is the contract this bullet's rationale serves, and wins on any disagreement. -- **Validate input/state consistency at entry, fail fast**: when a workflow's inputs must satisfy a cross-input or input-versus-derived-state invariant (e.g. the release branch must match the computed version's prerelease status, or two inputs are mutually exclusive), assert it **once** at entry, before any expensive build or publish work, rather than as partial checks scattered deep in later jobs. One gate that fails fast with a clear `::error::` beats a late or one-directional check. Where later **jobs** depend on the assertion, it is a job of its own that they `needs:`, since `needs:` takes job ids and cannot name a step. Where the work it guards is in the same job, an entry step in that job is enough. Examples: `build-release-task.yml`'s `validate-release` job (branch-versus-prerelease, both directions), and the input-validating entry step in `publish-docker-readme-task.yml`'s own first job, which also resolves the repository list, so a consumer of it depends on that job rather than on the validation alone. -- **Reusable workflows**: job-level `permissions:` are validated *before* the `if:` evaluates, so even a skipped job needs valid permissions declared. A `release` job with `permissions: contents: write` and `if: ${{ inputs.publish }}` will still cause `startup_failure` on a caller that doesn't grant `contents: write`. So declare an inner block only where **every** caller grants that scope at startup, and otherwise omit it and run under the calling job's grant, declaring the scope at the call site. -- **Allowlist `success` and `skipped` explicitly** when chaining jobs across optional dependencies, since `!= 'failure'` lets `cancelled` through (timeout, runner failure, manual cancel). Use `(needs.X.result == 'success' || needs.X.result == 'skipped')`, and pair it with a status-check function. An `if:` carrying no such function has `success()` applied implicitly, and that implicit `success()` is false the moment any `needs:` job skipped, which is the case the allowlist exists to admit. Either `always()` or `!failure() && !cancelled()` serves, the explicit `success`/`skipped` allowlist beside it being what excludes a failed or cancelled dependency either way. They differ on a cancelled **run** and on a failed sibling `needs:` job, both of which `always()` still runs through. That is why `WORKFLOW.md` D1.5 requires `always()` of the pull request aggregator, which has to report a failed or skipped dependency rather than skip with it. -- **Artifact retention**: an explicitly uploaded workflow artifact is an intra-run handoff only, so it must not survive the run and accumulate against the small account-wide artifact-storage quota. **Clean up each transfer artifact surgically at its point of consumption**: the job that downloads it deletes it by exact name/pattern right after consuming it, under the **condition that made it redundant**, which is the half of the consumption whose failure would mean it is not redundant yet. Where the consuming step is conditional, that condition is the consumer's: the `github-release` job deletes `release-asset--*` under the release-create step's own condition, narrowed by `inputs.expect_release_assets`, so a no-op re-run that skips the create skips the delete with it and leaves the freshly built assets alone. Where the consuming step always attempts once its job runs, the condition is the download's: a package repo's `publish-release.yml` deletes `nuget-build-` or `pypi-build-` in the `publish-` job under `if: ${{ !cancelled() && steps..outcome == 'success' }}`, because the artifact has served its handoff once it has been downloaded, whether or not the push that followed succeeded. Recovering a failed push is a rebuild rather than a re-download, and "Release Model" above routes to what that costs. A delete left to the implicit `success()` would skip on exactly that failed push, and the `!cancelled()` suppresses that implicit `success()` the way any status-check function does. That implicit `success()` is the same mechanism the optional-dependency bullet above names, reached there by a skipped `needs:` job and here by a failed prior step. Deletion needs `actions: write` granted on that job, and for a reusable callee (e.g. `github-release` inside `build-release-task.yml`) the **caller** grants it (`publish-release.yml`'s `publish` job does). **Never blanket-delete the run's artifacts** (`gh api .../artifacts --jq '.artifacts[].id'`). That also destroys diagnostic/log artifacts and the build-records actions emit automatically (`docker/build-push-action`'s `.dockerbuild`), which are exactly what you need to debug a failed run, and which the `retention-days: 1` backstop below never reaches, since it is set on an explicit upload step and an auto-emitted record has none, so they fall back to the repository's own retention period. Set `retention-days: 1` on **every** explicit `upload-artifact`: it is the failure-path backstop, since a job that dies before its consumer runs leaves its artifact to be reaped within a day, so no separate terminal cleanup job is needed. A repo customizing these jobs must preserve the consume-then-delete shape. -- **Docker layer cache**: cache to and from a registry tag (`type=registry`, e.g. `:buildcache-` on Docker Hub), not the GitHub Actions cache (`type=gha`), to keep large image layers off the 10 GB Actions cache. The cache is **asymmetric**: `cache-to` writes only on a push and only to the branch being built, so a pull request smoke run writes nothing at all and a `develop` publish writes only `buildcache-develop`, while `cache-from` reads both branches so a first build on a new branch still hits. A **multi-image** repo varies the cache **repository** rather than the tag, `:buildcache-` for each image, since one tag cannot distinguish two images. It does not fall back to `type=gha` for the extra images. -- **Tag pinning on releases**: when using `softprops/action-gh-release` (or any tag-creating action), pass `target_commitish` explicitly, because without it GitHub's REST API defaults the new tag to the repository's default branch instead of the commit that built the artifact. Pin it to the **exact built commit's SHA** (the publisher uses NBGV's `GitCommitId` output), not `github.sha` (which may differ from the exact commit NBGV versioned) and not a branch name (a moving ref that a mid-run commit could advance past the built tree). +These conventions bind every workflow. [`WORKFLOW.md`](./WORKFLOW.md) section 4 also states several of them as guarantees, and not only at D9. Where it does, a violation of an *applicable* one is a defect that makes the workflow **not operational**, on the same terms as any other. Each D-item names its own constructs, so read section 4 for which rule binds where rather than a mapping kept here. The target-state framing below settles *when* an unswept workflow is fixed rather than *whether* its violation counts. New and modified workflows respect these rules now, and the rest of the repo is brought up to the same standard. Sweep PRs that apply a rule everywhere are welcome when a rule changes. + +An overlap with `WORKFLOW.md` resolves **by subject**, never by blanket precedence. This section keeps the full style rules and wins on them, stating each in more detail than the guarantee that carries it. `WORKFLOW.md` wins on the architecture, the contract, and the test methodology. `WORKFLOW.md` section 2 points at this section rather than restating it. Throughout, a job is named by its id and a step by its `name:`. The `workflow-ci-contract` Skill at `.agents/skills/workflow-ci-contract/SKILL.md` in the hub surfaces it. That path is not a repo-relative link, since it is hub-local and not carried into every fleet repo. + +- **Action pinning**: pin **every** action, first-party (`actions/*`) and third-party alike, to a commit SHA with a trailing comment naming the release tag at that SHA. Renovate / Dependabot can then still bump it, but a tag swap can't change the executed code. The comment spells the tag exactly as the referenced repository publishes it. That is `# vX.Y.Z` where its tags carry a `v`, and the bare tag, `# X.Y.Z`, where they do not. So the comment never names a tag that does not exist. This binds a `uses:` wherever it appears, in a workflow and in a composite action under `.github/actions/**` alike. The exception is a local (`./`) reference or a self-repository (`$/`) one, which names no ref to pin. In a workflow file, `$/` is GitHub's syntax for a path in the repository holding that file, resolved at that file's own commit. Use a major-only comment, `# vX` or the bare `# X` by the same spelling rule, only where a condition holds. That condition is that the upstream's floating major tag doesn't correspond to a specific patch/minor release SHA. That is because pinning to the floating-tag SHA still gives the SHA guarantee, and the version comment just records the major line. Documented exception (no SHA pin at all): `dotnet/nbgv` is consumed via `@master`. That is because the upstream tag stream lags `master` substantially and Dependabot's tag-tracking would propose a downgrade. **This applies to repo-owned build-layer leaves too**, since a leaf owning its build specifics is not a reason to use floating tags. It is also because Dependabot still bumps SHA pins (updating the SHA + version comment). +- **Filename**: a workflow declaring `on: workflow_call` ends in `-task.yml`, **whatever else it is also triggered by**, since that is the half the suffix is about. A workflow without `workflow_call` is an entry point (`push`, `pull_request`, `pull_request_target`, `schedule`, `workflow_dispatch`) and takes no `-task` suffix. It ends instead with what it does: `-pull-request.yml`, `-release.yml`. The suffix says the file is meant to be `uses:`-d, which stays true of a file that is also dispatchable. Composite actions are named by their path (`.github/actions//action.yml`), so these suffix rules do not reach them. +- **Workflow `name:`** (the top-level `name:` field): a workflow declaring `workflow_call` takes a name ending in **"task"** (e.g. `Build project release task`). That matches the filename rule above and covers a file that is also dispatchable. Every other workflow takes one ending in **"action"** (e.g. `Publish project release action`, `Test pull request action`). The suffix tells an orchestrator from a callee while reading the source tree, and on the runs list for an entry point. It does not do that in the Actions UI for a callee. A called reusable workflow's jobs appear nested inside the caller's run as ` / `. And the runs list shows the caller's workflow name rather than the callee's own. +- **Job and step `name:` suffixes**: every job's `name:` ends in **"job"** and every step's `name:` ends in **"step"**. That includes the PR-gate aggregator. The name its `pull_request` run posts is a required-status-check `context:` in a branch ruleset (`Check pull request workflow status job` in `test-pull-request.yml`). Where another event also triggers the workflow, the `name:` is the expression form `WORKFLOW.md` D1.5 requires. Each string that expression yields ends in "job" too. A trailing parenthetical qualifier after the suffix is allowed and is the only exception (`Upload coverage to Codecov step (Python)`), and nothing enforces the rule mechanically. A ruleset-bound job's `pull_request` name and its ruleset `context:` are the **same string**: rename them **together**, or required-status-check enforcement silently breaks. Every surface whose staleness breaks that enforcement moves in the same change, never one without the others. In a repository the surfaces are the live ruleset and its own workflow. A rename of the fleet-wide string additionally moves the hub's `repo-config/` payloads and its `spec/files.json` `requiredCheckName`. It also moves each adopter-facing stub in its `catalog/` and `docs/reusable-workflows.md`. Those surfaces exist only in the hub. Prose naming the old string goes stale rather than breaking, and follows behind. +- **Concurrency**: top-level workflows declare `concurrency: { group: '${{ github.workflow }}-${{ github.ref }}', cancel-in-progress: true }` so a fresh push supersedes an in-flight run on the same ref. `cancel-in-progress: false` queues instead of cancelling, and queuing is not ordering. GitHub holds at most one pending run per group, and cancels the previously pending one when a newer run queues. So the guarantee it buys is that a **running** job finishes, rather than that every event runs in arrival order. **Documented exceptions** follow, each cited by number, never restated, in the comment above its implementing workflow's `concurrency:` key. (1) A merge-bot workflow keys the group on the **PR number** (`-${{ github.event.pull_request.number }}`) rather than on `-${{ github.ref }}`. Under `pull_request_target` the ref is the base branch, and keying on it would serialize every bot PR against it. The workflow takes `cancel-in-progress: false`, because cancelling mid-flight would leave auto-merge enabled or disabled inconsistently (`.github/workflows/merge-bot-pull-request.yml`). (2) A publisher keeps the ref-scoped group with `cancel-in-progress: false`. Cancelling a run can leave a partially pushed tag set or a half-created release. A newer run replaces the group's one pending run. So a push the publish plan does not publish appends `github.run_id` to the group, per `WORKFLOW.md` D7.1 (`catalog/snippets/workflows/publish-release.yml`). A `schedule` run on a publisher whose schedule never publishes does the same. The hub's own publisher is dispatch-only and omits the suffix. (3) A deploy workflow keys the group on the **environment** it deploys, with `cancel-in-progress: false`. That is because a cancelled deploy leaves a release uploaded and the pointer unflipped. (4) A workflow whose only write is one repository-scoped issue keys on the **workflow alone** with `cancel-in-progress: false`. That is because nothing it writes varies by ref. So a ref-scoped group would separate a scheduled run, which always runs on the default branch, from a dispatch made anywhere else. Each would then read no open issue and each would file one. A further reason is that a cancel between two writes can leave the first landed and the second unmade. (5) A workflow whose writes go to fixed branches regardless of the triggering ref keys on the **workflow alone** with `cancel-in-progress: true`. A ref-scoped group would let a dispatch from one ref and the scheduled run race on the same branches. Each run regenerates the whole branch and its pull request, so a newer run rewrites whatever a cancelled run left (`catalog/snippets/workflows/run-periodic-codegen-pull-request.yml`). +- **Shells**: every bash surface, a multi-line `run:` block and every committed bash script alike, starts with `set -Eeuo pipefail`. That means fail fast, fail on undefined vars, and fail on a failed pipe segment. It also lets an `ERR` trap inherit into functions, subshells, and command substitutions (`-E`). A shebang naming bash makes a script one whatever its extension. A single-line `run:` block is outside the rule. A one-liner that pipes or chains commands takes a multi-line block instead, since without `pipefail` a failed producer reads as success. The `-E` is defense in depth. The fleet ships no `ERR` trap today, so a script that later adds one inherits the behavior instead of silently losing it. A deliberately POSIX `#!/bin/sh` surface is not a bash surface. In practice that is a git hook that must run before any toolchain exists. It takes `set -eu`, dropping `-E` and `pipefail`, which `sh` does not carry. +- **Conditionals**: multi-line `if:` uses the folded scalar `if: >-`, which joins the wrapped source lines back into one line. `WORKFLOW.md` D9.3 requires it. A literal block (`if: |`) evaluates the same, the expression lexer skipping newlines along with other whitespace. So this is a legibility rule rather than a correctness one, and it binds as a guarantee regardless. +- **Boolean inputs**: a workflow triggered both via `workflow_call` and `workflow_dispatch` declares each boolean input in *both* trigger blocks. That is because one declaration does not propagate to the other. Which context reads it then decides the comparison, and the two are not the same. The `inputs` context **preserves the declared boolean** on both paths, so `if: ${{ inputs.foo }}` is read directly. The `github.event.inputs` context delivers **every** input as a string whatever its declared type, so a read through it is compared against `'true'`. Comparing a `github.event.inputs` read against the boolean `true` as well is dead rather than defensive. An operand-type mismatch casts each side to a number, and a non-numeric string casts to `NaN`. `NaN` compares equal to nothing, so `github.event.inputs.foo == true` is false even on the run where the input arrived as `true`. A both-forms comparison on an `inputs` read is merely redundant. `WORKFLOW.md` D7.3 is the contract this bullet's rationale serves, and wins on any disagreement. +- **Validate input/state consistency at entry, fail fast**: this applies when a workflow's inputs must satisfy a cross-input or input-versus-derived-state invariant. Examples are the release branch having to match the computed version's prerelease status, or two inputs being mutually exclusive. Assert the invariant **once** at entry, before any expensive build or publish work, rather than as partial checks scattered deep in later jobs. One gate that fails fast with a clear `::error::` beats a late or one-directional check. Where later **jobs** depend on the assertion, it is a job of its own that they `needs:`. That is because `needs:` takes job ids and cannot name a step. Where the work it guards is in the same job, an entry step in that job is enough. Examples: `build-release-task.yml`'s `validate-release` job (branch-versus-prerelease, both directions), and the input-validating entry step in `publish-docker-readme-task.yml`'s own first job. That job also resolves the repository list, so a consumer of it depends on that job rather than on the validation alone. +- **Reusable workflows**: job-level `permissions:` are validated *before* the `if:` evaluates, so even a skipped job needs valid permissions declared. A `release` job with `permissions: contents: write` and `if: ${{ inputs.publish }}` will still cause `startup_failure` on a caller that doesn't grant `contents: write`. So declare an inner block only where **every** caller grants that scope at startup. Otherwise omit it and run under the calling job's grant, declaring the scope at the call site. +- **Allowlist `success` and `skipped` explicitly** when chaining jobs across optional dependencies, since `!= 'failure'` lets `cancelled` through (timeout, runner failure, manual cancel). Use `(needs.X.result == 'success' || needs.X.result == 'skipped')`, and pair it with a status-check function. An `if:` carrying no such function has `success()` applied implicitly. That implicit `success()` is false the moment any `needs:` job skipped, which is the case the allowlist exists to admit. Either `always()` or `!failure() && !cancelled()` serves, the explicit `success`/`skipped` allowlist beside it being what excludes a failed or cancelled dependency either way. They differ on a cancelled **run** and on a failed sibling `needs:` job, both of which `always()` still runs through. That is why `WORKFLOW.md` D1.5 requires `always()` of the pull request aggregator. The aggregator has to report a failed or skipped dependency rather than skip with it. +- **Artifact retention**: an explicitly uploaded workflow artifact is an intra-run handoff only. So it must not survive the run and accumulate against the small account-wide artifact-storage quota. **Clean up each transfer artifact surgically at its point of consumption**: the job that downloads it deletes it by exact name/pattern right after consuming it. It deletes it under the **condition that made it redundant**. That is the half of the consumption whose failure would mean it is not redundant yet. Where the consuming step is conditional, that condition is the consumer's. The `github-release` job deletes `release-asset--*` under the release-create step's own condition, narrowed by `inputs.expect_release_assets`. So a no-op re-run that skips the create skips the delete with it, and leaves the freshly built assets alone. Where the consuming step always attempts once its job runs, the condition is the download's. A package repo's `publish-release.yml` deletes `nuget-build-` or `pypi-build-` in the `publish-` job. It does so under `if: ${{ !cancelled() && steps..outcome == 'success' }}`. That is because the artifact has served its handoff once it has been downloaded, whether or not the push that followed succeeded. Recovering a failed push is a rebuild rather than a re-download, and "Release Model" above routes to what that costs. A delete left to the implicit `success()` would skip on exactly that failed push. The `!cancelled()` suppresses that implicit `success()` the way any status-check function does. That implicit `success()` is the same mechanism the optional-dependency bullet above names. It is reached there by a skipped `needs:` job and here by a failed prior step. Deletion needs `actions: write` granted on that job, and for a reusable callee (e.g. `github-release` inside `build-release-task.yml`) the **caller** grants it (`publish-release.yml`'s `publish` job does). **Never blanket-delete the run's artifacts** (`gh api .../artifacts --jq '.artifacts[].id'`). That also destroys diagnostic/log artifacts and the build-records actions emit automatically (`docker/build-push-action`'s `.dockerbuild`). Those are exactly what you need to debug a failed run. The `retention-days: 1` backstop below never reaches them, since it is set on an explicit upload step and an auto-emitted record has none. So they fall back to the repository's own retention period. Set `retention-days: 1` on **every** explicit `upload-artifact`. It is the failure-path backstop, since a job that dies before its consumer runs leaves its artifact to be reaped within a day. So no separate terminal cleanup job is needed. A repo customizing these jobs must preserve the consume-then-delete shape. +- **Docker layer cache**: cache to and from a registry tag (`type=registry`, e.g. `:buildcache-` on Docker Hub), not the GitHub Actions cache (`type=gha`). That keeps large image layers off the 10 GB Actions cache. The cache is **asymmetric**. `cache-to` writes only on a push and only to the branch being built. So a pull request smoke run writes nothing at all, and a `develop` publish writes only `buildcache-develop`. Meanwhile `cache-from` reads both branches so a first build on a new branch still hits. A **multi-image** repo varies the cache **repository** rather than the tag, `:buildcache-` for each image, since one tag cannot distinguish two images. It does not fall back to `type=gha` for the extra images. +- **Tag pinning on releases**: when using `softprops/action-gh-release` (or any tag-creating action), pass `target_commitish` explicitly. Without it, GitHub's REST API defaults the new tag to the repository's default branch instead of the commit that built the artifact. Pin it to the **exact built commit's SHA** (the publisher uses NBGV's `GitCommitId` output). Do not pin it to `github.sha`, which may differ from the exact commit NBGV versioned. And do not pin it to a branch name, a moving ref that a mid-run commit could advance past the built tree. ## Supported Development Platforms -- **Cross-platform by default: Windows + macOS + Linux.** Linux runs natively (a Linux desktop, or SSH/remote into a Linux host), through a devcontainer on Windows or macOS, or through WSL2 on Windows, where the devcontainer and WSL routes carry their own nuances (mounts, path translation, SSH-agent forwarding) but deliver the same toolchain. Editing is cross-platform through the GUI regardless of where code runs. Assume this default. -- **A repo's platform ceiling is set by its dependencies, not by tooling effort, so decide it per repo before writing dev tooling.** Narrow below the default only for a hard runtime ceiling, where the code can only execute or test on one platform (e.g. a Home Assistant integration is Linux-only: HA Core has POSIX-only dependencies and will not run natively on Windows, so even maximal tooling yields only lint-only there). The narrowing axis is where code *executes* for dev and testing (native, SSH-remote, container, or CI), never where editing happens. +- **Cross-platform by default: Windows + macOS + Linux.** Linux runs natively (a Linux desktop, or SSH/remote into a Linux host), through a devcontainer on Windows or macOS, or through WSL2 on Windows. The devcontainer and WSL routes carry their own nuances (mounts, path translation, SSH-agent forwarding) but deliver the same toolchain. Editing is cross-platform through the GUI regardless of where code runs. Assume this default. +- **A repo's platform ceiling is set by its dependencies, not by tooling effort, so decide it per repo before writing dev tooling.** Narrow below the default only for a hard runtime ceiling, where the code can only execute or test on one platform. For example, a Home Assistant integration is Linux-only. HA Core has POSIX-only dependencies and will not run natively on Windows, so even maximal tooling yields only lint-only there. The narrowing axis is where code *executes* for dev and testing (native, SSH-remote, container, or CI), never where editing happens. - **Record a narrowed platform and its reason in the repo** (README/AGENTS) so the restriction reads as a deliberate dependency ceiling, not an omission. ## Devcontainer @@ -253,17 +292,17 @@ This repo ships no devcontainer. Development is native .NET SDK tooling on any o ## Editor and Tasks - **VS Code is the primary IDE, and the experience favors it.** Prefer VS Code tasks and launch configurations for building, running, and testing over ad-hoc shell scripts. A script is the fallback, not the default. -- The `.code-workspace` file carries the shared editor settings and the recommended-extension set. **All VS Code settings and extension recommendations live only here, never in a standalone `.vscode/settings.json` or `.vscode/extensions.json`** (`.vscode/` holds only `tasks.json` and `launch.json`). A **standard set** of extensions applies to every repo (markdownlint, cspell, editorconfig, markdown-all-in-one, better-todo-tree, github-actions, actionlint, shellcheck, claude-code); **language-specific** extensions are added per project (.NET: csdevkit, csharpier; Python: python, pylance, ruff, mypy; Docker: the Docker extension). -- The Table of Contents is maintained by the Markdown All in One extension, and `markdown.extension.toc.levels` in the workspace sets which heading levels it includes (see the Markdown rules for the authoring convention and the `` exclusion marker). -- **Agents: editing the active `.code-workspace` can reload the VS Code window and drop the agent's session.** Commit all state first, prefer opening the folder rather than the workspace while editing it, or leave workspace edits to the maintainer (a maintainer edit does not reload). +- The `.code-workspace` file carries the shared editor settings and the recommended-extension set. **All VS Code settings and extension recommendations live only here, never in a standalone `settings.json` or `extensions.json` under `.vscode/`.** The tracked files there are only `tasks.json` and `launch.json`. A repository shipping Python lists `.vscode/settings.json` in `.gitignore`, because the Python extension installs the Python Environments extension, which writes settings there. Settings live only in the `.code-workspace` file all the same, and the entry keeps git from adding the generated file. A **standard set** of extensions applies to every repo (markdownlint, cspell, editorconfig, markdown-all-in-one, better-todo-tree), kept to extensions that work without a separately installed tool. A repo shipping a language adds that language's **defaults** (.NET: csdevkit, csharpier. Python: python, pylance, ruff, mypy. Docker: the Docker extension). **Any repo can add further extensions** of its own on top of them. +- The Table of Contents is maintained by the Markdown All in One extension. `markdown.extension.toc.levels` in the workspace sets which heading levels it includes. See the Markdown rules for the authoring convention and the `` exclusion marker. +- **Agents: editing a `.code-workspace` a VS Code window has loaded as its workspace can reload that window and drop an agent session running in it.** "Verification Discipline" holds the rule. An agent leaves that edit to the maintainer while it knows a window with the file loaded hosts an agent session, the agent's own included. It treats any window but its own as hosting no session unless it knows otherwise. That is because another window's session state is normally unobservable, so assuming a live one would hand every such edit back to the maintainer. Where it still cannot tell whether the rule binds it, it asks the maintainer. An agent the rule does not bind edits the file directly. Opening the folder rather than the workspace, in every window holding the checkout, keeps a checkout's own file editable. ## Repository Details Every repo's GitHub repository details (the About panel) follow a fixed convention so the fleet stays consistent and self-describing. -- **Description** is one canonical sentence that carries to the README, the About panel, and (for a Docker repo) the Docker Hub short description alike, at most **100 characters**, Docker Hub's short-description cap and the tightest surface it feeds. Once a repo declares `registry/repos.json`'s optional `description` field, that field is the source, itself link-free plain text on one line for the same reason: `repo-config/configure.sh apply` writes it to the About panel directly, and the README's **tagline** (its first non-empty line after the `#` H1 heading) follows it rather than the other way around. A repo that has not adopted the field yet keeps the pre-existing convention, where the README tagline is the source of truth and the About panel is set from it by hand (`gh api -X PATCH repos// -f description=...`). `spec/audit.py`'s `description_findings()` reports drift either way, falling back to the tagline when no field is declared. It is that one line and not the paragraph it opens: a README may carry further paragraphs below the tagline, and no mirror reads them. When the current description is *more specific* than the declared source (a chip revision or variant it omits), surface the drift to the maintainer rather than silently discarding the detail, and the fix is to sharpen the declared source so the other mirrors follow it. Docker Hub receives it from the About panel, which the docker-readme task reads at publish time, so an About panel left diverged from the canonical value is carried onward rather than corrected there. +- **Description** is one canonical sentence that carries to the README, the About panel, and (for a Docker repo) the Docker Hub short description alike. It is at most **100 characters**, Docker Hub's short-description cap and the tightest surface it feeds. Once a repo declares `registry/repos.json`'s optional `description` field, that field is the source, itself link-free plain text on one line for the same reason. `repo-config/configure.sh apply` writes it to the About panel directly. The README's **tagline** (its first non-empty line after the `#` H1 heading) follows it rather than the other way around. A repo that has not adopted the field yet keeps the pre-existing convention. There the README tagline is the source of truth, and the About panel is set from it by hand (`gh api -X PATCH repos// -f description=...`). `spec/audit_markdown.py`'s `description_findings()`, which the audit runs, reports drift either way, falling back to the tagline when no field is declared. It is that one line and not the paragraph it opens: a README may carry further paragraphs below the tagline, and no mirror reads them. When the current description is *more specific* than the declared source (a chip revision or variant it omits), surface the drift to the maintainer. Do not silently discard the detail. The fix there is to sharpen the declared source so the other mirrors follow it. Docker Hub receives it from the About panel, which the docker-readme task reads at publish time. So an About panel left diverged from the canonical value is carried onward rather than corrected there. - **Topics** are optional, and any that are present match the repo's actual content. Do not invent topics to fill the field. -- **Include in the home page**: Releases on, Deployments off, Packages off. These toggles are UI-only, since the REST and GraphQL APIs neither read nor write them, so they are set by hand and cannot be audited through `gh`. +- **Include in the home page**: Releases on, Deployments off, Packages off. These toggles are UI-only, since the REST and GraphQL APIs neither read nor write them. So they are set by hand and cannot be audited through `gh`. ## Repository Layout